From 564215dc53134b9d6ce32a9110253f9899a194a7 Mon Sep 17 00:00:00 2001 From: hh <2587203630@qq.com> Date: Fri, 10 Jul 2026 15:06:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E5=95=86=E5=93=81?= =?UTF-8?q?=E2=80=94=E6=A8=A1=E7=89=B9=E5=BA=93-=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE-=E8=87=AA=E7=94=B1=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E7=AD=89=E5=88=A0=E9=99=A4=E5=85=88=E8=BF=9B=E5=9E=83=E5=9C=BE?= =?UTF-8?q?=E6=A1=B6=E5=8F=8A=E5=8F=AF=E6=81=A2=E5=A4=8D=E7=AD=89=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/free_video.py | 4 +- .../0026_conversation_task_purged_at.py | 29 ++ core/backend/apps/ai/models.py | 4 + core/backend/apps/ai/serializers.py | 9 +- core/backend/apps/ai/tests.py | 209 +++++++++++ core/backend/apps/ai/urls.py | 6 + core/backend/apps/ai/views.py | 181 +++++++++- .../migrations/0010_asset_model_purged_at.py | 29 ++ core/backend/apps/assets/models.py | 4 + core/backend/apps/assets/serializers.py | 4 +- core/backend/apps/assets/tests.py | 70 +++- core/backend/apps/assets/views.py | 74 ++-- .../migrations/0003_product_purged_at.py | 20 ++ core/backend/apps/products/models.py | 2 + core/backend/apps/products/tests.py | 42 ++- core/backend/apps/products/views.py | 15 +- .../migrations/0007_project_soft_delete.py | 25 ++ core/backend/apps/projects/models.py | 3 + core/backend/apps/projects/serializers.py | 6 +- core/backend/apps/projects/tests.py | 62 +++- core/backend/apps/projects/views.py | 62 +++- core/frontend/src/App.tsx | 3 +- core/frontend/src/api.ts | 40 ++- core/frontend/src/models-page.css | 16 +- core/frontend/src/routes/free-create.tsx | 2 +- core/frontend/src/routes/models.tsx | 5 +- core/frontend/src/routes/trash.tsx | 337 ++++++++++++------ core/frontend/src/types.ts | 13 + 28 files changed, 1086 insertions(+), 190 deletions(-) create mode 100644 core/backend/apps/ai/migrations/0026_conversation_task_purged_at.py create mode 100644 core/backend/apps/assets/migrations/0010_asset_model_purged_at.py create mode 100644 core/backend/apps/products/migrations/0003_product_purged_at.py create mode 100644 core/backend/apps/projects/migrations/0007_project_soft_delete.py diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index 87a4815..fb30957 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -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) diff --git a/core/backend/apps/ai/migrations/0026_conversation_task_purged_at.py b/core/backend/apps/ai/migrations/0026_conversation_task_purged_at.py new file mode 100644 index 0000000..f4618c5 --- /dev/null +++ b/core/backend/apps/ai/migrations/0026_conversation_task_purged_at.py @@ -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"), + ), + ] diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py index 6534b7b..7246056 100644 --- a/core/backend/apps/ai/models.py +++ b/core/backend/apps/ai/models.py @@ -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: diff --git a/core/backend/apps/ai/serializers.py b/core/backend/apps/ai/serializers.py index f9d8081..a41d2da 100644 --- a/core/backend/apps/ai/serializers.py +++ b/core/backend/apps/ai/serializers.py @@ -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", ] diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 5867ea9..add35ac 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -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) diff --git a/core/backend/apps/ai/urls.py b/core/backend/apps/ai/urls.py index e56df55..f9240a7 100644 --- a/core/backend/apps/ai/urls.py +++ b/core/backend/apps/ai/urls.py @@ -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//poll/", FreeVideoPollView.as_view(), name="ai-free-video-poll"), path("free-video//favorite/", FreeVideoFavoriteView.as_view(), name="ai-free-video-favorite"), + path("free-video//restore/", FreeVideoRestoreView.as_view(), name="ai-free-video-restore"), + path("free-video//purge/", FreeVideoPurgeView.as_view(), name="ai-free-video-purge"), path("free-video//", FreeVideoDetailView.as_view(), name="ai-free-video-detail"), ] + router.urls diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 41beb22..875fceb 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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//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//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) diff --git a/core/backend/apps/assets/migrations/0010_asset_model_purged_at.py b/core/backend/apps/assets/migrations/0010_asset_model_purged_at.py new file mode 100644 index 0000000..0101faa --- /dev/null +++ b/core/backend/apps/assets/migrations/0010_asset_model_purged_at.py @@ -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"), + ), + ] diff --git a/core/backend/apps/assets/models.py b/core/backend/apps/assets/models.py index c3d47bd..d876482 100644 --- a/core/backend/apps/assets/models.py +++ b/core/backend/apps/assets/models.py @@ -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"] diff --git a/core/backend/apps/assets/serializers.py b/core/backend/apps/assets/serializers.py index 685dee1..9596e53 100644 --- a/core/backend/apps/assets/serializers.py +++ b/core/backend/apps/assets/serializers.py @@ -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) diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index 05719bd..ee86dfa 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -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(与商品垃圾桶口径一致) diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 0d6fa62..88a53e3 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -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] diff --git a/core/backend/apps/products/migrations/0003_product_purged_at.py b/core/backend/apps/products/migrations/0003_product_purged_at.py new file mode 100644 index 0000000..b965fd6 --- /dev/null +++ b/core/backend/apps/products/migrations/0003_product_purged_at.py @@ -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"), + ), + ] diff --git a/core/backend/apps/products/models.py b/core/backend/apps/products/models.py index 5334377..14f1818 100644 --- a/core/backend/apps/products/models.py +++ b/core/backend/apps/products/models.py @@ -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"]), diff --git a/core/backend/apps/products/tests.py b/core/backend/apps/products/tests.py index 2cb3891..7e40aa9 100644 --- a/core/backend/apps/products/tests.py +++ b/core/backend/apps/products/tests.py @@ -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 → 彻底删找不到 diff --git a/core/backend/apps/products/views.py b/core/backend/apps/products/views.py index fc966e8..7cb56ed 100644 --- a/core/backend/apps/products/views.py +++ b/core/backend/apps/products/views.py @@ -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]) diff --git a/core/backend/apps/projects/migrations/0007_project_soft_delete.py b/core/backend/apps/projects/migrations/0007_project_soft_delete.py new file mode 100644 index 0000000..c16953a --- /dev/null +++ b/core/backend/apps/projects/migrations/0007_project_soft_delete.py @@ -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"), + ), + ] diff --git a/core/backend/apps/projects/models.py b/core/backend/apps/projects/models.py index 667285f..344068a 100644 --- a/core/backend/apps/projects/models.py +++ b/core/backend/apps/projects/models.py @@ -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: diff --git a/core/backend/apps/projects/serializers.py b/core/backend/apps/projects/serializers.py index 5b18990..54fc9de 100644 --- a/core/backend/apps/projects/serializers.py +++ b/core/backend/apps/projects/serializers.py @@ -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"] diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index def7278..d3914e6 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -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:读结构化全脚本,只重写目标镜、保留其余镜,落新版本,计费一次。""" diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index be17a2e..5c31a8a 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -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() diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index a82c5de..9a9908d 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -833,7 +833,7 @@ export function App() { ); case "models": - return ; + return setNotice({ type, text })} />; case "library": return 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": diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index f7b7f0c..a4cdc41 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -277,7 +277,7 @@ export const api = { return request(`/api/products/${id}/restore/`, { method: "POST" }); }, purgeProduct(id: string) { - // 彻底删除(不可恢复) + // 彻底删除 = 二级软删除(从垃圾桶隐藏,DB 记录保留) return request(`/api/products/${id}/purge/`, { method: "DELETE" }); }, projects() { @@ -302,6 +302,15 @@ export const api = { deleteProject(id: string) { return request(`/api/projects/${id}/`, { method: "DELETE" }); }, + projectsTrash() { + return request>("/api/projects/trash/"); + }, + restoreProject(id: string) { + return request(`/api/projects/${id}/restore/`, { method: "POST" }); + }, + purgeProject(id: string) { + return request(`/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(`/api/models/${id}/`, { method: "DELETE" }); }, + modelsTrash() { + return request>("/api/models/trash/"); + }, + restoreModel(id: string) { + return request(`/api/models/${id}/restore/`, { method: "POST" }); + }, + purgeModel(id: string) { + return request(`/api/models/${id}/purge/`, { method: "DELETE" }); + }, billingSummary() { return request("/api/billing/summary/"); }, @@ -650,6 +668,9 @@ export const api = { listConversations(mode: "image" | "model" | "cover" = "image") { return request>(`/api/ai/image-conversations/?mode=${mode}&page_size=100`); }, + conversationsTrash(mode: "image" | "model" | "cover" = "image") { + return request>(`/api/ai/image-conversations/trash/?mode=${mode}&page_size=100`); + }, createConversation(payload: { mode?: "image" | "model" | "cover"; title?: string; product?: string | null }) { return request("/api/ai/image-conversations/", { method: "POST", body: JSON.stringify(payload) }); }, @@ -659,6 +680,12 @@ export const api = { deleteConversation(id: string) { return request(`/api/ai/image-conversations/${id}/`, { method: "DELETE" }); }, + restoreConversation(id: string) { + return request(`/api/ai/image-conversations/${id}/restore/`, { method: "POST" }); + }, + purgeConversation(id: string) { + return request(`/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(`/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(`/api/ai/free-video/${id}/purge/`, { method: "DELETE" }); + }, uploadFreeVideoRef(formData: FormData) { return request("/api/ai/free-video/upload/", { method: "POST", body: formData }); }, diff --git a/core/frontend/src/models-page.css b/core/frontend/src/models-page.css index 4c99b40..33fa947 100644 --- a/core/frontend/src/models-page.css +++ b/core/frontend/src/models-page.css @@ -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 { diff --git a/core/frontend/src/routes/free-create.tsx b/core/frontend/src/routes/free-create.tsx index 989cf08..e87dd80 100644 --- a/core/frontend/src/routes/free-create.tsx +++ b/core/frontend/src/routes/free-create.tsx @@ -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 { diff --git a/core/frontend/src/routes/models.tsx b/core/frontend/src/routes/models.tsx index 652dfae..b8c8c51 100644 --- a/core/frontend/src/routes/models.tsx +++ b/core/frontend/src/routes/models.tsx @@ -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("all"); const [items, setItems] = useState([]); 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); } diff --git a/core/frontend/src/routes/trash.tsx b/core/frontend/src/routes/trash.tsx index cf58534..017bdc8 100644 --- a/core/frontend/src/routes/trash.tsx +++ b/core/frontend/src/routes/trash.tsx @@ -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 = { 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 | void; onPurge: (id: string) => Promise | void; + onChanged?: () => void; }) { - const [items, setItems] = useState([]); - const [assets, setAssets] = useState([]); + const [sections, setSections] = useState([]); const [loading, setLoading] = useState(true); const [confirmId, setConfirmId] = useState(null); - const [busyId, setBusyId] = useState(null); + const [busyKey, setBusyKey] = useState(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 { + 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 { + 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 }: {

垃圾桶

- // 已删除商品与资产 + // 已删除内容 · 可恢复,或彻底删除(不可恢复)
@@ -123,45 +269,45 @@ export function TrashPage({ onRestore, onPurge }: {
{loading ? ( -
// 加载中…
- ) : items.length === 0 && assets.length === 0 ? ( +
// 加载中...
+ ) : isEmpty ? (
// 垃圾桶是空的
) : ( <> {errText &&
// {errText}
} - {items.length > 0 && ( -
-
// 商品 · {items.length}
+ {sections.map((section) => ( +
+
// {section.title} · {section.rows.length}
- {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 ( -
-
- {!cover && 无图} +
+
+ {!row.cover && 无图}
-
{p.title}
-
// {p.category || "未分类"}
+
{row.title}
+
// {row.subtitle}
- {confirmId === p.id ? ( + {confirmId === key ? ( <> 彻底删除?不可恢复 - + ) : ( <> - - + + )}
@@ -170,44 +316,7 @@ export function TrashPage({ onRestore, onPurge }: { })}
- )} - {assets.length > 0 && ( -
-
// 资产 · {assets.length}
-
- {assets.map((a) => { - const cover = assetCoverOf(a); - const busy = busyId === a.id; - const deletedAt = dateOf(a.updated_at); - return ( -
-
- {!cover && 无图} -
-
-
{a.name}
-
// {ASSET_TYPE_LABEL[a.asset_type] || a.asset_type}{deletedAt ? ` · 删除于 ${deletedAt}` : ""}
-
-
- {confirmId === a.id ? ( - <> - 彻底删除?不可恢复 - - - - ) : ( - <> - - - - )} -
-
- ); - })} -
-
- )} + ))} )} diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 26f4a52..db70300 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -222,6 +222,7 @@ export type Product = { specs?: Record; 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; 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; + 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;