fix: 统一AI生成失败提示
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"])
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user