后端生成闸+多项修复;前端全站更新;QA 审计与报告

后端:
- 新增 celery_health 生成前置闸——无 worker 在线时图片/视频生成入口
  一律 503,防"提交到 ARK 后无人轮询、结果悬空+额度冻结"的数据丢失
- 拼接导出:帧率跟随源众数、字幕逐句重映射、原声/BGM 混音修复
- 资金核算审计脚本 + genesis 账目回填 + 滞留预留清理命令
- 接入 yunqi provider 与豆包 TTS 模型(catalog/migrations/bootstrap 命令)

前端:全站页面更新(pipeline/library/products/projects/team/account 等),
新增共享 pager 分页组件

QA:刷新 function-audit 全量输出,新增 full-qa 报告
文档:BP 产品介绍资料、design/CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-15 10:06:15 +08:00
co-authored by Claude Fable 5
parent 890cb9ab67
commit 216a711291
92 changed files with 5114 additions and 2027 deletions
+279 -93
View File
@@ -10,7 +10,7 @@ from django.db import transaction
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.ai.providers import VolcanoArkProvider
from apps.ai.providers import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider, YunqiProvider
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
@@ -22,6 +22,7 @@ from apps.projects.models import (
ScriptVersion,
StoryboardFrame,
StoryboardVersion,
Timeline,
VideoSegment,
VideoSegmentVersion,
)
@@ -36,6 +37,13 @@ def get_default_model(capability: str) -> ModelConfig:
)
def get_image_provider(model_config: ModelConfig):
"""生图按 provider 分流:yunqi 走 OpenAI 兼容网关(gpt-image-2),其余沿用火山 ARK。"""
if model_config.provider.name == "yunqi":
return YunqiProvider(base_url=model_config.provider.base_url or None)
return VolcanoArkProvider(base_url=model_config.provider.base_url or None)
def estimate_cost(model_config: ModelConfig) -> Decimal:
return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000")
@@ -48,7 +56,9 @@ def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[st
selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points)
system = (
"你是电商短视频脚本导演。请为 9:16 竖屏带货短视频生成 60 秒脚本,"
"拆成 4 个 15 秒段落。每段包含旁白、画面描述、商品露出方式和转场建议。"
"拆成 4 个 15 秒段落。严格按以下格式输出,段落之间空一行,不要输出其他内容:\n"
"镜头1\n旁白:这一镜要念出来的口播文案(一两句话)\n画面:这一镜的画面描述、商品露出方式和转场建议\n\n"
"镜头2\n旁白:…\n画面:…(依此类推到镜头4"
)
user = f"""
商品标题:{product.title}
@@ -65,6 +75,42 @@ def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[st
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def parse_segment_fields(block: str) -> tuple[str, str]:
"""从一镜文本里拆出(旁白, 画面)。
模型按 build_script_prompt 的格式输出「旁白:…/画面:…」标签行时精确拆分;
自带脚本/旧格式没有标签则两个字段都用整段(保持旧行为),字幕/故事板各自兜底。
"""
narration_lines: list[str] = []
visual_lines: list[str] = []
current: list[str] | None = None
for raw in (block or "").splitlines():
line = raw.strip()
if not line:
continue
matched = re.match(r"^(旁白|口播|台词|文案)\s*[:]\s*(.*)$", line)
if matched:
current = narration_lines
if matched.group(2):
current.append(matched.group(2))
continue
matched = re.match(r"^(画面|镜头描述|视觉|画面描述)\s*[:]\s*(.*)$", line)
if matched:
current = visual_lines
if matched.group(2):
current.append(matched.group(2))
continue
if re.match(r"^(镜头|分镜|场)\s*\d+", line):
continue # 「镜头N」标题行不计入任何字段
if current is not None:
current.append(line)
narration = " ".join(narration_lines).strip()
visual = " ".join(visual_lines).strip()
if not narration and not visual:
return block.strip(), block.strip()
return narration or visual, visual or narration
def split_script_into_segments(content: str, count: int = 4) -> list[str]:
"""把一段脚本稳健地拆成 `count` 个分镜文本,保证每镜都非空、且所有内容都被分配到某一镜。
@@ -124,7 +170,7 @@ def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig,
return task
def generate_project_script(*, project, user, user_prompt: str, selling_point_ids: list[str] | None = None) -> ScriptVersion:
def generate_project_script(*, project, user, user_prompt: str, selling_point_ids: list[str] | None = None, source: str = "ai") -> ScriptVersion:
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
raise ValueError("no active text model configured")
@@ -162,16 +208,17 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
task=task,
title="AI 脚本",
content=content,
source="ai",
source=source if source in ("ai", "theme", "manual") else "ai",
is_adopted=False,
)
for index, segment_text in enumerate(split_script_into_segments(content)):
narration, visual = parse_segment_fields(segment_text)
ScriptSegment.objects.create(
script_version=script,
sort_order=index,
duration_seconds=15,
narration=segment_text,
visual_prompt=segment_text,
narration=narration,
visual_prompt=visual,
)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
@@ -283,7 +330,7 @@ def generate_base_asset(*, project, user, kind: str, prompt: str) -> BaseAssetGr
)
reservation = task.credit_reservation
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = get_image_provider(model_config)
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
media = provider.extract_first_media_url(response)
with transaction.atomic():
@@ -408,7 +455,7 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
task.status = AITask.Status.SUBMITTED
task.save(update_fields=["status", "updated_at"])
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = get_image_provider(model_config)
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
response = provider.image_generation(
model=model_config.name,
@@ -416,12 +463,9 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
prompt=frame_prompt,
)
media = provider.extract_first_media_url(response)
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
# 注意顺序:task 是 poll 端的「占位锁」,必须等帧真正落库后才置 SUCCEEDED。
# 旧实现先置 SUCCEEDED 再上传 TOS(数秒)最后建帧,中间窗口 poll 会判「无在途且帧缺失」
# 为同一镜重复起线程 → 重复帧 + 重复扣费(实测 4 帧出 6 帧)。
asset = _store_generated_media(
team=project.team,
user=user,
@@ -432,13 +476,22 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
category=Asset.Category.SCENE,
asset_type=Asset.Type.IMAGE,
)
StoryboardFrame.objects.create(
storyboard=version,
script_segment=segment,
asset=asset,
sort_order=segment.sort_order,
prompt=segment.visual_prompt,
)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
# 幂等守卫:该镜已有帧(任何残余竞态/双 poll)就不再建,保持一镜一帧
if not StoryboardFrame.objects.filter(storyboard=version, script_segment=segment).exists():
StoryboardFrame.objects.create(
storyboard=version,
script_segment=segment,
asset=asset,
sort_order=segment.sort_order,
prompt=segment.visual_prompt,
)
except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务失败供 poll 上报
task.status = AITask.Status.FAILED
task.error_message = str(exc)
@@ -470,53 +523,64 @@ def generate_storyboard_frame(*, project, user) -> dict:
_finalize_storyboard(project, version)
return {"status": "succeeded", "done": total, "total": total, "version_id": str(version.id)}
# 该版本内是否已有帧在后台生成中(RESERVED/SUBMITTED 的故事板任务即为「占位锁」)。
# 仅算「近 3 分钟内」的任务:若进程/线程意外中断留下僵尸任务,超时后不再视为在生成,允许重新发起
# 每镜独立「占位锁」(该镜有 CREATED/RESERVED/SUBMITTED 的任务=在生成中)。
# 旧实现是版本级单锁、一次只生成一帧;改为缺哪几镜就同时起哪几镜的线程
# ★ 实测注记(2026-06-10):4 线程并发时当前 Seedream 端点在服务侧排队,单帧 25s→83-105s,
# 整版总时长 ≈ 串行(117s)。瓶颈是 ARK 端点并发配额而非本机;并行无额外成本,
# 配额提升后自动受益。可用 settings.STORYBOARD_MAX_PARALLEL 调并发(1=回到串行)。
# 仅算「近 3 分钟内」的任务:线程意外中断留下的僵尸任务超时后不再占锁,允许重新发起。
from django.conf import settings as dj_settings
STORYBOARD_MAX_PARALLEL = int(getattr(dj_settings, "STORYBOARD_MAX_PARALLEL", 4))
stale_cutoff = timezone.now() - timedelta(minutes=3)
inflight = AITask.objects.filter(
project=project,
task_type=AITask.Type.STORYBOARD,
status__in=[AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED],
request_payload__storyboard_version=str(version.id),
created_at__gte=stale_cutoff,
).exists()
if inflight:
return {"status": "generating", "done": done, "total": total, "version_id": str(version.id)}
inflight_segment_ids = {
str(v)
for v in AITask.objects.filter(
project=project,
task_type=AITask.Type.STORYBOARD,
status__in=[AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED],
request_payload__storyboard_version=str(version.id),
created_at__gte=stale_cutoff,
).values_list("request_payload__storyboard_segment", flat=True)
if v
}
pending = [s for s in segments if s.id not in done_segment_ids]
segment = pending[0]
# 单帧失败次数上限,避免持续失败时无限重试
failed_for_segment = AITask.objects.filter(
project=project,
task_type=AITask.Type.STORYBOARD,
status=AITask.Status.FAILED,
request_payload__storyboard_segment=str(segment.id),
).count()
if failed_for_segment >= 2:
last = AITask.objects.filter(project=project, task_type=AITask.Type.STORYBOARD, status=AITask.Status.FAILED,
request_payload__storyboard_segment=str(segment.id)).order_by("-created_at").first()
return {"status": "failed", "done": done, "total": total, "version_id": str(version.id),
"error": last.error_message if last else "storyboard frame failed"}
# 单帧失败次数上限,避免持续失败时无限重试;任一镜到上限即整版上报失败
for segment in pending:
failed_for_segment = AITask.objects.filter(
project=project,
task_type=AITask.Type.STORYBOARD,
status=AITask.Status.FAILED,
request_payload__storyboard_segment=str(segment.id),
).count()
if failed_for_segment >= 2:
last = AITask.objects.filter(project=project, task_type=AITask.Type.STORYBOARD, status=AITask.Status.FAILED,
request_payload__storyboard_segment=str(segment.id)).order_by("-created_at").first()
return {"status": "failed", "done": done, "total": total, "version_id": str(version.id),
"error": last.error_message if last else "storyboard frame failed"}
spawnable = [s for s in pending if str(s.id) not in inflight_segment_ids]
slots = max(0, STORYBOARD_MAX_PARALLEL - len(inflight_segment_ids))
model_config = get_default_model(ModelConfig.Capability.IMAGE)
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.STORYBOARD,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": build_storyboard_frame_prompt(project, version, segment),
"storyboard_version": str(version.id),
"storyboard_segment": str(segment.id),
},
)
threading.Thread(
target=_storyboard_frame_worker,
args=(str(task.id), str(version.id), str(segment.id), str(user.id)),
daemon=True,
).start()
for segment in spawnable[:slots]:
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.STORYBOARD,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": build_storyboard_frame_prompt(project, version, segment),
"storyboard_version": str(version.id),
"storyboard_segment": str(segment.id),
},
)
threading.Thread(
target=_storyboard_frame_worker,
args=(str(task.id), str(version.id), str(segment.id), str(user.id)),
daemon=True,
).start()
return {"status": "generating", "done": done, "total": total, "version_id": str(version.id)}
@@ -656,16 +720,17 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
if video_segment.status == VideoSegment.Status.FAILED:
return None
task = video_segment.versions.order_by("-created_at").first()
ai_task = None
if task:
ai_task = task.task
# ★ 先找「在途任务」再回退旧版本的任务。旧实现反过来:重跑时段上已有(旧)版本,
# 取到旧版本挂的已成功任务 → 短路返回旧版,在途的新任务永远没人轮询,
# 段永远卡「生成中」、新视频取不回来(实测重跑卡 40 分钟,ARK 侧其实早已生成完)。
ai_task = video_segment.project.ai_tasks.filter(
task_type=AITask.Type.VIDEO_SEGMENT,
request_payload__video_segment_id=str(video_segment.id),
status__in=[AITask.Status.SUBMITTED, AITask.Status.POLLING],
).order_by("-created_at").first()
if ai_task is None:
ai_task = video_segment.project.ai_tasks.filter(
task_type=AITask.Type.VIDEO_SEGMENT,
request_payload__video_segment_id=str(video_segment.id),
status__in=[AITask.Status.SUBMITTED, AITask.Status.POLLING],
).order_by("-created_at").first()
latest_version = video_segment.versions.order_by("-created_at").first()
ai_task = latest_version.task if latest_version else None
if ai_task is None:
raise ValueError("no active video generation task")
@@ -679,9 +744,11 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
response = provider.poll_video_task(endpoint=ai_task.model_config.endpoint, provider_task_id=ai_task.provider_task_id)
remote_status = response.get("status")
if remote_status in {"queued", "running", "processing"}:
ai_task.status = AITask.Status.POLLING
ai_task.response_payload = response
ai_task.save(update_fields=["status", "response_payload", "updated_at"])
# 仍在生成:只在状态首次进入 POLLING 时落一次库。旧实现每次 poll(5s 一次)都把完整
# response JSON 回写远程 MySQL——纯浪费写带宽,终态时反正会存完整 payload。
if ai_task.status != AITask.Status.POLLING:
ai_task.status = AITask.Status.POLLING
ai_task.save(update_fields=["status", "updated_at"])
return None
if remote_status in {"failed", "expired", "cancelled"}:
ai_task.status = AITask.Status.FAILED
@@ -706,23 +773,33 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
category=Asset.Category.VIDEO_CLIP,
asset_type=Asset.Type.VIDEO,
)
ai_task.status = AITask.Status.SUCCEEDED
ai_task.response_payload = response
ai_task.actual_cost = ai_task.estimated_cost
ai_task.completed_at = timezone.now()
ai_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=ai_task.credit_reservation, actual_amount=ai_task.actual_cost)
version = VideoSegmentVersion.objects.create(
video_segment=video_segment,
task=ai_task,
asset=asset,
prompt=ai_task.request_payload.get("prompt", ""),
is_adopted=True,
)
video_segment.adopted_version = version
video_segment.status = VideoSegment.Status.SUCCEEDED
video_segment.error_message = ""
video_segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
# 终态化必须持锁原子做:两个并发 poll(前端 5s 静默轮询 × 提交后轮询/worker)同时走到这里时,
# 旧实现会同 task 建两个版本 + charge_reserved_credit 双扣费(实测 03:08:25 同秒双版本)。
# select_for_update 锁 task 行,后到者看到 SUCCEEDED 直接回已有版,不再建版/扣费。
with transaction.atomic():
locked_task = AITask.objects.select_for_update().get(id=ai_task.id)
if locked_task.status == AITask.Status.SUCCEEDED:
existing = video_segment.versions.filter(task=locked_task).order_by("-created_at").first()
if existing is not None:
return existing
locked_task.status = AITask.Status.SUCCEEDED
locked_task.response_payload = response
locked_task.actual_cost = locked_task.estimated_cost
locked_task.completed_at = timezone.now()
locked_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=locked_task.credit_reservation, actual_amount=locked_task.actual_cost)
version = VideoSegmentVersion.objects.create(
video_segment=video_segment,
task=locked_task,
asset=asset,
prompt=locked_task.request_payload.get("prompt", ""),
is_adopted=True,
)
video_segment.versions.exclude(id=version.id).update(is_adopted=False)
video_segment.adopted_version = version
video_segment.status = VideoSegment.Status.SUCCEEDED
video_segment.error_message = ""
video_segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
return version
@@ -750,7 +827,7 @@ def generate_standalone_image(*, team, user, prompt: str, mode: str = "image", c
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
count = max(1, min(int(count or 1), 4))
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = get_image_provider(model_config)
assets: list[Asset] = []
for index in range(count):
cost = estimate_cost(model_config)
@@ -798,3 +875,112 @@ def generate_standalone_image(*, team, user, prompt: str, mode: str = "image", c
release_credit(reservation=reservation, reason=str(exc))
raise
return assets
# ── 旁白配音(TTS):每镜旁白合成一段语音,导出时作为人声轨混在 BGM 之上 ──
# 音色按「语音合成(经典版)」试用包实测可用清单配置;大模型音色(*_bigtts)需另开通「语音合成大模型」服务,当前账号 403
VOICEOVER_VOICES = [
{"key": "BV700_streaming", "label": "灿灿 · 活力女声"},
{"key": "BV034_streaming", "label": "知性姐姐 · 沉稳女声"},
{"key": "BV001_streaming", "label": "通用女声"},
{"key": "BV056_streaming", "label": "阳光男声"},
{"key": "BV102_streaming", "label": "儒雅青年 · 解说男声"},
{"key": "BV002_streaming", "label": "通用男声"},
]
DEFAULT_VOICEOVER_VOICE = VOICEOVER_VOICES[0]["key"]
def synthesize_project_voiceover(*, project, user, items: list[dict], voice_type: str, speed_ratio: float = 1.0) -> dict:
"""每镜旁白 → **逐句** TTS 配音资产(一句一段音频,带句内起点 offset_ms),映射写入
timeline.metadata["voiceover"]。逐句才能支持「拖动字幕块 = 字幕和它的语音一起移动」;
一次调用 = 一个 AITask = 计一次费;任何一句失败则整体失败并释放预留(不留半套配音)。"""
from apps.projects.services.export import _split_subtitle_text
texts = [] # (片段 index, 句序 cue, 句文本)
for n, item in enumerate(items or []):
text = str(item.get("text") or "").strip()
if not text:
continue
idx = int(item.get("index", n))
pieces = _split_subtitle_text(text) or [text]
for j, piece in enumerate(pieces):
texts.append((idx, j, piece))
if not texts:
raise ValueError("没有可配音的旁白文本")
provider = VolcanoTtsProvider()
if not provider.configured:
raise TtsNotConfigured(
"语音合成未配置:请在后端环境变量设置 VOLC_TTS_APPID 和 VOLC_TTS_ACCESS_TOKEN"
"(火山引擎控制台 → 语音技术 → 语音合成大模型 → 创建应用)"
)
voice_type = voice_type or DEFAULT_VOICEOVER_VOICE
model_config = get_default_model(ModelConfig.Capability.AUDIO)
if model_config is None:
raise ValueError("no active audio model configured")
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.VOICEOVER,
model_config=model_config,
request_payload={
"voice_type": voice_type,
"speed_ratio": float(speed_ratio or 1.0),
"items": [{"index": idx, "cue": j, "text": text} for idx, j, text in texts],
},
)
reservation = task.credit_reservation
try:
synthesized = []
for idx, j, text in texts:
audio, duration_ms = provider.synthesize(text=text, voice_type=voice_type, speed_ratio=speed_ratio, uid=str(user.id))
synthesized.append((idx, j, text, audio, duration_ms))
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.response_payload = {"segments": len(synthesized)}
task.save(update_fields=["status", "actual_cost", "completed_at", "response_payload", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
vo_items = []
offset_acc: dict[int, int] = {} # 同一片段内逐句顺排:句 j 的默认起点 = 前面句时长之和
for idx, j, text, audio, duration_ms in synthesized:
asset_id = uuid.uuid4()
object_key = f"teams/{project.team_id}/projects/{project.id}/voiceover/{asset_id}.mp3"
stored = TosStorage().upload_fileobj(fileobj=BytesIO(audio), object_key=object_key, content_type="audio/mpeg")
asset = Asset.objects.create(
id=asset_id, team=project.team, created_by=user,
name=f"配音 · 场 {idx + 1} · 句 {j + 1}", asset_type=Asset.Type.AUDIO,
source=Asset.Source.AI_GENERATED, category=Asset.Category.UNCATEGORIZED,
origin_task=task, description=text,
)
AssetFile.objects.create(
asset=asset, object_key=stored.object_key, bucket=stored.bucket,
content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True,
)
offset_ms = offset_acc.get(idx, 0)
offset_acc[idx] = offset_ms + (duration_ms or 0)
vo_items.append({
"index": idx, "cue": j, "text": text, "asset": str(asset.id),
"duration_ms": duration_ms, "offset_ms": offset_ms,
})
timeline, _ = Timeline.objects.get_or_create(
project=project, defaults={"name": f"{project.name} Timeline", "duration_seconds": 60}
)
metadata = dict(timeline.metadata or {})
metadata["voiceover"] = {
"enabled": True,
"voice_type": voice_type,
"speed_ratio": float(speed_ratio or 1.0),
"items": vo_items,
}
timeline.metadata = metadata
timeline.save(update_fields=["metadata", "updated_at"])
return metadata["voiceover"]
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise