fix: 隔离图片创作商品会话

This commit is contained in:
hh
2026-07-16 18:22:09 +08:00
parent ff5eaeee8a
commit 538c5024e2
7 changed files with 278 additions and 28 deletions
+138 -2
View File
@@ -1012,6 +1012,42 @@ class ImageConversationTests(TestCase):
self.assertEqual(self.client.get("/api/ai/image-conversations/?mode=image").json()["results"], [])
self.assertTrue(ImageConversation.objects.get(id=conv_id).is_deleted)
def test_listing_filters_product_and_unbound_scopes(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="范围商品")
unbound = ImageConversation.objects.create(team=self.team, created_by=self.user, title="通用会话")
bound = ImageConversation.objects.create(team=self.team, created_by=self.user, title="商品会话", product=product)
unbound_rows = self.client.get(
"/api/ai/image-conversations/?mode=image&scope=unbound"
).json()["results"]
product_rows = self.client.get(
f"/api/ai/image-conversations/?mode=image&product_id={product.id}"
).json()["results"]
self.assertEqual([row["id"] for row in unbound_rows], [str(unbound.id)])
self.assertEqual([row["id"] for row in product_rows], [str(bound.id)])
conflict = self.client.get(
f"/api/ai/image-conversations/?mode=image&scope=unbound&product_id={product.id}"
)
self.assertEqual(conflict.status_code, 400)
def test_conversation_scope_rejects_cross_team_product(self):
other = User.objects.create_user(username="conv-other", password="pass")
other_team = Team.objects.create(name="Conv Other", owner=other)
other_product = Product.objects.create(team=other_team, created_by=other, title="其他团队商品")
listed = self.client.get(
f"/api/ai/image-conversations/?mode=image&product_id={other_product.id}"
)
created = self.client.post(
"/api/ai/image-conversations/",
{"mode": "image", "title": "越权会话", "product": str(other_product.id)},
format="json",
)
self.assertEqual(listed.status_code, 400)
self.assertEqual(created.status_code, 400)
def test_trash_restore_and_purge_conversation_keeps_record(self):
conv = ImageConversation.objects.create(team=self.team, created_by=self.user, title="待删", mode=ImageConversation.Mode.IMAGE)
provider = ModelProvider.objects.create(name="conv-trash-provider", display_name="Conv Trash Provider")
@@ -1162,13 +1198,79 @@ class ImageConversationTests(TestCase):
self.assertTrue(all(t.conversation_id == conv.id for t in tasks))
self.assertEqual(conv.tasks.count(), 2)
def test_image_generate_without_product_keeps_conversation_and_task_unbound(self):
"""图片生成首页进入的图片创作不应静默补入当前/首个商品。"""
CreditAccount.objects.create(team=self.team, balance="100.0000")
Product.objects.create(team=self.team, created_by=self.user, title="不应自动关联的商品")
with patch("apps.ai.tasks.generate_standalone_image_task.delay"):
response = self.client.post(
"/api/ai/generate-image/",
{"prompt": "一只宇航猫", "mode": "image", "count": 1},
format="json",
)
self.assertEqual(response.status_code, 202, response.content)
conversation = ImageConversation.objects.get(id=response.json()["conversation_id"])
task = AITask.objects.get(conversation=conversation)
self.assertIsNone(conversation.product_id)
self.assertIsNone(task.request_payload.get("product_id"))
def test_image_generate_with_explicit_product_keeps_product_binding(self):
"""商品详情显式进入图片创作时,仍保留商品归属。"""
CreditAccount.objects.create(team=self.team, balance="100.0000")
product = Product.objects.create(team=self.team, created_by=self.user, title="显式关联商品")
with patch("apps.ai.tasks.generate_standalone_image_task.delay"):
response = self.client.post(
"/api/ai/generate-image/",
{"prompt": "商品海报", "mode": "image", "count": 1, "product_id": str(product.id)},
format="json",
)
self.assertEqual(response.status_code, 202, response.content)
conversation = ImageConversation.objects.get(id=response.json()["conversation_id"])
task = AITask.objects.get(conversation=conversation)
self.assertEqual(conversation.product_id, product.id)
self.assertEqual(task.request_payload.get("product_id"), str(product.id))
def test_new_generation_with_mismatched_conversation_creates_correct_scope(self):
CreditAccount.objects.create(team=self.team, balance="100.0000")
original_product = Product.objects.create(team=self.team, created_by=self.user, title="原商品")
target_product = Product.objects.create(team=self.team, created_by=self.user, title="目标商品")
original_conversation = ImageConversation.objects.create(
team=self.team,
created_by=self.user,
title="原商品会话",
product=original_product,
)
with patch("apps.ai.tasks.generate_standalone_image_task.delay"):
response = self.client.post(
"/api/ai/generate-image/",
{
"prompt": "目标商品海报",
"mode": "image",
"count": 1,
"product_id": str(target_product.id),
"conversation_id": str(original_conversation.id),
},
format="json",
)
self.assertEqual(response.status_code, 202, response.content)
self.assertNotEqual(response.json()["conversation_id"], str(original_conversation.id))
created_conversation = ImageConversation.objects.get(id=response.json()["conversation_id"])
self.assertEqual(created_conversation.product_id, target_product.id)
self.assertEqual(original_conversation.tasks.count(), 0)
self.assertEqual(created_conversation.tasks.get().request_payload.get("product_id"), str(target_product.id))
def test_tasks_endpoint_returns_grouped_history(self):
conv = ImageConversation.objects.create(team=self.team, created_by=self.user, title="历史")
product = Product.objects.create(team=self.team, created_by=self.user, title="历史商品")
conv = ImageConversation.objects.create(team=self.team, created_by=self.user, title="历史", product=product)
mc = ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE).first()
AITask.objects.create(
team=self.team, created_by=self.user, conversation=conv, task_type=AITask.Type.PRODUCT_IMAGE,
status=AITask.Status.SUCCEEDED, model_config=mc, idempotency_key="conv-test-1",
request_payload={"prompt": "", "batch_id": "bx", "ratio": "1:1"},
request_payload={"prompt": "", "batch_id": "bx", "ratio": "1:1", "product_id": str(product.id)},
)
r = self.client.get(f"/api/ai/image-conversations/{conv.id}/tasks/")
self.assertEqual(r.status_code, 200, r.content)
@@ -1176,6 +1278,40 @@ class ImageConversationTests(TestCase):
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["prompt"], "")
self.assertEqual(data[0]["batch_id"], "bx")
self.assertEqual(data[0]["product_id"], str(product.id))
def test_rerun_uses_original_batch_product_instead_of_request_product(self):
CreditAccount.objects.create(team=self.team, balance="100.0000")
original_product = Product.objects.create(team=self.team, created_by=self.user, title="原批次商品")
other_product = Product.objects.create(team=self.team, created_by=self.user, title="当前路由商品")
with patch("apps.ai.tasks.generate_standalone_image_task.delay"):
first = self.client.post(
"/api/ai/generate-image/",
{"prompt": "原商品海报", "mode": "image", "count": 1, "product_id": str(original_product.id)},
format="json",
)
self.assertEqual(first.status_code, 202, first.content)
original_task = AITask.objects.get(conversation_id=first.json()["conversation_id"])
original_task.status = AITask.Status.FAILED
original_task.save(update_fields=["status"])
rerun = self.client.post(
"/api/ai/generate-image/",
{
"prompt": "原商品海报",
"mode": "image",
"count": 1,
"product_id": str(other_product.id),
"conversation_id": first.json()["conversation_id"],
"batch_id": first.json()["batch_id"],
"retry_of_task_id": str(original_task.id),
},
format="json",
)
self.assertEqual(rerun.status_code, 202, rerun.content)
self.assertEqual(rerun.json()["conversation_id"], first.json()["conversation_id"])
newest = AITask.objects.filter(conversation_id=first.json()["conversation_id"]).order_by("created_at").last()
self.assertEqual(newest.request_payload.get("product_id"), str(original_product.id))
def test_generate_with_refs_persists_and_tasks_endpoint_returns_them(self):
"""HTTP 全链路:带 reference_image_ids 提交 → 任务 payload 落 ids → tasks 接口把参考图解析回 {name,url}。
+65
View File
@@ -1,8 +1,11 @@
import uuid
from django.db import transaction
from django.db.models import Count, Exists, OuterRef, Q
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -55,12 +58,50 @@ class GenerateImageView(APIView):
raw_refs = [s for s in raw_refs.split(",") if s.strip()]
reference_image_ids = [str(r).strip() for r in raw_refs if str(r).strip()]
team = get_current_team(request.user)
if product_id:
try:
normalized_product_id = str(uuid.UUID(product_id))
except (TypeError, ValueError, AttributeError):
return Response({"detail": "商品 ID 无效"}, status=status.HTTP_400_BAD_REQUEST)
if not Product.objects.filter(team=team, id=normalized_product_id).exists():
return Response({"detail": "商品不存在或不属于当前团队"}, status=status.HTTP_400_BAD_REQUEST)
product_id = normalized_product_id
# 对话归属:传了 id 就用现成对话(限本团队);没传则自动开一条新对话,标题取 prompt 前 24 字。
conversation = None
if conversation_id:
conversation = ImageConversation.objects.filter(
team=team, id=conversation_id, is_deleted=False, purged_at__isnull=True
).first()
# 合法重跑/补图必须能在当前对话里找到原批次;归属强制读取原批次,不信任当前路由或客户端值。
original_batch_task = None
if conversation is not None and batch_id:
candidates = AITask.objects.filter(
team=team,
conversation=conversation,
is_deleted=False,
purged_at__isnull=True,
request_payload__batch_id=batch_id,
).order_by("created_at")
original_batch_task = next(
(task for task in candidates if not (task.request_payload or {}).get("batch_append")),
None,
)
if original_batch_task is not None:
product_id = str((original_batch_task.request_payload or {}).get("product_id") or "").strip() or None
else:
# 任意 UUID 不能伪装成可追加批次;降级为普通新批次。
batch_id = None
retry_of_task_id = None
elif batch_id:
batch_id = None
retry_of_task_id = None
# 普通新批次不能写入其他商品/通用范围的旧会话;竞态或旧客户端出现错配时自动开正确范围会话。
if conversation is not None:
same_mode = conversation.mode == mode
same_product = str(conversation.product_id or "") == str(product_id or "")
if not same_mode or (mode == ImageConversation.Mode.IMAGE and original_batch_task is None and not same_product):
conversation = None
if conversation is None:
conversation = ImageConversation.objects.create(
team=team,
@@ -415,8 +456,31 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
mode = self.request.query_params.get("mode", "").strip()
if mode:
queryset = queryset.filter(mode=mode)
if self.action == "list":
scope = self.request.query_params.get("scope", "").strip()
product_id = self.request.query_params.get("product_id", "").strip()
if scope and product_id:
raise ValidationError({"detail": "scope 与 product_id 不能同时传入"})
if scope:
if scope != "unbound":
raise ValidationError({"detail": "scope 仅支持 unbound"})
queryset = queryset.filter(product__isnull=True)
elif product_id:
try:
normalized_product_id = str(uuid.UUID(product_id))
except (TypeError, ValueError, AttributeError) as exc:
raise ValidationError({"detail": "商品 ID 无效"}) from exc
if not Product.objects.filter(team=self.get_team(), id=normalized_product_id).exists():
raise ValidationError({"detail": "商品不存在或不属于当前团队"})
queryset = queryset.filter(product_id=normalized_product_id)
return queryset
def perform_create(self, serializer):
product = serializer.validated_data.get("product")
if product is not None and product.team_id != self.get_team().id:
raise ValidationError({"product": "商品不存在或不属于当前团队"})
super().perform_create(serializer)
def perform_destroy(self, instance):
# 只联动该会话生成任务产出的 Asset,不碰用户上传的参考素材。
with transaction.atomic():
@@ -499,6 +563,7 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"prompt": (t.request_payload or {}).get("prompt", ""),
"batch_id": (t.request_payload or {}).get("batch_id", ""),
"ratio": (t.request_payload or {}).get("ratio") or "",
"product_id": str((t.request_payload or {}).get("product_id") or ""),
# 重跑/补图任务:不计入批次「应出张数」,前端据此正确渲染失败格数量
"rerun": bool((t.request_payload or {}).get("batch_append")),
"retry_of_task_id": str((t.request_payload or {}).get("retry_of_task_id") or ""),