feat(ai): 生成失败落站内通知,正文带第三方 API 原始报错
故事板/视频之外,补齐 基础资产/商品三视图/图片创作 三条出图失败路径: 失败时落一条 priority=err 通知,正文 = 友好提示 + 第三方服务商 API 原始 报错(如 400 moderation_blocked),原始报错同存 metadata.api_error。 同任务 dedupe 去重,通知本身出错不影响主失败处理。 排查唐卡展故事板「随机失败」时发现:UI 显示的"胸罩/内衣"提示是写死的 误导文案,真因是 gpt-image-2 概率性 moderation_blocked,原始报错此前 只埋在 AITask.error_message 里用户看不到。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
@@ -41,6 +42,8 @@ from apps.projects.models import (
|
||||
VideoSegmentVersion,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_default_model(capability: str) -> ModelConfig:
|
||||
qs = (
|
||||
@@ -1400,6 +1403,10 @@ def run_base_asset_task(*, task_id: str) -> None:
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
release_credit(reservation=reservation, reason=str(exc))
|
||||
notify_generation_failure(
|
||||
task=task, project=project, recipient=user,
|
||||
stage_label="基础资产生成", raw=str(exc), hint=friendly_generation_error(str(exc)),
|
||||
)
|
||||
|
||||
|
||||
def generate_person_triview(*, project, user, portrait_asset) -> AITask:
|
||||
@@ -1504,6 +1511,10 @@ def run_triview_task(*, task_id: str) -> None:
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
release_credit(reservation=reservation, reason=str(exc))
|
||||
notify_generation_failure(
|
||||
task=task, project=project, recipient=user,
|
||||
stage_label="商品三视图生成", raw=str(exc), hint=friendly_generation_error(str(exc)),
|
||||
)
|
||||
|
||||
|
||||
def _scene_context(project) -> str:
|
||||
@@ -1815,6 +1826,49 @@ def friendly_generation_error(raw: str) -> str:
|
||||
return "生成失败,请重试;若多次失败请联系技术支持。"
|
||||
|
||||
|
||||
def notify_generation_failure(
|
||||
*, task, project, recipient, stage_label: str, raw: str, hint: str = ""
|
||||
) -> None:
|
||||
"""生成失败时落一条「失败」站内通知,**把第三方服务商 API 返回的原始报错原样写进通知正文**,
|
||||
让用户/排查能直接看到真因(如 `400 moderation_blocked ...`),而不是只看到翻译后的友好提示。
|
||||
|
||||
best-effort:同一任务用 dedupe_key 去重(重跑只更新不重复),通知本身出错绝不反过来弄挂主失败流程。"""
|
||||
from apps.ops.models import Notification
|
||||
|
||||
raw_clean = (raw or "").strip()
|
||||
friendly = (hint or friendly_generation_error(raw_clean) or "生成失败").strip()
|
||||
body = (
|
||||
f"{friendly}\n\n—— 第三方服务商 API 返回的原始报错 ——\n{raw_clean}"
|
||||
if raw_clean else friendly
|
||||
)
|
||||
try:
|
||||
Notification.objects.update_or_create(
|
||||
team=task.team,
|
||||
dedupe_key=f"task:{task.id}:failed",
|
||||
defaults=dict(
|
||||
recipient=recipient,
|
||||
project=project,
|
||||
notification_type=Notification.Type.TASK,
|
||||
priority=Notification.Priority.ERR,
|
||||
title=f"{stage_label}失败",
|
||||
brief=friendly[:300],
|
||||
body=body,
|
||||
source="AI 生成",
|
||||
stage=stage_label,
|
||||
owner_label=getattr(recipient, "username", "") or "成员",
|
||||
cost_label="-",
|
||||
related_url=f"pipeline.html?project_id={project.id}" if project is not None else "",
|
||||
metadata={
|
||||
"task_id": str(task.id),
|
||||
"task_type": getattr(task, "task_type", ""),
|
||||
"api_error": raw_clean[:1000],
|
||||
},
|
||||
),
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 通知是附带能力,绝不能反过来弄挂主失败处理
|
||||
logger.exception("notify_generation_failure failed for task %s", getattr(task, "id", "?"))
|
||||
|
||||
|
||||
def _call_image_with_retry(fn, *, attempts: int = 2, base_delay: float = 2.0):
|
||||
"""对一次出图网络调用做有界重试:仅瞬时错误重试(指数退避),确定性失败立即抛出。
|
||||
出图无副作用(失败=没拿到图),重试安全;成功一次即返回。
|
||||
@@ -1905,6 +1959,11 @@ def _storyboard_shot_worker(task_id, shot_id, user_id) -> None:
|
||||
# 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 友好提示供前端显示
|
||||
StoryboardShot.objects.filter(id=shot.id).update(
|
||||
status=StoryboardShot.Status.FAILED, error_message=hint, updated_at=timezone.now())
|
||||
# 落一条失败通知,正文带上第三方服务商 API 的原始报错(真因),供用户/排查直接查看
|
||||
notify_generation_failure(
|
||||
task=task, project=project, recipient=user,
|
||||
stage_label=f"故事板·场 {shot.sort_order + 1}", raw=raw, hint=hint,
|
||||
)
|
||||
finally:
|
||||
connections.close_all() # 释放该线程的 DB 连接
|
||||
|
||||
@@ -2149,6 +2208,11 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
|
||||
video_segment.status = VideoSegment.Status.FAILED
|
||||
video_segment.error_message = str(exc)
|
||||
video_segment.save(update_fields=["status", "error_message", "updated_at"])
|
||||
notify_generation_failure(
|
||||
task=task, project=project, recipient=user,
|
||||
stage_label=f"视频·段 {video_segment.sort_order + 1}",
|
||||
raw=str(exc), hint=friendly_generation_error(str(exc)),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@@ -2199,6 +2263,11 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
|
||||
video_segment.status = VideoSegment.Status.FAILED
|
||||
video_segment.error_message = ai_task.error_message
|
||||
video_segment.save(update_fields=["status", "error_message", "updated_at"])
|
||||
notify_generation_failure(
|
||||
task=ai_task, project=video_segment.project, recipient=user,
|
||||
stage_label=f"视频·段 {video_segment.sort_order + 1}",
|
||||
raw=ai_task.error_message, hint=friendly_generation_error(ai_task.error_message),
|
||||
)
|
||||
return None
|
||||
|
||||
media = provider.extract_first_media_url(response)
|
||||
@@ -2491,6 +2560,10 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
release_credit(reservation=reservation, reason=str(exc))
|
||||
notify_generation_failure(
|
||||
task=task, project=task.project, recipient=user,
|
||||
stage_label="图片创作", raw=str(exc), hint=friendly_generation_error(str(exc)),
|
||||
)
|
||||
|
||||
|
||||
# ── 旁白配音(TTS):每镜旁白合成一段语音,导出时作为人声轨混在 BGM 之上 ──
|
||||
|
||||
Reference in New Issue
Block a user