fix(extract): 实体提取改异步(worker+轮询),根治 502 + 刷新丢 loading + 重复扣费

问题:进资产页点"提取人物/场景"——同步死等豆包思考模型(数十秒)→ 撞网关超时
502;"提取中"loading 是纯前端内存态,刷新即丢;且无幂等,刷新后重点会重复扣费。

改动(对齐全站异步三件套:出图/三视图/故事板同款):
- 后端 submit_extract_entities:Web 只建 RESERVED 任务 + 预留额度秒回(不再 502);
  已有在途提取则复用,绝不二次预扣(防刷新后重点 / 并发重复扣费)。
- 后端 run_extract_entities_task(Celery worker):流式调豆包 + 解析 + 落库 + 扣费;
  失败退费并把可读错误记进 task.error_message。
- 新增 AITask.Type.entity_extraction(迁移 0011)+ extract-status 端点(轮询进度/成败)。
- 前端 runExtract 改提交+轮询;进资产页自动认领在途提取,刷新后 loading 自己回来。

验收:完整 Django 套件 149/149 全绿(含新增 4 测:异步落库+计费一次 / content空
回退 reasoning / 在途防重复扣费 / 状态端点成败透出);tsc + vite build 全绿;
makemigrations --check 无遗漏。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-22 23:15:11 +08:00
co-authored by Claude Opus 4.8
parent 71af0ae166
commit c8cb6d3d51
8 changed files with 304 additions and 86 deletions
+48 -1
View File
@@ -4,7 +4,7 @@ from unittest.mock import patch
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import ModelConfig, ModelProvider
from apps.ai.models import AITask, ModelConfig, ModelProvider
from apps.assets.models import Asset
from apps.billing.models import CreditAccount, CreditLedger
from apps.products.models import Product
@@ -205,6 +205,53 @@ class ProjectApiTests(TestCase):
project.refresh_from_db()
self.assertIn("男主", project.metadata.get("cast", []))
@patch("apps.ai.services.VolcanoArkProvider")
def test_extract_entities_inflight_guard_reuses_task(self, provider_cls):
"""已有在途提取任务时再次提交 → 复用同一任务,不新建、不二次预扣、不重跑模型
(用户刷新后手痒重点 / 并发点击都不会重复扣费)。"""
from apps.ai.services import submit_extract_entities
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P3")
script = ScriptVersion.objects.create(
project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []},
)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
inflight = AITask.objects.create(
team=self.team, created_by=self.user, project=project, model_config=self.model,
task_type=AITask.Type.ENTITY_EXTRACTION, status=AITask.Status.SUBMITTED,
idempotency_key="entity_extraction:inflight:1",
)
before = AITask.objects.filter(project=project, task_type=AITask.Type.ENTITY_EXTRACTION).count()
task = submit_extract_entities(project=project, user=self.user)
self.assertEqual(str(task.id), str(inflight.id)) # 复用在途任务
self.assertEqual( # 没新建任务
AITask.objects.filter(project=project, task_type=AITask.Type.ENTITY_EXTRACTION).count(), before
)
provider_cls.return_value.chat_completion_stream.assert_not_called() # 没重跑模型
# 没二次预扣额度(复用直接 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 且把可读错误透给前端(刷新后也能看到失败原因)。"""
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,
task_type=AITask.Type.ENTITY_EXTRACTION, status=AITask.Status.SUBMITTED,
idempotency_key="entity_extraction:status:1",
)
res = self.client.get(f"/api/projects/{project.id}/extract-status/")
self.assertEqual(res.status_code, 200)
self.assertTrue(res.data["running"])
t.status = AITask.Status.FAILED
t.error_message = "提取结果解析失败(模型未返回有效 JSON),请重试"
t.save(update_fields=["status", "error_message"])
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"])
@patch("apps.ai.services._store_generated_media")
@patch("apps.ai.services.get_image_provider")
def test_generate_base_asset_stores_tag_label(self, get_provider, store_media):
+30 -7
View File
@@ -19,13 +19,14 @@ from apps.ai.services import (
DEFAULT_VOICEOVER_VOICE,
VOICEOVER_VOICES,
create_export_job,
extract_entities_for_project,
generate_base_asset,
generate_person_triview,
generate_storyboard_frame,
get_default_model,
get_inflight_extraction,
poll_video_segment,
regenerate_script_segment,
submit_extract_entities,
submit_storyboard,
submit_video_segment,
synthesize_project_voiceover,
@@ -294,15 +295,37 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="extract-entities")
def extract_entities_action(self, request, pk=None):
"""从已定稿脚本提取角色 / 场景实体(独立步,进资产阶段时调)。AI 读剧本 → 角色 / 场景 + 每镜 refs,
落库覆盖 project.metadata + 每镜 entity_refs;**商品不提**(参考图层用真实主图 / 三视图无条件带上)。
前端进资产页蒙版上「只提取 / 提取+生成」按钮点的就是它(生成由前端按返回实体循环调 generate-base-asset)。"""
"""从已定稿脚本提取角色 / 场景实体(独立步,进资产阶段时调)——**异步**:豆包思考模型流式抽取较慢,
Web 层只建任务秒回(不再 502),慢活交 worker(run_extract_entities_task)。前端拿 task_id 轮询
extract-status 取结果。已有在途提取则复用(防刷新后重点重复扣费)。落库覆盖 project.metadata +
每镜 entity_refs;**商品不提**(参考图层用真实主图)。"""
require_worker() # 异步提取依赖 worker 兜底执行,没 worker 直接拒绝(否则任务永远 RESERVED)
project = self.get_object()
try:
result = extract_entities_for_project(project=project, user=request.user)
except ValueError as exc: # 无脚本 / 无模型 / 额度不足 / 解析失败 等,立即反馈
task = submit_extract_entities(project=project, user=request.user)
except ValueError as exc: # 无脚本 / 无分镜 / 无模型 / 额度不足,立即反馈
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(result)
return Response({"task_id": str(task.id), "status": task.status})
@action(detail=True, methods=["get"], url_path="extract-status")
def extract_status_action(self, request, pk=None):
"""实体提取进度(异步)。前端进资产页 / 点提取后据此轮询 + 刷新后重建「提取中」loading:
running=有在途任务;否则读最近一条提取任务的成败。成功时回带 entities(供「提取+生成」继续出参考图)。"""
project = self.get_object()
inflight = get_inflight_extraction(project)
if inflight is not None:
return Response({"running": True, "status": inflight.status, "error": "", "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": []})
entities = (project.metadata or {}).get("script_entities", []) if last.status == AITask.Status.SUCCEEDED else []
return Response(
{"running": False, "status": last.status, "error": last.error_message or "", "entities": entities}
)
@action(detail=True, methods=["post"], url_path="generate-base-asset")
def generate_base_asset_action(self, request, pk=None):