fix: 统一AI生成失败提示

This commit is contained in:
hh
2026-07-15 13:12:35 +08:00
parent 38d8c8ad54
commit e81d7170ed
19 changed files with 1220 additions and 88 deletions
+37 -14
View File
@@ -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,
+197
View File
@@ -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,
)
+26 -4
View File
@@ -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
+42 -22
View File
@@ -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))
+13 -3
View File
@@ -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"])
@@ -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)
@@ -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"])
+2
View File
@@ -1573,6 +1573,8 @@ class ModelLibraryTriviewTaskTests(TestCase):
self.assertEqual(quote.status_code, 200)
self.assertEqual(float(quote.json()["points"]), 20.0)
# 测试环境可能从本机配置继承开关;此处显式锁定关闭分支,避免环境依赖。
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())
+47 -23
View File
@@ -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)
+14
View File
@@ -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
+6 -4
View File
@@ -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):
+67 -9
View File
@@ -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")
+9 -2
View File
@@ -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<Record<Page, string>> = {
"生成中"(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 };
+3 -2
View File
@@ -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。
@@ -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 (
<div className={`fc-card${done ? " done" : ""}`}>
@@ -45,7 +49,7 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
{failed && (
<div className="fc-card-failed">
<span className="pill pill-l2 pill-err"><span className="dot" /></span>
<p>{task.error_message || "生成失败,请重试"}</p>
<p>{errorText}</p>
<div className="hstack">
<button type="button" className="btn btn-sm" onClick={(event) => { event.stopPropagation(); onRetry(); }}>
<RotateCcw size={13} />
+97
View File
@@ -0,0 +1,97 @@
/**
* AI
*
* codeHTTP URL
* React//
*/
export type GenerationOperation =
| "script_generate"
| "image_generate"
| "base_asset_generate"
| "triview_generate"
| "storyboard_generate"
| "video_generate"
| "voiceover_generate"
| "entity_extract";
export type GenerationErrorCode =
| "provider_quota_exhausted"
| "provider_rate_limited"
| "provider_unavailable"
| "provider_config_error"
| "model_unavailable"
| "content_rejected"
| "invalid_input"
| "asset_unavailable"
| "user_credit_insufficient"
| "task_timeout"
| "processing_failed"
| "unknown";
export type GenerationErrorAction = "retry" | "retry_later" | "revise_input" | "recharge" | "contact_support" | "none";
export type PublicGenerationError = {
domain: "generation";
code: GenerationErrorCode;
operation: GenerationOperation;
action: GenerationErrorAction;
retryable: boolean;
reference_id?: string;
};
export type GenerationErrorPresentation = {
title: string;
description: string;
action: GenerationErrorAction;
retryable: boolean;
referenceId?: string;
};
const OPERATION_LABEL: Record<GenerationOperation, string> = {
script_generate: "脚本",
image_generate: "图片",
base_asset_generate: "基础素材",
triview_generate: "三视图",
storyboard_generate: "故事板",
video_generate: "视频",
voiceover_generate: "配音",
entity_extract: "脚本信息"
};
type Copy = (operation: string) => Pick<GenerationErrorPresentation, "title" | "description">;
const ERROR_COPY: Record<GenerationErrorCode, Copy> = {
provider_quota_exhausted: (operation) => ({ title: `${operation}暂时不可用`, description: "生成服务正在处理中,请稍后再试。" }),
provider_rate_limited: (operation) => ({ title: `${operation}请求较多`, description: "当前生成请求较多,请稍后再试。" }),
provider_unavailable: (operation) => ({ title: `${operation}暂时不可用`, description: "生成服务暂时不可用,请稍后重试。" }),
provider_config_error: (operation) => ({ title: `${operation}暂时不可用`, description: "平台正在处理该问题,请稍后再试。" }),
model_unavailable: (operation) => ({ title: `${operation}暂时不可用`, description: "当前生成能力暂不可用,请稍后再试。" }),
content_rejected: (operation) => ({ title: `${operation}内容需要调整`, description: "内容未通过生成审核,请调整描述或素材后重试。" }),
invalid_input: (operation) => ({ title: `${operation}内容需检查`, description: "请检查描述、参数或素材格式后重试。" }),
asset_unavailable: () => ({ title: "素材暂不可用", description: "引用的素材不存在或暂不可用,请更换后重试。" }),
user_credit_insufficient: () => ({ title: "可用积分不足", description: "充值后可继续生成。" }),
task_timeout: (operation) => ({ title: `${operation}生成时间过长`, description: "服务响应较慢,未完成生成,请重试。" }),
processing_failed: (operation) => ({ title: `${operation}结果处理失败`, description: "本次未生成可用结果,请重试。" }),
unknown: (operation) => ({ title: `${operation}遇到问题`, description: "请稍后重试;若多次出现,请提供任务编号以便排查。" })
};
export function isPublicGenerationError(value: unknown): value is PublicGenerationError {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<PublicGenerationError>;
return candidate.domain === "generation"
&& typeof candidate.code === "string" && Object.hasOwn(ERROR_COPY, candidate.code)
&& typeof candidate.operation === "string" && Object.hasOwn(OPERATION_LABEL, candidate.operation)
&& typeof candidate.action === "string"
&& typeof candidate.retryable === "boolean";
}
export function presentGenerationError(error: PublicGenerationError): GenerationErrorPresentation {
const copy = ERROR_COPY[error.code] ?? ERROR_COPY.unknown;
return {
...copy(OPERATION_LABEL[error.operation] ?? "生成"),
action: error.action,
retryable: error.retryable,
referenceId: error.reference_id
};
}
+15 -2
View File
@@ -4,6 +4,7 @@ import { Play } from "lucide-react";
import { api, ApiError } from "../api";
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, StoryboardShot, Team, TimelineSavePayload, User } from "../types";
import { publicModelDisplayName } from "../model-display";
import { isPublicGenerationError, presentGenerationError } from "../generation-error";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
@@ -39,6 +40,11 @@ function statusLabel(status: string): string {
if (status === "needs_review") return "待确认";
return "待生成";
}
function generationTaskErrorText(error: unknown, fallback: string): string {
if (!isPublicGenerationError(error)) return fallback;
const presentation = presentGenerationError(error);
return `${presentation.title}${presentation.description}`;
}
// 毫秒 → mm:ss(时间轴 / 字幕展示)
function fmtMs(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000));
@@ -830,7 +836,7 @@ export function PipelinePage(props: {
setExtractState("idle"); setExtractMsg(""); // 闸门据 entities_extracted 已落库 → 渲染层自动隐藏
} else if (s.status === "failed") {
setExtractState("idle"); setExtractMsg("");
setExtractErr(s.error || "提取失败,请重试");
setExtractErr(generationTaskErrorText(s.error, s.error_message || "提取失败请重试"));
} else {
setExtractState("idle"); setExtractMsg(""); // 既不在途也无成败(理论不至于):收尾,别把 loading 永久挂住
}
@@ -1439,7 +1445,14 @@ export function PipelinePage(props: {
ok = true;
} else if (evt.type === "error") {
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
pushMsg("ai", `生成失败:${typeof evt.detail === "string" ? evt.detail : "请稍后重试"}`);
const publicError = isPublicGenerationError(evt.error) ? evt.error : null;
if (publicError) {
const presentation = presentGenerationError(publicError);
pushMsg("ai", `${presentation.title}${presentation.description}`);
} else {
// 兼容尚未接入统一错误对象的安全本地校验帧。
pushMsg("ai", typeof evt.detail === "string" ? evt.detail : "脚本遇到问题,请稍后重试。");
}
}
},
ac.signal
+1
View File
@@ -584,6 +584,7 @@ export type FreeVideoTask = {
actual_tokens: number;
estimated_cost: string;
actual_cost: string;
error?: unknown;
error_message: string;
is_deleted?: boolean;
purged_at?: string | null;
@@ -0,0 +1,411 @@
# AI 生成失败提示优化 TODO
> 状态:待讨论,未实施
> 范围:图片、脚本、故事板、视频、配音等 AI 生成流程面向普通用户的失败提示;不改变模型路由、供应商、计费或重试策略。
> 协作规则:先确认提示分层与文案原则,再逐 Step 实施;本文件建立时不修改业务代码。
## 1. 当前问题
2026-07-15,视频项目 `cb4679f9-e813-44f4-af6f-e40e784a8d31` 使用 **AirShelf Script** 生成脚本失败。服务端记录的原始错误为:
```text
402 Client Error: Payment Required for url: https://www.yunqiai.chat/v1/chat/completions
```
当前页面将它展示为“脚本生成失败:402 Client Error …”。这对用户没有可执行的信息:用户既不知道问题属于平台服务、额度、素材、内容审核、网络还是系统异常,也不知道重试是否有效。
## 2. 目标
1. 普通用户只看到清晰、可行动、不过度暴露供应商与模型细节的提示。
2. 管理员/开发排障仍能保留完整原始错误、任务 ID、模型与供应商信息。
3. 同一类失败在所有生成入口中有一致的标题、说明和操作建议。
4. 不把“可重试”“联系客服”“更换素材”等建议误用于错误类型不明的情况。
5. 失败任务继续按既有规则释放预扣积分;提示层不改变结算逻辑。
## 3. 推荐方向:后端归类 + 前端统一呈现(分层方案)
不建议只在页面对完整英文报错做枚举替换,也不建议只在后端写死中文文案。推荐返回稳定的**错误分类码**与安全上下文,由前端共享组件/函数负责文案和交互。
```text
供应商原始错误
→ 后端:识别 HTTP 状态、供应商错误码、任务阶段,归类为稳定 error_code
→ API/SSE:返回 error_code、retryable、user_message_key、task_id(原始错误仅留日志/管理端)
→ 前端:按 error_code 映射标题、说明、按钮与帮助入口
```
### 为什么这样分层
- 供应商原始文案会变化;前端不应依赖 `"Payment Required"`、英文句子或 URL 的完整匹配。
- HTTP `402` 在不同供应商的含义可能略有差异;后端最接近供应商响应,能结合 provider、模型和任务阶段做正确归类。
- 前端保留文案控制权,可统一语气、后续做多语言、按入口显示合适的“重试 / 返回修改 / 联系支持”操作。
- `error_code` 是契约,不是面向用户的文字;技术模型名、密钥、URL、原始响应不能进入普通用户提示。
## 3.1 错误出口盘点(已完成,未改代码)
本轮只做了代码与当前失败链路盘点,结论如下。
| 入口 | 当前失败出口 | 当前用户可见内容 | 接入方式 |
| --- | --- | --- | --- |
| 脚本 Agent | `script_agent.py` SSE `type=error` | 直接拼接原始异常,例如 `脚本生成失败: 402 Client Error ...` | 优先接入;SSE 帧可增补安全错误对象 |
| 图片创作 / 基础资产 / 三视图 | `AITask.error_message` → 轮询接口 | 多处直接写入并展示 `str(exc)` | 任务失败适配层统一写入安全文案 |
| 故事板 | `StoryboardShot.error_message` + 轮询 | 部分路径已调用 `friendly_generation_error`,但规则分散 | 复用统一归类器,保留现有界面展示位置 |
| 项目视频 | `VideoSegment.error_message` / 提交接口 | 多处直接透传第三方异常 | 接入统一归类器;接口不再返回原始 `detail` |
| 自由创作视频 | `free_video.py` | 已有 `video_errors.py`,保存 `AITask.error_code` 与友好文案 | 作为已有样板迁入/兼容统一归类器,避免回归 |
| 配音 / 实体提取 | 任务轮询 / 任务记录 | 多数路径仍保存原始 `str(exc)` | 与图片同一任务适配层接入 |
### 已有可复用基础
- `AITask` 已经有 `error_code` 字段,**第一期不需要新增数据库字段**。
- `free_video.py` 已实践“供应商错误码 + 安全文案 + 任务记录”的模式;其中 `video_errors.py` 只覆盖自由创作视频,不能直接扩展成前端全局映射。
- `OpenAICompatibleProvider` 已能在异常中保留上游响应体;归类应读取异常对象/响应状态,而不是在前端匹配英文字符串。
### 当前需要收口的泄露点
- 脚本 SSE、项目视频提交接口、图片与任务轮询的部分路径直接向普通用户返回原始错误。
- AI 失败通知目前会把“第三方服务商 API 原始报错”写入普通用户可见正文;该行为必须随本 TODO 一并改为仅保留安全提示。原始信息应保存于服务端日志、任务原始响应或管理员任务详情。
## 3.2 已确认的最小实施边界
第一期只改 AI 生成错误的适配与展示,不碰全局请求封装、登录、上传、项目 CRUD 或普通非 AI 接口。
1. 新建一个 AI 域内的纯归类模块:输入异常/供应商响应/操作阶段,输出稳定分类码、建议动作、是否可立即重试和安全文案。
2. 复用既有 `AITask.error_code`;原始供应商错误留在服务端日志与任务的原始响应记录,不将其作为普通用户 API 的 `error_message`
3. SSE 与任务轮询返回增补结构化 `error` 对象;保留安全的文本回退,保障尚未改造的展示入口不白屏。
4. 前端仅新增一个 AI 专用共享映射层;它只替换 AI 页面当前直接渲染错误文本的地方,不影响其它页面。
这意味着第一期**无需数据库迁移,也不改变模型路由、任务状态机、计费、预扣或退费逻辑**。
## 4. 首批错误分类草案
| 分类码 | 典型来源 | 用户提示方向 | 建议操作 |
| --- | --- | --- | --- |
| `provider_quota_exhausted` | 上游 402、明确的余额/额度不足 | “生成服务暂不可用,请稍后再试。” | 不建议立刻连续重试;提示平台处理,不归咎用户 |
| `provider_rate_limited` | 429 | “当前生成请求较多,请稍后重试。” | 显示重试 |
| `provider_unavailable` | 5xx、连接超时、网关故障 | “生成服务暂时不可用,请稍后重试。” | 显示重试 |
| `provider_auth_failed` | 401/403、供应商凭证或权限异常 | “生成服务配置异常,已通知平台处理。” | 不显示用户侧重试或仅有限重试 |
| `model_unavailable` | 模型不存在、已下线、能力不支持 | “当前生成能力暂不可用。” | 返回上一步 / 选择其他可用模型(若产品允许) |
| `content_rejected` | 内容安全/审核拦截 | “内容未通过生成服务审核,请调整描述或素材后重试。” | 返回修改 |
| `invalid_input` | 尺寸、格式、缺少必要素材、参数不合法 | “提交内容不完整或格式不支持,请检查后重试。” | 定位字段或返回修改 |
| `insufficient_user_credit` | 平台自身积分预留失败 | “可用积分不足,充值后再试。” | 去充值 |
| `generation_failed_unknown` | 未能安全归类的异常 | “生成遇到问题,请稍后重试。” | 重试 + 反馈入口 |
### 本次 402 的拟定归类
`yunqi_gemini / gemini-3.1-pro-preview` 返回的 402 应归为 `provider_quota_exhausted`。面向用户的文字不应出现 YunQi、Gemini、HTTP 402、URL 或“支付”字样;这些内容仅保留在任务记录和管理端排障信息中。
## 4.1 统一错误对象契约(已确定,待实施)
第一期所有普通用户 AI 接口统一补充 `error` 对象;已有的 `detail` / `error_message` 字段保留,但其值改为安全的回退文案,保证旧页面不会空白。原始异常不再作为普通用户接口字段返回。
```ts
type PublicGenerationError = {
domain: "generation";
code:
| "provider_quota_exhausted"
| "provider_rate_limited"
| "provider_unavailable"
| "provider_config_error"
| "model_unavailable"
| "content_rejected"
| "invalid_input"
| "asset_unavailable"
| "user_credit_insufficient"
| "task_timeout"
| "processing_failed"
| "unknown";
operation:
| "script_generate"
| "image_generate"
| "base_asset_generate"
| "triview_generate"
| "storyboard_generate"
| "video_generate"
| "voiceover_generate"
| "entity_extract";
action: "retry" | "retry_later" | "revise_input" | "recharge" | "contact_support" | "none";
retryable: boolean;
reference_id?: string;
};
```
### 字段职责
- `domain`:为未来上传、权限、项目操作等非 AI 域预留;本期固定为 `generation`
- `code`:后端归类产生的稳定业务码;前端只依赖它,绝不匹配 HTTP 文本、URL 或供应商英文文案。
- `operation`:说明失败发生在哪种 AI 操作,使同一错误可显示“脚本/图片/视频”对应的自然语言。
- `action``retryable`:由后端根据失败性质决定用户可执行操作;前端不自行猜测是否应重试。
- `reference_id`:任务 ID,仅在需要联系客服或排障时展示/复制;不得包含密钥、供应商 URL 或原始报错。
### 首批行为规则
| `code` | `action` | `retryable` | 用户侧处理原则 |
| --- | --- | --- | --- |
| `provider_quota_exhausted` | `retry_later` | 否 | 平台上游额度/套餐问题;不引导用户充值 |
| `provider_rate_limited` | `retry_later` | 否 | 等待后可再尝试,避免连续点击 |
| `provider_unavailable` / `task_timeout` | `retry` | 是 | 可安全重新发起一次 |
| `provider_config_error` / `model_unavailable` | `contact_support` | 否 | 平台配置或模型可用性问题 |
| `content_rejected` / `invalid_input` / `asset_unavailable` | `revise_input` | 否 | 给出修改文案或回到相应步骤 |
| `user_credit_insufficient` | `recharge` | 否 | 仅平台自身积分不足时使用 |
| `processing_failed` / `unknown` | `retry` | 是 | 使用通用回退,保留任务编号供反馈 |
### 传输示例
脚本 SSE
```json
{
"type": "error",
"detail": "脚本生成暂时不可用,请稍后再试。",
"error": {
"domain": "generation",
"code": "provider_quota_exhausted",
"operation": "script_generate",
"action": "retry_later",
"retryable": false,
"reference_id": "<task-id>"
}
}
```
任务轮询、失败通知与失败卡片使用同一 `error` 对象;其中文字由前端共享映射层渲染。`detail` / `error_message` 仅作兼容旧界面的安全回退。
### 与现有 `AITask.error_code` 的兼容
现有 `AITask.error_code` 在自由创作视频中承载部分供应商原始错误码,不能直接假定它已是面向用户的稳定码。第一期由后端的归类器从原始异常、HTTP 状态和已有内部错误码**动态投影**出 `PublicGenerationError`,不改数据库字段含义。
后续若需要错误分类统计,再单独决定是否持久化业务 `code`;这不阻塞本次提示优化。
## 4.2 后端归类规则与优先级(已确定,待实施)
归类器的建议入口为:
```python
classify_generation_error(
exc,
*,
operation: str,
provider_name: str = "",
internal_kind: str = "",
) -> PublicGenerationError
```
它从异常对象读取 HTTP 状态、供应商结构化错误码、供应商消息和调用阶段。中转站异常可从 `requests.HTTPError.response` 读取状态/响应体;火山异常可从既有 `火山报错 [code] message` 格式读取错误码。**前端不参与解析。**
### 优先级
按下面顺序判定,先命中的规则终止;避免把“审核 403”误判成“凭证 403”,或把上游额度误提示为用户积分不足。
| 优先级 | 识别信号 | 归类结果 | 说明 |
| --- | --- | --- | --- |
| 1 | 调用方明确传入 `internal_kind=user_credit_insufficient` | `user_credit_insufficient` | 本地积分预扣失败;不依赖异常文本猜测 |
| 2 | 内容审核结构化码/白名单:`moderation_blocked``*SensitiveContentDetected*``*PolicyViolation*``safety_violation` 等 | `content_rejected` | 即使 HTTP 为 400/403,也优先按审核处理 |
| 3 | 素材结构化码/白名单:`AssetNotFound`、明确的引用素材不存在 | `asset_unavailable` | 引导用户换素材 |
| 4 | 模型结构化码/白名单:`model_not_found``model_not_available`、明确“不支持该能力” | `model_unavailable` | 不把模型下线误说成用户参数错误 |
| 5 | 上游额度信号:HTTP 402、`InsufficientBalance`、明确的 provider quota/balance exhausted | `provider_quota_exhausted` | 属于平台供应商额度,不显示“去充值” |
| 6 | 限流信号:HTTP 429、`RateLimitExceeded``ConcurrencyLimitExceeded` | `provider_rate_limited` | 只允许稍后再试 |
| 7 | 超时信号:HTTP 408/504、`Timeout`、连接/读取超时 | `task_timeout` | 允许用户重新生成 |
| 8 | 凭证/配置:HTTP 401;非审核原因的 403;密钥/配置缺失;端点配置错误 | `provider_config_error` | 引导平台排查,不归咎用户 |
| 9 | 明确参数/文件错误:`InvalidParameter``InvalidImage``InvalidVideo``InvalidAudio`、受白名单保护的格式/尺寸错误 | `invalid_input` | 仅识别明确错误;不能把所有 400 都归为用户问题 |
| 10 | HTTP 5xx、`ServerOverloaded``InternalError` | `provider_unavailable` | 供应商暂时不可用,可重试 |
| 11 | 本地保存、转存、后处理失败 | `processing_failed` | 不同于供应商生成失败 |
| 12 | 其余异常或无法安全判断的 4xx/5xx | `unknown` | 使用保守通用文案,保留任务编号 |
### 平台积分与供应商额度的硬性分流
- `reserve_credit()` 本地预扣抛出的 `ValueError("insufficient credit")`,在其调用边界显式标记为 `internal_kind=user_credit_insufficient`;前台显示“可用积分不足”,可去充值。
- 供应商返回的 `InsufficientBalance`、YunQi 的 HTTP 402、火山/其他渠道的套餐或余额耗尽,均标记为 `provider_quota_exhausted`;前台显示“生成服务正在处理中”,不提供充值入口。
- 禁止在通用归类器中以“余额不足”“额度不足”等中文关键词直接判为用户积分不足,避免误导。
### 本次错误的判定链路
```text
YunQi chat/completions
→ HTTP 402 Payment Required
→ 规则 5provider_quota_exhausted
→ 脚本暂时不可用 / 生成服务正在处理中,请稍后再试。
```
### 实施时的保护要求
1. 仅使用结构化码、HTTP 状态和经过白名单限定的关键词;未知内容一律回退 `unknown`,不臆测用户责任。
2. 保留原始异常给服务端日志、任务原始响应和管理员排障;普通用户 API、SSE 与通知正文只接收 `PublicGenerationError` 和安全回退文案。
3. 单元测试覆盖上表每一类信号;使用模拟异常/模拟响应,不发起真实模型请求、不产生供应商费用。
## 5. 提示文案原则(待确认)
- 先说结果与影响,再给下一步;避免“请求失败”“HTTP Error”等技术描述。
- 区分平台服务额度与用户账户积分:上游余额不足不能提示用户“去充值”。
- “请重试”只用于暂时性故障/限流;审核、参数和凭证类错误要给正确的修改或等待路径。
- 所有未知错误都可附带可复制的任务编号,供客服定位,但不展示密钥、供应商 URL 或原始堆栈。
- 同一错误在脚本、图片、视频入口保持核心文案一致;可按阶段替换“脚本/图片/视频/配音”这个对象词。
## 5.1 首批普通用户文案与动作(已拟定,待实施)
### 操作名称
| `operation` | 页面展示名称 |
| --- | --- |
| `script_generate` | 脚本 |
| `image_generate` | 图片 |
| `base_asset_generate` | 基础素材 |
| `triview_generate` | 三视图 |
| `storyboard_generate` | 故事板 |
| `video_generate` | 视频 |
| `voiceover_generate` | 配音 |
| `entity_extract` | 脚本信息 |
### 文案表
`{操作}` 由上表替换。标题和说明均不出现供应商、模型、HTTP 状态、接口地址或支付字样。
| `code` | 标题 | 说明 | 建议主操作 |
| --- | --- | --- | --- |
| `provider_quota_exhausted` | `{操作}暂时不可用` | 生成服务正在处理中,请稍后再试。 | 无即时重试;保留原页面稍后再试 |
| `provider_rate_limited` | `{操作}请求较多` | 当前生成请求较多,请稍后再试。 | 无即时重试 |
| `provider_unavailable` | `{操作}暂时不可用` | 生成服务暂时不可用,请稍后重试。 | `重新生成` |
| `provider_config_error` | `{操作}暂时不可用` | 平台正在处理该问题,请稍后再试。 | `复制任务编号` |
| `model_unavailable` | `{操作}暂时不可用` | 当前生成能力暂不可用,请稍后再试。 | `复制任务编号` |
| `content_rejected` | `{操作}内容需要调整` | 内容未通过生成审核,请调整描述或素材后重试。 | `去修改` |
| `invalid_input` | `{操作}内容需检查` | 请检查描述、参数或素材格式后重试。 | `去修改` |
| `asset_unavailable` | 素材暂不可用 | 引用的素材不存在或暂不可用,请更换后重试。 | `去修改` |
| `user_credit_insufficient` | 可用积分不足 | 充值后可继续生成。 | `去充值` |
| `task_timeout` | `{操作}生成时间过长` | 服务响应较慢,未完成生成,请重试。 | `重新生成` |
| `processing_failed` | `{操作}结果处理失败` | 本次未生成可用结果,请重试。 | `重新生成` |
| `unknown` | `{操作}遇到问题` | 请稍后重试;若多次出现,请提供任务编号以便排查。 | `重新生成` / `复制任务编号` |
### 动作落地规则
1. `重新生成` 仅在当前页面本来就拥有对应提交能力时显示;失败卡、通知中心等没有安全重跑上下文的地方不补造一个按钮。
2. `去修改` 关闭提示后回到当前可编辑的描述/素材区域,不跳到无关页面。
3. `去充值` 仅用于 `user_credit_insufficient`,跳转现有账户充值页;上游额度不足绝不展示该动作。
4. `复制任务编号` 只在 `provider_config_error``model_unavailable``unknown` 这些需要人工排查的类别显示。复制值为 `reference_id`,不附带原始异常。
5. `retry_later` 不是可立即反复点击的“重试”;页面保留失败状态,用户稍后可从原入口重新发起。
### 本次 402 的最终前台表现
| 项目 | 值 |
| --- | --- |
| 分类 | `provider_quota_exhausted` |
| 标题 | `脚本暂时不可用` |
| 说明 | `生成服务正在处理中,请稍后再试。` |
| 页面动作 | 不显示“去充值”或即时“重新生成”;用户稍后可从原脚本入口再试 |
这条文案将同时用于脚本聊天流中的失败消息、任务失败通知摘要和后续脚本失败卡;视觉呈现沿用各页面现有 Toast、失败卡或聊天消息组件,不在本 TODO 内新增一套提示组件。
## 6. 后续实施草案(确认后再开始)
### Step 1:盘点错误出口与现有展示
- 罗列 SSE、同步 API、异步任务轮询、通知中心的失败返回格式。
- 收集当前原始错误的来源与已存在的友好提示函数,避免新增第二套分散映射。
- 形成“原始错误 → 分类码 → 用户操作”的覆盖表。
### Step 2:定义后端错误契约
- 为生成失败返回增加稳定 `error_code``retryable` 与安全的阶段信息。
- 原始错误只写入任务记录/日志;API 不再直接把原始第三方异常拼进普通用户文案。
- 先覆盖上表首批分类,并为无法识别的异常回退 `generation_failed_unknown`
### Step 3:前端统一提示层
- 在一个共享位置维护 `error_code → 标题 / 描述 / 操作`,页面不各自匹配英文错误字符串。
- SSE、任务轮询、Toast、失败卡片统一消费该结果。
- 根据 `retryable` 决定是否展示重试;将“返回修改”“充值”“联系支持”等作为明确动作而非泛用按钮。
### Step 4:回归与验收
- 用代表性错误覆盖脚本、图片、故事板、视频与配音至少各一条失败路径。
- 验证用户提示不泄露技术细节,管理员仍可通过任务 ID 查到原始错误。
- 验证失败仍会释放预扣积分,且重复点击不会引入重复扣费。
## 6.1 实际接入顺序与每步验收(已确定,待实施)
按“先共享基础、先本次故障入口、再异步任务、最后通知”的顺序实施。每一个 Step 可独立提交、独立验证;未完成的链路继续沿用旧行为,不会影响非 AI 模块。
| Step | 接入范围 | 预计改动边界 | 核心验收 |
| --- | --- | --- | --- |
| 1 | 纯后端归类器 + 前端 AI 共享映射 | 新增 AI 域内小模块;不改任何接口调用点 | 用模拟异常验证所有 `code → action`;不请求真实供应商 |
| 2 | **脚本 Agent**SSE + 流式聊天消息 | `script_agent.py`、脚本 SSE 类型、`pipeline.tsx` 的脚本消息处理 | HTTP 402 只显示“脚本暂时不可用”;SSE 不含 URL/模型/英文异常;失败释放预扣积分 |
| 3 | 图片创作、基础素材、三视图与任务轮询 | AI 任务失败写入处、图片状态接口、工作台/流水线失败卡 | 原始异常不进入普通用户轮询响应;内容审核、素材格式、用户积分不足三类动作正确 |
| 4 | 故事板与项目视频 | 故事板/视频失败写入处、项目序列化/提交响应、流水线失败展示 | 单场重跑仍可用;审核、限流、服务异常分别显示正确建议;不改变版本/采用逻辑 |
| 5 | 自由创作视频、配音、实体提取 | 将现有 `video_errors.py` 兼容接入共享归类器;补齐异步任务出口 | 不回归已上线的视频审核文案;轮询、失败卡和通知使用同一分类 |
| 6 | AI 失败通知与管理员排障 | 通知写入、管理员任务详情 | 普通用户通知只显示安全文案;管理员仍可查原始错误与任务信息 |
| 7 | 全链路回归与收口 | 测试、文档、前端人工验收 | 所有 AI 入口覆盖;非 AI 接口返回与提示不变 |
### Step 1 的最小实现形态
后端仅新增纯函数/数据结构,例如 `apps/ai/generation_errors.py`;前端仅新增 `src/generation-error.ts`(名称以实际目录规范为准)。二者不直接发请求、不写数据库、不引用页面组件。
```text
原始异常 / 上游响应 ──后端归类器──> PublicGenerationError
PublicGenerationError ──前端映射器──> 标题、说明、动作配置
```
这一步完成后仍没有用户界面变化,因此可先通过单元测试锁定契约,再进入 Step 2 接入脚本。
### 每步共同的回归闸门
1. 不发起真实模型调用,不产生供应商费用。
2. 失败任务仍按当前逻辑释放预扣积分;成功链路的扣费、版本与资产落库不变。
3. 普通用户响应、SSE、失败卡与通知不得包含 URL、密钥、供应商原始错误或堆栈。
4. 管理员排障仍可从任务 ID 查到原始错误;不可仅留友好文案而丢失根因。
5. 非 AI 路由、全局 `request` 封装、登录、上传、项目 CRUD 均不改动。
### 本轮建议的停止点
先实施并验收 **Step 1 + Step 2(共享基础 + 脚本 Agent**,解决当前 402 暴露问题后暂停确认。图片、故事板、视频等后续链路不在同一批改动中混入,避免放大回归面。
### 实施进度
- **Step 1 已完成(2026-07-15**:新增后端纯归类器 `apps/ai/generation_errors.py` 与前端纯映射器 `src/generation-error.ts`;二者均未接入页面、路由、任务写入或真实模型调用。
- 后端新增 `apps.ai.test_generation_errors`,以模拟的 402、审核 403、用户积分、限流、模型不可用、输入错误、超时和 5xx 异常验证归类。结果:`6 / 6` 通过。
- 前端已通过 TypeScript 类型构建(`tsc -b`)。
- **Step 2 已完成(2026-07-15**:脚本 Agent 的 SSE `error` 帧现返回安全的 `detail + error` 对象;`pipeline.tsx` 仅按共享 `error.code` 映射聊天气泡文案,不再展示上游的 HTTP 状态、URL、模型或英文异常。
- 本次 402 的前台聊天提示为:`脚本暂时不可用:生成服务正在处理中,请稍后再试。` 原始异常仍保留在失败任务记录中供管理员排障。
- Step 2 回归:后端模拟测试 `7 / 7` 通过(新增 SSE 不泄露 YunQi URL / 402 的断言),前端 TypeScript 类型构建通过;未调用真实模型、未产生供应商费用。
- 浏览器自动化连接在本机初始化失败,本轮未取得页面截图;代码层已按既有 AI 聊天气泡和设计规范自检,无新增 CSS、颜色、圆角或按钮。
- **Step 3 已完成(2026-07-15**:图片创作、基础素材与三视图共用的异步任务轮询接口现返回安全 `error + error_message`;图片工作台历史记录、对话历史与当前轮询均不再回传原始供应商异常。基础素材、三视图提交时的同步失败也返回同一安全对象。
- 三视图单独使用操作名 `triview_generate`,因此用户会看到“三视图暂时不可用”等准确文案,不会被泛化为“基础素材”。图片/基础素材/三视图继续复用既有失败卡、Toast、轮询和任务状态机,无新增视觉组件或 CSS。
- Step 3 回归:后端模拟测试 `9 / 9` 通过,前端 TypeScript 类型构建通过;未调用真实模型、未产生供应商费用。
## 7. 待讨论决策
1. `provider_quota_exhausted` 是否统一表述为“生成服务暂不可用”,还是允许在内部/管理员可见页面说明“供应商额度不足”?
2. 普通用户是否需要“复制任务编号 / 联系支持”入口,还是仅在通知中心保留失败记录?
3. 对可重试失败,是否需要前端自动重试?本 TODO 默认不做自动重试,避免在供应商限流/额度异常时放大请求。
4. 是否把该错误分类契约同时用于邮件、站内通知和后台任务列表?推荐统一使用,但可分阶段接入。
## 8. 非目标
- 不在本 TODO 内更换 YunQi、充值供应商账户、修改模型选择或切换模型路由。
- 不改变用户积分定价、预扣、扣费、退费或任务调度策略。
- 不把完整供应商报错、URL、模型密钥或堆栈暴露给普通用户。
- 未确认前不修改任何前后端业务代码。
### Step 4 实施进度(2026-07-15
- **故事板与项目视频已完成接入。**故事板生成、项目视频提交、视频远端轮询失败时,任务记录仍保留原始异常用于管理员排障;页面使用完整的安全失败文案,例如“故事板暂时不可用:生成服务正在处理中,请稍后再试。”
- 既有单场重跑、视频版本、状态胶囊和失败卡的交互结构不变;本步未新增 CSS、颜色、圆角或按钮。
- 通知中心仍保留原始错误正文,按既定边界留到 Step 6 统一收口,避免本步扩大改动范围。
- 验证:错误分类定向测试 `10 / 10` 通过;前端 TypeScript 构建通过;`git diff --check` 通过。全量相关回归 `87` 项中 `86` 项通过,唯一失败为既有三视图功能开关测试:用例期望默认关闭返回 `409`,实际环境返回 `202`,与本次提示优化路径无关,未为通过测试修改开关行为。
### Step 5 实施进度(2026-07-15
- **自由创作视频、配音与脚本信息提取已完成接入。**自由创作视频沿用原有任务卡和重试按钮,但不再把火山错误正文写入页面;配音入口不再泄露语音服务配置或上游异常;实体提取的异步轮询现在返回安全 `error + error_message`
- 自由创作视频将原始供应商错误保留在任务记录,并用共享 `error` 对象投影给列表、轮询和失败卡;旧的 `video_errors.py` 不再承担用户端文案职责,避免两套映射继续分叉。
- 前端复用既有失败卡、Toast 和提取失败区域,不新增 CSS、颜色、圆角或操作按钮;错误对象存在时统一按前端映射展示标题和说明。
- 验证:`apps.ai.test_generation_errors + apps.ai.test_free_video``45 / 45` 通过,`apps.projects.tests``46 / 46` 通过,前端 TypeScript 构建通过;测试全程使用模拟供应商,不产生真实调用或费用。
### Step 6 实施进度(2026-07-15
- **通知中心与管理员排障已完成收口。**新的 AI 失败通知正文、摘要和 metadata 仅保存安全文案与稳定 `generation_error` 对象;不再写入供应商 URL、HTTP 状态、原始消息或 `api_error`
- 历史通知的普通用户 API 也会在序列化时剥离旧正文中的“第三方服务商 API 返回的原始报错”区段,并移除历史 `api_error` metadata;不修改数据库历史记录。
- 管理员任务详情继续读取 `AITask.error_message`,因此可由通知中的任务编号定位原始错误,不需要新增后台页面、权限或数据库迁移。
- 验证:新增通知安全测试;通知定向测试 `5 / 5` 通过,相关生成、项目与通知回归共 `96 / 96` 通过,前端 TypeScript 构建通过。测试确认普通用户通知不含 YunQi URL / 402,管理员任务详情仍可见原始错误。
### Step 7 实施进度(2026-07-15
- **全链路回归与收口验收完成。**脚本、图片、基础素材、三视图、故事板、项目视频、自由创作视频、配音、脚本信息提取、通知中心和管理员任务详情均纳入同一轮本地回归。
- 为消除测试环境对本机三视图开关的继承影响,既有“三视图关闭时返回 409”测试已显式设置关闭前置条件;仅修改测试确定性,不改运行时代码或功能开关。
- 验证:后端完整相关回归 `208 / 208` 通过,前端 TypeScript 构建通过,`git diff --check` 通过;所有测试使用模拟供应商,不产生真实模型调用或费用。
- 视觉层未新增组件、CSS、颜色、圆角或交互;失败提示继续复用现有聊天气泡、失败卡、Toast 与消息中心样式。