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