大量优化修改扣积分规则

This commit is contained in:
Azmat@qq.com
2026-09-04 17:20:22 +08:00
parent 3e904479d9
commit 15718a60bf
41 changed files with 1590 additions and 429 deletions
+41 -93
View File
@@ -198,123 +198,71 @@ class ProjectApiTests(TestCase):
)
self.assertEqual(response.status_code, 404)
@patch("apps.ai.services.VolcanoArkProvider")
def test_extract_entities_streams_and_persists(self, provider_cls):
"""提取走流式通道(豆包 2.0 Pro 思考模型):思考事件丢弃、只收正文 JSON →
落库 cast/scenes + 每镜 entity_refs + entities_extracted 标记,计费一次。"""
import json as _json
extracted = {
"entities": [
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "26岁都市女性,利落短发"},
{"id": "s1", "type": "scene", "name": "出租屋客厅", "visual_prompt": "ins风小客厅,暖白自然光"},
],
"segments": [
{"index": 0, "entity_refs": ["c1", "s1"]},
{"index": 1, "entity_refs": ["c1", "s1"]},
],
}
provider = provider_cls.return_value
provider.chat_completion_stream.return_value = iter([
{"type": "reasoning", "text": "先通读分镜"}, # 思考事件:必须被丢弃,不进正文
{"type": "delta", "text": "```json\n" + _json.dumps(extracted, ensure_ascii=False)},
{"type": "delta", "text": "\n```"},
{"type": "done"},
])
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
def test_extract_entities_local_from_script_metadata(self):
"""本地提取:读脚本 metadata.entities,不调模型、不扣积分,落 cast/scenes + entities_extracted。"""
from apps.billing.models import CreditLedger
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-local")
script = ScriptVersion.objects.create(
project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []},
project=project,
title="脚本",
content="{}",
is_adopted=True,
metadata={
"entities": [
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "都市女性"},
{"id": "s1", "type": "scene", "name": "出租屋客厅", "visual_prompt": "小客厅"},
]
},
)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1")
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0", entity_refs=[])
ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1", entity_refs=[])
response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
self.assertEqual(response.status_code, 200)
# 锁定豆包 2.0 Pro,且走的是流式通道(而非非流式 chat_completion)
self.assertEqual(provider.chat_completion_stream.call_args.kwargs["model"], "doubao-seed-2-0-pro-260215")
provider.chat_completion.assert_not_called()
# 实体落库:cast/scenes + entities_extracted 标记 + 每镜 entity_refs 回填
self.assertEqual(response.data["status"], "succeeded")
self.assertEqual(response.data["mode"], "local")
project.refresh_from_db()
self.assertIn("女主", project.metadata.get("cast", []))
self.assertIn("出租屋客厅", project.metadata.get("scenes", []))
self.assertTrue(project.metadata.get("entities_extracted"))
self.assertEqual(project.metadata.get("entities_extract_mode"), "local")
segs = list(script.segments.order_by("sort_order"))
self.assertEqual(segs[0].entity_refs, ["c1", "s1"])
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1)
self.assertEqual(CreditLedger.objects.filter(team=self.team).count(), 0)
@patch("apps.ai.services.VolcanoArkProvider")
def test_extract_entities_recovers_when_content_empty_uses_reasoning(self, provider_cls):
"""极端兜底:思考模型整轮只发了 reasoning、正文 content 为空 → 回退用 reasoning 里的 JSON,
不再像旧非流式那样拿到空串报「未返回有效 JSON」。"""
import json as _json
extracted = {
"entities": [{"id": "c1", "type": "character", "name": "男主", "visual_prompt": "28岁居家男性"}],
"segments": [{"index": 0, "entity_refs": ["c1"]}],
}
provider = provider_cls.return_value
provider.chat_completion_stream.return_value = iter([
{"type": "reasoning", "text": _json.dumps(extracted, ensure_ascii=False)},
{"type": "done"},
])
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P2")
def test_extract_entities_local_fills_defaults_when_missing(self):
"""脚本没带 entities 时,本地补最少 1 角色 + 1 场景。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-default")
script = ScriptVersion.objects.create(
project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []},
project=project, title="脚本", content="{}", is_adopted=True, metadata={"entities": []},
)
ScriptSegment.objects.create(
script_version=script, sort_order=0, narration="女主介绍产品", visual_prompt="明亮客厅口播",
)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
self.assertEqual(response.status_code, 200)
project.refresh_from_db()
self.assertIn("男主", project.metadata.get("cast", []))
self.assertTrue(project.metadata.get("cast"))
self.assertTrue(project.metadata.get("scenes"))
self.assertTrue(project.metadata.get("entities_extracted"))
@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")
def test_extract_status_after_local_success(self):
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-status")
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_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,
task_type=AITask.Type.ENTITY_EXTRACTION, status=AITask.Status.SUBMITTED,
idempotency_key="entity_extraction:status:1",
project=project, title="脚本", content="{}", is_adopted=True,
metadata={"entities": [{"id": "c1", "type": "character", "name": "男主", "visual_prompt": "x"},
{"id": "s1", "type": "scene", "name": "厨房", "visual_prompt": "y"}]},
)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="a", visual_prompt="b")
self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
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.assertEqual(res.data["error"]["code"], "unknown")
self.assertNotIn("有效 JSON", res.data["error_message"])
self.assertEqual(res.data["status"], "succeeded")
self.assertTrue(any(e.get("name") == "男主" for e in res.data["entities"]))
@patch("apps.ai.services._store_generated_media")
@patch("apps.ai.services.get_image_provider")