feat: 生图模型可选(火山/gpt-image) + 工作台图「加入资产库」收纳 + 任务中心排序修复

- AI 工作室生图支持显式选模型:resolve_image_model 解析 volcano(Seedream 图生图,
  无 image_edit 时走 image_generation 带参考图)/ gpt-image(image_edit 多图编辑),
  未选回落系统默认;enqueue_standalone_images 透传 image_model
- 资产库收纳:Asset 加 in_library 字段(迁移 0007,既有资产 db_default=True 不动);
  工作台生成图默认 in_library=False,只在工作台展示,用户「加入资产库」后才进库列表;
  assets 视图/序列化器/library 页/types 配套
- 任务中心修复:AITaskViewSet 默认 order_by(-created_at),前端 aiTasks 取 page_size=200。
  原因:列表无排序时 MySQL 按 UUID 主键乱序返回,把一批旧失败记录顶到首页,
  前端又只取首页 20 条算 全部/已完成/失败 → 误显示「全部失败」(实为 421 成功/少量失败)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-27 10:34:12 +08:00
co-authored by Claude Opus 4.8
parent de4b20cc7b
commit 26577b99b1
15 changed files with 472 additions and 104 deletions
+95 -6
View File
@@ -1,4 +1,5 @@
from pathlib import Path
import re
import uuid
import requests
@@ -61,6 +62,27 @@ def _tab_q(tab: str) -> Q:
return Q()
# 图片趴三类按「生成批次」成组展示:同一次提交的 N 张图共享 metadata.batch_id。
BATCH_TABS = {"tryon", "kits", "creations"}
# 资产名形如「AI 生成 · 模特上身图 · 4」,去掉尾部的「· 序号」得到批次名「AI 生成 · 模特上身图」。
_BATCH_NAME_RE = re.compile(r"\s*·\s*\d+\s*$")
def _batch_key(batch_id, origin_task_id, asset_id) -> str:
"""资产归批:优先 metadata.batch_id;旧图无 batch_id 时回落 origin_task,再无则自身成单图批。"""
if batch_id:
return f"batch:{batch_id}"
if origin_task_id:
return f"task:{origin_task_id}"
return f"asset:{asset_id}"
def _batch_name(name: str) -> str:
cleaned = _BATCH_NAME_RE.sub("", name or "").strip()
return cleaned or (name or "")
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
# select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)。
# ★ defer 掉 AITask 的两个巨型 JSON 列(request/response payload):列表序列化只需 project.product_id,
@@ -82,6 +104,10 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
参数:tab(资产库分桶)/category/asset_type/source/product/q(搜索)/m_<key>(metadata 过滤)/ordering。
默认按 -created_at 排序——无 ORDER BY 时分页会重/漏。"""
qs = super().get_queryset().filter(is_deleted=False) # 软删资产不出现在资产库
# 资产库列表/批次只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。
# 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。
if self.action in ("list", "batches"):
qs = qs.filter(in_library=True)
p = self.request.query_params
if p.get("tab"):
qs = qs.filter(_tab_q(p["tab"]))
@@ -110,11 +136,63 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=False, methods=["get"])
def summary(self, request):
"""资产库 tab 计数(人物/场景/商品图/成片/我的上传/未分类),供 tab 徽标——不必取全量。"""
base = Asset.objects.filter(team=self.get_team(), is_deleted=False)
# 资产库成品化:对外只数成品三类 + 其他(角色/场景/商品图各回各库,视频成品走 video-packs)
tabs = ["tryon", "kits", "creations", "others"]
return Response({t: base.filter(_tab_q(t)).count() for t in tabs})
"""资产库 tab 计数,供 tab 徽标——不必取全量。
图片趴三类(模特上身图/平台套图/自由创作)按「生成批次」计数(与卡片成组展示一致);
其他(我的上传 + 兜底)仍按资产张数计。"""
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
out = {}
for t in BATCH_TABS: # 图片趴:数批次
seen = set()
rows = base.filter(_tab_q(t)).values("id", "origin_task_id", "metadata")
for row in rows:
seen.add(_batch_key((row["metadata"] or {}).get("batch_id"), row["origin_task_id"], row["id"]))
out[t] = len(seen)
out["others"] = base.filter(_tab_q("others")).count()
return Response(out)
@action(detail=False, methods=["get"])
def batches(self, request):
"""图片趴(模特上身图/平台套图/自由创作)按生成批次成组:同一次提交的 N 张图
共享 metadata.batch_id = 一批;无 batch_id 的旧图回落 origin_task / 自身成单图批。
复用 get_queryset 的 tab/source/q/m_* 过滤 + ordering,按「批」分页返回,每批含封面 + 整批资产。"""
from apps.assets.serializers import _asset_preview
qs = self.get_queryset() # team scope + tab/source/q/meta 过滤 + ordering + defer
groups: dict[str, list] = {}
order: list[str] = [] # 保留资产 ordering 决定的批次先后(尊重最近/最早排序)
for asset in qs:
key = _batch_key((asset.metadata or {}).get("batch_id"), asset.origin_task_id, asset.id)
bucket = groups.get(key)
if bucket is None:
groups[key] = bucket = []
order.append(key)
bucket.append(asset)
try:
page = max(1, int(request.query_params.get("page", 1)))
except (TypeError, ValueError):
page = 1
try:
page_size = min(200, max(1, int(request.query_params.get("page_size", 20))))
except (TypeError, ValueError):
page_size = 20
total = len(order)
start = (page - 1) * page_size
ctx = self.get_serializer_context()
results = []
for key in order[start:start + page_size]:
assets = groups[key]
first = assets[0]
results.append({
"batch_id": key,
"name": _batch_name(first.name),
"count": len(assets),
"cover": _asset_preview(first),
"created_at": first.created_at,
"items": AssetSerializer(assets, many=True, context=ctx).data,
})
return Response({"count": total, "results": results})
@action(detail=True, methods=["post"], url_path="submit-review")
def submit_review(self, request, pk=None):
@@ -136,6 +214,17 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
)
return Response({"review_status": asset.review_status, "review_error": asset.review_error or ""})
@action(detail=True, methods=["post"], url_path="set-library")
def set_library(self, request, pk=None):
"""单图「加入资产库 / 取消加入」:置 in_library。加入 → 出现在资产库列表;取消 → 从列表移出。
注:资产在生成时已落库并扣费,此口只控制是否进「资产库」展示列表,不增删真实资产、不二次扣费。"""
asset = self.get_object() # team-scoped;此 action 不过滤 in_library,故未加入的图也能取到
in_lib = bool(request.data.get("in_library", True))
if asset.in_library != in_lib:
asset.in_library = in_lib
asset.save(update_fields=["in_library"])
return Response({"id": str(asset.id), "in_library": asset.in_library})
@action(detail=False, methods=["get"], url_path="video-packs")
def video_packs(self, request):
"""视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源:
@@ -180,7 +269,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
def facets(self, request):
"""某 tab 下真实存在的筛选项(来源/类型/指定 metadata 键的取值),供下拉「只列真有的」。
参数:tab、meta_keys=gender,age,role,...(逗号分隔)。"""
base = Asset.objects.filter(team=self.get_team(), is_deleted=False)
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
if request.query_params.get("tab"):
base = base.filter(_tab_q(request.query_params["tab"]))
sources = sorted(s for s in base.values_list("source", flat=True).distinct() if s)