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:
co-authored by
Claude Opus 4.8
parent
71af0ae166
commit
c8cb6d3d51
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user