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
+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")