feat(models): 期1 模特库地基 — Model 顶级实体 + 模特库页 + 审核范围扩展
后端:
- 新增 Model(模特)团队级实体:形象图+三视图+声线(声线尾期),官方模板标签,软删
- Asset.Category 枚举一次加全(model_portrait/tri_view/voice/model_tryon/platform_kit/free_create/storyboard),逐期接线
- 送审范围 person → REVIEW_CATEGORIES{person,tri_view,storyboard}(review.py + adminpanel 队列),图片趴不送审;当前行为不变(forward-compat)
- /api/models/ ModelLibraryViewSet:本团队∪官方模板、官方/我的 tab、真人上传、软删(官方不可删)、跨团队资产引用防越权
- backfill_models 命令把历史成套模特(kind=model + 项目流命名配对)归集成 Model,dry-run 默认
前端:
- 左菜单新增「模特库」顶级入口(person 图标)+ 路由 /models
- 模特库页:全部/官方模板/我的模特 tab + 真人上传 + 模特卡(形象图+三视图缩略+官方标签),仅用 design token
测试:assets 10/10 绿;tsc+build 全绿;无头走查 0 console error(全部5/官方3/我的2,官方标签达标)
基线既有 7 失败(ai/projects provider mock 漂移)零新增
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1c9143ce61
commit
44e90233ff
@@ -0,0 +1,145 @@
|
||||
"""把历史「成套模特(形象图 + 三视图)」归集成 Model 实体,顺带报告/清理孤儿错图。
|
||||
|
||||
两条历史来源都收编:
|
||||
1) 模特库生成(model_library.generate_model):metadata.kind="model" 的 PERSON 资产,
|
||||
成对的 view=frontal / view=three_view 共享同一 brief、同团队 → 按 brief 配对。
|
||||
2) 项目立绘流(run_triview_task):三视图资产带 metadata.triview_of=<立绘 asset id> → 立绘=形象图、它=三视图。
|
||||
|
||||
幂等(局部):已被某个 Model 引用过的 portrait/triview 资产会跳过,同一张图不会被重复归组。
|
||||
注意这是 **一次性数据工具**:同名前缀若有多余的重生版本(例如同项目多次出图),再次运行会把剩余版本另配成新组。
|
||||
所以跑一次即可,别反复跑。默认 **dry-run** 只打印计划;--apply 才真正写库。清理孤儿用 --soft-delete-orphans(只置 is_deleted,可逆)。
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from apps.assets.models import Asset, Model
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "把历史成套模特(形象图+三视图)归集成 Model 实体;报告并可软删孤儿错图。"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--apply", action="store_true", help="真正写库(默认 dry-run 只打印)")
|
||||
parser.add_argument(
|
||||
"--soft-delete-orphans",
|
||||
action="store_true",
|
||||
help="把无法配对、且无文件的 model 类孤儿资产置 is_deleted=True(可逆)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
apply = opts["apply"]
|
||||
soft_del = opts["soft_delete_orphans"]
|
||||
self.stdout.write(self.style.WARNING("=== backfill_models %s ===" % ("APPLY" if apply else "DRY-RUN")))
|
||||
|
||||
# 已被 Model 引用的资产(幂等跳过)
|
||||
used = set()
|
||||
for pid, tid in Model.objects.values_list("portrait_asset_id", "triview_asset_id"):
|
||||
if pid:
|
||||
used.add(str(pid))
|
||||
if tid:
|
||||
used.add(str(tid))
|
||||
|
||||
plans: list[dict] = [] # {name, portrait, triview, source}
|
||||
|
||||
# —— 来源 2:项目立绘流(triview_of) ——
|
||||
triviews = Asset.objects.filter(metadata__triview_of__isnull=False, is_deleted=False)
|
||||
for tv in triviews:
|
||||
portrait_id = str((tv.metadata or {}).get("triview_of") or "")
|
||||
if not portrait_id or str(tv.id) in used or portrait_id in used:
|
||||
continue
|
||||
portrait = Asset.objects.filter(id=portrait_id, is_deleted=False).first()
|
||||
if portrait is None:
|
||||
continue
|
||||
plans.append(
|
||||
{"name": (portrait.name or "模特").split("·")[0][:255], "portrait": portrait, "triview": tv, "source": "project"}
|
||||
)
|
||||
used.update({str(tv.id), portrait_id})
|
||||
|
||||
# —— 来源 1:模特库生成(kind=model,按 team+brief 配对 frontal/three_view) ——
|
||||
groups: dict[tuple, dict[str, list[Asset]]] = defaultdict(lambda: {"frontal": [], "three_view": []})
|
||||
models_qs = Asset.objects.filter(metadata__kind="model", is_deleted=False).order_by("created_at")
|
||||
for a in models_qs:
|
||||
if str(a.id) in used:
|
||||
continue
|
||||
view = (a.metadata or {}).get("view")
|
||||
if view in ("frontal", "three_view"):
|
||||
groups[(a.team_id, (a.metadata or {}).get("brief", ""))][view].append(a)
|
||||
for (team_id, brief), bucket in groups.items():
|
||||
frontals, tvs = bucket["frontal"], bucket["three_view"]
|
||||
for portrait, triview in zip(frontals, tvs):
|
||||
if str(portrait.id) in used or str(triview.id) in used:
|
||||
continue
|
||||
plans.append(
|
||||
{"name": (portrait.name or brief or "模特").split("·")[0][:255], "portrait": portrait, "triview": triview, "source": "model_lib"}
|
||||
)
|
||||
used.update({str(portrait.id), str(triview.id)})
|
||||
|
||||
# —— 来源 3:项目立绘流按命名后缀配对(立绘 "X-person" ↔ 三视图 "X-三视图",同前缀同团队) ——
|
||||
# 历史项目流的 person 资产没写 triview_of,但命名成对;按名前缀收编成套的。
|
||||
PORT_SUF = ("-person", "-立绘")
|
||||
TRI_SUF = ("-三视图", "·三视图")
|
||||
|
||||
def _strip(name: str):
|
||||
for s in TRI_SUF:
|
||||
if name.endswith(s):
|
||||
return name[: -len(s)], "tri"
|
||||
for s in PORT_SUF:
|
||||
if name.endswith(s):
|
||||
return name[: -len(s)], "port"
|
||||
return None, None
|
||||
|
||||
named: dict[tuple, dict[str, Asset]] = defaultdict(dict)
|
||||
for a in Asset.objects.filter(category="person", is_deleted=False).order_by("created_at"):
|
||||
if str(a.id) in used:
|
||||
continue
|
||||
prefix, role = _strip(a.name or "")
|
||||
if role is None:
|
||||
continue
|
||||
slot = named[(a.team_id, prefix)]
|
||||
slot.setdefault(role, a) # 同前缀多版本取最早一张,避免错配
|
||||
for (team_id, prefix), slot in named.items():
|
||||
portrait, triview = slot.get("port"), slot.get("tri")
|
||||
if not portrait or not triview or str(portrait.id) in used or str(triview.id) in used:
|
||||
continue
|
||||
plans.append({"name": prefix.strip(" ·-")[:255] or "模特", "portrait": portrait, "triview": triview, "source": "named"})
|
||||
used.update({str(portrait.id), str(triview.id)})
|
||||
|
||||
# —— 孤儿:model 类资产没进任何配对(疑似早期错图 / 半成品) ——
|
||||
orphans = [
|
||||
a for a in models_qs
|
||||
if str(a.id) not in used and (a.metadata or {}).get("view") in ("frontal", "three_view")
|
||||
]
|
||||
no_file_orphans = [a for a in orphans if not a.files.exists()]
|
||||
|
||||
# —— 打印计划 ——
|
||||
self.stdout.write(f" 待建 Model:{len(plans)} 组")
|
||||
for p in plans:
|
||||
self.stdout.write(f" [{p['source']}] {p['name']} portrait={p['portrait'].id} triview={p['triview'].id}")
|
||||
self.stdout.write(f" 孤儿(model 类未配对):{len(orphans)};其中无文件:{len(no_file_orphans)}")
|
||||
|
||||
if not apply:
|
||||
self.stdout.write(self.style.WARNING("DRY-RUN 结束,未写库。加 --apply 执行。"))
|
||||
return
|
||||
|
||||
created = 0
|
||||
with transaction.atomic():
|
||||
for p in plans:
|
||||
Model.objects.create(
|
||||
team_id=p["portrait"].team_id,
|
||||
created_by_id=p["portrait"].created_by_id,
|
||||
name=p["name"],
|
||||
source=Model.Source.AI,
|
||||
portrait_asset=p["portrait"],
|
||||
triview_asset=p["triview"],
|
||||
metadata={"backfill": p["source"]},
|
||||
)
|
||||
created += 1
|
||||
soft_deleted = 0
|
||||
if soft_del:
|
||||
for a in no_file_orphans:
|
||||
a.is_deleted = True
|
||||
a.save(update_fields=["is_deleted", "updated_at"])
|
||||
soft_deleted += 1
|
||||
self.stdout.write(self.style.SUCCESS(f"完成:建 {created} 个 Model;软删孤儿 {soft_deleted if soft_del else 0} 张。"))
|
||||
Reference in New Issue
Block a user