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:
@@ -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:
|
||||
|
||||
@@ -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 重试二次扣费)
|
||||
|
||||
@@ -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/ 只统计 mode∈model/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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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 的真实视频资产)。"""
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源:
|
||||
|
||||
@@ -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 拦截。"""
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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) # 封面不受影响
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user