fix: 测试清单遗留bug批量修复(15条:商品库/消费/团队/设置/消息中心/平台套图/资产库)

- R6 商品图删除不生效: 封面asset移交+绕开prefetch缓存, 前端封面缩图补删除入口
- R48 消息中心长文本与UI框错位: 胶囊单行省略+优先级标签禁压缩
- R70 账单流水筛选: 后端加ledger_type过滤, count随筛选变化, 切筛选重置页码
- R72 月限额改后不刷新: 团队页/消费页自取最新teamSettings, 两页同源
- R83/R86 平台套图提示词框加大+模型胶囊嵌入输入框(复用Pill组件)
- R96 未读角标: 修KeyTextTransform注解过滤触发MySQL 3141致接口500
- R100 工作台记录丢失: 新增GET /api/ai/tasks/workbench/ 同源恢复, 废除localStorage方案
- R104 卖点添加按钮移出输入框, 新建抽屉+编辑态统一
- R106 商品库宽屏锁5列, 10个/页铺满两行
- R108 资产删除改软删+单张删除+回收站二次确认; trash页商品/资产双分区+restore/purge端点
- R109 生成图自动入资产库(person演员立绘除外), 迁移0008存量回填, 移除全部加入资产库入口
- R65 无需改动(22268d6已修, 测旧部署所致)

验证: tsc/build/check/makemigrations --check 通过, 后端测试210/210, perf-probe PASS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-02 11:30:06 +08:00
co-authored by Claude Fable 5
parent 22268d608b
commit aab4d7ea4c
28 changed files with 924 additions and 476 deletions
+7 -4
View File
@@ -283,26 +283,29 @@ class AdminAssetReviewTests(TestCase):
self.a_active = Asset.objects.create(team=self.teamA, name="active", asset_type="image", category=Asset.Category.PERSON, review_status="active")
self.a_failed = Asset.objects.create(team=self.teamB, name="failed", asset_type="image", category=Asset.Category.PERSON, review_status="failed")
self.a_none = Asset.objects.create(team=self.teamA, name="none", asset_type="image", category=Asset.Category.PERSON, review_status="")
# 非 person 资产不该进队列
# scene 属于 REVIEW_CATEGORIES(场景图可能真人出镜,44e9023 扩审核范围),该进队列
Asset.objects.create(team=self.teamA, name="scene", asset_type="image", category=Asset.Category.SCENE, review_status="")
# 图片趴上传素材不在 REVIEW_CATEGORIES,不该进队列
Asset.objects.create(team=self.teamA, name="upload", asset_type="image", category=Asset.Category.UPLOAD, review_status="")
self.ac = APIClient()
self.ac.force_authenticate(self.admin)
self.nc = APIClient()
self.nc.force_authenticate(self.normal)
def test_list_permission_and_only_person_cross_team(self):
def test_list_permission_and_review_scope_cross_team(self):
self.assertEqual(self.nc.get("/api/admin/asset-reviews/").status_code, 403)
r = self.ac.get("/api/admin/asset-reviews/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 4) # 4 person 资产(跨 2 团队),scene 排除
self.assertEqual(r.data["count"], 5) # 4 person + 1 scene(跨 2 团队),范围外 upload 排除
self.assertEqual({row["team_name"] for row in r.data["results"]}, {"TeamA", "TeamB"})
self.assertNotIn("upload", {row["name"] for row in r.data["results"]})
def test_filter_by_status(self):
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=processing").data["count"], 1)
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=active").data["count"], 1)
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=failed").data["count"], 1)
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=none").data["count"], 1)
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=none").data["count"], 2) # person a_none + scene
def test_submit_calls_review_and_audit(self):
with patch("apps.adminpanel.views.submit_asset_for_review") as mock_submit:
+5 -2
View File
@@ -2598,8 +2598,11 @@ def run_standalone_image_task(*, task_id: str) -> None:
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=asset_category, origin_task=task,
metadata=asset_meta,
# 工作台生成的图默认不进资产库列表,只在工作台展示;用户「加入资产库」后才置 True
in_library=False,
# R109:图片生成(模特上身图/平台套图/图片创作/商品三视图)的成图**自动入资产库**,
# 不再由用户点「加入资产库」手动入库。仅「生成演员」(mode=model 无商品 → person)
# 仍暂存(in_library=False),由演员库「保存人物」时入库——否则未保存的立绘会
# 混进「我的演员」列表(ZWQ#6)。
in_library=asset_category != Asset.Category.PERSON,
)
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
+118
View File
@@ -427,19 +427,25 @@ class StandaloneCategoryTests(TestCase):
self.assertEqual(a.metadata.get("mode"), "model")
self.assertTrue(a.metadata.get("batch_id")) # 成组
self.assertEqual(a.metadata.get("model_entity_id"), "ent-1") # 溯源模特库
self.assertTrue(a.in_library) # R109:图片生成成图自动入资产库
def test_platform_kit_category(self):
a = self._run("cover", product_id=str(self.product.id))
self.assertEqual(a.category, Asset.Category.PLATFORM_KIT)
self.assertTrue(a.in_library) # R109
def test_free_create_category(self):
a = self._run("image")
self.assertEqual(a.category, Asset.Category.FREE_CREATE)
self.assertTrue(a.in_library) # R109
def test_generate_actor_stays_person(self):
# 生成演员:mode=model 但无 product → 视频角色,仍 person(送审范围内)
a = self._run("model")
self.assertEqual(a.category, Asset.Category.PERSON)
# R109 例外:演员立绘仍暂存(不自动入库),由演员库「保存人物」时置 True——
# 否则未保存立绘会混进「我的演员」列表(ZWQ#6)
self.assertFalse(a.in_library)
class TriviewAutoEnrollTests(TestCase):
@@ -544,3 +550,115 @@ class ChatStreamReasoningTests(SimpleTestCase):
self.assertEqual([e["type"] for e in events], ["reasoning", "reasoning", "delta", "delta", "done"])
self.assertEqual([e["text"] for e in events if e["type"] == "reasoning"], ["先想想", "用户要4镜"])
self.assertEqual("".join(e["text"] for e in events if e["type"] == "delta"), "正在生成脚本…")
class WorkbenchAndUnreadTests(TestCase):
"""R100(工作台记录后端持久化)+ R96(未读生成任务角标)+ R109(删除联动)端到端:
· /api/ai/tasks/workbench/ mode+product 回放批次流(与任务中心同源),软删的图不回显;
· /api/ai/tasks/unread/ 只统计 modemodel/cover/image 的未读任务,按团队隔离;
· /api/ai/tasks/mark-read/ 按商品清零"""
def setUp(self):
self.user = User.objects.create_user(username="wbowner", password="pass")
self.team = Team.objects.create(name="WB", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
CreditAccount.objects.create(team=self.team, balance="100.0000")
self.product = Product.objects.create(team=self.team, created_by=self.user, title="工作台商品")
cover = 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,
)
AssetFile.objects.create(asset=cover, object_key="c.png", bucket="b", content_type="image/png", preview_url="http://x/cover.png", is_primary=True)
self.product.cover_asset = cover
self.product.save(update_fields=["cover_asset"])
self.client = APIClient()
self.client.force_authenticate(self.user)
def _patch_provider(self):
provider = patch("apps.ai.services.get_image_provider").start()
prov = provider.return_value
prov.image_edit.return_value = {"data": [{"url": "http://x/out.png"}]}
prov.image_generation.return_value = {"data": [{"url": "http://x/out.png"}]}
prov.extract_first_media_url.return_value = "http://x/out.png"
media = patch("apps.ai.services.VolcanoArkProvider.media_to_bytes").start()
media.return_value = (BytesIO(b"img"), "image/png")
store = patch("apps.ai.services.TosStorage").start()
stored = store.return_value.upload_fileobj.return_value
stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "o.png", "b", "image/png", 3
self.addCleanup(patch.stopall)
return prov
def _generate(self, mode="cover", count=2, platform_id=None):
from apps.ai.services import run_standalone_image_task
self._patch_provider()
tasks = enqueue_standalone_images(
team=self.team, user=self.user, prompt="平台套图测试", mode=mode, count=count,
product_id=str(self.product.id), ratio="1:1", platform_id=platform_id,
)
for t in tasks:
run_standalone_image_task(task_id=str(t.id))
return tasks
def test_workbench_returns_batches_and_hides_deleted_assets(self):
tasks = self._generate(mode="cover", count=2, platform_id="douyin")
r = self.client.get(f"/api/ai/tasks/workbench/?mode=cover&product={self.product.id}")
self.assertEqual(r.status_code, 200, r.content)
rows = r.json()["tasks"]
self.assertEqual(len(rows), 2)
# 同一批共享 batch_id;prompt/ratio/platform/product 从 payload 抽出;每任务带成图
self.assertEqual(len({row["batch_id"] for row in rows}), 1)
self.assertEqual(rows[0]["prompt"], "平台套图测试")
self.assertEqual(rows[0]["ratio"], "1:1")
self.assertEqual(rows[0]["platform_id"], "douyin")
self.assertEqual(rows[0]["product_id"], str(self.product.id))
self.assertEqual(len(rows[0]["assets"]), 1)
# 其他 mode / 其他商品查不到这批
self.assertEqual(self.client.get("/api/ai/tasks/workbench/?mode=model").json()["tasks"], [])
# R108/R109:软删其中一张 → 该任务的 assets 随之为空(删除联动),资产不再回显
asset = Asset.objects.filter(origin_task=tasks[0]).first()
d = self.client.delete(f"/api/assets/{asset.id}/")
self.assertEqual(d.status_code, 204, d.content)
asset.refresh_from_db()
self.assertTrue(asset.is_deleted) # 回收站软删,不是物理删除
rows = self.client.get(f"/api/ai/tasks/workbench/?mode=cover&product={self.product.id}").json()["tasks"]
by_id = {row["id"]: row for row in rows}
self.assertEqual(by_id[str(tasks[0].id)]["assets"], [])
self.assertEqual(len(by_id[str(tasks[1].id)]["assets"]), 1)
def test_workbench_rejects_unknown_mode(self):
# mode 白名单:脚本 agent 复用的 auto/theme/revise 不能混进来
self.assertEqual(self.client.get("/api/ai/tasks/workbench/?mode=auto").status_code, 400)
self.assertEqual(self.client.get("/api/ai/tasks/workbench/").status_code, 400)
def test_unread_counts_and_mark_read(self):
self._generate(mode="cover", count=2, platform_id="douyin")
r = self.client.get("/api/ai/tasks/unread/")
self.assertEqual(r.status_code, 200, r.content)
body = r.json()
self.assertEqual(body["total"], 2)
self.assertEqual(body["by_product"], {str(self.product.id): 2})
# 按商品标记已读 → 清零
m = self.client.post("/api/ai/tasks/mark-read/", {"product_id": str(self.product.id)}, format="json")
self.assertEqual(m.status_code, 200, m.content)
self.assertEqual(m.json()["updated"], 2)
body = self.client.get("/api/ai/tasks/unread/").json()
self.assertEqual(body["total"], 0)
self.assertEqual(body["by_product"], {})
def test_unread_is_team_scoped_and_mode_whitelisted(self):
self._generate(mode="cover", count=1)
# 非生成任务(脚本 agent 复用 mode=auto)不计入未读
AITask.objects.create(
team=self.team, created_by=self.user, task_type=AITask.Type.SCRIPT_GENERATION,
model_config=ModelConfig.objects.first(), idempotency_key="script-x",
request_payload={"mode": "auto"},
)
self.assertEqual(self.client.get("/api/ai/tasks/unread/").json()["total"], 1)
# 另一团队的用户看不到本团队的未读
other = User.objects.create_user(username="wbother", password="pass")
other_team = Team.objects.create(name="WB2", owner=other)
TeamMember.objects.create(team=other_team, user=other, role="owner", status="active")
c2 = APIClient()
c2.force_authenticate(other)
self.assertEqual(c2.get("/api/ai/tasks/unread/").json()["total"], 0)
+69 -4
View File
@@ -127,8 +127,11 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
_GEN_MODES = ("model", "cover", "image")
def _unread_base(self):
"""本团队、属于图片生成、且未读(read_at is null)的任务集合(已 annotate rp_mode/rp_product_id)。"""
return self.get_queryset().filter(rp_mode__in=self._GEN_MODES, read_at__isnull=True)
"""本团队、属于图片生成、且未读(read_at is null)的任务集合(已 annotate rp_mode/rp_product_id)。
过滤必须走 request_payload__mode 路径查找而非 rp_mode 注解:KeyTextTransform 注解上的
exact/in 查找在 MySQL 会把裸字符串塞进 JSON_EXTRACT 当文档解析 3141 全查询炸
(sqlite 编译路径不同,单测测不出来);路径查找 RHS 会被正确 JSON 编码"""
return self.get_queryset().filter(request_payload__mode__in=self._GEN_MODES, read_at__isnull=True)
@action(detail=False, methods=["get"], url_path="unread")
def unread(self, request):
@@ -142,6 +145,68 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
by_product[str(pid)] = by_product.get(str(pid), 0) + 1
return Response({"total": total, "by_product": by_product})
@action(detail=False, methods=["get"], url_path="workbench")
def workbench(self, request):
"""R100:工作台(模特上身图/平台套图/图片创作)的生成记录改从后端持久数据拉取 ——
原先只存前端 localStorage(1 小时过期换浏览器/清缓存即空),任务中心有记录而工作台丢
与任务中心同源 = 同一批 AITask; request_payload.mode(白名单)+ product 过滤,
附带每个任务的存活成图资产,前端按 batch_id 归批还原批次流
性能:仍只用 KeyTextTransform SQL 层抽 payload 标量defer 两个巨型 payload ,不整列拖出"""
from django.db.models.fields.json import KeyTextTransform
mode = str(request.query_params.get("mode") or "").strip()
if mode not in self._GEN_MODES:
return Response({"detail": "mode 仅支持 model / cover / image"}, status=status.HTTP_400_BAD_REQUEST)
try:
limit = min(500, max(1, int(request.query_params.get("limit") or 200)))
except (TypeError, ValueError):
limit = 200
qs = (
AITask.objects.filter(
team=self.get_team(),
project__isnull=True, # 工作台独立生图不挂项目;排除流水线内部任务
task_type__in=[AITask.Type.PERSON_IMAGE, AITask.Type.PRODUCT_IMAGE],
)
.defer("request_payload", "response_payload")
.annotate(
rp_mode=KeyTextTransform("mode", "request_payload"),
rp_batch_id=KeyTextTransform("batch_id", "request_payload"),
rp_product_id=KeyTextTransform("product_id", "request_payload"),
rp_prompt=KeyTextTransform("prompt", "request_payload"),
rp_ratio=KeyTextTransform("ratio", "request_payload"),
rp_platform_id=KeyTextTransform("platform_id", "request_payload"),
rp_model_id=KeyTextTransform("model_id", "request_payload"),
rp_model_entity_id=KeyTextTransform("model_entity_id", "request_payload"),
)
.filter(request_payload__mode=mode) # 路径查找,不能用 rp_mode 注解比较(MySQL 3141,见 _unread_base)
)
product_id = str(request.query_params.get("product") or "").strip()
if product_id:
qs = qs.filter(request_payload__product_id=product_id)
tasks = list(qs.order_by("-created_at").prefetch_related("generated_assets", "generated_assets__files")[:limit])
tasks.reverse() # 旧 → 新,与对话流/工作台批次流的时间序一致
data = [
{
"id": str(t.id),
"status": t.status,
"error_message": t.error_message,
"prompt": t.rp_prompt or "",
"batch_id": t.rp_batch_id or "",
"ratio": t.rp_ratio or "",
"product_id": t.rp_product_id or "",
"model_id": t.rp_model_id or "",
"model_entity_id": t.rp_model_entity_id or "",
"platform_id": t.rp_platform_id or "",
"created_at": t.created_at,
# 软删的图不再出现在工作台记录里(R109:删除资产库图片 → 任务记录联动)
"assets": AssetSerializer(
[a for a in t.generated_assets.all() if not a.is_deleted], many=True
).data,
}
for t in tasks
]
return Response({"tasks": data})
@action(detail=False, methods=["post"], url_path="mark-read")
def mark_read(self, request):
"""标记已读 → 清零未读胶囊。
@@ -154,9 +219,9 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
if isinstance(ids, str):
ids = [s for s in ids.split(",") if s.strip()]
if product_id:
qs = qs.filter(rp_product_id=product_id)
qs = qs.filter(request_payload__product_id=product_id)
if batch_id:
qs = qs.filter(rp_batch_id=batch_id)
qs = qs.filter(request_payload__batch_id=batch_id)
if ids:
qs = qs.filter(id__in=[str(i).strip() for i in ids if str(i).strip()])
updated = qs.update(read_at=timezone.now())
@@ -0,0 +1,29 @@
# R109:图片生成(工作台)产物自动入资产库 —— 回填历史存量。
# 影响范围只限图片生成三类产物(model_tryon 模特上身图 / platform_kit 平台套图 / free_create 自由创作):
# 这三个 category 只由独立生图 worker 产出,把其中 in_library=False(旧「未加入资产库」)的置 True。
# 不碰 person(演员立绘暂存,由「保存人物」入库,ZWQ#6)、不碰其他任何分类。
from django.db import migrations
def backfill_workbench_in_library(apps, schema_editor):
Asset = apps.get_model("assets", "Asset")
Asset.objects.filter(
category__in=["model_tryon", "platform_kit", "free_create"],
in_library=False,
).update(in_library=True)
def noop(apps, schema_editor):
# 不可逆:回滚不把这些图再踢出资产库(无害)
pass
class Migration(migrations.Migration):
dependencies = [
("assets", "0007_asset_in_library"),
]
operations = [
migrations.RunPython(backfill_workbench_in_library, noop),
]
+46
View File
@@ -105,6 +105,52 @@ class AssetSoftDeleteTests(TestCase):
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 1)
class AssetTrashApiTests(TestCase):
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 硬删且被引用时 409。"""
def setUp(self):
self.user, self.team = _mk_team("utr", "TeamTR")
_, self.other_team = _mk_team("utr2", "TeamTR2")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.live = Asset.objects.create(team=self.team, name="活图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON)
self.dead = Asset.objects.create(team=self.team, name="删掉的图", asset_type="image", source="ai_generated", category=Asset.Category.MODEL_TRYON, is_deleted=True)
# 别队软删资产:不得出现在本队垃圾桶(team 隔离)
self.other_dead = Asset.objects.create(team=self.other_team, name="别队删图", asset_type="image", source="ai_generated", is_deleted=True)
def test_trash_lists_only_own_deleted(self):
ids = {a["id"] for a in self.client.get("/api/assets/trash/").json()["results"]}
self.assertEqual(ids, {str(self.dead.id)}) # 只含本队软删;不含活图 / 别队删图
def test_restore_makes_asset_visible_in_library_again(self):
res = self.client.post(f"/api/assets/{self.dead.id}/restore/")
self.assertEqual(res.status_code, 200)
self.dead.refresh_from_db()
self.assertFalse(self.dead.is_deleted)
ids = {a["id"] for a in self.client.get("/api/assets/?page_size=50").json()["results"]}
self.assertIn(str(self.dead.id), ids) # 资产库重新可见
# 恢复后不再在垃圾桶
self.assertEqual(self.client.get("/api/assets/trash/").json()["count"], 0)
def test_purge_hard_deletes(self):
res = self.client.delete(f"/api/assets/{self.dead.id}/purge/")
self.assertEqual(res.status_code, 204)
self.assertFalse(Asset.objects.filter(id=self.dead.id).exists())
def test_purge_referenced_asset_returns_409(self):
from apps.products.models import Product, ProductImage
product = Product.objects.create(team=self.team, created_by=self.user, title="P")
ProductImage.objects.create(product=product, asset=self.dead) # PROTECT 引用
res = self.client.delete(f"/api/assets/{self.dead.id}/purge/")
self.assertEqual(res.status_code, 409)
self.assertTrue(Asset.objects.filter(id=self.dead.id, is_deleted=True).exists()) # 留在垃圾桶可恢复
def test_restore_rejects_live_asset(self):
# 未删资产不在 trash 态 queryset → 404(与商品垃圾桶口径一致)
self.assertEqual(self.client.post(f"/api/assets/{self.live.id}/restore/").status_code, 404)
class VideoPacksTests(TestCase):
"""视频成品按项目素材包分组(与导出同源:取 VideoSegment.adopted_version 的真实视频资产)。"""
+50 -1
View File
@@ -5,6 +5,7 @@ import uuid
import requests
from django.db import transaction
from django.db.models import Q
from django.db.models.deletion import ProtectedError
from django.http import StreamingHttpResponse
from rest_framework import status
from rest_framework.decorators import action
@@ -99,11 +100,24 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
search_fields = ["name", "description"]
ordering_fields = ["created_at", "updated_at", "name"]
def perform_destroy(self, instance):
"""R108:资产删除 = 回收站软删(is_deleted 置位),不再物理删行(原默认 hard delete 不可恢复)。
资产库列表/批次/任务中心批次弹窗/工作台记录/对话历史引用的都是同一 Asset 查询(均过滤
is_deleted),软删后各处即联动消失(R109生成记录与资产库数据绑定)TOS 文件保留,可恢复"""
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
def get_queryset(self):
"""服务端过滤,支持各页按需懒加载(不再前端取全量后客户端切片)。
参数:tab(资产库分桶)/category/asset_type/source/product/q(搜索)/m_<key>(metadata 过滤)/ordering
默认按 -created_at 排序 ORDER BY 时分页会重/"""
qs = super().get_queryset().filter(is_deleted=False) # 软删资产不出现在资产库
qs = super().get_queryset()
# R108 垃圾桶:trash/restore/purge 只看软删资产(镜像商品垃圾桶的写法)。
# perform_destroy 软删时 update updated_at → -updated_at ≈ 删除时间倒序;不过滤 in_library
# (未入库的工作台图删了也得能恢复)。基类 queryset 的 defer(AITask 巨型 payload)保持生效。
if self.action in ("trash", "restore", "purge"):
return qs.filter(is_deleted=True).order_by("-updated_at")
qs = qs.filter(is_deleted=False) # 软删资产不出现在资产库
p = self.request.query_params
# 资产库列表/批次默认只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。
# 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。
@@ -244,6 +258,41 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
asset.save(update_fields=["in_library"])
return Response({"id": str(asset.id), "in_library": asset.in_library})
@action(detail=False, methods=["get"], url_path="trash")
def trash(self, request):
"""垃圾桶:列出本团队已软删(is_deleted=True)的资产 —— R108 删除弹窗承诺
删除的图片将回收到垃圾桶,此口兑现承诺(与商品垃圾桶 /api/products/trash/ 同构)"""
qs = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(qs)
if page is not None:
return self.get_paginated_response(self.get_serializer(page, many=True).data)
return Response(self.get_serializer(qs, many=True).data)
@action(detail=True, methods=["post"], url_path="restore")
def restore(self, request, pk=None):
"""从垃圾桶恢复:is_deleted → False。资产库/批次/商品素材区引用的都是同一 Asset 查询
(均过滤 is_deleted),恢复后各处即联动复现;in_library 保持删除前原样,不额外置位"""
asset = self.get_object() # trash 态 queryset:只有软删资产可恢复
asset.is_deleted = False
asset.save(update_fields=["is_deleted", "updated_at"])
return Response(self.get_serializer(asset).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["delete"], url_path="purge")
def purge(self, request, pk=None):
"""彻底删除(仅垃圾桶内软删资产可彻底删,不可恢复)。与商品 purge 语义对齐:
硬删 DB (AssetFile 记录级联删除),TOS 对象不动(商品 purge 同样不触碰对象存储)
分镜/视频片段/商品图等以 PROTECT 引用资产:被引用的资产硬删会炸 ProtectedError,
如实回 409(留在垃圾桶,仍可恢复),别让它 500"""
asset = self.get_object()
try:
asset.delete()
except ProtectedError:
return Response(
{"detail": "该资产仍被商品图/分镜/视频片段等引用,无法彻底删除;可恢复后先解除引用"},
status=status.HTTP_409_CONFLICT,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["get"], url_path="video-packs")
def video_packs(self, request):
"""视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源:
+32
View File
@@ -100,6 +100,38 @@ class MemberLimitTests(TestCase):
reserve_credit(team=self.team, user=self.user, task=self._task(f"z{i}"), amount=Decimal("10.0000"))
class LedgerTypeFilterTests(TestCase):
"""账单流水按类型过滤(R70):count/结果随 ledger_type 变化,未知类型忽略回全量。"""
def setUp(self):
self.owner = User.objects.create_user(username="lf-owner", password="ownerpass123")
self.team = Team.objects.create(name="Ledger Filter Team", owner=self.owner)
TeamMember.objects.create(team=self.team, user=self.owner, role=TeamMember.Role.OWNER, status=TeamMember.Status.ACTIVE)
CreditAccount.objects.create(team=self.team, balance=Decimal("100.0000"))
for i, ltype in enumerate([CreditLedger.Type.RECHARGE, CreditLedger.Type.CHARGE, CreditLedger.Type.CHARGE]):
CreditLedger.objects.create(
team=self.team, user=self.owner, ledger_type=ltype,
amount=Decimal("10.0000"), balance_after=Decimal("100.0000"), reason=f"row {i}",
)
self.client_api = APIClient()
self.client_api.force_authenticate(self.owner)
def test_filter_by_type_changes_count_and_rows(self):
response = self.client_api.get("/api/billing/ledgers/?ledger_type=charge")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 2)
self.assertTrue(all(row["ledger_type"] == "charge" for row in response.data["results"]))
response = self.client_api.get("/api/billing/ledgers/?ledger_type=recharge")
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["results"][0]["ledger_type"], "recharge")
def test_unknown_type_returns_all(self):
response = self.client_api.get("/api/billing/ledgers/?ledger_type=bogus")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 3)
class RechargePermissionTests(TestCase):
"""充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客被 403 拦截。"""
+4
View File
@@ -59,10 +59,14 @@ def ledgers(request):
)
project_id = request.query_params.get("project")
user_id = request.query_params.get("user")
# 按类型过滤(账户页「全部类型/扣费/充值…」下拉):count/分页随过滤变化,未知值忽略
ledger_type = request.query_params.get("ledger_type")
if project_id:
queryset = queryset.filter(project_id=project_id)
if user_id:
queryset = queryset.filter(user_id=user_id)
if ledger_type and ledger_type in CreditLedger.Type.values:
queryset = queryset.filter(ledger_type=ledger_type)
# 服务端分页:总数随流水增长(原先写死 [:100] 导致永远 100 条)
try:
page = max(1, int(request.query_params.get("page", 1)))
+21
View File
@@ -132,3 +132,24 @@ class ProductImageDeleteGuardTests(TestCase):
res = self.client.delete(f"/api/products/{self.product.id}/images/{self.img0.id}/") # 再删最后一张
self.assertEqual(res.status_code, 400)
self.assertEqual(self.product.images.count(), 1) # 没删掉,仍保留一张
def test_delete_cover_image_reassigns_cover(self):
# YYX R6:被删的图正是封面来源(创建时 cover_asset = 第一张图)→ 封面移交给剩余首图,
# 否则 cover_asset 悬空指向已移除的图,前端会把它重建成「没有删除入口的封面缩图」,体感删除不生效。
self.product.cover_asset = self.img0.asset
self.product.save(update_fields=["cover_asset"])
res = self.client.delete(f"/api/products/{self.product.id}/images/{self.img0.id}/")
self.assertEqual(res.status_code, 200)
self.product.refresh_from_db()
self.assertEqual(self.product.cover_asset_id, self.img1.asset_id) # 封面移交剩余首图
data = res.json()
self.assertEqual(data["cover_asset"], str(self.img1.asset_id))
self.assertNotIn(str(self.img0.asset_id), [im["asset"] for im in data["images"]]) # 被删图不再回来
def test_delete_non_cover_image_keeps_cover(self):
self.product.cover_asset = self.img0.asset
self.product.save(update_fields=["cover_asset"])
res = self.client.delete(f"/api/products/{self.product.id}/images/{self.img1.id}/")
self.assertEqual(res.status_code, 200)
self.product.refresh_from_db()
self.assertEqual(self.product.cover_asset_id, self.img0.asset_id) # 封面不受影响
+14 -2
View File
@@ -206,13 +206,25 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
"""移除商品图(删 ProductImage 关联,保留底层 Asset)。
商品至少保留一张图(创建必须带图一致):删到最后一张则拒绝 要清空只能删整个商品"""
product = self.get_object()
if not ProductImage.objects.filter(product=product, id=image_id).exists():
image = ProductImage.objects.filter(product=product, id=image_id).first()
if image is None:
return Response({"detail": "image not found"}, status=status.HTTP_404_NOT_FOUND)
if product.images.count() <= 1:
return Response(
{"detail": "商品至少保留一张图片,如需移除请删除整个商品(或先上传新图再删旧图)"},
status=status.HTTP_400_BAD_REQUEST,
)
ProductImage.objects.filter(product=product, id=image_id).delete()
deleted_asset_id = image.asset_id
image.delete()
# 被删的图若正是封面来源(创建时 cover_asset = 第一张图的 asset):把封面移交给剩余首图,
# 否则 cover_asset 悬空指向已移除的图 → 序列化时它不在 images 里,前端会把它重建成
# 「无 ProductImage 关联、没有删除入口的封面缩图」,体感「弹了删除成功但图又回来了,
# 悬停也不出垃圾桶」(YYX R6)。
if product.cover_asset_id == deleted_asset_id:
# ★ 不走 product.images(get_object 的 prefetch 缓存删完不失效,会把刚删的图再取出来),
# 直接查表拿剩余首图(Meta.ordering = sort_order, created_at)。
next_image = ProductImage.objects.filter(product=product).first()
product.cover_asset_id = next_image.asset_id if next_image else None
product.save(update_fields=["cover_asset", "updated_at"])
product.refresh_from_db()
return Response(ProductSerializer(product).data, status=status.HTTP_200_OK)