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)