From 5fc316112c1426bfc2989c913975d13fe87bb446 Mon Sep 17 00:00:00 2001 From: hh <2587203630@qq.com> Date: Thu, 16 Jul 2026 18:24:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E5=9E=83=E5=9C=BE?= =?UTF-8?q?=E6=A1=B6=E5=BC=82=E5=B8=B8=E6=89=B9=E6=AC=A1=E7=BC=A9=E7=95=A5?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/tests.py | 81 ++++++++++++++++++++++++++++++++++- core/backend/apps/ai/views.py | 43 ++++++++++++++++--- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 0ad7e5a..72eca52 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -1903,6 +1903,9 @@ class WorkbenchAndUnreadTests(TestCase): failed.status = AITask.Status.FAILED failed.save(update_fields=["status"]) succeeded_asset = Asset.objects.get(origin_task=succeeded) + succeeded_file = succeeded_asset.files.get(is_primary=True) + succeeded_file.preview_url = "http://x/generated-success.png" + succeeded_file.save(update_fields=["preview_url"]) deleted = self.client.delete(f"/api/ai/tasks/{failed.id}/workbench-batch/") self.assertEqual(deleted.status_code, 200, deleted.content) @@ -1922,7 +1925,8 @@ class WorkbenchAndUnreadTests(TestCase): 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") + self.assertEqual(rows[0]["cover_preview_url"], "http://x/generated-success.png") + self.assertEqual(rows[0]["product_title"], self.product.title) 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) @@ -1948,6 +1952,81 @@ class WorkbenchAndUnreadTests(TestCase): self.assertIsNotNone(succeeded_asset.purged_at) self.assertEqual(self.client.get("/api/ai/tasks/workbench-exception-batches-trash/").json()["results"], []) + def test_exception_batch_without_successful_image_uses_no_image_even_with_product_cover(self): + """纯失败批次即使商品有封面也必须返回空缩略图,由前端显示“无图”。""" + task = self._generate(mode="cover", count=1, platform_id="douyin")[0] + Asset.objects.filter(origin_task=task).delete() + task.status = AITask.Status.FAILED + task.save(update_fields=["status"]) + + deleted = self.client.delete(f"/api/ai/tasks/{task.id}/workbench-batch/") + self.assertEqual(deleted.status_code, 200, deleted.content) + rows = self.client.get("/api/ai/tasks/workbench-exception-batches-trash/").json()["results"] + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["cover_preview_url"], "") + self.assertEqual(rows[0]["product_title"], self.product.title) + + def test_exception_batch_uses_first_successful_image_in_original_task_order(self): + """多张成功图时跳过前序失败任务,稳定选择原始批次中第一张成功图。""" + tasks = self._generate(mode="cover", count=3, platform_id="douyin") + failed, first_succeeded, later_succeeded = tasks + Asset.objects.filter(origin_task=failed).delete() + failed.status = AITask.Status.FAILED + failed.save(update_fields=["status"]) + AssetFile.objects.filter(asset__origin_task=first_succeeded).update(preview_url="http://x/first-success.png") + AssetFile.objects.filter(asset__origin_task=later_succeeded).update(preview_url="http://x/later-success.png") + + deleted = self.client.delete(f"/api/ai/tasks/{failed.id}/workbench-batch/") + self.assertEqual(deleted.status_code, 200, deleted.content) + rows = self.client.get("/api/ai/tasks/workbench-exception-batches-trash/").json()["results"] + self.assertEqual(rows[0]["cover_preview_url"], "http://x/first-success.png") + + def test_exception_batch_uses_successful_append_when_original_tasks_all_failed(self): + """原始任务均失败但同批补图成功时,使用最先成功的补图作为缩略图。""" + original_tasks = self._generate(mode="cover", count=2, platform_id="douyin") + for task in original_tasks: + Asset.objects.filter(origin_task=task).delete() + task.status = AITask.Status.FAILED + task.save(update_fields=["status"]) + + payload = dict(original_tasks[0].request_payload) + payload.update({ + "index": 0, + "batch_append": True, + "retry_of_task_id": str(original_tasks[0].id), + }) + appended = AITask.objects.create( + team=self.team, + created_by=self.user, + task_type=original_tasks[0].task_type, + status=AITask.Status.SUCCEEDED, + model_config=original_tasks[0].model_config, + idempotency_key="exception-batch-successful-append", + request_payload=payload, + ) + appended_asset = Asset.objects.create( + team=self.team, + created_by=self.user, + name="成功补图", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.PLATFORM_KIT, + origin_task=appended, + ) + AssetFile.objects.create( + asset=appended_asset, + object_key="append.png", + bucket="b", + content_type="image/png", + preview_url="http://x/successful-append.png", + is_primary=True, + ) + + deleted = self.client.delete(f"/api/ai/tasks/{original_tasks[0].id}/workbench-batch/") + self.assertEqual(deleted.status_code, 200, deleted.content) + rows = self.client.get("/api/ai/tasks/workbench-exception-batches-trash/").json()["results"] + self.assertEqual(rows[0]["cover_preview_url"], "http://x/successful-append.png") + 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)) diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 3ac2d07..608e46a 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -295,9 +295,36 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): if product_id: product_ids.add(product_id) + # 异常批次缩略图只来自该批任务自己的成功成图。批次删除时这些资产会一起软删, + # 这里批量取回并按任务映射,避免逐批查询;商品封面不再参与缩略图回退。 + preview_by_task_id = {} + task_ids = [task.id for task in tasks] + generated_assets = ( + Asset.objects.filter( + team=self.get_team(), + origin_task_id__in=task_ids, + asset_type=Asset.Type.IMAGE, + is_deleted=True, + purged_at__isnull=True, + ) + .prefetch_related("files") + .order_by("created_at", "id") + ) + for asset in generated_assets: + files = sorted( + asset.files.all(), + key=lambda item: (not item.is_primary, item.created_at, str(item.id)), + ) + for file in files: + preview_url = AssetFileSerializer(file).data.get("preview_url", "") + if preview_url: + preview_by_task_id.setdefault(asset.origin_task_id, preview_url) + break + + # 商品仍只用于异常批次标题文字回退,不再关联或预取商品封面资源。 products = Product.objects.filter( team=self.get_team(), id__in=product_ids, status=Product.Status.ACTIVE, purged_at__isnull=True - ).select_related("cover_asset").prefetch_related("cover_asset__files") + ).only("id", "title") product_by_id = {str(product.id): product for product in products} rows = [] @@ -307,12 +334,14 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): first = group[0] payload = first.request_payload or {} product = product_by_id.get(str(payload.get("product_id") or "")) - cover = "" - if product and product.cover_asset and not product.cover_asset.is_deleted and product.cover_asset.purged_at is None: - files = list(product.cover_asset.files.all()) - primary = next((item for item in files if item.is_primary), files[0] if files else None) - if primary: - cover = AssetFileSerializer(primary).data.get("preview_url", "") + cover = next( + ( + preview_by_task_id[task.id] + for task in group + if task.status == AITask.Status.SUCCEEDED and task.id in preview_by_task_id + ), + "", + ) intended = sum(1 for task in group if not (task.request_payload or {}).get("batch_append")) rows.append({ "id": str(first.id),