diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index f0f1ef9..aef3b09 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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 之上 ──