From e81d7170edae080a9b269c380f10c69ee166e90d Mon Sep 17 00:00:00 2001 From: hh <2587203630@qq.com> Date: Wed, 15 Jul 2026 13:12:35 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80AI=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/free_video.py | 51 ++- core/backend/apps/ai/generation_errors.py | 197 +++++++++ core/backend/apps/ai/script_agent.py | 30 +- core/backend/apps/ai/services.py | 66 ++- core/backend/apps/ai/test_free_video.py | 16 +- .../backend/apps/ai/test_generation_errors.py | 155 +++++++ .../apps/ai/test_generation_notifications.py | 71 +++ core/backend/apps/ai/tests.py | 4 +- core/backend/apps/ai/views.py | 70 ++- core/backend/apps/ops/serializers.py | 14 + core/backend/apps/projects/tests.py | 10 +- core/backend/apps/projects/views.py | 76 +++- core/frontend/src/App.tsx | 11 +- core/frontend/src/api.ts | 5 +- .../free-create/generation-card.tsx | 6 +- core/frontend/src/generation-error.ts | 97 +++++ core/frontend/src/routes/pipeline.tsx | 17 +- core/frontend/src/types.ts | 1 + docs/bug_todo/AI生成失败提示优化-todo.md | 411 ++++++++++++++++++ 19 files changed, 1220 insertions(+), 88 deletions(-) create mode 100644 core/backend/apps/ai/generation_errors.py create mode 100644 core/backend/apps/ai/test_generation_errors.py create mode 100644 core/backend/apps/ai/test_generation_notifications.py create mode 100644 core/frontend/src/generation-error.ts create mode 100644 docs/bug_todo/AI生成失败提示优化-todo.md diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index aeafc33..da93ee3 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -29,7 +29,8 @@ from apps.billing.services.ledger import charge_reserved_credit, release_credit, from .models import AITask, ModelConfig from .providers.volcano import VolcanoArkProvider -from .video_errors import map_video_error, parse_provider_error +from .generation_errors import classify_generation_error, public_error_for_task +from .video_errors import parse_provider_error from .video_pricing import get_resolution logger = logging.getLogger(__name__) @@ -452,14 +453,23 @@ def submit_free_video(*, team, user, params: dict) -> AITask: task.save(update_fields=["provider_task_id", "response_payload", "status", "submitted_at", "updated_at"]) except Exception as exc: # noqa: BLE001 — 创建失败:标失败退费,返回失败卡(不向上抛) code, raw_message = parse_provider_error(exc) - friendly = map_video_error(code, raw_message) + public_error = classify_generation_error( + exc, + operation="video_generate", + provider_code=code, + reference_id=str(task.id), + ) task.status = AITask.Status.FAILED task.error_code = (code or "CreateTaskError")[:64] - task.error_message = friendly + task.error_message = raw_message[:2000] task.completed_at = timezone.now() task.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"]) - release_credit(reservation=task.credit_reservation, reason=friendly) - _notify_failure(task, raw=f"[{code}] {raw_message}" if code else raw_message) + release_credit(reservation=task.credit_reservation, reason=raw_message[:200]) + _notify_failure( + task, + raw=f"[{code}] {raw_message}" if code else raw_message, + hint=public_error.fallback_message, + ) logger.warning("free video create failed: %s", exc) return task @@ -474,7 +484,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask: return task -def _notify_failure(task: AITask, *, raw: str) -> None: +def _notify_failure(task: AITask, *, raw: str, hint: str) -> None: from .services import notify_generation_failure notify_generation_failure( @@ -483,7 +493,7 @@ def _notify_failure(task: AITask, *, raw: str) -> None: recipient=task.created_by, stage_label="自由创作视频", raw=raw, - hint=task.error_message, + hint=hint, ) @@ -586,21 +596,26 @@ def finalize_free_video(*, task: AITask) -> AITask: err = response.get("error") or {} code = str(err.get("code") or "") raw_message = str(err.get("message") or "video generation failed") - friendly = map_video_error(code, raw_message) + public_error = classify_generation_error( + RuntimeError(raw_message), + operation="video_generate", + provider_code=code, + reference_id=str(task.id), + ) with transaction.atomic(): locked = AITask.objects.select_for_update().get(id=task.id) if locked.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING): return locked locked.status = AITask.Status.FAILED locked.error_code = code[:64] - locked.error_message = friendly + locked.error_message = raw_message[:2000] locked.response_payload = response locked.completed_at = timezone.now() locked.save( update_fields=["status", "error_code", "error_message", "response_payload", "completed_at", "updated_at"] ) - release_credit(reservation=locked.credit_reservation, reason=friendly) - _notify_failure(locked, raw=f"[{code}] {raw_message}") + release_credit(reservation=locked.credit_reservation, reason=raw_message[:200]) + _notify_failure(locked, raw=f"[{code}] {raw_message}", hint=public_error.fallback_message) return locked # succeeded —— 认领 POSTPROCESSING(并发 finalize 只有一路进入慢活) @@ -676,11 +691,17 @@ def finalize_free_video(*, task: AITask) -> AITask: return locked locked.status = AITask.Status.FAILED locked.error_code = "PostprocessError" - locked.error_message = "视频结果处理失败,请重试" + public_error = classify_generation_error( + exc, + operation="video_generate", + internal_kind="processing_failed", + reference_id=str(locked.id), + ) + locked.error_message = str(exc)[:2000] locked.completed_at = timezone.now() locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"]) release_credit(reservation=locked.credit_reservation, reason=str(exc)[:200]) - _notify_failure(locked, raw=str(exc)) + _notify_failure(locked, raw=str(exc), hint=public_error.fallback_message) return locked @@ -719,6 +740,7 @@ def serialize_free_video_task(task: AITask, *, include_deleted_assets: bool = Fa if not video_url and not generated_assets: video_url = payload.get("fallback_video_url") or "" + public_error = public_error_for_task(task, operation="video_generate") return { "id": str(task.id), "status": task.status, @@ -736,7 +758,8 @@ def serialize_free_video_task(task: AITask, *, include_deleted_assets: bool = Fa "actual_tokens": payload.get("actual_tokens") or 0, "estimated_cost": str(task.estimated_cost), "actual_cost": str(task.actual_cost), - "error_message": task.error_message or "", + "error": public_error.as_dict() if public_error else None, + "error_message": public_error.fallback_message if public_error else "", "fallback_note": payload.get("fallback_note") or "", "is_favorited": task.is_favorited, "video_url": video_url, diff --git a/core/backend/apps/ai/generation_errors.py b/core/backend/apps/ai/generation_errors.py new file mode 100644 index 0000000..6d25a0f --- /dev/null +++ b/core/backend/apps/ai/generation_errors.py @@ -0,0 +1,197 @@ +"""AI 生成失败的安全归类。 + +这个模块故意不依赖 Django 模型、任务写入或 HTTP 视图:调用方传入异常及操作上下文, +得到可安全发送到普通用户端的稳定错误对象。原始异常仍由调用方写日志/任务原始响应, +绝不能由此模块直接回传。 +""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +import re +from typing import Any + +import requests + + +DOMAIN = "generation" + +OPERATION_LABELS = { + "script_generate": "脚本", + "image_generate": "图片", + "base_asset_generate": "基础素材", + "triview_generate": "三视图", + "storyboard_generate": "故事板", + "video_generate": "视频", + "voiceover_generate": "配音", + "entity_extract": "脚本信息", +} + +TASK_OPERATIONS = { + "script_generation": "script_generate", + "script_optimization": "script_generate", + "product_image": "image_generate", + "person_image": "image_generate", + "scene_image": "base_asset_generate", + "model_triview": "triview_generate", + "entity_extraction": "entity_extract", + "storyboard": "storyboard_generate", + "video_segment": "video_generate", + "voiceover": "voiceover_generate", + "free_video": "video_generate", +} + + +@dataclass(frozen=True) +class PublicGenerationError: + """面向普通用户 API/SSE 的生成失败契约。""" + + code: str + operation: str + action: str + retryable: bool + fallback_message: str + reference_id: str | None = None + domain: str = DOMAIN + + def as_dict(self) -> dict[str, Any]: + """序列化为 API/SSE 可直接使用的安全载荷。""" + payload = asdict(self) + if payload["reference_id"] is None: + payload.pop("reference_id") + return payload + + +_VOLCANO_ERROR_RE = re.compile(r"火山报错\s*\[([^\]]*)\]\s*(.*)", re.S) + + +def _operation_label(operation: str) -> str: + return OPERATION_LABELS.get(operation, "生成") + + +def _build_error(code: str, operation: str, *, reference_id: str | None = None) -> PublicGenerationError: + label = _operation_label(operation) + # 后端文本只是旧客户端/未接入页面的安全回退;正式页面由前端共享映射决定标题与动作呈现。 + templates: dict[str, tuple[str, bool, str, str]] = { + "provider_quota_exhausted": ("retry_later", False, f"{label}暂时不可用", "生成服务正在处理中,请稍后再试。"), + "provider_rate_limited": ("retry_later", False, f"{label}请求较多", "当前生成请求较多,请稍后再试。"), + "provider_unavailable": ("retry", True, f"{label}暂时不可用", "生成服务暂时不可用,请稍后重试。"), + "provider_config_error": ("contact_support", False, f"{label}暂时不可用", "平台正在处理该问题,请稍后再试。"), + "model_unavailable": ("contact_support", False, f"{label}暂时不可用", "当前生成能力暂不可用,请稍后再试。"), + "content_rejected": ("revise_input", False, f"{label}内容需要调整", "内容未通过生成审核,请调整描述或素材后重试。"), + "invalid_input": ("revise_input", False, f"{label}内容需检查", "请检查描述、参数或素材格式后重试。"), + "asset_unavailable": ("revise_input", False, "素材暂不可用", "引用的素材不存在或暂不可用,请更换后重试。"), + "user_credit_insufficient": ("recharge", False, "可用积分不足", "充值后可继续生成。"), + "task_timeout": ("retry", True, f"{label}生成时间过长", "服务响应较慢,未完成生成,请重试。"), + "processing_failed": ("retry", True, f"{label}结果处理失败", "本次未生成可用结果,请重试。"), + "unknown": ("retry", True, f"{label}遇到问题", "请稍后重试;若多次出现,请提供任务编号以便排查。"), + } + action, retryable, title, message = templates.get(code, templates["unknown"]) + return PublicGenerationError( + code=code if code in templates else "unknown", + operation=operation, + action=action, + retryable=retryable, + fallback_message=f"{title}:{message}", + reference_id=reference_id, + ) + + +def _response_details(exc: Exception) -> tuple[int | None, str, str]: + """尽力抽取 HTTP 状态与供应商 code/message;失败时仅返回异常文本。""" + response = getattr(exc, "response", None) + status = getattr(response, "status_code", None) + code = message = "" + + if response is not None: + try: + payload = response.json() + err = payload.get("error") if isinstance(payload, dict) else None + if isinstance(err, dict): + code = str(err.get("code") or err.get("type") or "") + message = str(err.get("message") or "") + except Exception: # noqa: BLE001 - 错误响应不保证是 JSON。 + pass + + raw = str(exc) + match = _VOLCANO_ERROR_RE.search(raw) + if match: + code = code or match.group(1).strip() + message = message or match.group(2).strip() + return status if isinstance(status, int) else None, code, message or raw + + +def _contains(text: str, *tokens: str) -> bool: + return any(token in text for token in tokens) + + +def classify_generation_error( + exc: Exception, + *, + operation: str, + provider_name: str = "", + provider_code: str = "", + internal_kind: str = "", + reference_id: str | None = None, +) -> PublicGenerationError: + """将异常投影成安全、稳定的业务错误码。 + + ``provider_name`` 暂供调用方保留上下文,规则不依赖某个固定供应商。平台业务错误 + 必须通过 ``internal_kind`` 显式传入,避免把第三方的“余额不足”误判为用户积分不足。 + """ + del provider_name # 预留给各 provider 的精细规则,当前保持跨渠道通用。 + if internal_kind in { + "user_credit_insufficient", + "processing_failed", + "provider_rate_limited", + "model_unavailable", + "content_rejected", + "invalid_input", + "asset_unavailable", + }: + return _build_error(internal_kind, operation, reference_id=reference_id) + + status, response_code, provider_message = _response_details(exc) + provider_code = provider_code or response_code + signal = f"{provider_code} {provider_message} {exc}".lower() + compact_code = re.sub(r"[^a-z0-9]", "", provider_code.lower()) + + # 内容审核必须先于 HTTP 403 判断:部分供应商会以 403 返回 policy violation。 + if _contains(signal, "moderation_blocked", "safety_violation", "safety system", "content_policy", "policyviolation", "sensitivecontentdetected"): + return _build_error("content_rejected", operation, reference_id=reference_id) + if _contains(compact_code, "assetnotfound") or _contains(signal, "asset not found", "referenced asset not found"): + return _build_error("asset_unavailable", operation, reference_id=reference_id) + if _contains(compact_code, "modelnotfound", "modelnotavailable") or _contains(signal, "model not found", "model is not available", "does not support"): + return _build_error("model_unavailable", operation, reference_id=reference_id) + if status == 402 or _contains(compact_code, "insufficientbalance", "quotaexhausted") or _contains(signal, "payment required", "quota exhausted", "balance exhausted"): + return _build_error("provider_quota_exhausted", operation, reference_id=reference_id) + if status == 429 or _contains(compact_code, "ratelimitexceeded", "concurrencylimitexceeded") or _contains(signal, "rate limit", "too many requests"): + return _build_error("provider_rate_limited", operation, reference_id=reference_id) + if isinstance(exc, requests.Timeout) or status in (408, 504) or _contains(compact_code, "timeout") or _contains(signal, "read timed out", "connection timed out"): + return _build_error("task_timeout", operation, reference_id=reference_id) + if isinstance(exc, requests.ConnectionError) or (status is not None and 500 <= status <= 599) or _contains(compact_code, "serveroverloaded", "internalerror"): + return _build_error("provider_unavailable", operation, reference_id=reference_id) + if status == 401 or status == 403 or exc.__class__.__name__ == "TtsNotConfigured" or _contains(signal, "api_key", "unauthorized", "not configured", "credential"): + return _build_error("provider_config_error", operation, reference_id=reference_id) + if _contains(compact_code, "invalidparameter", "invalidimage", "invalidvideo", "invalidaudio") or _contains(signal, "invalid_image", "invalid image", "invalid parameter", "image_file"): + return _build_error("invalid_input", operation, reference_id=reference_id) + if _contains(compact_code, "postprocesserror"): + return _build_error("processing_failed", operation, reference_id=reference_id) + return _build_error("unknown", operation, reference_id=reference_id) + + +def public_error_for_task(task, *, operation: str | None = None) -> PublicGenerationError | None: + """把已有任务记录安全投影给普通用户接口,不改变其原始错误留存。""" + raw_message = str(getattr(task, "error_message", "") or "").strip() + if not raw_message: + return None + task_type = str(getattr(task, "task_type", "") or "") + if operation is None and getattr(task, "project_id", None) and task_type in { + "product_image", "person_image", "scene_image" + }: + operation = "base_asset_generate" + return classify_generation_error( + RuntimeError(raw_message), + operation=operation or TASK_OPERATIONS.get(task_type, "image_generate"), + provider_code=str(getattr(task, "error_code", "") or ""), + reference_id=str(getattr(task, "id", "") or "") or None, + ) diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index a35d887..122048a 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -14,7 +14,7 @@ SSE 事件(每帧 `data: {json}\n\n`,json 带 type): saved {script_version_id, version} —— 已落库的 ScriptVersion(含 segments/metadata) summary {text} —— 模型自己写的收尾交付语(当 AI 回复气泡,替代写死的「已生成」) done {} —— 结束 - error {detail} —— 失败(已回滚额度) + error {detail,error} —— 失败(已回滚额度;error 为安全业务错误对象) """ from __future__ import annotations @@ -26,6 +26,7 @@ from pathlib import Path from django.conf import settings from django.utils import timezone +from apps.ai.generation_errors import classify_generation_error from apps.ai.models import AITask, ModelConfig from apps.billing.services.ledger import charge_reserved_credit, release_credit @@ -621,7 +622,8 @@ def stream_script_agent( }, ) except Exception as exc: # noqa: BLE001 — 多为额度不足 - yield _sse({"type": "error", "detail": f"任务创建失败(可能额度不足):{exc}"}) + internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else "" + yield _sse(_script_error_event(exc, internal_kind=internal_kind)) return reservation = task.credit_reservation # 额度是否已结算(charge 成功 / release 失败)。客户端中途断连时,生成器被 .close() 抛 @@ -676,7 +678,7 @@ def stream_script_agent( _fail_task(task, reservation, str(exc)) settled = True yield _sse({"type": "tool", "id": "generate", "status": "error"}) - yield _sse({"type": "error", "detail": f"脚本生成失败:{exc}"}) + yield _sse(_script_error_event(exc, reference_id=str(task.id))) return yield _sse({"type": "tool", "id": "generate", "status": "done"}) @@ -707,7 +709,7 @@ def stream_script_agent( except Exception as exc: # noqa: BLE001 — 落库失败:atomic 已回滚 charge,补释放预留 _fail_task(task, reservation, f"保存脚本失败:{exc}") settled = True - yield _sse({"type": "error", "detail": f"保存脚本失败:{exc}"}) + yield _sse(_script_error_event(exc, reference_id=str(task.id), internal_kind="processing_failed")) return from apps.projects.serializers import ScriptVersionSerializer @@ -743,6 +745,26 @@ def _fail_task(task, reservation, message: str) -> None: pass +def _script_error_event( + exc: Exception, + *, + reference_id: str | None = None, + internal_kind: str = "", +) -> dict: + """脚本 SSE 的安全失败帧;原始异常只落任务记录/日志,不回传普通用户。""" + public_error = classify_generation_error( + exc, + operation="script_generate", + internal_kind=internal_kind, + reference_id=reference_id, + ) + return { + "type": "error", + "detail": public_error.fallback_message, + "error": public_error.as_dict(), + } + + def _load_base_draft(project, base_version_id: str) -> dict | None: from apps.projects.models import ScriptVersion diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index d0c70e3..8087724 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -18,6 +18,7 @@ from django.db import transaction from django.utils import timezone from apps.ai.models import AITask, ModelConfig +from apps.ai.generation_errors import TASK_OPERATIONS, classify_generation_error, public_error_for_task from apps.ai.providers import ( OpenAICompatibleProvider, TtsNotConfigured, @@ -579,14 +580,13 @@ def run_extract_entities_task(*, task_id: str) -> None: seg.save(update_fields=["entity_refs", "updated_at"]) except Exception as exc: # noqa: BLE001 — 失败退费并把错误记进 AITask 供前端轮询;不向上抛(避免 celery 重试二次扣费) # ValueError 是我们给用户写好的可读话术(解析失败 / 没识别到角色等);其余(网络/模型异常)给通用话术。 - msg = str(exc) if isinstance(exc, ValueError) else "提取调用失败,请重试" with transaction.atomic(): task.status = AITask.Status.FAILED - task.error_message = msg + task.error_message = str(exc)[:2000] task.response_payload = response # 即便失败也存下模型输出片段(content/reasoning 长度等),供事后定位 task.completed_at = timezone.now() task.save(update_fields=["status", "response_payload", "error_message", "completed_at", "updated_at"]) - release_credit(reservation=reservation, reason=msg) + release_credit(reservation=reservation, reason=str(exc)[:200]) def split_script_into_segments(content: str, count: int = 4) -> list[str]: @@ -2174,18 +2174,26 @@ def friendly_generation_error(raw: str) -> str: def notify_generation_failure( *, task, project, recipient, stage_label: str, raw: str, hint: str = "" ) -> None: - """生成失败时落一条「失败」站内通知,**把第三方服务商 API 返回的原始报错原样写进通知正文**, - 让用户/排查能直接看到真因(如 `400 moderation_blocked ...`),而不是只看到翻译后的友好提示。 + """生成失败时落一条普通用户可见的安全通知。 - best-effort:同一任务用 dedupe_key 去重(重跑只更新不重复),通知本身出错绝不反过来弄挂主失败流程。""" + 原始错误只留在 AITask、日志和管理员任务详情,绝不能写进通知正文或 metadata。 + 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 - ) + public_error = public_error_for_task(task) + if public_error is None: + public_error = classify_generation_error( + RuntimeError(raw_clean or hint or "generation failed"), + operation=TASK_OPERATIONS.get(getattr(task, "task_type", ""), "image_generate"), + reference_id=str(getattr(task, "id", "") or "") or None, + ) + friendly = public_error.fallback_message + # hint 可能来自旧调用方;仅作为空回退,不能覆盖统一安全文案。 + if not friendly: + friendly = (hint or "生成失败").strip() + body = friendly try: Notification.objects.update_or_create( team=task.team, @@ -2206,7 +2214,7 @@ def notify_generation_failure( metadata={ "task_id": str(task.id), "task_type": getattr(task, "task_type", ""), - "api_error": raw_clean[:1000], + "generation_error": public_error.as_dict(), }, ), ) @@ -2295,7 +2303,9 @@ def _storyboard_shot_worker(task_id, shot_id, user_id) -> None: transaction.on_commit(lambda a=asset: submit_asset_for_review(a)) except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务+shot 失败供 poll 上报 raw = str(exc) - hint = friendly_generation_error(raw) # 翻成前端友好提示;原始报错(含 yunqi body)留在 task 供排查 + public_error = classify_generation_error( + exc, operation="storyboard_generate", reference_id=str(task.id) + ) task.status = AITask.Status.FAILED task.error_message = raw[:2000] task.completed_at = timezone.now() @@ -2303,11 +2313,11 @@ def _storyboard_shot_worker(task_id, shot_id, user_id) -> None: release_credit(reservation=reservation, reason=raw[:200]) # 重跑失败时保留旧 adopted_version(画面不丢),只把状态标 FAILED + 友好提示供前端显示 StoryboardShot.objects.filter(id=shot.id).update( - status=StoryboardShot.Status.FAILED, error_message=hint, updated_at=timezone.now()) + status=StoryboardShot.Status.FAILED, error_message=public_error.fallback_message, 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, + stage_label=f"故事板·场 {shot.sort_order + 1}", raw=raw, hint=public_error.fallback_message, ) finally: connections.close_all() # 释放该线程的 DB 连接 @@ -2545,7 +2555,7 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V try: provider = build_provider(model_config) # 不再静默退文生兜底:火山报错(如人脸需走素材库的 InputImageSensitiveContentDetected)直接抛, - # 由下方 except 落 error_message → 前端如实显示真实报错,绝不假装成功出一条没锁脸的视频。 + # 由下方 except 保存原始错误供排障,并将安全提示写给普通用户。 response = provider.create_video_task( model=model_config.name, endpoint=model_config.endpoint, @@ -2564,18 +2574,21 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V video_segment.save(update_fields=["status", "updated_at"]) return None except Exception as exc: + public_error = classify_generation_error( + exc, operation="video_generate", reference_id=str(task.id) + ) task.status = AITask.Status.FAILED - task.error_message = str(exc) + task.error_message = str(exc)[:2000] task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) release_credit(reservation=task.credit_reservation, reason=str(exc)) video_segment.status = VideoSegment.Status.FAILED - video_segment.error_message = str(exc) + video_segment.error_message = public_error.fallback_message 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)), + raw=str(exc), hint=public_error.fallback_message, ) raise @@ -2620,17 +2633,24 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers if remote_status in {"failed", "expired", "cancelled"}: ai_task.status = AITask.Status.FAILED ai_task.response_payload = response - ai_task.error_message = response.get("error", {}).get("message", "video generation failed") + error_info = response.get("error") or {} + ai_task.error_message = error_info.get("message", "video generation failed") + public_error = classify_generation_error( + RuntimeError(ai_task.error_message), + operation="video_generate", + provider_code=str(error_info.get("code") or ""), + reference_id=str(ai_task.id), + ) ai_task.completed_at = timezone.now() ai_task.save(update_fields=["status", "response_payload", "error_message", "completed_at", "updated_at"]) release_credit(reservation=ai_task.credit_reservation, reason=ai_task.error_message) video_segment.status = VideoSegment.Status.FAILED - video_segment.error_message = ai_task.error_message + video_segment.error_message = public_error.fallback_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), + raw=ai_task.error_message, hint=public_error.fallback_message, ) return None @@ -3212,7 +3232,7 @@ def synthesize_project_voiceover(*, project, user, items: list[dict], voice_type return metadata["voiceover"] except Exception as exc: task.status = AITask.Status.FAILED - task.error_message = str(exc) + task.error_message = str(exc)[:2000] task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) release_credit(reservation=reservation, reason=str(exc)) diff --git a/core/backend/apps/ai/test_free_video.py b/core/backend/apps/ai/test_free_video.py index 855fa2e..2d72e01 100644 --- a/core/backend/apps/ai/test_free_video.py +++ b/core/backend/apps/ai/test_free_video.py @@ -15,6 +15,7 @@ from apps.ai.free_video import ( build_content_items, finalize_free_video, find_orphan_material_mention, + serialize_free_video_task, submit_free_video, ) from apps.ai.models import AITask, ModelConfig @@ -238,8 +239,12 @@ class SubmitFreeVideoTests(TestCase): ) task = submit_free_video(team=self.team, user=self.user, params=self._params()) self.assertEqual(task.status, AITask.Status.FAILED) - self.assertEqual(task.error_message, "提示词包含敏感内容,请修改后重试") + self.assertEqual(task.error_message, "text blocked") self.assertEqual(task.error_code, "InputTextSensitiveContentDetected") + public_task = serialize_free_video_task(task) + self.assertEqual(public_task["error"]["code"], "content_rejected") + self.assertIn("内容需要调整", public_task["error_message"]) + self.assertNotIn("text blocked", public_task["error_message"]) account = CreditAccount.objects.get(team=self.team) self.assertEqual(account.reserved_balance, Decimal("0")) reservation = CreditReservation.objects.get(task=task) @@ -321,7 +326,10 @@ class FinalizeFreeVideoTests(TestCase): } task = finalize_free_video(task=self.task) self.assertEqual(task.status, AITask.Status.FAILED) - self.assertIn("已被系统拦截", task.error_message) + self.assertEqual(task.error_message, "output blocked") + public_task = serialize_free_video_task(task) + self.assertEqual(public_task["error"]["code"], "content_rejected") + self.assertNotIn("output blocked", public_task["error_message"]) reservation = CreditReservation.objects.get(task=task) self.assertEqual(reservation.status, CreditReservation.Status.RELEASED) @@ -460,4 +468,6 @@ class FreeVideoApiTests(TestCase): } resp = self.client.post(f"/api/ai/free-video/{task_id}/poll/") self.assertEqual(resp.json()["task"]["status"], AITask.Status.FAILED) - self.assertIn("服务异常", resp.json()["task"]["error_message"]) + self.assertEqual(resp.json()["task"]["error"]["code"], "provider_unavailable") + self.assertIn("服务暂时不可用", resp.json()["task"]["error_message"]) + self.assertNotIn("boom", resp.json()["task"]["error_message"]) diff --git a/core/backend/apps/ai/test_generation_errors.py b/core/backend/apps/ai/test_generation_errors.py new file mode 100644 index 0000000..7ded973 --- /dev/null +++ b/core/backend/apps/ai/test_generation_errors.py @@ -0,0 +1,155 @@ +import requests +from types import SimpleNamespace +from django.test import SimpleTestCase + +from apps.ai.generation_errors import classify_generation_error, public_error_for_task +from apps.ai.script_agent import _script_error_event + + +class _Response: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + + def json(self): + return self._payload + + +def _http_error(status, code="", message=""): + return requests.HTTPError( + f"{status} {message}".strip(), + response=_Response(status, {"error": {"code": code, "message": message}}), + ) + + +class GenerationErrorClassifierTests(SimpleTestCase): + def test_yunqi_payment_required_is_provider_quota_not_user_credit(self): + error = classify_generation_error( + _http_error(402, message="Payment Required"), + operation="script_generate", + reference_id="task-402", + ) + self.assertEqual(error.code, "provider_quota_exhausted") + self.assertEqual(error.action, "retry_later") + self.assertFalse(error.retryable) + self.assertEqual(error.reference_id, "task-402") + self.assertNotIn("402", error.fallback_message) + self.assertNotIn("Payment", error.fallback_message) + + def test_local_credit_failure_requires_explicit_internal_kind(self): + error = classify_generation_error( + ValueError("insufficient credit"), + operation="image_generate", + internal_kind="user_credit_insufficient", + ) + self.assertEqual(error.code, "user_credit_insufficient") + self.assertEqual(error.action, "recharge") + + def test_moderation_takes_precedence_over_403(self): + error = classify_generation_error( + _http_error(403, "PolicyViolation", "safety_violation"), + operation="storyboard_generate", + ) + self.assertEqual(error.code, "content_rejected") + self.assertEqual(error.action, "revise_input") + + def test_provider_codes_cover_rate_limit_model_and_input(self): + cases = [ + (429, "RateLimitExceeded", "busy", "provider_rate_limited"), + (400, "model_not_found", "unknown model", "model_unavailable"), + (400, "InvalidImage", "invalid image", "invalid_input"), + ] + for status, code, message, expected in cases: + with self.subTest(code=code): + error = classify_generation_error( + _http_error(status, code, message), operation="video_generate" + ) + self.assertEqual(error.code, expected) + + def test_unknown_bad_request_is_not_blamed_on_user(self): + error = classify_generation_error( + _http_error(400, "", "gateway rejected request"), operation="image_generate" + ) + self.assertEqual(error.code, "unknown") + + def test_timeout_and_server_error_are_retryable(self): + timeout = classify_generation_error(requests.Timeout("read timed out"), operation="voiceover_generate") + unavailable = classify_generation_error( + _http_error(503, "InternalError", "busy"), operation="video_generate" + ) + self.assertEqual(timeout.code, "task_timeout") + self.assertTrue(timeout.retryable) + self.assertEqual(unavailable.code, "provider_unavailable") + self.assertTrue(unavailable.retryable) + + def test_script_sse_error_hides_provider_details(self): + event = _script_error_event( + _http_error(402, message="Payment Required for https://www.yunqiai.chat/v1/chat/completions"), + reference_id="task-safe", + ) + self.assertEqual(event["type"], "error") + self.assertEqual(event["error"]["code"], "provider_quota_exhausted") + self.assertEqual(event["error"]["reference_id"], "task-safe") + self.assertEqual(event["detail"], "脚本暂时不可用:生成服务正在处理中,请稍后再试。") + self.assertNotIn("yunqiai", event["detail"]) + self.assertNotIn("402", event["detail"]) + + def test_task_projection_uses_safe_error_and_keeps_raw_out_of_payload(self): + task = SimpleNamespace( + id="image-task", + task_type="person_image", + error_code="", + error_message="402 Client Error: Payment Required for https://www.yunqiai.chat/v1/images/generations", + ) + error = public_error_for_task(task) + self.assertIsNotNone(error) + assert error is not None + self.assertEqual(error.code, "provider_quota_exhausted") + self.assertEqual(error.reference_id, "image-task") + self.assertNotIn("yunqiai", error.fallback_message) + + def test_project_asset_and_triview_tasks_keep_their_user_operation_names(self): + common = {"id": "project-task", "error_code": "", "error_message": "Payment Required"} + asset_error = public_error_for_task( + SimpleNamespace(**common, task_type="person_image", project_id="project-1") + ) + triview_error = public_error_for_task( + SimpleNamespace(**common, task_type="model_triview", project_id="project-1") + ) + self.assertEqual(asset_error.operation, "base_asset_generate") + self.assertEqual(triview_error.operation, "triview_generate") + + def test_storyboard_and_video_fallbacks_use_operation_specific_safe_copy(self): + storyboard = classify_generation_error( + _http_error(402, message="Payment Required"), operation="storyboard_generate" + ) + video = classify_generation_error( + _http_error(400, "InputImageSensitiveContentDetected", "sensitive"), operation="video_generate" + ) + self.assertEqual(storyboard.fallback_message, "故事板暂时不可用:生成服务正在处理中,请稍后再试。") + self.assertEqual(video.fallback_message, "视频内容需要调整:内容未通过生成审核,请调整描述或素材后重试。") + + def test_free_video_voiceover_and_entity_tasks_use_their_own_operations(self): + common = {"id": "task-5", "error_code": "InsufficientBalance", "error_message": "upstream balance exhausted"} + for task_type, operation in ( + ("free_video", "video_generate"), + ("voiceover", "voiceover_generate"), + ("entity_extraction", "entity_extract"), + ): + with self.subTest(task_type=task_type): + error = public_error_for_task(SimpleNamespace(**common, task_type=task_type)) + self.assertIsNotNone(error) + assert error is not None + self.assertEqual(error.operation, operation) + self.assertEqual(error.code, "provider_quota_exhausted") + + def test_explicit_local_input_and_processing_errors_stay_safe(self): + input_error = classify_generation_error( + ValueError("internal detail"), operation="entity_extract", internal_kind="invalid_input" + ) + processing_error = classify_generation_error( + RuntimeError("storage url failed"), operation="video_generate", internal_kind="processing_failed" + ) + self.assertEqual(input_error.code, "invalid_input") + self.assertEqual(processing_error.code, "processing_failed") + self.assertNotIn("storage", processing_error.fallback_message) diff --git a/core/backend/apps/ai/test_generation_notifications.py b/core/backend/apps/ai/test_generation_notifications.py new file mode 100644 index 0000000..7772306 --- /dev/null +++ b/core/backend/apps/ai/test_generation_notifications.py @@ -0,0 +1,71 @@ +from django.test import TestCase + +from apps.accounts.models import Team, User +from apps.adminpanel.serializers import AdminTaskDetailSerializer +from apps.ai.models import AITask, ModelConfig, ModelProvider +from apps.ai.services import notify_generation_failure +from apps.ops.models import Notification +from apps.ops.serializers import NotificationSerializer + + +class GenerationNotificationSafetyTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="notification-owner", password="p") + self.team = Team.objects.create(name="Notification Team", owner=self.user) + provider = ModelProvider.objects.create(name="notification-provider", display_name="Notification Provider") + model = ModelConfig.objects.create( + provider=provider, + name="notification-model", + display_name="Notification Model", + capability=ModelConfig.Capability.TEXT, + ) + self.raw_error = "402 Client Error: Payment Required for https://www.yunqiai.chat/v1/chat/completions" + self.task = AITask.objects.create( + team=self.team, + created_by=self.user, + model_config=model, + task_type=AITask.Type.SCRIPT_GENERATION, + status=AITask.Status.FAILED, + idempotency_key="notification-safety-task", + error_message=self.raw_error, + ) + + def test_user_notification_hides_raw_provider_error_but_admin_task_keeps_it(self): + notify_generation_failure( + task=self.task, + project=None, + recipient=self.user, + stage_label="脚本", + raw=self.raw_error, + ) + notification = Notification.objects.get(team=self.team) + + self.assertNotIn("yunqiai", notification.body) + self.assertNotIn("402", notification.body) + self.assertNotIn("api_error", notification.metadata) + self.assertEqual(notification.metadata["generation_error"]["code"], "provider_quota_exhausted") + + public_data = NotificationSerializer(notification).data + self.assertNotIn("yunqiai", public_data["body"]) + self.assertNotIn("api_error", public_data["metadata"]) + + admin_data = AdminTaskDetailSerializer(self.task).data + self.assertIn("yunqiai", admin_data["error_message"]) + self.assertIn("402", admin_data["error_message"]) + + def test_historical_notification_serializer_strips_legacy_raw_error(self): + notification = Notification.objects.create( + team=self.team, + recipient=self.user, + dedupe_key="legacy-generation-failure", + notification_type=Notification.Type.TASK, + priority=Notification.Priority.ERR, + title="脚本失败", + brief="脚本暂时不可用", + body="脚本暂时不可用。\n\n—— 第三方服务商 API 返回的原始报错 ——\nhttps://www.yunqiai.chat 402", + metadata={"api_error": "https://www.yunqiai.chat 402"}, + ) + + data = NotificationSerializer(notification).data + self.assertEqual(data["body"], "脚本暂时不可用。") + self.assertNotIn("api_error", data["metadata"]) diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index a9b5cb7..6d67225 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -1573,7 +1573,9 @@ class ModelLibraryTriviewTaskTests(TestCase): self.assertEqual(quote.status_code, 200) self.assertEqual(float(quote.json()["points"]), 20.0) - disabled = self.client.post(f"/api/models/{self.model.id}/generate-triview/") + # 测试环境可能从本机配置继承开关;此处显式锁定关闭分支,避免环境依赖。 + with self.settings(MODEL_TRIVIEW_GENERATION_ENABLED=False): + disabled = self.client.post(f"/api/models/{self.model.id}/generate-triview/") self.assertEqual(disabled.status_code, 409) self.assertFalse(AITask.objects.filter(task_type=AITask.Type.MODEL_TRIVIEW).exists()) diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 875fceb..ca13d12 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -13,6 +13,7 @@ from apps.assets.serializers import AssetSerializer from apps.common.api import TeamScopedViewSetMixin, get_current_team from apps.common.celery_health import require_worker +from .generation_errors import classify_generation_error, public_error_for_task from .models import AITask, ImageConversation, ModelConfig from .serializers import AITaskSerializer, ImageConversationSerializer, ModelConfigSerializer from .services import enqueue_standalone_images @@ -69,7 +70,14 @@ class GenerateImageView(APIView): try: tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model, conversation=conversation, reference_image_ids=reference_image_ids, platform_id=platform_id, batch_id=batch_id) except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈 - return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else "" + public_error = classify_generation_error( + exc, operation="image_generate", internal_kind=internal_kind + ) + return Response( + {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + status=status.HTTP_400_BAD_REQUEST, + ) # 本次提交即刷新对话活跃时间,左栏「最近」据此置顶 ImageConversation.objects.filter(id=conversation.id).update(last_active_at=timezone.now()) # batch_id 回传给前端存进批次卡:后续「重跑这张 / 重跑整批」带它回来即可归回原批次 @@ -90,17 +98,18 @@ class GenerateImageView(APIView): tasks = AITask.objects.filter(team=team, id__in=ids).prefetch_related( "generated_assets", "generated_assets__files" ) - data = [ - { - "id": str(t.id), - "status": t.status, - "error_message": t.error_message, + data = [] + for task in tasks: + public_error = public_error_for_task(task) + data.append({ + "id": str(task.id), + "status": task.status, + "error": public_error.as_dict() if public_error else None, + "error_message": public_error.fallback_message if public_error else "", "assets": AssetSerializer( - [a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True + [a for a in task.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True ).data, - } - for t in tasks - ] + }) return Response({"tasks": data}) @@ -199,11 +208,14 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): qs = qs.filter(request_payload__product_id=product_id) tasks = list(qs.order_by("-created_at").prefetch_related("generated_assets", "generated_assets__files")[:limit]) tasks.reverse() # 旧 → 新,与对话流/工作台批次流的时间序一致 - data = [ - { + data = [] + for t in tasks: + public_error = public_error_for_task(t) + data.append({ "id": str(t.id), "status": t.status, - "error_message": t.error_message, + "error": public_error.as_dict() if public_error else None, + "error_message": public_error.fallback_message if public_error else "", "prompt": t.rp_prompt or "", "batch_id": t.rp_batch_id or "", "ratio": t.rp_ratio or "", @@ -217,9 +229,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): "assets": AssetSerializer( [a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True ).data, - } - for t in tasks - ] + }) return Response({"tasks": data}) @action(detail=False, methods=["post"], url_path="mark-read") @@ -335,11 +345,14 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): out.append(ref_cache[rid]) return out - data = [ - { + data = [] + for t in tasks: + public_error = public_error_for_task(t) + data.append({ "id": str(t.id), "status": t.status, - "error_message": t.error_message, + "error": public_error.as_dict() if public_error else None, + "error_message": public_error.fallback_message if public_error else "", "prompt": (t.request_payload or {}).get("prompt", ""), "batch_id": (t.request_payload or {}).get("batch_id", ""), "ratio": (t.request_payload or {}).get("ratio") or "", @@ -350,9 +363,7 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): "assets": AssetSerializer( [a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True ).data, - } - for t in tasks - ] + }) return Response({"conversation_id": str(conversation.id), "tasks": data}) @@ -409,7 +420,20 @@ class FreeVideoView(APIView): try: task = submit_free_video(team=team, user=request.user, params=request.data or {}) except ValueError as exc: - return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + message = str(exc) + internal_kind = ( + "user_credit_insufficient" if "余额不足" in message + else "model_unavailable" if "模型未配置" in message + else "provider_rate_limited" if "任务进行中" in message + else "invalid_input" + ) + public_error = classify_generation_error( + exc, operation="video_generate", internal_kind=internal_kind + ) + return Response( + {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + status=status.HTTP_400_BAD_REQUEST, + ) # 重取带 prefetch 的实例,序列化统一走同一条路 task = _free_video_task_queryset(team).get(id=task.id) return Response({"task": serialize_free_video_task(task)}, status=status.HTTP_202_ACCEPTED) diff --git a/core/backend/apps/ops/serializers.py b/core/backend/apps/ops/serializers.py index 003259d..63c82a5 100644 --- a/core/backend/apps/ops/serializers.py +++ b/core/backend/apps/ops/serializers.py @@ -7,6 +7,8 @@ class NotificationSerializer(serializers.ModelSerializer): type = serializers.CharField(source="notification_type", read_only=True) unread = serializers.SerializerMethodField() project_name = serializers.CharField(source="project.name", read_only=True) + body = serializers.SerializerMethodField() + metadata = serializers.SerializerMethodField() class Meta: model = Notification @@ -45,3 +47,15 @@ class NotificationSerializer(serializers.ModelSerializer): def get_unread(self, obj): return not obj.is_read + + def get_body(self, obj): + """兼容早期 AI 失败通知:不再向普通用户回传嵌入正文的上游原始异常。""" + body = obj.body or "" + marker = "—— 第三方服务商 API 返回的原始报错 ——" + return body.split(marker, 1)[0].strip() + + def get_metadata(self, obj): + """旧通知的 api_error 也必须从普通用户 API 中移除。""" + metadata = dict(obj.metadata or {}) + metadata.pop("api_error", None) + return metadata diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index fe1d08b..9670cb8 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -299,8 +299,8 @@ class ProjectApiTests(TestCase): # 没二次预扣额度(复用直接 return,根本没走到 create_ai_task) self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.RESERVE).count(), 0) - def test_extract_status_reports_running_then_failure(self): - """extract-status:在途→running=True;失败→running=False 且把可读错误透给前端(刷新后也能看到失败原因)。""" + def test_extract_status_reports_running_then_safe_failure(self): + """extract-status:在途→running=True;失败→安全错误对象,原始任务错误不透给前端。""" project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P4") t = AITask.objects.create( team=self.team, created_by=self.user, project=project, model_config=self.model, @@ -317,7 +317,8 @@ class ProjectApiTests(TestCase): res = self.client.get(f"/api/projects/{project.id}/extract-status/") self.assertFalse(res.data["running"]) self.assertEqual(res.data["status"], "failed") - self.assertIn("未返回有效 JSON", res.data["error"]) + self.assertEqual(res.data["error"]["code"], "unknown") + self.assertNotIn("有效 JSON", res.data["error_message"]) @patch("apps.ai.services._store_generated_media") @patch("apps.ai.services.get_image_provider") @@ -811,7 +812,8 @@ class ProjectApiTests(TestCase): format="json", ) self.assertEqual(response.status_code, 400) - self.assertIn("语音合成未配置", response.json()["detail"]) + self.assertEqual(response.json()["error"]["code"], "provider_config_error") + self.assertIn("配音暂时不可用", response.json()["detail"]) self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 0) def test_export_command_mixes_voiceover_above_bgm(self): diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 6507a06..4a91b8d 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -38,6 +38,7 @@ from apps.assets.serializers import AssetFileSerializer from apps.assets.storage import TosStorage from apps.common.api import TeamScopedViewSetMixin from apps.common.celery_health import require_worker +from apps.ai.generation_errors import classify_generation_error, public_error_for_task from .models import ( BaseAssetGroup, @@ -504,7 +505,19 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): try: task = submit_extract_entities(project=project, user=request.user) except ValueError as exc: # 无脚本 / 无分镜 / 无模型 / 额度不足,立即反馈 - return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + message = str(exc) + internal_kind = ( + "user_credit_insufficient" if "额度不足" in message + else "model_unavailable" if "模型" in message + else "invalid_input" + ) + public_error = classify_generation_error( + exc, operation="entity_extract", internal_kind=internal_kind + ) + return Response( + {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + status=status.HTTP_400_BAD_REQUEST, + ) return Response({"task_id": str(task.id), "status": task.status}) @action(detail=True, methods=["get"], url_path="extract-status") @@ -514,17 +527,24 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): project = self.get_object() inflight = get_inflight_extraction(project) if inflight is not None: - return Response({"running": True, "status": inflight.status, "error": "", "entities": []}) + return Response({"running": True, "status": inflight.status, "error": None, "error_message": "", "entities": []}) last = ( AITask.objects.filter(project=project, task_type=AITask.Type.ENTITY_EXTRACTION) .order_by("-created_at") .first() ) if last is None: - return Response({"running": False, "status": "", "error": "", "entities": []}) + return Response({"running": False, "status": "", "error": None, "error_message": "", "entities": []}) entities = (project.metadata or {}).get("script_entities", []) if last.status == AITask.Status.SUCCEEDED else [] + public_error = public_error_for_task(last, operation="entity_extract") return Response( - {"running": False, "status": last.status, "error": last.error_message or "", "entities": entities} + { + "running": False, + "status": last.status, + "error": public_error.as_dict() if public_error else None, + "error_message": public_error.fallback_message if public_error else "", + "entities": entities, + } ) @action(detail=True, methods=["post"], url_path="generate-base-asset") @@ -546,7 +566,14 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): auto_triview=False, ) except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈 - return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else "" + public_error = classify_generation_error( + exc, operation="base_asset_generate", internal_kind=internal_kind + ) + return Response( + {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + status=status.HTTP_400_BAD_REQUEST, + ) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS) stage.status = ProjectStage.Status.NEEDS_REVIEW stage.save(update_fields=["status", "updated_at"]) @@ -749,7 +776,14 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): try: task = generate_person_triview(project=project, user=request.user, portrait_asset=portrait_asset) except ValueError as exc: - return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else "" + public_error = classify_generation_error( + exc, operation="triview_generate", internal_kind=internal_kind + ) + return Response( + {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + status=status.HTTP_400_BAD_REQUEST, + ) # 异步:image_edit 出图在 worker 里跑,秒回 RESERVED 任务,前端轮询取结果后刷新项目即见三视图 return Response({"task": {"id": str(task.id), "status": task.status}}, status=status.HTTP_202_ACCEPTED) @@ -1010,11 +1044,15 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): project = self.get_object() segment = VideoSegment.objects.get(project=project, id=request.data.get("video_segment_id")) # 火山/中转报错(如人脸需走素材库的 InputImageSensitiveContentDetected)→ 返回真实报错的 JSON, - # 前端如实显示(段已被 submit_video_segment 标 FAILED + error_message);绝不让它变 500 HTML → 前端白屏。 + # 任务仍保留原始错误供排障,但普通用户仅收到安全错误对象,不让 500 HTML 导致前端白屏。 try: submit_video_segment(video_segment=segment, user=request.user, prompt=request.data.get("prompt", "")) except Exception as exc: # noqa: BLE001 - return Response({"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY) + public_error = classify_generation_error(exc, operation="video_generate") + return Response( + {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + status=status.HTTP_502_BAD_GATEWAY, + ) # worker 在线由上面的闸保证;此处入队失败只剩极小窗口(刚提交完 broker 闪断), # 任务已在 ARK,前端轮询仍可兜底取回,故不让提交 500。 try: @@ -1184,7 +1222,27 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): speed_ratio=float(request.data.get("speed_ratio") or 1.0), ) except (TtsNotConfigured, ValueError) as exc: - return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + message = str(exc) + internal_kind = ( + "user_credit_insufficient" if "insufficient credit" in message.lower() + else "model_unavailable" if "audio model" in message.lower() + else "invalid_input" + ) + public_error = classify_generation_error( + exc, + operation="voiceover_generate", + internal_kind=internal_kind if not isinstance(exc, TtsNotConfigured) else "", + ) + return Response( + {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as exc: # noqa: BLE001 — TTS 上游异常已在任务中留原始信息,HTTP 仅返回安全提示。 + public_error = classify_generation_error(exc, operation="voiceover_generate") + return Response( + {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + status=status.HTTP_502_BAD_GATEWAY, + ) return Response({"voiceover": voiceover}, status=status.HTTP_201_CREATED) @action(detail=True, methods=["post", "put"], url_path="save-timeline") diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index f231dcc..40212d4 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -19,6 +19,7 @@ import type { UserPreference } from "./types"; import { publicModelDisplayName } from "./model-display"; +import { isPublicGenerationError, presentGenerationError } from "./generation-error"; import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell"; import { AccountPage, @@ -73,6 +74,12 @@ const crumbLabels: Partial> = { 刷新后可恢复"生成中"占位并继续轮询(worker 在后台出图,任务永不丢)。 */ const imgwbKey = (mode?: string) => `airshelf:imgwb:${mode || "image"}`; type ImgwbSaved = { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string }; + +function generationTaskErrorText(error: unknown, fallback: string): string { + if (!isPublicGenerationError(error)) return fallback; + const presentation = presentGenerationError(error); + return `${presentation.title}:${presentation.description}`; +} function loadImgwb(mode?: string): ImgwbSaved | null { try { const raw = localStorage.getItem(imgwbKey(mode)); @@ -622,7 +629,7 @@ export function App() { if (!TERMINAL.has(t.status)) continue; pending.delete(t.id); if (t.status === "succeeded") assets.push(...(t.assets || [])); - else if (t.error_message) lastErr = t.error_message; + else if (t.error_message) lastErr = generationTaskErrorText(t.error, t.error_message); } // 进度回写:保留还在跑的 id + 已出结果,供刷新后恢复 saveImgwb(mode, { pending: [...pending], results: assets }); @@ -658,7 +665,7 @@ export function App() { if (!TERMINAL.has(t.status)) continue; pending.delete(t.id); if (t.status === "succeeded") assets.push(...(t.assets || [])); - else if (t.error_message) error = t.error_message; + else if (t.error_message) error = generationTaskErrorText(t.error, t.error_message); } } return { assets, error }; diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 88f8f99..d6ced6d 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -389,7 +389,8 @@ export const api = { return request<{ running: boolean; status: string; - error: string; + error?: unknown; + error_message: string; entities: Array<{ id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number }>; }>(`/api/projects/${projectId}/extract-status/`); }, @@ -730,7 +731,7 @@ export const api = { return request<{ conversation_id: string; tasks: ImageConversationTask[] }>(`/api/ai/image-conversations/${id}/tasks/`); }, generateImageStatus(ids: string[]) { - return request<{ tasks: { id: string; status: string; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`); + return request<{ tasks: { id: string; status: string; error?: unknown; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`); }, // —— 自由创作·视频生成 —— // 提交秒回(火山 create 同步、慢轮询交给 worker 兜底 + 前端主动 poll);校验失败 400 带中文 detail。 diff --git a/core/frontend/src/components/free-create/generation-card.tsx b/core/frontend/src/components/free-create/generation-card.tsx index 36030e4..05b6f67 100644 --- a/core/frontend/src/components/free-create/generation-card.tsx +++ b/core/frontend/src/components/free-create/generation-card.tsx @@ -3,6 +3,7 @@ import { useRef } from "react"; import { Download, Heart, RotateCcw, Trash2 } from "lucide-react"; import type { FreeVideoTask } from "../../types"; import { MODE_LABELS, STATUS_LABELS, isInFlight, modelLabel } from "./constants"; +import { isPublicGenerationError, presentGenerationError } from "../../generation-error"; function ratioStyle(ratio: string) { const [w, h] = ratio.split(":").map(Number); @@ -22,6 +23,9 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori const inFlight = isInFlight(task.status); const failed = task.status === "failed" || task.status === "cancelled"; const done = task.status === "succeeded" && !!task.video_url; + const errorText = isPublicGenerationError(task.error) + ? (() => { const presentation = presentGenerationError(task.error); return `${presentation.title}:${presentation.description}`; })() + : task.error_message || "视频生成失败,请重试"; return (
@@ -45,7 +49,7 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori {failed && (
失败 -

{task.error_message || "生成失败,请重试"}

+

{errorText}