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
+2 -2
View File
@@ -682,7 +682,7 @@ def finalize_free_video(*, task: AITask) -> AITask:
return locked
def serialize_free_video_task(task: AITask) -> dict:
def serialize_free_video_task(task: AITask, *, include_deleted_assets: bool = False) -> dict:
"""任务 → 前端契约。视频/封面直链从 generated_assets 取(TOS 公读),转存失败回落火山临时 URL。"""
payload = task.request_payload or {}
video_url = ""
@@ -704,7 +704,7 @@ def serialize_free_video_task(task: AITask) -> dict:
generated_assets = list(task.generated_assets.all())
for asset in generated_assets:
if asset.is_deleted:
if asset.purged_at is not None or (asset.is_deleted and not include_deleted_assets):
continue
for f in asset.files.all():
url = _file_url(f)
@@ -0,0 +1,29 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ai", "0025_points_rescale"),
]
operations = [
migrations.AddField(
model_name="imageconversation",
name="purged_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="aitask",
name="purged_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddIndex(
model_name="imageconversation",
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="ai_imagecon_team_id_651707_idx"),
),
migrations.AddIndex(
model_name="aitask",
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="ai_aitask_team_id_1a915a_idx"),
),
]
+4
View File
@@ -73,12 +73,14 @@ class ImageConversation(TeamOwnedModel):
related_name="image_conversations",
)
is_deleted = models.BooleanField(default=False)
purged_at = models.DateTimeField(null=True, blank=True)
# 每次在该对话里发起生成都刷新,左栏「最近」按它倒序
last_active_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=["team", "mode", "-last_active_at"]),
models.Index(fields=["team", "is_deleted", "purged_at"]),
]
def __str__(self) -> str:
@@ -149,6 +151,7 @@ class AITask(TeamOwnedModel):
# 自由创作任务流的收藏 / 软删(其它任务类型恒 False,无行为影响)
is_favorited = models.BooleanField(default=False)
is_deleted = models.BooleanField(default=False)
purged_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
@@ -163,6 +166,7 @@ class AITask(TeamOwnedModel):
models.Index(fields=["team", "read_at"]),
# 自由创作任务流:按团队 + 类型倒序分页
models.Index(fields=["team", "task_type", "-created_at"]),
models.Index(fields=["team", "is_deleted", "purged_at"]),
]
def __str__(self) -> str:
+7 -2
View File
@@ -26,8 +26,11 @@ class ImageConversationSerializer(serializers.ModelSerializer):
class Meta:
model = ImageConversation
fields = ["id", "title", "mode", "product", "task_count", "last_active_at", "created_at", "updated_at"]
read_only_fields = ["id", "task_count", "last_active_at", "created_at", "updated_at"]
fields = [
"id", "title", "mode", "product", "task_count",
"is_deleted", "purged_at", "last_active_at", "created_at", "updated_at",
]
read_only_fields = ["id", "task_count", "is_deleted", "purged_at", "last_active_at", "created_at", "updated_at"]
def get_task_count(self, obj) -> int:
# list 接口已 annotate;无 annotate 时回落实时 count(详情/创建场景)
@@ -65,6 +68,8 @@ class AITaskSerializer(serializers.ModelSerializer):
"error_message",
"submitted_at",
"completed_at",
"is_deleted",
"purged_at",
"created_at",
"updated_at",
]
+209
View File
@@ -230,6 +230,14 @@ class FreeVideoAssetDeletionTests(TestCase):
preview_url="http://tos.example/video.mp4",
is_primary=True,
)
AssetFile.objects.create(
asset=self.asset,
object_key="free-video-poster.jpg",
bucket="b",
content_type="image/jpeg",
preview_url="http://tos.example/free-video-poster.jpg",
is_primary=False,
)
def test_deleted_persisted_asset_does_not_fall_back_to_raw_video_url(self):
from apps.ai.free_video import serialize_free_video_task
@@ -240,7 +248,76 @@ class FreeVideoAssetDeletionTests(TestCase):
task = AITask.objects.prefetch_related("generated_assets", "generated_assets__files").get(id=self.task.id)
data = serialize_free_video_task(task)
self.assertEqual(data["video_url"], "")
self.assertEqual(data["thumbnail_url"], "")
self.assertEqual(data["fallback_note"], "fallback")
trash_data = serialize_free_video_task(task, include_deleted_assets=True)
self.assertEqual(trash_data["video_url"], "http://tos.example/video.mp4")
self.assertEqual(trash_data["thumbnail_url"], "http://tos.example/free-video-poster.jpg")
def test_direct_asset_delete_stays_in_asset_trash(self):
client = APIClient()
client.force_authenticate(self.user)
delete = client.delete(f"/api/assets/{self.asset.id}/")
self.assertEqual(delete.status_code, 204)
self.task.refresh_from_db()
self.asset.refresh_from_db()
self.assertFalse(self.task.is_deleted)
self.assertTrue(self.asset.is_deleted)
trash_ids = {a["id"] for a in client.get("/api/assets/trash/").json()["results"]}
self.assertIn(str(self.asset.id), trash_ids)
self.assertEqual(client.get("/api/ai/free-video/").json()["total"], 0)
restore = client.post(f"/api/assets/{self.asset.id}/restore/")
self.assertEqual(restore.status_code, 200)
live_ids = {t["id"] for t in client.get("/api/ai/free-video/").json()["results"]}
self.assertIn(str(self.task.id), live_ids)
def test_trash_restore_and_purge_free_video_keeps_record(self):
self.client = APIClient()
self.client.force_authenticate(self.user)
delete = self.client.delete(f"/api/ai/free-video/{self.task.id}/")
self.assertEqual(delete.status_code, 204)
self.task.refresh_from_db()
self.asset.refresh_from_db()
self.assertTrue(self.task.is_deleted)
self.assertTrue(self.asset.is_deleted)
self.assertIsNone(self.task.purged_at)
self.assertEqual(self.client.get("/api/ai/free-video/").json()["total"], 0)
trash_items = self.client.get("/api/ai/free-video/trash/").json()["results"]
trash_ids = {t["id"] for t in trash_items}
self.assertIn(str(self.task.id), trash_ids)
asset_trash_ids = {a["id"] for a in self.client.get("/api/assets/trash/").json()["results"]}
self.assertNotIn(str(self.asset.id), asset_trash_ids)
trash_item = next(t for t in trash_items if t["id"] == str(self.task.id))
self.assertEqual(trash_item["thumbnail_url"], "http://tos.example/free-video-poster.jpg")
restore = self.client.post(f"/api/ai/free-video/{self.task.id}/restore/")
self.assertEqual(restore.status_code, 200)
self.task.refresh_from_db()
self.asset.refresh_from_db()
self.assertFalse(self.task.is_deleted)
self.assertFalse(self.asset.is_deleted)
self.assertIsNone(self.task.purged_at)
live_ids = {t["id"] for t in self.client.get("/api/ai/free-video/").json()["results"]}
self.assertIn(str(self.task.id), live_ids)
self.client.delete(f"/api/ai/free-video/{self.task.id}/")
purge = self.client.delete(f"/api/ai/free-video/{self.task.id}/purge/")
self.assertEqual(purge.status_code, 204)
self.task.refresh_from_db()
self.asset.refresh_from_db()
self.assertTrue(self.task.is_deleted)
self.assertTrue(self.asset.is_deleted)
self.assertIsNotNone(self.task.purged_at)
self.assertIsNotNone(self.asset.purged_at)
self.assertTrue(AITask.objects.filter(id=self.task.id).exists())
self.assertTrue(Asset.objects.filter(id=self.asset.id).exists())
self.assertEqual(self.asset.files.count(), 2)
trash_ids = {t["id"] for t in self.client.get("/api/ai/free-video/trash/").json()["results"]}
self.assertNotIn(str(self.task.id), trash_ids)
class StandaloneImageReferenceTests(TestCase):
@@ -378,6 +455,138 @@ 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_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")
model = ModelConfig.objects.create(
provider=provider,
name="conv-trash-model",
display_name="Conv Trash Model",
capability=ModelConfig.Capability.IMAGE,
)
task = AITask.objects.create(
team=self.team,
created_by=self.user,
conversation=conv,
task_type=AITask.Type.PRODUCT_IMAGE,
status=AITask.Status.SUCCEEDED,
model_config=model,
idempotency_key="conv-trash-task",
)
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.FREE_CREATE,
origin_task=task,
)
asset_file = AssetFile.objects.create(
asset=asset,
object_key="free-create/image.png",
bucket="test",
content_type="image/png",
is_primary=True,
)
delete = self.client.delete(f"/api/ai/image-conversations/{conv.id}/")
self.assertIn(delete.status_code, (204, 200))
conv.refresh_from_db()
task.refresh_from_db()
asset.refresh_from_db()
self.assertTrue(conv.is_deleted)
self.assertTrue(task.is_deleted)
self.assertTrue(asset.is_deleted)
self.assertIsNone(conv.purged_at)
trash_ids = {c["id"] for c in self.client.get("/api/ai/image-conversations/trash/?mode=image").json()["results"]}
self.assertIn(str(conv.id), trash_ids)
asset_trash_ids = {a["id"] for a in self.client.get("/api/assets/trash/").json()["results"]}
self.assertNotIn(str(asset.id), asset_trash_ids)
restore = self.client.post(f"/api/ai/image-conversations/{conv.id}/restore/")
self.assertEqual(restore.status_code, 200)
conv.refresh_from_db()
task.refresh_from_db()
asset.refresh_from_db()
self.assertFalse(conv.is_deleted)
self.assertFalse(task.is_deleted)
self.assertFalse(asset.is_deleted)
self.assertIsNone(conv.purged_at)
live_ids = {c["id"] for c in self.client.get("/api/ai/image-conversations/?mode=image").json()["results"]}
self.assertIn(str(conv.id), live_ids)
self.client.delete(f"/api/ai/image-conversations/{conv.id}/")
purge = self.client.delete(f"/api/ai/image-conversations/{conv.id}/purge/")
self.assertEqual(purge.status_code, 204)
conv.refresh_from_db()
task.refresh_from_db()
asset.refresh_from_db()
self.assertTrue(conv.is_deleted)
self.assertTrue(task.is_deleted)
self.assertTrue(asset.is_deleted)
self.assertIsNotNone(conv.purged_at)
self.assertIsNotNone(task.purged_at)
self.assertIsNotNone(asset.purged_at)
self.assertTrue(ImageConversation.objects.filter(id=conv.id).exists())
self.assertTrue(AITask.objects.filter(id=task.id).exists())
self.assertTrue(Asset.objects.filter(id=asset.id).exists())
self.assertTrue(AssetFile.objects.filter(id=asset_file.id).exists())
trash_ids = {c["id"] for c in self.client.get("/api/ai/image-conversations/trash/?mode=image").json()["results"]}
self.assertNotIn(str(conv.id), trash_ids)
def test_direct_asset_delete_hides_image_but_keeps_conversation(self):
conv = ImageConversation.objects.create(
team=self.team,
created_by=self.user,
title="资产独立删除",
mode=ImageConversation.Mode.IMAGE,
)
provider = ModelProvider.objects.create(name="asset-delete-provider", display_name="Asset Delete Provider")
model = ModelConfig.objects.create(
provider=provider,
name="asset-delete-model",
display_name="Asset Delete Model",
capability=ModelConfig.Capability.IMAGE,
)
task = AITask.objects.create(
team=self.team,
created_by=self.user,
conversation=conv,
task_type=AITask.Type.PRODUCT_IMAGE,
status=AITask.Status.SUCCEEDED,
model_config=model,
idempotency_key="direct-asset-delete-image",
)
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.FREE_CREATE,
origin_task=task,
)
delete = self.client.delete(f"/api/assets/{asset.id}/")
self.assertEqual(delete.status_code, 204)
conv.refresh_from_db()
task.refresh_from_db()
self.assertFalse(conv.is_deleted)
self.assertFalse(task.is_deleted)
conversation_ids = {
c["id"] for c in self.client.get("/api/ai/image-conversations/?mode=image").json()["results"]
}
self.assertIn(str(conv.id), conversation_ids)
deleted_tasks = self.client.get(f"/api/ai/image-conversations/{conv.id}/tasks/").json()["tasks"]
self.assertEqual(deleted_tasks[0]["assets"], [])
restore = self.client.post(f"/api/assets/{asset.id}/restore/")
self.assertEqual(restore.status_code, 200)
restored_tasks = self.client.get(f"/api/ai/image-conversations/{conv.id}/tasks/").json()["tasks"]
self.assertEqual([a["id"] for a in restored_tasks[0]["assets"]], [str(asset.id)])
def test_team_isolation(self):
other = User.objects.create_user(username="other", password="pass")
other_team = Team.objects.create(name="Other", owner=other)
+6
View File
@@ -5,7 +5,10 @@ from .views import (
AITaskViewSet,
FreeVideoDetailView,
FreeVideoFavoriteView,
FreeVideoPurgeView,
FreeVideoPollView,
FreeVideoRestoreView,
FreeVideoTrashView,
FreeVideoUploadView,
FreeVideoView,
GenerateImageView,
@@ -21,8 +24,11 @@ router.register("image-conversations", ImageConversationViewSet, basename="image
urlpatterns = [
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
path("free-video/", FreeVideoView.as_view(), name="ai-free-video"),
path("free-video/trash/", FreeVideoTrashView.as_view(), name="ai-free-video-trash"),
path("free-video/upload/", FreeVideoUploadView.as_view(), name="ai-free-video-upload"),
path("free-video/<uuid:task_id>/poll/", FreeVideoPollView.as_view(), name="ai-free-video-poll"),
path("free-video/<uuid:task_id>/favorite/", FreeVideoFavoriteView.as_view(), name="ai-free-video-favorite"),
path("free-video/<uuid:task_id>/restore/", FreeVideoRestoreView.as_view(), name="ai-free-video-restore"),
path("free-video/<uuid:task_id>/purge/", FreeVideoPurgeView.as_view(), name="ai-free-video-purge"),
path("free-video/<uuid:task_id>/", FreeVideoDetailView.as_view(), name="ai-free-video-detail"),
] + router.urls
+163 -18
View File
@@ -1,4 +1,5 @@
from django.db.models import Count
from django.db import transaction
from django.db.models import Count, Exists, OuterRef
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
@@ -7,6 +8,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from apps.assets.models import Asset
from apps.assets.serializers import AssetSerializer
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from apps.common.celery_health import require_worker
@@ -53,7 +55,9 @@ class GenerateImageView(APIView):
# 对话归属:传了 id 就用现成对话(限本团队);没传则自动开一条新对话,标题取 prompt 前 24 字。
conversation = None
if conversation_id:
conversation = ImageConversation.objects.filter(team=team, id=conversation_id, is_deleted=False).first()
conversation = ImageConversation.objects.filter(
team=team, id=conversation_id, is_deleted=False, purged_at__isnull=True
).first()
if conversation is None:
conversation = ImageConversation.objects.create(
team=team,
@@ -92,7 +96,7 @@ class GenerateImageView(APIView):
"status": t.status,
"error_message": t.error_message,
"assets": AssetSerializer(
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
[a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
).data,
}
for t in tasks
@@ -114,7 +118,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
def get_queryset(self):
# 可选 ?task_type=a,b,c 过滤:生图工作室的任务中心只想看生图任务(模特上身图/平台套图/
# 图片创作 = person_image / product_image),不掺脚本/实体抽取/故事板等流水线内部任务。
queryset = super().get_queryset()
queryset = super().get_queryset().filter(is_deleted=False, purged_at__isnull=True)
# 从 request_payload(已 defer)里只抽 batch_id / mode 两个 JSON 标量供前端「按批分组 + 标签」用:
# KeyTextTransform 在 SQL 层 JSON_EXTRACT,不会把几 MB 的 payload 整列拉回(避开 payload 性能坑)。
from django.db.models.fields.json import KeyTextTransform
@@ -211,7 +215,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
"created_at": t.created_at,
# 软删的图不再出现在工作台记录里(R109:删除资产库图片 → 任务记录联动)
"assets": AssetSerializer(
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
[a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
).data,
}
for t in tasks
@@ -247,20 +251,63 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"""
serializer_class = ImageConversationSerializer
queryset = ImageConversation.objects.filter(is_deleted=False).select_related("product").order_by("-last_active_at")
queryset = ImageConversation.objects.select_related("product").order_by("-last_active_at")
def get_queryset(self):
queryset = super().get_queryset().annotate(_task_count=Count("tasks"))
if self.action in ("trash", "restore", "purge"):
queryset = queryset.filter(is_deleted=True, purged_at__isnull=True)
else:
queryset = queryset.filter(is_deleted=False, purged_at__isnull=True)
mode = self.request.query_params.get("mode", "").strip()
if mode:
queryset = queryset.filter(mode=mode)
return queryset
def perform_destroy(self, instance):
# 软删:对话从列表消失,但其 AITask.conversation 置空由 DB on_delete=SET_NULL 不触发(我们没真删),
# 成图始终留在资产库。仅打标记。
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
# 只联动该会话生成任务产出的 Asset,不碰用户上传的参考素材。
with transaction.atomic():
Asset.objects.filter(team=instance.team, origin_task__conversation=instance, purged_at__isnull=True).update(is_deleted=True)
AITask.objects.filter(team=instance.team, conversation=instance, purged_at__isnull=True).update(is_deleted=True)
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):
"""从垃圾桶恢复自由创作图片会话。"""
conversation = self.get_object()
with transaction.atomic():
conversation.is_deleted = False
conversation.purged_at = None
conversation.save(update_fields=["is_deleted", "purged_at", "updated_at"])
AITask.objects.filter(team=conversation.team, conversation=conversation, purged_at__isnull=True).update(is_deleted=False)
Asset.objects.filter(team=conversation.team, origin_task__conversation=conversation, purged_at__isnull=True).update(is_deleted=False)
return Response(self.get_serializer(conversation).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["delete"], url_path="purge")
def purge(self, request, pk=None):
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录保留。"""
conversation = self.get_object()
now = timezone.now()
with transaction.atomic():
conversation.purged_at = now
conversation.save(update_fields=["purged_at", "updated_at"])
AITask.objects.filter(team=conversation.team, conversation=conversation, purged_at__isnull=True).update(
is_deleted=True, purged_at=now
)
Asset.objects.filter(team=conversation.team, origin_task__conversation=conversation, purged_at__isnull=True).update(
is_deleted=True, purged_at=now
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get"])
def tasks(self, request, pk=None):
@@ -281,7 +328,7 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
for rid in ids or []:
rid = str(rid)
if rid not in ref_cache:
a = Asset.objects.filter(id=rid).prefetch_related("files").first()
a = Asset.objects.filter(id=rid, is_deleted=False, purged_at__isnull=True).prefetch_related("files").first()
# 带上 id:前端重跑时凭它原样复用参考图(否则刷新恢复的批次只有 {name,url},重跑丢参考)
ref_cache[rid] = {"id": rid, "name": a.name, "url": _asset_preview(a)} if a else None
if ref_cache[rid]:
@@ -301,7 +348,7 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"reference_images": resolve_refs((t.request_payload or {}).get("reference_image_ids")),
"created_at": t.created_at,
"assets": AssetSerializer(
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
[a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
).data,
}
for t in tasks
@@ -311,12 +358,42 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
def _free_video_task_queryset(team):
return (
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=False)
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=False, purged_at__isnull=True)
.select_related("model_config")
.prefetch_related("generated_assets", "generated_assets__files")
)
def _free_video_list_queryset(team):
"""正常任务流隐藏已从资产库删除的成品,但保留生成中/失败及无落库资产的历史任务。"""
video_assets = Asset.objects.filter(origin_task_id=OuterRef("pk"), asset_type=Asset.Type.VIDEO)
active_video_assets = video_assets.filter(is_deleted=False, purged_at__isnull=True)
return (
_free_video_task_queryset(team)
.annotate(
_has_video_asset=Exists(video_assets),
_has_active_video_asset=Exists(active_video_assets),
)
.exclude(
status=AITask.Status.SUCCEEDED,
_has_video_asset=True,
_has_active_video_asset=False,
)
)
def _free_video_trash_queryset(team):
return (
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=True, purged_at__isnull=True)
.select_related("model_config")
.prefetch_related("generated_assets", "generated_assets__files")
)
def _set_free_video_generated_assets_deleted(task, deleted):
Asset.objects.filter(team=task.team, origin_task=task, purged_at__isnull=True).update(is_deleted=deleted)
class FreeVideoView(APIView):
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
@@ -349,7 +426,7 @@ class FreeVideoView(APIView):
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
except (TypeError, ValueError):
page_size = 20
qs = _free_video_task_queryset(team).order_by("-created_at")
qs = _free_video_list_queryset(team).order_by("-created_at")
total = qs.count()
tasks = list(qs[offset : offset + page_size])
return Response(
@@ -389,7 +466,9 @@ class FreeVideoFavoriteView(APIView):
def post(self, request, task_id):
team = get_current_team(request.user)
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
task = AITask.objects.filter(
team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False, purged_at__isnull=True
).first()
if task is None:
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
task.is_favorited = not task.is_favorited
@@ -402,13 +481,79 @@ class FreeVideoDetailView(APIView):
def delete(self, request, task_id):
team = get_current_team(request.user)
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
task = AITask.objects.filter(
team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False, purged_at__isnull=True
).first()
if task is None:
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING):
return Response({"detail": "任务生成中,请等待完成后再删除"}, status=status.HTTP_400_BAD_REQUEST)
task.is_deleted = True
task.save(update_fields=["is_deleted", "updated_at"])
with transaction.atomic():
_set_free_video_generated_assets_deleted(task, True)
task.is_deleted = True
task.save(update_fields=["is_deleted", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
class FreeVideoTrashView(APIView):
"""GET /api/ai/free-video/trash/ —— 自由创作视频垃圾桶。"""
def get(self, request):
from .free_video import serialize_free_video_task
team = get_current_team(request.user)
try:
offset = max(0, int(request.query_params.get("offset") or 0))
except (TypeError, ValueError):
offset = 0
try:
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
except (TypeError, ValueError):
page_size = 20
qs = _free_video_trash_queryset(team).order_by("-updated_at")
total = qs.count()
tasks = list(qs[offset : offset + page_size])
return Response(
{
"results": [serialize_free_video_task(t, include_deleted_assets=True) for t in tasks],
"total": total,
"has_more": offset + page_size < total,
}
)
class FreeVideoRestoreView(APIView):
"""POST /api/ai/free-video/<id>/restore/ —— 从垃圾桶恢复自由创作视频。"""
def post(self, request, task_id):
from .free_video import serialize_free_video_task
team = get_current_team(request.user)
task = _free_video_trash_queryset(team).filter(id=task_id).first()
if task is None:
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
with transaction.atomic():
task.is_deleted = False
task.purged_at = None
task.save(update_fields=["is_deleted", "purged_at", "updated_at"])
_set_free_video_generated_assets_deleted(task, False)
task = _free_video_task_queryset(team).get(id=task.id)
return Response({"task": serialize_free_video_task(task)}, status=status.HTTP_200_OK)
class FreeVideoPurgeView(APIView):
"""DELETE /api/ai/free-video/<id>/purge/ —— 二级软删除,从垃圾桶隐藏。"""
def delete(self, request, task_id):
team = get_current_team(request.user)
task = _free_video_trash_queryset(team).filter(id=task_id).first()
if task is None:
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
now = timezone.now()
with transaction.atomic():
task.purged_at = now
task.save(update_fields=["purged_at", "updated_at"])
Asset.objects.filter(team=task.team, origin_task=task, purged_at__isnull=True).update(is_deleted=True, purged_at=now)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -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 键的取值),供下拉「只列真有的」。
参数:tabmeta_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]
@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("products", "0002_product_products_pr_team_id_ebc2f5_idx"),
]
operations = [
migrations.AddField(
model_name="product",
name="purged_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddIndex(
model_name="product",
index=models.Index(fields=["team", "status", "purged_at"], name="products_pr_team_id_47f4ec_idx"),
),
]
+2
View File
@@ -15,6 +15,7 @@ class Product(TeamOwnedModel):
specs = models.JSONField(default=dict, blank=True)
description = models.TextField(blank=True)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
purged_at = models.DateTimeField(null=True, blank=True)
cover_asset = models.ForeignKey(
"assets.Asset",
on_delete=models.SET_NULL,
@@ -26,6 +27,7 @@ class Product(TeamOwnedModel):
class Meta:
indexes = [
models.Index(fields=["team", "status"]),
models.Index(fields=["team", "status", "purged_at"]),
models.Index(fields=["team", "category"]),
# 商品库默认按团队 + 创建时间倒序翻页
models.Index(fields=["team", "-created_at"]),
+38 -4
View File
@@ -2,12 +2,13 @@ from rest_framework.test import APIClient
from django.test import TestCase
from apps.accounts.models import Team, TeamMember, User
from apps.assets.models import Asset, AssetFile
from .models import Product
from .models import Product, ProductImage
class ProductTrashTests(TestCase):
"""软删除 = 进垃圾桶(status=archived):正常列表只看 active,可恢复 / 可彻底删。"""
"""软删除 = 进垃圾桶(status=archived):正常列表只看 active,可恢复 / 可二级软删。"""
def setUp(self):
self.user = User.objects.create_user(username="u1", password="x")
@@ -16,12 +17,34 @@ class ProductTrashTests(TestCase):
self.client = APIClient()
self.client.force_authenticate(self.user)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="商品A")
self.asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="商品主图",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PRODUCT_IMAGE,
)
self.asset_file = AssetFile.objects.create(
asset=self.asset,
object_key="products/trash-cover.png",
bucket="test",
content_type="image/png",
is_primary=True,
)
ProductImage.objects.create(product=self.product, asset=self.asset, is_primary=True)
self.product.cover_asset = self.asset
self.product.save(update_fields=["cover_asset"])
def test_delete_is_soft_and_hidden_from_list(self):
res = self.client.delete(f"/api/products/{self.product.id}/")
self.assertEqual(res.status_code, 204)
self.product.refresh_from_db()
self.assertEqual(self.product.status, Product.Status.ARCHIVED) # 软删,数据还在
self.asset.refresh_from_db()
self.assertFalse(self.asset.is_deleted)
self.assertIsNone(self.asset.purged_at)
self.assertTrue(AssetFile.objects.filter(id=self.asset_file.id).exists())
# 正常列表不含已删
ids = [p["id"] for p in self.client.get("/api/products/").json()["results"]]
self.assertNotIn(str(self.product.id), ids)
@@ -37,14 +60,25 @@ class ProductTrashTests(TestCase):
self.assertEqual(res.status_code, 200)
self.product.refresh_from_db()
self.assertEqual(self.product.status, Product.Status.ACTIVE)
self.asset.refresh_from_db()
self.assertFalse(self.asset.is_deleted)
self.assertIsNone(self.asset.purged_at)
ids = [p["id"] for p in self.client.get("/api/products/").json()["results"]]
self.assertIn(str(self.product.id), ids)
def test_purge_hard_deletes(self):
def test_purge_soft_hides_from_trash(self):
self.client.delete(f"/api/products/{self.product.id}/")
res = self.client.delete(f"/api/products/{self.product.id}/purge/")
self.assertEqual(res.status_code, 204)
self.assertFalse(Product.objects.filter(id=self.product.id).exists())
self.product.refresh_from_db()
self.assertIsNotNone(self.product.purged_at)
self.assertTrue(Product.objects.filter(id=self.product.id).exists())
self.asset.refresh_from_db()
self.assertFalse(self.asset.is_deleted)
self.assertIsNone(self.asset.purged_at)
self.assertTrue(AssetFile.objects.filter(id=self.asset_file.id).exists())
trash_ids = [p["id"] for p in self.client.get("/api/products/trash/").json()["results"]]
self.assertNotIn(str(self.product.id), trash_ids)
def test_purge_rejects_active(self):
# 未删除(active)商品不在垃圾桶 qs → 彻底删找不到
+9 -6
View File
@@ -2,6 +2,7 @@ from pathlib import Path
import uuid
from django.db import transaction
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser
@@ -32,8 +33,8 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
# 回收站相关动作(trash/restore/purge)才看 archived。
qs = super().get_queryset()
if self.action in ("trash", "restore", "purge"):
return qs.filter(status=Product.Status.ARCHIVED)
return qs.filter(status=Product.Status.ACTIVE)
return qs.filter(status=Product.Status.ARCHIVED, purged_at__isnull=True)
return qs.filter(status=Product.Status.ACTIVE, purged_at__isnull=True)
def perform_destroy(self, instance):
# 删除 = 软删进垃圾桶(可恢复),不真删数据
@@ -61,7 +62,7 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
# 含 request_payload / response_payload 两个巨型 JSON 列(实测 114 行就要 ~66s 纯传输)。
# 视图与序列化器只用到 origin_task.project(取项目名/id),从不读 payload → .defer() 掉这两列。
base = (
Asset.objects.filter(team=team, is_deleted=False)
Asset.objects.filter(team=team, is_deleted=False, purged_at__isnull=True)
.select_related("origin_task__project")
.defer("origin_task__request_payload", "origin_task__response_payload")
.prefetch_related("files")
@@ -146,14 +147,16 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
"""从垃圾桶恢复:archived → active。"""
product = self.get_object()
product.status = Product.Status.ACTIVE
product.save(update_fields=["status", "updated_at"])
product.purged_at = None
product.save(update_fields=["status", "purged_at", "updated_at"])
return Response(ProductSerializer(product).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["delete"], url_path="purge")
def purge(self, request, pk=None):
"""彻底删除(仅垃圾桶内 archived 商品可彻底删,不可恢复)"""
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录保留"""
product = self.get_object()
product.delete()
product.purged_at = timezone.now()
product.save(update_fields=["purged_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"], url_path="images", parser_classes=[MultiPartParser, FormParser])
@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("projects", "0006_migrate_storyboard_to_shots"),
]
operations = [
migrations.AddField(
model_name="project",
name="is_deleted",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="project",
name="purged_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddIndex(
model_name="project",
index=models.Index(fields=["team", "is_deleted", "purged_at"], name="projects_pr_team_id_9bc32b_idx"),
),
]
+3
View File
@@ -23,6 +23,8 @@ class Project(TeamOwnedModel):
budget_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
failure_reason = 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 = [
@@ -30,6 +32,7 @@ class Project(TeamOwnedModel):
models.Index(fields=["team", "current_stage"]),
# 项目列表默认按团队 + 更新时间倒序
models.Index(fields=["team", "-updated_at"]),
models.Index(fields=["team", "is_deleted", "purged_at"]),
]
def __str__(self) -> str:
+4 -2
View File
@@ -327,7 +327,7 @@ class ProjectListSerializer(serializers.ModelSerializer):
fields = [
"id", "name", "product", "product_title", "cover_preview_url",
"status", "current_stage", "script_version_count", "video_segment_count",
"created_at", "updated_at",
"is_deleted", "purged_at", "created_at", "updated_at",
]
def get_cover_preview_url(self, obj) -> str:
@@ -354,6 +354,8 @@ class ProjectSerializer(serializers.ModelSerializer):
"budget_limit",
"failure_reason",
"metadata",
"is_deleted",
"purged_at",
"stages",
"script_versions",
"base_asset_groups",
@@ -364,4 +366,4 @@ class ProjectSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
]
read_only_fields = ["id", "status", "current_stage", "failure_reason", "created_at", "updated_at"]
read_only_fields = ["id", "status", "current_stage", "failure_reason", "is_deleted", "purged_at", "created_at", "updated_at"]
+61 -1
View File
@@ -5,7 +5,7 @@ from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider
from apps.assets.models import Asset
from apps.assets.models import Asset, AssetFile
from apps.billing.models import CreditAccount, CreditLedger
from apps.products.models import Product
from apps.projects.models import (
@@ -88,6 +88,66 @@ class ProjectApiTests(TestCase):
)
self.assertTrue(VideoSegment.objects.filter(project=project).exists())
def test_delete_restore_and_purge_project_trash(self):
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="Trash Project")
segment = VideoSegment.objects.create(project=project, sort_order=0, target_duration_seconds=15)
asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="项目视频片段",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.VIDEO_CLIP,
)
asset_file = AssetFile.objects.create(
asset=asset,
object_key="projects/clip.mp4",
bucket="test",
content_type="video/mp4",
is_primary=True,
)
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
segment.adopted_version = version
segment.save(update_fields=["adopted_version"])
delete = self.client.delete(f"/api/projects/{project.id}/")
self.assertEqual(delete.status_code, 204)
project.refresh_from_db()
self.assertTrue(project.is_deleted)
self.assertIsNone(project.purged_at)
list_ids = [p["id"] for p in self.client.get("/api/projects/").json()["results"]]
self.assertNotIn(str(project.id), list_ids)
trash_ids = [p["id"] for p in self.client.get("/api/projects/trash/").json()["results"]]
self.assertIn(str(project.id), trash_ids)
asset.refresh_from_db()
self.assertFalse(asset.is_deleted)
self.assertIsNone(asset.purged_at)
restore = self.client.post(f"/api/projects/{project.id}/restore/")
self.assertEqual(restore.status_code, 200)
project.refresh_from_db()
self.assertFalse(project.is_deleted)
self.assertIsNone(project.purged_at)
list_ids = [p["id"] for p in self.client.get("/api/projects/").json()["results"]]
self.assertIn(str(project.id), list_ids)
self.assertTrue(VideoSegmentVersion.objects.filter(id=version.id).exists())
self.client.delete(f"/api/projects/{project.id}/")
purge = self.client.delete(f"/api/projects/{project.id}/purge/")
self.assertEqual(purge.status_code, 204)
project.refresh_from_db()
self.assertTrue(project.is_deleted)
self.assertIsNotNone(project.purged_at)
self.assertTrue(Project.objects.filter(id=project.id).exists())
self.assertTrue(VideoSegment.objects.filter(project=project).exists())
self.assertTrue(VideoSegmentVersion.objects.filter(id=version.id).exists())
asset.refresh_from_db()
self.assertFalse(asset.is_deleted)
self.assertIsNone(asset.purged_at)
self.assertTrue(AssetFile.objects.filter(id=asset_file.id).exists())
trash_ids = [p["id"] for p in self.client.get("/api/projects/trash/").json()["results"]]
self.assertNotIn(str(project.id), trash_ids)
@patch("apps.ai.services.VolcanoArkProvider")
def test_rerun_script_segment_via_agent_rewrites_one_and_charges_once(self, provider_cls):
"""单镜重跑改走 agent:读结构化全脚本,只重写目标镜、保留其余镜,落新版本,计费一次。"""
+55 -7
View File
@@ -5,6 +5,7 @@ import uuid
from django.db import transaction
from django.db.models import Count, Q
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser
@@ -246,7 +247,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
return # 项目内已有商品三视图,不覆盖
tri = (
Asset.objects.filter(
team=team, is_deleted=False,
team=team, is_deleted=False, purged_at__isnull=True,
metadata__product_id=str(product_id), metadata__view="three_view",
)
.order_by("-created_at")
@@ -267,7 +268,12 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
# 按采用版分镜数收口一次(幂等;只裁从未生成过的尾段,生成中/已出片的段绝不动)。
# 用轻量查询判定 + 同步,再交给 super 做重 prefetch 序列化(避免重查询跑两遍)。
proj = (
Project.objects.filter(pk=kwargs.get("pk"), **{self.team_field: self.get_team()})
Project.objects.filter(
pk=kwargs.get("pk"),
is_deleted=False,
purged_at__isnull=True,
**{self.team_field: self.get_team()},
)
.only("id", "current_stage", "status", "product_id")
.first()
)
@@ -289,6 +295,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
return super().retrieve(request, *args, **kwargs)
def get_queryset(self):
if self.action in ("trash", "restore", "purge"):
return (
Project.objects.select_related("product", "product__cover_asset")
.prefetch_related("product__cover_asset__files")
.filter(is_deleted=True, purged_at__isnull=True, **{self.team_field: self.get_team()})
.order_by("-updated_at")
)
# 列表:轻查询(只 select_related product + 计数注解),不做详情那串 12 个重 prefetch
# ——原列表把每个项目的 阶段/片段/故事板/时间线/资产文件全拉出,20 个项目实测 ~2s。
if self.action == "list":
@@ -300,8 +313,43 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
video_segment_count=Count("video_segments", distinct=True),
)
)
return qs.filter(**{self.team_field: self.get_team()}).order_by("-updated_at")
return super().get_queryset()
return qs.filter(
is_deleted=False,
purged_at__isnull=True,
**{self.team_field: self.get_team()},
).order_by("-updated_at")
return super().get_queryset().filter(is_deleted=False, purged_at__isnull=True)
def perform_destroy(self, instance):
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)
serializer_cls = ProjectListSerializer
if page is not None:
return self.get_paginated_response(serializer_cls(page, many=True).data)
return Response(serializer_cls(qs, many=True).data)
@action(detail=True, methods=["post"], url_path="restore")
def restore(self, request, pk=None):
"""从垃圾桶恢复视频项目。"""
project = self.get_object()
project.is_deleted = False
project.purged_at = None
project.save(update_fields=["is_deleted", "purged_at", "updated_at"])
return Response(ProjectSerializer(project).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["delete"], url_path="purge")
def purge(self, request, pk=None):
"""彻底删除 = 二级软删除:从垃圾桶隐藏,项目 DB 记录保留。"""
project = self.get_object()
project.purged_at = timezone.now()
project.save(update_fields=["purged_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get"], url_path="export-clips")
def export_clips(self, request, pk=None):
@@ -359,7 +407,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=False, methods=["get"])
def summary(self, request):
"""项目统计(总数 + 各状态计数),供仪表盘/侧栏徽标——只跑 COUNT,不拉项目对象。"""
base = Project.objects.filter(team=self.get_team())
base = Project.objects.filter(team=self.get_team(), is_deleted=False, purged_at__isnull=True)
by_status = {row["status"]: row["n"] for row in base.values("status").annotate(n=Count("id"))}
return Response({"total": base.count(), "by_status": by_status})
@@ -594,7 +642,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
if asset is None and asset_id:
# 跨团队可见的官方模特形象图 / 三视图 → 克隆进本团队再挂
official = (
Asset.objects.filter(id=asset_id, is_deleted=False)
Asset.objects.filter(id=asset_id, is_deleted=False, purged_at__isnull=True)
.filter(Q(model_as_portrait__is_official=True) | Q(model_as_triview__is_official=True))
.first()
)
@@ -659,7 +707,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
if source_portrait_id:
candidate_ids.add(str(source_portrait_id))
model = (
ModelEntity.objects.filter(portrait_asset_id__in=candidate_ids, is_deleted=False)
ModelEntity.objects.filter(portrait_asset_id__in=candidate_ids, is_deleted=False, purged_at__isnull=True)
.exclude(triview_asset__isnull=True)
.select_related("triview_asset")
.first()
+2 -1
View File
@@ -833,7 +833,7 @@ export function App() {
</div>
);
case "models":
return <ModelsPage />;
return <ModelsPage onNotify={(type, text) => setNotice({ type, text })} />;
case "library":
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
case "trash":
@@ -842,6 +842,7 @@ export function App() {
navigate={navigate}
onRestore={(id) => action(() => api.restoreProduct(id), "已恢复到商品库")}
onPurge={(id) => action(() => api.purgeProduct(id), "已彻底删除")}
onChanged={() => { void loadData(); }}
/>
);
case "account":
+39 -1
View File
@@ -277,7 +277,7 @@ export const api = {
return request<Product>(`/api/products/${id}/restore/`, { method: "POST" });
},
purgeProduct(id: string) {
// 彻底删除(不可恢复)
// 彻底删除 = 二级软删除(从垃圾桶隐藏,DB 记录保留)
return request<void>(`/api/products/${id}/purge/`, { method: "DELETE" });
},
projects() {
@@ -302,6 +302,15 @@ export const api = {
deleteProject(id: string) {
return request<void>(`/api/projects/${id}/`, { method: "DELETE" });
},
projectsTrash() {
return request<Paginated<Project>>("/api/projects/trash/");
},
restoreProject(id: string) {
return request<Project>(`/api/projects/${id}/restore/`, { method: "POST" });
},
purgeProject(id: string) {
return request<void>(`/api/projects/${id}/purge/`, { method: "DELETE" });
},
// 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
async agentScriptStream(
@@ -603,6 +612,15 @@ export const api = {
deleteModel(id: string) {
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
},
modelsTrash() {
return request<Paginated<ModelEntity>>("/api/models/trash/");
},
restoreModel(id: string) {
return request<ModelEntity>(`/api/models/${id}/restore/`, { method: "POST" });
},
purgeModel(id: string) {
return request<void>(`/api/models/${id}/purge/`, { method: "DELETE" });
},
billingSummary() {
return request<BillingSummary>("/api/billing/summary/");
},
@@ -650,6 +668,9 @@ export const api = {
listConversations(mode: "image" | "model" | "cover" = "image") {
return request<Paginated<ImageConversation>>(`/api/ai/image-conversations/?mode=${mode}&page_size=100`);
},
conversationsTrash(mode: "image" | "model" | "cover" = "image") {
return request<Paginated<ImageConversation>>(`/api/ai/image-conversations/trash/?mode=${mode}&page_size=100`);
},
createConversation(payload: { mode?: "image" | "model" | "cover"; title?: string; product?: string | null }) {
return request<ImageConversation>("/api/ai/image-conversations/", { method: "POST", body: JSON.stringify(payload) });
},
@@ -659,6 +680,12 @@ export const api = {
deleteConversation(id: string) {
return request<void>(`/api/ai/image-conversations/${id}/`, { method: "DELETE" });
},
restoreConversation(id: string) {
return request<ImageConversation>(`/api/ai/image-conversations/${id}/restore/`, { method: "POST" });
},
purgeConversation(id: string) {
return request<void>(`/api/ai/image-conversations/${id}/purge/`, { method: "DELETE" });
},
// 切换对话时回填:该对话历次生成的任务 + 成图
conversationTasks(id: string) {
return request<{ conversation_id: string; tasks: ImageConversationTask[] }>(`/api/ai/image-conversations/${id}/tasks/`);
@@ -686,6 +713,11 @@ export const api = {
`/api/ai/free-video/?offset=${offset}&page_size=${pageSize}`
);
},
freeVideoTrash(offset = 0, pageSize = 20) {
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
`/api/ai/free-video/trash/?offset=${offset}&page_size=${pageSize}`
);
},
// web 进程内单次轮询+终态化(幂等):前端渐进轮询打这里,本地无 worker 也能收尾
pollFreeVideo(id: string) {
return request<{ task: FreeVideoTask }>(`/api/ai/free-video/${id}/poll/`, { method: "POST" });
@@ -696,6 +728,12 @@ export const api = {
deleteFreeVideo(id: string) {
return request<void>(`/api/ai/free-video/${id}/`, { method: "DELETE" });
},
restoreFreeVideo(id: string) {
return request<{ task: FreeVideoTask }>(`/api/ai/free-video/${id}/restore/`, { method: "POST" });
},
purgeFreeVideo(id: string) {
return request<void>(`/api/ai/free-video/${id}/purge/`, { method: "DELETE" });
},
uploadFreeVideoRef(formData: FormData) {
return request<FreeVideoUploadResult>("/api/ai/free-video/upload/", { method: "POST", body: formData });
},
+15 -1
View File
@@ -67,8 +67,22 @@
gap: 8px;
background: var(--black-alpha-56);
}
.model-confirm .btn {
background: var(--surface);
color: var(--accent-black);
border-color: var(--border-faint);
}
.model-confirm .btn:hover {
background: var(--surface);
border-color: var(--border-loud);
}
.model-del { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
.model-del:hover { filter: brightness(1.06); }
.model-confirm .model-del,
.model-confirm .model-del:hover {
background: var(--accent-crimson);
border-color: var(--accent-crimson);
color: var(--accent-white);
}
.model-body { padding: 10px 12px 12px; }
.model-name {
+1 -1
View File
@@ -434,7 +434,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
setTasks((prev) => prev.filter((t) => t.id !== target.id));
setTotal((prev) => Math.max(0, prev - 1));
if (detailId === target.id) setDetailId(null);
notify("success", "已删除");
notify("success", "已移至垃圾桶");
} catch (error) {
notify("error", error instanceof Error ? error.message : "删除失败");
} finally {
+4 -1
View File
@@ -74,7 +74,7 @@ function ModelDetailModal({ model, close, onZoom }: { model: ModelEntity | null;
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
export function ModelsPage() {
export function ModelsPage({ onNotify }: { onNotify?: (type: "success" | "error", text: string) => void }) {
const [tab, setTab] = useState<Tab>("all");
const [items, setItems] = useState<ModelEntity[]>([]);
const [loading, setLoading] = useState(true);
@@ -115,6 +115,9 @@ export function ModelsPage() {
try {
await api.deleteModel(id);
setItems((list) => list.filter((m) => m.id !== id));
onNotify?.("success", "已移至垃圾桶");
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "删除失败");
} finally {
setConfirmId(null);
}
+223 -114
View File
@@ -1,111 +1,257 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import { api } from "../api";
import { ConfirmModal } from "../components/overlays";
import type { NavigateFn } from "./route-config";
import type { Asset, Product } from "../types";
import type { Asset, FreeVideoTask, ImageConversation, ModelEntity, Product, Project } from "../types";
import "../trash-page.css";
const coverOf = (p: Product): string => p.cover_preview_url || p.images?.find((i) => i.preview_url)?.preview_url || "";
// 资产缩略图:主文件直链优先,任一带 preview_url 的文件兜底
const assetCoverOf = (a: Asset): string =>
a.files?.find((f) => f.is_primary && f.preview_url)?.preview_url || a.files?.find((f) => f.preview_url)?.preview_url || "";
type TrashKind = "product" | "asset" | "model" | "image" | "freeVideo" | "project";
type TrashRow = {
id: string;
kind: TrashKind;
title: string;
subtitle: string;
cover?: string;
};
type TrashSection = {
key: TrashKind;
title: string;
rows: TrashRow[];
};
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
// mono 时间戳(设计规范 // 05.22 式):软删走 update updated_at → updated_at ≈ 删除时间
const dateOf = (iso?: string): string => {
const dateOf = (iso?: string | null): string => {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return `${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`;
};
const coverOf = (p: Product): string => p.cover_preview_url || p.images?.find((i) => i.preview_url)?.preview_url || "";
const assetCoverOf = (a: Asset): string =>
a.files?.find((f) => f.is_primary && f.preview_url)?.preview_url || a.files?.find((f) => f.preview_url)?.preview_url || "";
const ASSET_TYPE_LABEL: Record<string, string> = { image: "图片", video: "视频", audio: "音频" };
// 垃圾桶:已软删商品(status=archived)+ 已软删资产(is_deleted,R108)· 均可恢复 / 可彻底删除(不可恢复)
export function TrashPage({ onRestore, onPurge }: {
const rowsFromProducts = (items: Product[]): TrashRow[] =>
items.map((p) => ({
id: p.id,
kind: "product",
title: p.title,
subtitle: p.category || "未分类",
cover: coverOf(p)
}));
const rowsFromAssets = (items: Asset[]): TrashRow[] =>
items.map((a) => {
const deletedAt = dateOf(a.updated_at);
return {
id: a.id,
kind: "asset",
title: a.name,
subtitle: `${ASSET_TYPE_LABEL[a.asset_type] || a.asset_type}${deletedAt ? ` · 删除于 ${deletedAt}` : ""}`,
cover: assetCoverOf(a)
};
});
const rowsFromModels = (items: ModelEntity[]): TrashRow[] =>
items.map((m) => ({
id: m.id,
kind: "model",
title: m.name,
subtitle: m.source === "upload" ? "上传模特" : "AI 模特",
cover: m.portrait || m.triview || ""
}));
const rowsFromConversations = (items: ImageConversation[]): TrashRow[] =>
items.map((c) => ({
id: c.id,
kind: "image",
title: c.title || "未命名图片创作",
subtitle: `${c.task_count || 0} 个生成任务${dateOf(c.updated_at) ? ` · 删除于 ${dateOf(c.updated_at)}` : ""}`
}));
const rowsFromFreeVideos = (items: FreeVideoTask[]): TrashRow[] =>
items.map((task) => ({
id: task.id,
kind: "freeVideo",
title: task.prompt || "未命名视频创作",
subtitle: `${task.status}${task.duration ? ` · ${task.duration}s` : ""}${task.aspect_ratio ? ` · ${task.aspect_ratio}` : ""}`,
cover: task.thumbnail_url || ""
}));
const rowsFromProjects = (items: Project[]): TrashRow[] =>
items.map((p) => ({
id: p.id,
kind: "project",
title: p.name,
subtitle: p.product_title || p.current_stage || p.status,
cover: p.cover_preview_url || ""
}));
export function TrashPage({ onRestore, onPurge, onChanged }: {
navigate: NavigateFn;
onRestore: (id: string) => Promise<unknown> | void;
onPurge: (id: string) => Promise<unknown> | void;
onChanged?: () => void;
}) {
const [items, setItems] = useState<Product[]>([]);
const [assets, setAssets] = useState<Asset[]>([]);
const [sections, setSections] = useState<TrashSection[]>([]);
const [loading, setLoading] = useState(true);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [errText, setErrText] = useState("");
const [bulkBusy, setBulkBusy] = useState<"restore" | "purge" | null>(null);
const [purgeAllOpen, setPurgeAllOpen] = useState(false);
const isEmpty = items.length === 0 && assets.length === 0;
const allRows = useMemo(() => sections.flatMap((section) => section.rows), [sections]);
const isEmpty = allRows.length === 0;
useEffect(() => {
let alive = true;
void Promise.allSettled([api.productsTrash(), api.assetsTrash()]).then(([p, a]) => {
void Promise.allSettled([
api.productsTrash(),
api.assetsTrash(),
api.modelsTrash(),
api.conversationsTrash("image"),
api.freeVideoTrash(0, 100),
api.projectsTrash()
]).then(([products, assets, models, conversations, freeVideos, projects]) => {
if (!alive) return;
setItems(p.status === "fulfilled" ? p.value.results : []);
setAssets(a.status === "fulfilled" ? a.value.results : []);
const rawSections: TrashSection[] = [
{
key: "product",
title: "商品",
rows: products.status === "fulfilled" ? rowsFromProducts(products.value.results) : []
},
{
key: "asset",
title: "资产",
rows: assets.status === "fulfilled" ? rowsFromAssets(assets.value.results) : []
},
{
key: "model",
title: "模特",
rows: models.status === "fulfilled" ? rowsFromModels(models.value.results) : []
},
{
key: "image",
title: "自由创作图片",
rows: conversations.status === "fulfilled" ? rowsFromConversations(conversations.value.results) : []
},
{
key: "freeVideo",
title: "自由创作视频",
rows: freeVideos.status === "fulfilled" ? rowsFromFreeVideos(freeVideos.value.results) : []
},
{
key: "project",
title: "视频项目",
rows: projects.status === "fulfilled" ? rowsFromProjects(projects.value.results) : []
}
];
const nextSections = rawSections.filter((section) => section.rows.length > 0);
setSections(nextSections);
setLoading(false);
});
return () => { alive = false; };
}, []);
async function restore(id: string) {
setBusyId(id);
try { await onRestore(id); setItems((list) => list.filter((p) => p.id !== id)); }
finally { setBusyId(null); }
function removeRow(row: TrashRow) {
setSections((list) =>
list
.map((section) => section.key === row.kind ? { ...section, rows: section.rows.filter((item) => item.id !== row.id) } : section)
.filter((section) => section.rows.length > 0)
);
}
async function purge(id: string) {
setBusyId(id);
try { await onPurge(id); setItems((list) => list.filter((p) => p.id !== id)); setConfirmId(null); }
finally { setBusyId(null); }
function rowKey(row: TrashRow): string {
return `${row.kind}:${row.id}`;
}
// 资产的恢复/彻底删除直接走 api(商品那对经 App 的 action 包装,只发商品口)。失败不移行、如实亮错误。
async function restoreAsset(id: string) {
setBusyId(id);
function restoreRow(row: TrashRow): Promise<unknown> {
if (row.kind === "product") return Promise.resolve(onRestore(row.id));
if (row.kind === "asset") return api.restoreAsset(row.id);
if (row.kind === "model") return api.restoreModel(row.id);
if (row.kind === "image") return api.restoreConversation(row.id);
if (row.kind === "freeVideo") return api.restoreFreeVideo(row.id);
return api.restoreProject(row.id);
}
function purgeRow(row: TrashRow): Promise<unknown> {
if (row.kind === "product") return Promise.resolve(onPurge(row.id));
if (row.kind === "asset") return api.purgeAsset(row.id);
if (row.kind === "model") return api.purgeModel(row.id);
if (row.kind === "image") return api.purgeConversation(row.id);
if (row.kind === "freeVideo") return api.purgeFreeVideo(row.id);
return api.purgeProject(row.id);
}
async function restore(row: TrashRow) {
setBusyKey(rowKey(row));
setErrText("");
try { await api.restoreAsset(id); setAssets((list) => list.filter((a) => a.id !== id)); }
catch (error) { setErrText(error instanceof Error ? error.message : "恢复失败"); }
finally { setBusyId(null); }
try {
await restoreRow(row);
removeRow(row);
onChanged?.();
} catch (error) {
setErrText(error instanceof Error ? error.message : "恢复失败");
} finally {
setBusyKey(null);
}
}
async function purgeAsset(id: string) {
setBusyId(id);
async function purge(row: TrashRow) {
setBusyKey(rowKey(row));
setErrText("");
try { await api.purgeAsset(id); setAssets((list) => list.filter((a) => a.id !== id)); setConfirmId(null); }
catch (error) { setErrText(error instanceof Error ? error.message : "彻底删除失败"); }
finally { setBusyId(null); }
try {
await purgeRow(row);
removeRow(row);
setConfirmId(null);
onChanged?.();
} catch (error) {
setErrText(error instanceof Error ? error.message : "彻底删除失败");
} finally {
setBusyKey(null);
}
}
// 全部恢复:商品走 onRestore、资产走 api.restoreAsset,各自独立通道并发跑;失败的留在列表、错误如实亮出
// 按 id 而非下标比对结果:防止执行期间单条操作改动了 items/assets 导致下标错位
async function restoreAll() {
setBulkBusy("restore");
setErrText("");
const itemIds = items.map((p) => p.id);
const assetIds = assets.map((a) => a.id);
const itemResults = await Promise.allSettled(itemIds.map((id) => Promise.resolve(onRestore(id))));
const assetResults = await Promise.allSettled(assetIds.map((id) => api.restoreAsset(id)));
const failedItemIds = new Set(itemIds.filter((_, i) => itemResults[i].status === "rejected"));
const failedAssetIds = new Set(assetIds.filter((_, i) => assetResults[i].status === "rejected"));
setItems((list) => list.filter((p) => failedItemIds.has(p.id)));
setAssets((list) => list.filter((a) => failedAssetIds.has(a.id)));
const firstFail = [...itemResults, ...assetResults].find((r): r is PromiseRejectedResult => r.status === "rejected");
const rows = [...allRows];
const results = await Promise.allSettled(rows.map((row) => restoreRow(row)));
const failed = new Set(rows.filter((_, i) => results[i].status === "rejected").map(rowKey));
setSections((list) =>
list
.map((section) => ({ ...section, rows: section.rows.filter((row) => failed.has(rowKey(row))) }))
.filter((section) => section.rows.length > 0)
);
const firstFail = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分恢复失败");
if (results.some((r) => r.status === "fulfilled")) onChanged?.();
setBulkBusy(null);
}
// 清空垃圾桶:同上,走各自的彻底删除通道
async function purgeAll() {
setPurgeAllOpen(false);
setBulkBusy("purge");
setErrText("");
const itemIds = items.map((p) => p.id);
const assetIds = assets.map((a) => a.id);
const itemResults = await Promise.allSettled(itemIds.map((id) => Promise.resolve(onPurge(id))));
const assetResults = await Promise.allSettled(assetIds.map((id) => api.purgeAsset(id)));
const failedItemIds = new Set(itemIds.filter((_, i) => itemResults[i].status === "rejected"));
const failedAssetIds = new Set(assetIds.filter((_, i) => assetResults[i].status === "rejected"));
setItems((list) => list.filter((p) => failedItemIds.has(p.id)));
setAssets((list) => list.filter((a) => failedAssetIds.has(a.id)));
const firstFail = [...itemResults, ...assetResults].find((r): r is PromiseRejectedResult => r.status === "rejected");
const rows = [...allRows];
const results = await Promise.allSettled(rows.map((row) => purgeRow(row)));
const failed = new Set(rows.filter((_, i) => results[i].status === "rejected").map(rowKey));
setSections((list) =>
list
.map((section) => ({ ...section, rows: section.rows.filter((row) => failed.has(rowKey(row))) }))
.filter((section) => section.rows.length > 0)
);
const firstFail = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (firstFail) setErrText(firstFail.reason instanceof Error ? firstFail.reason.message : "部分删除失败");
if (results.some((r) => r.status === "fulfilled")) onChanged?.();
setBulkBusy(null);
}
@@ -115,7 +261,7 @@ export function TrashPage({ onRestore, onPurge }: {
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 已删除商品与资产</span>
<span className="mono">// 已删除内容</span>
<span>·</span>
<span>,()</span>
</div>
@@ -123,45 +269,45 @@ export function TrashPage({ onRestore, onPurge }: {
<div className="actions">
<button className="btn" type="button" disabled={isEmpty || bulkBusy !== null} onClick={() => setPurgeAllOpen(true)}></button>
<button className="btn btn-primary" type="button" disabled={isEmpty || bulkBusy !== null} onClick={() => void restoreAll()}>
{bulkBusy === "restore" ? "恢复中" : "全部恢复"}
{bulkBusy === "restore" ? "恢复中..." : "全部恢复"}
</button>
</div>
</div>
{loading ? (
<div className="placeholder" style={{ minHeight: 160 }}><span className="ph-frame">// 加载中</span></div>
) : items.length === 0 && assets.length === 0 ? (
<div className="placeholder" style={{ minHeight: 160 }}><span className="ph-frame">// 加载中...</span></div>
) : isEmpty ? (
<div className="placeholder trash-empty"><span className="ph-frame">// 垃圾桶是空的</span></div>
) : (
<>
{errText && <div className="trash-err mono">// {errText}</div>}
{items.length > 0 && (
<div className="trash-section">
<div className="trash-section-title mono">// 商品 · {items.length}</div>
{sections.map((section) => (
<div className="trash-section" key={section.key}>
<div className="trash-section-title mono">// {section.title} · {section.rows.length}</div>
<div className="trash-list">
{items.map((p) => {
const cover = coverOf(p);
const busy = busyId === p.id;
{section.rows.map((row) => {
const key = rowKey(row);
const busy = busyKey === key;
return (
<div className="trash-row" key={p.id}>
<div className={`placeholder trash-thumb${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}>
{!cover && <span className="ph-frame"></span>}
<div className="trash-row" key={key}>
<div className={`placeholder trash-thumb${row.cover ? " has-mock-media" : ""}`} style={row.cover ? mediaStyle(row.cover) : undefined}>
{!row.cover && <span className="ph-frame"></span>}
</div>
<div className="trash-meta">
<div className="trash-name" title={p.title}>{p.title}</div>
<div className="trash-sub mono">// {p.category || "未分类"}</div>
<div className="trash-name" title={row.title}>{row.title}</div>
<div className="trash-sub mono">// {row.subtitle}</div>
</div>
<div className="trash-actions">
{confirmId === p.id ? (
{confirmId === key ? (
<>
<span className="trash-confirm-txt mono">?</span>
<button className="btn btn-sm trash-del" type="button" disabled={busy} onClick={() => void purge(p.id)}></button>
<button className="btn btn-sm trash-del" type="button" disabled={busy} onClick={() => void purge(row)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(null)}></button>
</>
) : (
<>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void restore(p.id)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(p.id)}></button>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void restore(row)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(key)}></button>
</>
)}
</div>
@@ -170,44 +316,7 @@ export function TrashPage({ onRestore, onPurge }: {
})}
</div>
</div>
)}
{assets.length > 0 && (
<div className="trash-section">
<div className="trash-section-title mono">// 资产 · {assets.length}</div>
<div className="trash-list">
{assets.map((a) => {
const cover = assetCoverOf(a);
const busy = busyId === a.id;
const deletedAt = dateOf(a.updated_at);
return (
<div className="trash-row" key={a.id}>
<div className={`placeholder trash-thumb${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}>
{!cover && <span className="ph-frame"></span>}
</div>
<div className="trash-meta">
<div className="trash-name" title={a.name}>{a.name}</div>
<div className="trash-sub mono">// {ASSET_TYPE_LABEL[a.asset_type] || a.asset_type}{deletedAt ? ` · 删除于 ${deletedAt}` : ""}</div>
</div>
<div className="trash-actions">
{confirmId === a.id ? (
<>
<span className="trash-confirm-txt mono">?</span>
<button className="btn btn-sm trash-del" type="button" disabled={busy} onClick={() => void purgeAsset(a.id)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(null)}></button>
</>
) : (
<>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void restoreAsset(a.id)}></button>
<button className="btn btn-sm" type="button" disabled={busy} onClick={() => setConfirmId(a.id)}></button>
</>
)}
</div>
</div>
);
})}
</div>
</div>
)}
))}
</>
)}
+13
View File
@@ -222,6 +222,7 @@ export type Product = {
specs?: Record<string, unknown>;
description: string;
status: string;
purged_at?: string | null;
cover_asset?: string | null;
cover_preview_url?: string;
images?: Array<{ id: string; asset: string; preview_url?: string; sort_order: number; is_primary: boolean }>;
@@ -239,6 +240,8 @@ export type Asset = {
description: string;
metadata?: Record<string, unknown>;
origin_task?: string | null;
is_deleted?: boolean;
purged_at?: string | null;
// 是否在「资产库」列表展示:图片生成的成图自动 true(R109);演员立绘暂存 false,「保存人物」后置 true
in_library?: boolean;
// 归属商品 id(后端解析:metadata.product_id 或 origin_task→project→product),无归属为 null
@@ -267,6 +270,8 @@ export type ModelEntity = {
source: "ai" | "upload";
description: string;
metadata?: Record<string, unknown>;
is_deleted?: boolean;
purged_at?: string | null;
portrait_asset: string | null;
triview_asset: string | null;
voice_asset: string | null;
@@ -446,6 +451,8 @@ export type Project = {
status: string;
current_stage: string;
failure_reason: string;
is_deleted?: boolean;
purged_at?: string | null;
// 轻量列表序列化(ProjectListSerializer)专有字段;详情序列化不含,故可选
product_title?: string;
cover_preview_url?: string;
@@ -576,6 +583,8 @@ export type FreeVideoTask = {
estimated_cost: string;
actual_cost: string;
error_message: string;
is_deleted?: boolean;
purged_at?: string | null;
fallback_note?: string;
is_favorited: boolean;
video_url: string;
@@ -625,6 +634,8 @@ export type AITask = {
batch_id?: string | null;
mode?: string | null;
status: string;
is_deleted?: boolean;
purged_at?: string | null;
idempotency_key?: string;
provider_task_id?: string;
created_at: string;
@@ -638,6 +649,8 @@ export type ImageConversation = {
mode: "image" | "model" | "cover";
product: string | null;
task_count: number;
is_deleted?: boolean;
purged_at?: string | null;
last_active_at: string;
created_at: string;
updated_at: string;