feat(product): 商品图删除接通 + 至少保留一张

- 详情页编辑态的删除叉子原是纯 CSS ::after 装饰、没绑事件,且列表用的是 asset id 而非删除接口要的
  ProductImage id —— 接通:编辑态点图真调删除接口、用 ProductImage id,非编辑态仍点击放大
- 商品至少保留一张图(与「创建必须带图」一致):后端删到最后一张返回 400;前端只剩一张时那张不显示
  叉子 + 提示「商品至少保留一张图;要移除请删除整个商品」;封面无对应 ProductImage(如三视图采用为封面)
  则只展示、不可删
- 加 2 个后端测试(多张可删 200 / 删最后一张 400)

真实接口验:两张删一张 200、再删最后一张 400 并保留一张;后端测试通过,tsc/build 通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-23 16:29:05 +08:00
co-authored by Claude Opus 4.8
parent f02c8053a1
commit f9c52a171c
4 changed files with 72 additions and 18 deletions
+34
View File
@@ -98,3 +98,37 @@ class ProductMaterialsTests(TestCase):
self.assertEqual(len(data["project_packs"]), 1)
self.assertEqual(data["project_packs"][0]["project_name"], "项目甲")
self.assertEqual(len(data["project_packs"][0]["items"]), 2) # 场景 + 视频素材
class ProductImageDeleteGuardTests(TestCase):
"""商品至少保留一张图:删到最后一张则拒绝(要清空只能删整个商品)。"""
def setUp(self):
from apps.assets.models import Asset, AssetFile
from .models import ProductImage
self.user = User.objects.create_user(username="idg", password="x")
self.team = Team.objects.create(name="IDG", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.client = APIClient()
self.client.force_authenticate(self.user)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
def mk_img(i):
a = Asset.objects.create(team=self.team, created_by=self.user, name=f"img{i}", asset_type="image", category="product_image")
AssetFile.objects.create(asset=a, object_key=f"k{i}", bucket="b", content_type="image/png", size_bytes=1, is_primary=True)
return ProductImage.objects.create(product=self.product, asset=a, sort_order=i, is_primary=(i == 0))
self.img0 = mk_img(0)
self.img1 = mk_img(1)
def test_delete_when_multiple_ok(self):
res = self.client.delete(f"/api/products/{self.product.id}/images/{self.img1.id}/")
self.assertEqual(res.status_code, 200)
self.assertEqual(self.product.images.count(), 1)
def test_delete_last_image_rejected(self):
self.client.delete(f"/api/products/{self.product.id}/images/{self.img1.id}/") # 删到只剩一张
res = self.client.delete(f"/api/products/{self.product.id}/images/{self.img0.id}/") # 再删最后一张
self.assertEqual(res.status_code, 400)
self.assertEqual(self.product.images.count(), 1) # 没删掉,仍保留一张
+9 -3
View File
@@ -192,10 +192,16 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["delete"], url_path=r"images/(?P<image_id>[^/.]+)")
def delete_image(self, request, pk=None, image_id=None):
"""移除商品图(删 ProductImage 关联,保留底层 Asset)。"""
"""移除商品图(删 ProductImage 关联,保留底层 Asset)。
商品至少保留一张图(与「创建必须带图」一致):删到最后一张则拒绝 —— 要清空只能删整个商品。"""
product = self.get_object()
deleted, _ = ProductImage.objects.filter(product=product, id=image_id).delete()
if not deleted:
if not ProductImage.objects.filter(product=product, id=image_id).exists():
return Response({"detail": "image not found"}, status=status.HTTP_404_NOT_FOUND)
if product.images.count() <= 1:
return Response(
{"detail": "商品至少保留一张图片,如需移除请删除整个商品(或先上传新图再删旧图)"},
status=status.HTTP_400_BAD_REQUEST,
)
ProductImage.objects.filter(product=product, id=image_id).delete()
product.refresh_from_db()
return Response(ProductSerializer(product).data, status=status.HTTP_200_OK)