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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user