fix: 完善商品—模特库-视频项目-自由创作等删除先进垃圾桶及可恢复等流程

This commit is contained in:
hh
2026-07-10 15:06:07 +08:00
parent 3e52942547
commit 564215dc53
28 changed files with 1086 additions and 190 deletions
@@ -0,0 +1,29 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("assets", "0009_freeassetgroup_freeasset_and_more"),
]
operations = [
migrations.AddField(
model_name="asset",
name="purged_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="model",
name="purged_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddIndex(
model_name="asset",
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="assets_asse_team_id_d7db74_idx"),
),
migrations.AddIndex(
model_name="model",
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="assets_mode_team_id_21e90d_idx"),
),
]
+4
View File
@@ -55,6 +55,7 @@ class Asset(TeamOwnedModel):
)
metadata = models.JSONField(default=dict, blank=True)
is_deleted = models.BooleanField(default=False)
purged_at = models.DateTimeField(null=True, blank=True)
# 是否进「资产库」列表:工作台生成的图(模特上身图/平台套图/自由创作)默认 False(只在工作台展示),
# 用户「加入资产库」后才置 True 出现在资产库列表;「取消加入」置回 False 即从列表移出。
# 字段默认 True 是为了让历史数据 / 上传 / 项目内资产保持原样可见(只对新生成的工作台图显式置 False)。
@@ -69,6 +70,7 @@ class Asset(TeamOwnedModel):
# 资产库按团队 + 分类倒序时间翻页(products 详情素材趴 / 资产库)
models.Index(fields=["team", "category", "-created_at"]),
models.Index(fields=["team", "asset_type"]),
models.Index(fields=["team", "is_deleted", "purged_at"]),
]
def __str__(self) -> str:
@@ -102,11 +104,13 @@ class Model(TeamOwnedModel):
description = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
is_deleted = models.BooleanField(default=False)
purged_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
# 模特库默认按团队 + 新→旧;官方模板优先靠前由序列化/查询控制
models.Index(fields=["team", "is_official", "-created_at"]),
models.Index(fields=["team", "is_deleted", "purged_at"]),
]
ordering = ["-created_at"]
+3 -1
View File
@@ -141,6 +141,8 @@ class ModelLibrarySerializer(serializers.ModelSerializer):
"source",
"description",
"metadata",
"is_deleted",
"purged_at",
"portrait_asset",
"triview_asset",
"voice_asset",
@@ -150,7 +152,7 @@ class ModelLibrarySerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
]
read_only_fields = ["id", "is_official", "source", "created_at", "updated_at"]
read_only_fields = ["id", "is_official", "source", "is_deleted", "purged_at", "created_at", "updated_at"]
def get_portrait(self, obj):
return _asset_preview(obj.portrait_asset)
+63 -7
View File
@@ -9,7 +9,7 @@ from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from . import assets_client
from .models import Asset, FreeAsset, FreeAssetGroup, Model
from .models import Asset, AssetFile, FreeAsset, FreeAssetGroup, Model
def _mk_team(username, team_name):
@@ -56,6 +56,48 @@ class ModelLibraryApiTests(TestCase):
ids = {m["id"] for m in self.client.get("/api/models/").json()["results"]}
self.assertNotIn(str(self.mine.id), ids)
def test_trash_restore_and_purge_model_keeps_record_and_asset(self):
portrait = Asset.objects.create(team=self.teamA, name="模特形象", asset_type="image", source="upload")
portrait_file = AssetFile.objects.create(
asset=portrait,
object_key="models/portrait.png",
bucket="test",
content_type="image/png",
is_primary=True,
)
self.mine.portrait_asset = portrait
self.mine.save(update_fields=["portrait_asset"])
self.client.delete(f"/api/models/{self.mine.id}/")
trash_ids = {m["id"] for m in self.client.get("/api/models/trash/").json()["results"]}
self.assertIn(str(self.mine.id), trash_ids)
portrait.refresh_from_db()
self.assertFalse(portrait.is_deleted)
self.assertIsNone(portrait.purged_at)
restore = self.client.post(f"/api/models/{self.mine.id}/restore/")
self.assertEqual(restore.status_code, 200)
self.mine.refresh_from_db()
self.assertFalse(self.mine.is_deleted)
self.assertIsNone(self.mine.purged_at)
ids = {m["id"] for m in self.client.get("/api/models/?tab=mine").json()["results"]}
self.assertIn(str(self.mine.id), ids)
self.client.delete(f"/api/models/{self.mine.id}/")
purge = self.client.delete(f"/api/models/{self.mine.id}/purge/")
self.assertEqual(purge.status_code, 204)
self.mine.refresh_from_db()
self.assertTrue(self.mine.is_deleted)
self.assertIsNotNone(self.mine.purged_at)
self.assertTrue(Model.objects.filter(id=self.mine.id).exists())
self.assertTrue(Asset.objects.filter(id=portrait.id).exists())
portrait.refresh_from_db()
self.assertFalse(portrait.is_deleted)
self.assertIsNone(portrait.purged_at)
self.assertTrue(AssetFile.objects.filter(id=portrait_file.id).exists())
trash_ids = {m["id"] for m in self.client.get("/api/models/trash/").json()["results"]}
self.assertNotIn(str(self.mine.id), trash_ids)
def test_official_cannot_be_deleted(self):
res = self.client.delete(f"/api/models/{self.official.id}/")
self.assertEqual(res.status_code, 403)
@@ -108,7 +150,7 @@ class AssetSoftDeleteTests(TestCase):
class AssetTrashApiTests(TestCase):
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 硬删且被引用时 409。"""
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 二级软删。"""
def setUp(self):
self.user, self.team = _mk_team("utr", "TeamTR")
@@ -117,6 +159,13 @@ class AssetTrashApiTests(TestCase):
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)
self.dead_file = AssetFile.objects.create(
asset=self.dead,
object_key="assets/deleted.png",
bucket="test",
content_type="image/png",
is_primary=True,
)
# 别队软删资产:不得出现在本队垃圾桶(team 隔离)
self.other_dead = Asset.objects.create(team=self.other_team, name="别队删图", asset_type="image", source="ai_generated", is_deleted=True)
@@ -134,19 +183,26 @@ class AssetTrashApiTests(TestCase):
# 恢复后不再在垃圾桶
self.assertEqual(self.client.get("/api/assets/trash/").json()["count"], 0)
def test_purge_hard_deletes(self):
def test_purge_soft_hides_from_trash(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())
self.dead.refresh_from_db()
self.assertIsNotNone(self.dead.purged_at)
self.assertTrue(Asset.objects.filter(id=self.dead.id).exists())
self.assertTrue(AssetFile.objects.filter(id=self.dead_file.id).exists())
self.assertEqual(self.client.get("/api/assets/trash/").json()["count"], 0)
def test_purge_referenced_asset_returns_409(self):
def test_purge_referenced_asset_keeps_record(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()) # 留在垃圾桶可恢复
self.assertEqual(res.status_code, 204)
self.dead.refresh_from_db()
self.assertTrue(self.dead.is_deleted)
self.assertIsNotNone(self.dead.purged_at)
self.assertTrue(Asset.objects.filter(id=self.dead.id).exists())
def test_restore_rejects_live_asset(self):
# 未删资产不在 trash 态 queryset → 404(与商品垃圾桶口径一致)
+53 -21
View File
@@ -5,8 +5,8 @@ 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 django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
@@ -120,8 +120,15 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
# 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) # 软删资产不出现在资产库
trash_qs = qs.filter(is_deleted=True, purged_at__isnull=True)
# 自由创作父记录已进垃圾桶时,生成 Asset 由父记录统一恢复/彻底隐藏,
# 不在“资产”分类重复展示。单独从资产库删除时父记录仍为正常态,仍会出现在这里。
owned_by_deleted_free_creation = (
Q(origin_task__task_type="free_video", origin_task__is_deleted=True)
| Q(origin_task__conversation__is_deleted=True)
)
return trash_qs.exclude(owned_by_deleted_free_creation).order_by("-updated_at")
qs = qs.filter(is_deleted=False, purged_at__isnull=True) # 软删资产不出现在资产库
p = self.request.query_params
# 资产库列表/批次默认只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。
# 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。
@@ -176,7 +183,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
"""资产库 tab 计数,供 tab 徽标——不必取全量。
图片趴三类(模特上身图/平台套图/自由创作)按「生成批次」计数(与卡片成组展示一致);
其他(我的上传 + 兜底)仍按资产张数计。"""
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, purged_at__isnull=True, in_library=True)
out = {}
for t in BATCH_TABS: # 图片趴:数批次
seen = set()
@@ -279,23 +286,16 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
(均过滤 is_deleted),恢复后各处即联动复现;in_library 保持删除前原样,不额外置位。"""
asset = self.get_object() # trash 态 queryset:只有软删资产可恢复
asset.is_deleted = False
asset.save(update_fields=["is_deleted", "updated_at"])
asset.purged_at = None
asset.save(update_fields=["is_deleted", "purged_at", "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。"""
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录和 TOS 文件保留。"""
asset = self.get_object()
try:
asset.delete()
except ProtectedError:
return Response(
{"detail": "该资产仍被商品图/分镜/视频片段等引用,无法彻底删除;可恢复后先解除引用"},
status=status.HTTP_409_CONFLICT,
)
asset.purged_at = timezone.now()
asset.save(update_fields=["purged_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["get"], url_path="video-packs")
@@ -323,7 +323,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
clips = []
for seg in segs:
a = getattr(seg.adopted_version, "asset", None)
if a is None or a.is_deleted:
if a is None or a.is_deleted or a.purged_at is not None:
continue
clips.append({"id": str(a.id), "name": a.name, "url": _asset_preview(a)})
if not clips:
@@ -342,7 +342,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
def facets(self, request):
"""某 tab 下真实存在的筛选项(来源/类型/关联商品/指定 metadata 键的取值),供下拉「只列真有的」。
参数:tab、meta_keys=gender,age,role,...(逗号分隔)。"""
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, purged_at__isnull=True, in_library=True)
if request.query_params.get("tab"):
base = base.filter(_tab_q(request.query_params["tab"]))
sources = sorted(s for s in base.values_list("source", flat=True).distinct() if s)
@@ -430,8 +430,14 @@ class ModelLibraryViewSet(ModelViewSet):
def get_queryset(self):
team = self.get_team()
if self.action in ("trash", "restore", "purge"):
return (
Model.objects.filter(team=team, is_official=False, is_deleted=True, purged_at__isnull=True)
.prefetch_related("portrait_asset__files", "triview_asset__files")
.order_by("-updated_at")
)
qs = (
Model.objects.filter(Q(team=team) | Q(is_official=True), is_deleted=False)
Model.objects.filter(Q(team=team) | Q(is_official=True), is_deleted=False, purged_at__isnull=True)
.prefetch_related("portrait_asset__files", "triview_asset__files")
)
tab = self.request.query_params.get("tab")
@@ -456,6 +462,32 @@ class ModelLibraryViewSet(ModelViewSet):
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
@action(detail=False, methods=["get"], url_path="trash")
def trash(self, request):
"""垃圾桶:列出本团队已软删且未彻底隐藏的自建模特。"""
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):
"""从垃圾桶恢复模特。"""
model = self.get_object()
model.is_deleted = False
model.purged_at = None
model.save(update_fields=["is_deleted", "purged_at", "updated_at"])
return Response(self.get_serializer(model).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["delete"], url_path="purge")
def purge(self, request, pk=None):
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录和关联资产保留。"""
model = self.get_object()
model.purged_at = timezone.now()
model.save(update_fields=["purged_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["post"], url_path="upload", parser_classes=[MultiPartParser, FormParser])
def upload(self, request):
"""真人上传:一张人像图 → 建 model_portrait 资产 + Model(source=upload)。三视图/声线后续补。"""
@@ -507,10 +539,10 @@ class ModelLibraryViewSet(ModelViewSet):
资产生成时已落库扣过费,此处只引用、不重新上传/不二次扣费。同一资产已建过则幂等返回。"""
team = self.get_team()
asset_id = request.data.get("asset_id")
asset = Asset.objects.filter(id=asset_id, team=team, is_deleted=False).first() if asset_id else None
asset = Asset.objects.filter(id=asset_id, team=team, is_deleted=False, purged_at__isnull=True).first() if asset_id else None
if asset is None:
raise ValidationError({"asset_id": "资产不存在或不属于本团队"})
existing = Model.objects.filter(team=team, portrait_asset=asset, is_deleted=False).first()
existing = Model.objects.filter(team=team, portrait_asset=asset, is_deleted=False, purged_at__isnull=True).first()
if existing is not None:
return Response(ModelLibrarySerializer(existing).data, status=status.HTTP_200_OK)
name = (request.data.get("name") or (asset.name or "模特").split("·")[0].strip() or "模特")[:255]