fix: 支持删除图片异常批次

This commit is contained in:
hh
2026-07-15 17:52:29 +08:00
parent ff0a93677a
commit 755e210951
9 changed files with 531 additions and 51 deletions
+116
View File
@@ -1732,6 +1732,122 @@ class WorkbenchAndUnreadTests(TestCase):
self.assertEqual(body["total"], 0)
self.assertEqual(body["by_product"], {})
def test_exception_batch_delete_restore_and_purge_are_atomic(self):
"""部分失败批次进入“图片异常批次”,而非与资产垃圾桶重复;恢复后整批回放。"""
tasks = self._generate(mode="cover", count=2, platform_id="douyin")
failed, succeeded = tasks
# 模拟其中一张供应商失败:失败任务无成图,另一张保持成功,构成真实的混合批次。
Asset.objects.filter(origin_task=failed).delete()
failed.status = AITask.Status.FAILED
failed.save(update_fields=["status"])
succeeded_asset = Asset.objects.get(origin_task=succeeded)
deleted = self.client.delete(f"/api/ai/tasks/{failed.id}/workbench-batch/")
self.assertEqual(deleted.status_code, 200, deleted.content)
self.assertEqual(deleted.json()["storage"], "exception_batch")
failed.refresh_from_db()
succeeded.refresh_from_db()
succeeded_asset.refresh_from_db()
self.assertTrue(failed.is_deleted)
self.assertTrue(succeeded.is_deleted)
self.assertTrue(succeeded_asset.is_deleted)
self.assertEqual(
self.client.get(f"/api/ai/tasks/workbench/?mode=cover&product={self.product.id}").json()["tasks"], []
)
trash = self.client.get("/api/ai/tasks/workbench-exception-batches-trash/")
self.assertEqual(trash.status_code, 200, trash.content)
rows = trash.json()["results"]
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["id"], str(failed.id))
self.assertEqual(rows[0]["cover_preview_url"], "http://x/cover.png")
asset_trash_ids = {row["id"] for row in self.client.get("/api/assets/trash/").json()["results"]}
self.assertNotIn(str(succeeded_asset.id), asset_trash_ids)
restored = self.client.post(f"/api/ai/tasks/{failed.id}/restore-workbench-batch/")
self.assertEqual(restored.status_code, 200, restored.content)
failed.refresh_from_db()
succeeded.refresh_from_db()
succeeded_asset.refresh_from_db()
self.assertFalse(failed.is_deleted)
self.assertFalse(succeeded.is_deleted)
self.assertFalse(succeeded_asset.is_deleted)
rows = self.client.get(f"/api/ai/tasks/workbench/?mode=cover&product={self.product.id}").json()["tasks"]
self.assertEqual({row["id"] for row in rows}, {str(failed.id), str(succeeded.id)})
self.client.delete(f"/api/ai/tasks/{failed.id}/workbench-batch/")
purged = self.client.delete(f"/api/ai/tasks/{failed.id}/purge-workbench-batch/")
self.assertEqual(purged.status_code, 204, purged.content)
failed.refresh_from_db()
succeeded.refresh_from_db()
succeeded_asset.refresh_from_db()
self.assertIsNotNone(failed.purged_at)
self.assertIsNotNone(succeeded.purged_at)
self.assertIsNotNone(succeeded_asset.purged_at)
self.assertEqual(self.client.get("/api/ai/tasks/workbench-exception-batches-trash/").json()["results"], [])
def test_successful_batch_keeps_existing_asset_trash_behavior(self):
tasks = self._generate(mode="cover", count=2, platform_id="douyin")
assets = list(Asset.objects.filter(origin_task__in=tasks))
deleted = self.client.delete(f"/api/ai/tasks/{tasks[0].id}/workbench-batch/")
self.assertEqual(deleted.status_code, 200, deleted.content)
self.assertEqual(deleted.json()["storage"], "asset")
self.assertFalse(AITask.objects.get(id=tasks[0].id).is_deleted)
self.assertTrue(all(Asset.objects.get(id=asset.id).is_deleted for asset in assets))
asset_trash_ids = {row["id"] for row in self.client.get("/api/assets/trash/").json()["results"]}
self.assertTrue({str(asset.id) for asset in assets}.issubset(asset_trash_ids))
self.assertEqual(self.client.get("/api/ai/tasks/workbench-exception-batches-trash/").json()["results"], [])
def test_image_conversation_exception_batch_hides_and_restores_only_its_tasks(self):
"""图片创作走对话任务读取,也必须在异常批次删除/恢复后保持一致。"""
conversation = ImageConversation.objects.create(
team=self.team, created_by=self.user, mode=ImageConversation.Mode.IMAGE, title="失败图片创作", product=self.product
)
task = AITask.objects.create(
team=self.team,
created_by=self.user,
conversation=conversation,
task_type=AITask.Type.PRODUCT_IMAGE,
status=AITask.Status.FAILED,
model_config=ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE).first(),
idempotency_key="conversation-exception-batch",
request_payload={"mode": "image", "batch_id": "conversation-exception-batch", "prompt": "失败的图片创作", "product_id": str(self.product.id)},
)
task_rows = self.client.get(f"/api/ai/image-conversations/{conversation.id}/tasks/").json()["tasks"]
self.assertEqual([row["id"] for row in task_rows], [str(task.id)])
self.assertEqual(self.client.delete(f"/api/ai/tasks/{task.id}/workbench-batch/").status_code, 200)
self.assertEqual(self.client.get(f"/api/ai/image-conversations/{conversation.id}/tasks/").json()["tasks"], [])
self.assertEqual(self.client.post(f"/api/ai/tasks/{task.id}/restore-workbench-batch/").status_code, 200)
task_rows = self.client.get(f"/api/ai/image-conversations/{conversation.id}/tasks/").json()["tasks"]
self.assertEqual([row["id"] for row in task_rows], [str(task.id)])
def test_inflight_batch_cannot_be_deleted(self):
tasks = self._generate(mode="cover", count=1, platform_id="douyin")
task = tasks[0]
task.status = AITask.Status.POLLING
task.save(update_fields=["status"])
deleted = self.client.delete(f"/api/ai/tasks/{task.id}/workbench-batch/")
self.assertEqual(deleted.status_code, 409, deleted.content)
task.refresh_from_db()
self.assertFalse(task.is_deleted)
def test_other_team_cannot_delete_image_batch(self):
task = self._generate(mode="cover", count=1, platform_id="douyin")[0]
other = User.objects.create_user(username="other-batch-owner", password="pass")
other_team = Team.objects.create(name="Other batch team", owner=other)
TeamMember.objects.create(team=other_team, user=other, role="owner", status="active")
other_client = APIClient()
other_client.force_authenticate(other)
deleted = other_client.delete(f"/api/ai/tasks/{task.id}/workbench-batch/")
self.assertEqual(deleted.status_code, 404, deleted.content)
task.refresh_from_db()
self.assertFalse(task.is_deleted)
def test_unread_is_team_scoped_and_mode_whitelisted(self):
self._generate(mode="cover", count=1)
# 非生成任务(脚本 agent 复用 mode=auto)不计入未读