fix: 测试清单遗留bug批量修复(15条:商品库/消费/团队/设置/消息中心/平台套图/资产库)
- R6 商品图删除不生效: 封面asset移交+绕开prefetch缓存, 前端封面缩图补删除入口 - R48 消息中心长文本与UI框错位: 胶囊单行省略+优先级标签禁压缩 - R70 账单流水筛选: 后端加ledger_type过滤, count随筛选变化, 切筛选重置页码 - R72 月限额改后不刷新: 团队页/消费页自取最新teamSettings, 两页同源 - R83/R86 平台套图提示词框加大+模型胶囊嵌入输入框(复用Pill组件) - R96 未读角标: 修KeyTextTransform注解过滤触发MySQL 3141致接口500 - R100 工作台记录丢失: 新增GET /api/ai/tasks/workbench/ 同源恢复, 废除localStorage方案 - R104 卖点添加按钮移出输入框, 新建抽屉+编辑态统一 - R106 商品库宽屏锁5列, 10个/页铺满两行 - R108 资产删除改软删+单张删除+回收站二次确认; trash页商品/资产双分区+restore/purge端点 - R109 生成图自动入资产库(person演员立绘除外), 迁移0008存量回填, 移除全部加入资产库入口 - R65 无需改动(22268d6已修, 测旧部署所致) 验证: tsc/build/check/makemigrations --check 通过, 后端测试210/210, perf-probe PASS Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# R109:图片生成(工作台)产物自动入资产库 —— 回填历史存量。
|
||||
# 影响范围只限图片生成三类产物(model_tryon 模特上身图 / platform_kit 平台套图 / free_create 自由创作):
|
||||
# 这三个 category 只由独立生图 worker 产出,把其中 in_library=False(旧「未加入资产库」)的置 True。
|
||||
# 不碰 person(演员立绘暂存,由「保存人物」入库,ZWQ#6)、不碰其他任何分类。
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def backfill_workbench_in_library(apps, schema_editor):
|
||||
Asset = apps.get_model("assets", "Asset")
|
||||
Asset.objects.filter(
|
||||
category__in=["model_tryon", "platform_kit", "free_create"],
|
||||
in_library=False,
|
||||
).update(in_library=True)
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
# 不可逆:回滚不把这些图再踢出资产库(无害)
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("assets", "0007_asset_in_library"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(backfill_workbench_in_library, noop),
|
||||
]
|
||||
@@ -105,6 +105,52 @@ class AssetSoftDeleteTests(TestCase):
|
||||
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 1)
|
||||
|
||||
|
||||
class AssetTrashApiTests(TestCase):
|
||||
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 硬删且被引用时 409。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user, self.team = _mk_team("utr", "TeamTR")
|
||||
_, self.other_team = _mk_team("utr2", "TeamTR2")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.live = Asset.objects.create(team=self.team, name="活图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON)
|
||||
self.dead = Asset.objects.create(team=self.team, name="删掉的图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON, is_deleted=True)
|
||||
# 别队软删资产:不得出现在本队垃圾桶(team 隔离)
|
||||
self.other_dead = Asset.objects.create(team=self.other_team, name="别队删图", asset_type="image", source="ai_generated", is_deleted=True)
|
||||
|
||||
def test_trash_lists_only_own_deleted(self):
|
||||
ids = {a["id"] for a in self.client.get("/api/assets/trash/").json()["results"]}
|
||||
self.assertEqual(ids, {str(self.dead.id)}) # 只含本队软删;不含活图 / 别队删图
|
||||
|
||||
def test_restore_makes_asset_visible_in_library_again(self):
|
||||
res = self.client.post(f"/api/assets/{self.dead.id}/restore/")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.dead.refresh_from_db()
|
||||
self.assertFalse(self.dead.is_deleted)
|
||||
ids = {a["id"] for a in self.client.get("/api/assets/?page_size=50").json()["results"]}
|
||||
self.assertIn(str(self.dead.id), ids) # 资产库重新可见
|
||||
# 恢复后不再在垃圾桶
|
||||
self.assertEqual(self.client.get("/api/assets/trash/").json()["count"], 0)
|
||||
|
||||
def test_purge_hard_deletes(self):
|
||||
res = self.client.delete(f"/api/assets/{self.dead.id}/purge/")
|
||||
self.assertEqual(res.status_code, 204)
|
||||
self.assertFalse(Asset.objects.filter(id=self.dead.id).exists())
|
||||
|
||||
def test_purge_referenced_asset_returns_409(self):
|
||||
from apps.products.models import Product, ProductImage
|
||||
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="P")
|
||||
ProductImage.objects.create(product=product, asset=self.dead) # PROTECT 引用
|
||||
res = self.client.delete(f"/api/assets/{self.dead.id}/purge/")
|
||||
self.assertEqual(res.status_code, 409)
|
||||
self.assertTrue(Asset.objects.filter(id=self.dead.id, is_deleted=True).exists()) # 留在垃圾桶可恢复
|
||||
|
||||
def test_restore_rejects_live_asset(self):
|
||||
# 未删资产不在 trash 态 queryset → 404(与商品垃圾桶口径一致)
|
||||
self.assertEqual(self.client.post(f"/api/assets/{self.live.id}/restore/").status_code, 404)
|
||||
|
||||
|
||||
class VideoPacksTests(TestCase):
|
||||
"""视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。"""
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
import requests
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.db.models.deletion import ProtectedError
|
||||
from django.http import StreamingHttpResponse
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
@@ -99,11 +100,24 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
search_fields = ["name", "description"]
|
||||
ordering_fields = ["created_at", "updated_at", "name"]
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""R108:资产删除 = 回收站软删(is_deleted 置位),不再物理删行(原默认 hard delete 不可恢复)。
|
||||
资产库列表/批次/任务中心批次弹窗/工作台记录/对话历史引用的都是同一 Asset 查询(均过滤
|
||||
is_deleted),软删后各处即联动消失(R109「生成记录与资产库数据绑定」)。TOS 文件保留,可恢复。"""
|
||||
instance.is_deleted = True
|
||||
instance.save(update_fields=["is_deleted", "updated_at"])
|
||||
|
||||
def get_queryset(self):
|
||||
"""服务端过滤,支持各页按需懒加载(不再前端取全量后客户端切片)。
|
||||
参数:tab(资产库分桶)/category/asset_type/source/product/q(搜索)/m_<key>(metadata 过滤)/ordering。
|
||||
默认按 -created_at 排序——无 ORDER BY 时分页会重/漏。"""
|
||||
qs = super().get_queryset().filter(is_deleted=False) # 软删资产不出现在资产库
|
||||
qs = super().get_queryset()
|
||||
# R108 垃圾桶:trash/restore/purge 只看软删资产(镜像商品垃圾桶的写法)。
|
||||
# perform_destroy 软删时 update updated_at → -updated_at ≈ 删除时间倒序;不过滤 in_library
|
||||
# (未入库的工作台图删了也得能恢复)。基类 queryset 的 defer(AITask 巨型 payload)保持生效。
|
||||
if self.action in ("trash", "restore", "purge"):
|
||||
return qs.filter(is_deleted=True).order_by("-updated_at")
|
||||
qs = qs.filter(is_deleted=False) # 软删资产不出现在资产库
|
||||
p = self.request.query_params
|
||||
# 资产库列表/批次默认只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。
|
||||
# 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。
|
||||
@@ -244,6 +258,41 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
asset.save(update_fields=["in_library"])
|
||||
return Response({"id": str(asset.id), "in_library": asset.in_library})
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="trash")
|
||||
def trash(self, request):
|
||||
"""垃圾桶:列出本团队已软删(is_deleted=True)的资产 —— R108 删除弹窗承诺
|
||||
「删除的图片将回收到垃圾桶」,此口兑现承诺(与商品垃圾桶 /api/products/trash/ 同构)。"""
|
||||
qs = self.filter_queryset(self.get_queryset())
|
||||
page = self.paginate_queryset(qs)
|
||||
if page is not None:
|
||||
return self.get_paginated_response(self.get_serializer(page, many=True).data)
|
||||
return Response(self.get_serializer(qs, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="restore")
|
||||
def restore(self, request, pk=None):
|
||||
"""从垃圾桶恢复:is_deleted → False。资产库/批次/商品素材区引用的都是同一 Asset 查询
|
||||
(均过滤 is_deleted),恢复后各处即联动复现;in_library 保持删除前原样,不额外置位。"""
|
||||
asset = self.get_object() # trash 态 queryset:只有软删资产可恢复
|
||||
asset.is_deleted = False
|
||||
asset.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(self.get_serializer(asset).data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["delete"], url_path="purge")
|
||||
def purge(self, request, pk=None):
|
||||
"""彻底删除(仅垃圾桶内软删资产可彻底删,不可恢复)。与商品 purge 语义对齐:
|
||||
硬删 DB 行(AssetFile 记录级联删除),TOS 对象不动(商品 purge 同样不触碰对象存储)。
|
||||
分镜/视频片段/商品图等以 PROTECT 引用资产:被引用的资产硬删会炸 ProtectedError,
|
||||
如实回 409(留在垃圾桶,仍可恢复),别让它 500。"""
|
||||
asset = self.get_object()
|
||||
try:
|
||||
asset.delete()
|
||||
except ProtectedError:
|
||||
return Response(
|
||||
{"detail": "该资产仍被商品图/分镜/视频片段等引用,无法彻底删除;可恢复后先解除引用"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="video-packs")
|
||||
def video_packs(self, request):
|
||||
"""视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源:
|
||||
|
||||
Reference in New Issue
Block a user