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:
seaislee1209
2026-06-20 22:53:46 +08:00
co-authored by Claude Opus 4.8
parent 1c9143ce61
commit 44e90233ff
23 changed files with 1003 additions and 12 deletions
+1
View File
@@ -10,6 +10,7 @@ urlpatterns = [
path("api/auth/", include("apps.accounts.urls")),
path("api/products/", include("apps.products.urls")),
path("api/assets/", include("apps.assets.urls")),
path("api/models/", include("apps.assets.model_urls")),
path("api/projects/", include("apps.projects.urls")),
path("api/billing/", include("apps.billing.urls")),
path("api/ai/", include("apps.ai.urls")),
+4 -4
View File
@@ -289,9 +289,9 @@ _REVIEW_STATUSES = {"", "processing", "active", "failed"}
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_asset_reviews(request):
"""跨团队真人(person)资产审核队列。?review_status=none|processing|active|failed 过滤(none=未送审)。"""
"""跨团队送审资产审核队列(角色定妆照/三视图/分镜图)。?review_status=none|processing|active|failed 过滤(none=未送审)。"""
qs = (
Asset.objects.filter(category=Asset.Category.PERSON, is_deleted=False)
Asset.objects.filter(category__in=Asset.REVIEW_CATEGORIES, is_deleted=False)
.select_related("team")
.prefetch_related("files")
.order_by("-created_at")
@@ -314,7 +314,7 @@ def admin_asset_reviews(request):
def admin_asset_reviews_submit(request):
"""批量送审(也用于失败重试):对给定 person 资产逐个 submit_asset_for_review。"""
ids = request.data.get("asset_ids") or []
assets = list(Asset.objects.filter(id__in=ids, category=Asset.Category.PERSON, is_deleted=False))
assets = list(Asset.objects.filter(id__in=ids, category__in=Asset.REVIEW_CATEGORIES, is_deleted=False))
for asset in assets:
submit_asset_for_review(asset)
statuses = {str(a.id): a.review_status for a in Asset.objects.filter(id__in=ids)}
@@ -333,7 +333,7 @@ def admin_asset_reviews_submit(request):
def admin_asset_reviews_poll(request):
"""轮询审核中(processing)资产的最新状态。给 asset_ids 则只轮询这些,否则轮询全平台 processing。"""
ids = request.data.get("asset_ids")
qs = Asset.objects.filter(category=Asset.Category.PERSON, review_status="processing", is_deleted=False)
qs = Asset.objects.filter(category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False)
if ids:
qs = qs.filter(id__in=ids)
statuses = {}
@@ -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} 张。"))
@@ -0,0 +1,46 @@
# Generated by Django 5.1.15 on 2026-06-20 14:32
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_invitation_kind_alter_invitation_team'),
('assets', '0005_asset_assets_asse_team_id_4b1b57_idx_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='asset',
name='category',
field=models.CharField(choices=[('person', 'Person'), ('scene', 'Scene'), ('product_image', 'Product Image'), ('video_clip', 'Video Clip'), ('final_video', 'Final Video'), ('upload', 'Upload'), ('uncategorized', 'Uncategorized'), ('model_portrait', 'Model Portrait'), ('tri_view', 'Tri View'), ('voice', 'Voice'), ('model_tryon', 'Model Try-on'), ('platform_kit', 'Platform Kit'), ('free_create', 'Free Create'), ('storyboard', 'Storyboard')], default='uncategorized', max_length=32),
),
migrations.CreateModel(
name='Model',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=255)),
('is_official', models.BooleanField(default=False)),
('source', models.CharField(choices=[('ai', 'AI Generated'), ('upload', 'Upload')], default='ai', max_length=16)),
('description', models.TextField(blank=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('is_deleted', models.BooleanField(default=False)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(class)s_set', to=settings.AUTH_USER_MODEL)),
('portrait_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_as_portrait', to='assets.asset')),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='accounts.team')),
('triview_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_as_triview', to='assets.asset')),
('voice_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_as_voice', to='assets.asset')),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['team', 'is_official', '-created_at'], name='assets_mode_team_id_0caf77_idx')],
},
),
]
+9
View File
@@ -0,0 +1,9 @@
"""模特库路由(顶级实体,挂在 /api/models/)。与 AssetViewSet(/api/assets/)分开,避免 "" 前缀 pk 冲突。"""
from rest_framework.routers import DefaultRouter
from .views import ModelLibraryViewSet
router = DefaultRouter()
router.register("", ModelLibraryViewSet, basename="model")
urlpatterns = router.urls
+51 -1
View File
@@ -18,13 +18,24 @@ class Asset(TeamOwnedModel):
SYSTEM = "system", "System"
class Category(models.TextChoices):
PERSON = "person", "Person"
PERSON = "person", "Person" # 视频趴·角色定妆照(送审)
SCENE = "scene", "Scene"
PRODUCT_IMAGE = "product_image", "Product Image"
VIDEO_CLIP = "video_clip", "Video Clip"
FINAL_VIDEO = "final_video", "Final Video"
UPLOAD = "upload", "Upload"
UNCATEGORIZED = "uncategorized", "Uncategorized"
# ↓ 模特库 + 资产模型重构(2026-06-20)新增,逐期接线。
MODEL_PORTRAIT = "model_portrait", "Model Portrait" # 模特形象图(图片域·不送审)
TRI_VIEW = "tri_view", "Tri View" # 三视图(送审)
VOICE = "voice", "Voice" # 模特声线(占位·尾期)
MODEL_TRYON = "model_tryon", "Model Try-on" # 模特上身图(图片趴·不送审)
PLATFORM_KIT = "platform_kit", "Platform Kit" # 平台套图(图片趴)
FREE_CREATE = "free_create", "Free Create" # 自由创作(图片趴)
STORYBOARD = "storyboard", "Storyboard" # 分镜图(视频趴·送审)
# 送审范围(火山人像审核):视频趴含人脸 = 角色定妆照 / 三视图 / 分镜图。图片趴(上身图/套图/创作)不送审。
REVIEW_CATEGORIES = ("person", "tri_view", "storyboard")
name = models.CharField(max_length=255)
asset_type = models.CharField(max_length=24, choices=Type.choices)
@@ -56,6 +67,45 @@ class Asset(TeamOwnedModel):
return self.name
class Model(TeamOwnedModel):
"""模特(顶级实体 = 模特库)· 团队级、可复用。
一个完整模特 = 形象图 + 三视图 + 声线(声线尾期做)。图片线(上身图)+ 视频线(角色)都能引用它。
来源:官方预制(is_official=True,打「官方模板」标签)/ 商家自建(AI 生成或真人上传)。
portrait/triview/voice 指向具体 Asset(复用资产表,不重复存文件)。
"""
class Source(models.TextChoices):
AI = "ai", "AI Generated"
UPLOAD = "upload", "Upload"
name = models.CharField(max_length=255)
is_official = models.BooleanField(default=False) # 官方模板标签
source = models.CharField(max_length=16, choices=Source.choices, default=Source.AI)
portrait_asset = models.ForeignKey(
Asset, on_delete=models.SET_NULL, null=True, blank=True, related_name="model_as_portrait"
)
triview_asset = models.ForeignKey(
Asset, on_delete=models.SET_NULL, null=True, blank=True, related_name="model_as_triview"
)
voice_asset = models.ForeignKey( # 声线,尾期接线,先留空
Asset, on_delete=models.SET_NULL, null=True, blank=True, related_name="model_as_voice"
)
description = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
is_deleted = models.BooleanField(default=False)
class Meta:
indexes = [
# 模特库默认按团队 + 新→旧;官方模板优先靠前由序列化/查询控制
models.Index(fields=["team", "is_official", "-created_at"]),
]
ordering = ["-created_at"]
def __str__(self) -> str:
return self.name
class AssetReviewGroup(TimeStampedModel):
"""一团队一火山人像素材组(真人资产审核统一上传到这里,单组可放 500 万)。"""
+2 -2
View File
@@ -61,7 +61,7 @@ def get_or_create_team_group(team) -> AssetReviewGroup:
def submit_asset_for_review(asset: Asset) -> None:
"""真人资产静默送审:建组(若无)→ 传素材 → 标 processing。出错只记日志,不抛。"""
if not assets_client.is_enabled() or asset.category != Asset.Category.PERSON:
if not assets_client.is_enabled() or asset.category not in Asset.REVIEW_CATEGORIES:
return
url = _asset_url(asset)
if not url:
@@ -112,7 +112,7 @@ def poll_team_reviews(team) -> dict:
"""轮询该团队所有「审核中」真人资产,更新状态。返回 {asset_id: status}。"""
out: dict[str, str] = {}
pending = Asset.objects.filter(
team=team, category=Asset.Category.PERSON, review_status="processing", is_deleted=False
team=team, category__in=Asset.REVIEW_CATEGORIES, review_status="processing", is_deleted=False
)
for asset in pending:
out[str(asset.id)] = poll_asset_review(asset)
+69 -1
View File
@@ -1,7 +1,7 @@
from django.conf import settings
from rest_framework import serializers
from .models import Asset, AssetFile
from .models import Asset, AssetFile, Model
from .storage import TosStorage
@@ -105,3 +105,71 @@ class AssetUploadSerializer(serializers.Serializer):
category = serializers.ChoiceField(choices=Asset.Category.choices, default=Asset.Category.UPLOAD)
description = serializers.CharField(required=False, allow_blank=True)
def _asset_preview(asset) -> str:
"""取资产主文件的公读直链(模特卡缩略图用)。无文件返回 """""
if asset is None:
return ""
f = asset.files.filter(is_primary=True).first() or asset.files.first()
if f is None:
return ""
if f.preview_url:
return f.preview_url
if not f.object_key or not settings.TOS.get("endpoint"):
return ""
try:
return _tos().public_url(object_key=f.object_key, bucket=f.bucket or None)
except Exception:
return ""
class ModelLibrarySerializer(serializers.ModelSerializer):
"""模特库卡片:实体字段 + 形象图/三视图缩略图直链 + 是否已挂声线。"""
portrait = serializers.SerializerMethodField()
triview = serializers.SerializerMethodField()
has_voice = serializers.SerializerMethodField()
class Meta:
model = Model
fields = [
"id",
"name",
"is_official",
"source",
"description",
"metadata",
"portrait_asset",
"triview_asset",
"voice_asset",
"portrait",
"triview",
"has_voice",
"created_at",
"updated_at",
]
read_only_fields = ["id", "is_official", "source", "created_at", "updated_at"]
def get_portrait(self, obj):
return _asset_preview(obj.portrait_asset)
def get_triview(self, obj):
return _asset_preview(obj.triview_asset)
def get_has_voice(self, obj):
return bool(obj.voice_asset_id)
def validate(self, attrs):
"""防越权:引用的形象图/三视图/声线资产必须属于当前团队。"""
from apps.common.api import get_current_team
request = self.context.get("request")
if request is None:
return attrs
team = get_current_team(request.user)
for field in ("portrait_asset", "triview_asset", "voice_asset"):
asset = attrs.get(field)
if asset is not None and asset.team_id != team.id:
raise serializers.ValidationError({field: "资产不属于当前团队"})
return attrs
+110
View File
@@ -0,0 +1,110 @@
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import patch
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from .models import Asset, Model
def _mk_team(username, team_name):
user = User.objects.create_user(username=username, password="x")
team = Team.objects.create(name=team_name, owner=user)
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
return user, team
class ModelLibraryApiTests(TestCase):
"""模特库:本团队模特 ∪ 官方模板;官方跨团队可见;软删;官方不可删;团队隔离。"""
def setUp(self):
self.user, self.teamA = _mk_team("ua", "TeamA")
self.other, self.teamB = _mk_team("ub", "TeamB")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.mine = Model.objects.create(team=self.teamA, name="我的模特")
self.official = Model.objects.create(team=self.teamB, name="官方小美", is_official=True)
self.bs_other = Model.objects.create(team=self.teamB, name="别队私有") # B 的私有,A 看不到
def test_list_returns_mine_plus_official(self):
ids = {m["id"] for m in self.client.get("/api/models/").json()["results"]}
self.assertIn(str(self.mine.id), ids)
self.assertIn(str(self.official.id), ids) # 官方跨团队可见
self.assertNotIn(str(self.bs_other.id), ids) # 别队私有不可见
def test_tab_official_and_mine(self):
off = {m["id"] for m in self.client.get("/api/models/?tab=official").json()["results"]}
self.assertEqual(off, {str(self.official.id)})
mine = {m["id"] for m in self.client.get("/api/models/?tab=mine").json()["results"]}
self.assertEqual(mine, {str(self.mine.id)})
def test_official_flag_in_payload(self):
row = next(m for m in self.client.get("/api/models/").json()["results"] if m["id"] == str(self.official.id))
self.assertTrue(row["is_official"])
def test_delete_is_soft(self):
res = self.client.delete(f"/api/models/{self.mine.id}/")
self.assertEqual(res.status_code, 204)
self.mine.refresh_from_db()
self.assertTrue(self.mine.is_deleted)
ids = {m["id"] for m in self.client.get("/api/models/").json()["results"]}
self.assertNotIn(str(self.mine.id), ids)
def test_official_cannot_be_deleted(self):
res = self.client.delete(f"/api/models/{self.official.id}/")
self.assertEqual(res.status_code, 403)
self.official.refresh_from_db()
self.assertFalse(self.official.is_deleted)
def test_cannot_delete_other_team_model(self):
res = self.client.delete(f"/api/models/{self.bs_other.id}/")
self.assertIn(res.status_code, (403, 404)) # 别队私有:不在可见 qs → 404,或命中守卫 → 403
self.bs_other.refresh_from_db()
self.assertFalse(self.bs_other.is_deleted)
def test_create_rejects_cross_team_asset(self):
b_asset = Asset.objects.create(team=self.teamB, name="别队图", asset_type="image", source="upload")
res = self.client.post("/api/models/", {"name": "盗用", "portrait_asset": str(b_asset.id)}, format="json")
self.assertEqual(res.status_code, 400)
def test_upload_real_person_creates_asset_and_model(self):
stored = SimpleNamespace(object_key="teams/x/models/a.png", bucket="b", content_type="image/png", size_bytes=10)
with patch("apps.assets.views.TosStorage") as Tos:
Tos.return_value.upload_fileobj.return_value = stored
f = BytesIO(b"img")
f.name = "lady.png"
res = self.client.post("/api/models/upload/", {"name": "上传妹", "file": f}, format="multipart")
self.assertEqual(res.status_code, 201)
body = res.json()
self.assertEqual(body["source"], "upload")
model = Model.objects.get(id=body["id"])
self.assertIsNotNone(model.portrait_asset)
self.assertEqual(model.portrait_asset.category, Asset.Category.MODEL_PORTRAIT)
class ReviewScopeTests(TestCase):
"""送审范围 = 角色定妆照(person)/ 三视图(tri_view)/ 分镜图(storyboard);图片趴不送审。"""
def test_review_categories_constant(self):
self.assertEqual(Asset.REVIEW_CATEGORIES, ("person", "tri_view", "storyboard"))
def test_poll_team_reviews_only_scans_review_categories(self):
from apps.assets import review
_, team = _mk_team("uc", "TeamC")
keep = {}
for cat in ("person", "tri_view", "storyboard"):
a = Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="processing")
keep[cat] = str(a.id)
# 图片趴 / 场景:不应进送审队列
for cat in ("model_tryon", "model_portrait", "scene"):
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="processing")
with patch("apps.assets.review.assets_client.is_enabled", return_value=False):
out = review.poll_team_reviews(team)
self.assertEqual(set(out.keys()), set(keep.values()))
+91 -3
View File
@@ -7,16 +7,17 @@ from django.db.models import Q
from django.http import StreamingHttpResponse
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.pagination import PageNumberPagination
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from .models import Asset, AssetFile
from .serializers import AssetSerializer, AssetUploadSerializer
from .models import Asset, AssetFile, Model
from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer
from .storage import TosStorage
@@ -141,6 +142,93 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
return response
class ModelLibraryViewSet(ModelViewSet):
"""模特库(顶级实体)· 团队级 + 官方模板跨团队可见。
list 返回「本团队模特 官方模板」;?tab=official 只看官方、?tab=mine 只看自建。
真人上传走 upload action(传图 → 建 model_portrait 资产 + Model 实体)。删除 = 软删(官方不可删)。"""
serializer_class = ModelLibrarySerializer
pagination_class = AssetPagination
parser_classes = [JSONParser, MultiPartParser, FormParser]
search_fields = ["name", "description"]
def get_team(self):
return get_current_team(self.request.user)
def get_queryset(self):
team = self.get_team()
qs = (
Model.objects.filter(Q(team=team) | Q(is_official=True), is_deleted=False)
.prefetch_related("portrait_asset__files", "triview_asset__files")
)
tab = self.request.query_params.get("tab")
if tab == "official":
qs = qs.filter(is_official=True)
elif tab == "mine":
qs = qs.filter(team=team, is_official=False)
if self.request.query_params.get("q"):
q = self.request.query_params["q"]
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q))
# 官方模板靠前,再按新→旧
return qs.order_by("-is_official", "-created_at")
def perform_create(self, serializer):
serializer.save(team=self.get_team(), created_by=self.request.user)
def perform_destroy(self, instance):
if instance.is_official:
raise PermissionDenied("官方模特不可删除")
if instance.team_id != self.get_team().id:
raise PermissionDenied("无权删除其他团队的模特")
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
@action(detail=False, methods=["post"], url_path="upload", parser_classes=[MultiPartParser, FormParser])
def upload(self, request):
"""真人上传:一张人像图 → 建 model_portrait 资产 + Model(source=upload)。三视图/声线后续补。"""
upload = request.FILES.get("file")
if upload is None:
raise ValidationError({"file": "请上传一张模特形象图"})
team = self.get_team()
name = (request.data.get("name") or Path(upload.name).stem or "模特")[:255]
asset_id = uuid.uuid4()
suffix = Path(upload.name).suffix.lower() or ".png"
object_key = f"teams/{team.id}/models/{asset_id}{suffix}"
with transaction.atomic():
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "image/png",
)
portrait = Asset.objects.create(
id=asset_id,
team=team,
created_by=request.user,
name=f"{name}·形象图",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.MODEL_PORTRAIT,
metadata={"kind": "model", "view": "frontal", "source": "upload"},
)
AssetFile.objects.create(
asset=portrait,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
model = Model.objects.create(
team=team,
created_by=request.user,
name=name,
source=Model.Source.UPLOAD,
portrait_asset=portrait,
metadata={"source": "upload"},
)
return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED)
class AssetUploadView(APIView):
parser_classes = [MultiPartParser, FormParser]
+3
View File
@@ -41,6 +41,7 @@ import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./r
import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
import { AdminApp } from "./routes/admin/admin-app";
import { TrashPage } from "./routes/trash";
import { ModelsPage } from "./routes/models";
import { money } from "./routes/stage-config";
const crumbLabels: Partial<Record<Page, string>> = {
@@ -743,6 +744,8 @@ export function App() {
</div>
</div>
);
case "models":
return <ModelsPage />;
case "library":
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
case "trash":
+17
View File
@@ -21,6 +21,7 @@ import type {
LoginSession,
Invitation,
ModelConfig,
ModelEntity,
Notification,
NotificationList,
Paginated,
@@ -478,6 +479,22 @@ export const api = {
updateAsset(id: string, payload: { name?: string; description?: string }) {
return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
// 模特库:列出(本团队 官方模板);tab=official 只看官方、mine 只看自建
listModels(params: { tab?: "official" | "mine"; q?: string; pageSize?: number } = {}) {
const qs = new URLSearchParams();
if (params.tab) qs.set("tab", params.tab);
if (params.q) qs.set("q", params.q);
qs.set("page_size", String(params.pageSize ?? 200));
return request<Paginated<ModelEntity>>(`/api/models/?${qs.toString()}`);
},
// 真人上传:一张人像图 → 建 model_portrait 资产 + Model 实体
uploadModel(formData: FormData) {
return request<ModelEntity>("/api/models/upload/", { method: "POST", body: formData });
},
// 删除模特(软删;官方模特不可删)
deleteModel(id: string) {
return request<void>(`/api/models/${id}/`, { method: "DELETE" });
},
billingSummary() {
return request<BillingSummary>("/api/billing/summary/");
},
@@ -4,6 +4,7 @@ const iconPaths: Record<string, string> = {
layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
model: '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>',
images: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
@@ -190,6 +190,7 @@ type NavDef = { id: string; page: Page; label: string; icon: string; badge?: num
const NAV: NavDef[] = [
{ id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" },
{ id: "products", page: "products", label: "商品库", icon: "package" },
{ id: "models", page: "models", label: "模特库", icon: "model" },
{ id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" },
{ id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" },
{ id: "library", page: "library", label: "资产库", icon: "library" },
@@ -204,6 +205,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
products: "products",
productDetail: "products",
productCreateUpload: "products",
models: "models",
projects: "projects",
projectWizard: "projects",
pipeline: "projects",
+98
View File
@@ -0,0 +1,98 @@
/* 模特库 · 顶级实体(形象图 + 三视图)· 仅用 design-restraint token,不写裸 hex */
.models-empty { min-height: 240px; flex-direction: column; gap: 8px; }
.models-empty-hint { font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.models-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.model-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
transition: border-color .15s;
}
.model-card:hover { border-color: var(--border-loud); }
/* 形象图(9:16 氛围正面图)· 主视觉 */
.model-portrait {
position: relative;
width: 100%;
aspect-ratio: 3 / 4;
border-radius: 0;
}
/* 官方模板标签:复用 .pill.info(单橙锚点),定位左上 */
.model-official {
position: absolute;
top: 8px;
left: 8px;
pointer-events: none;
}
/* 删除按钮:默认半隐,hover 卡片才显;crimson 危险态 */
.model-del-btn {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--r-sm);
background: var(--black-alpha-48);
color: var(--accent-white);
font-size: 16px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity .15s, background .15s;
}
.model-card:hover .model-del-btn { opacity: 1; }
.model-del-btn:hover { background: var(--accent-crimson); }
.model-confirm {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--black-alpha-56);
}
.model-del { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
.model-del:hover { filter: brightness(1.06); }
.model-body { padding: 10px 12px 12px; }
.model-name {
font-size: 13.5px;
font-weight: 500;
color: var(--accent-black);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 三视图(16:9 白底)· 成套证明 */
.model-triview {
width: 100%;
aspect-ratio: 16 / 9;
margin-top: 8px;
border-radius: var(--r-sm);
}
.model-triview .ph-frame { font-size: 11px; }
.model-tags {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 11.5px;
color: var(--black-alpha-48);
letter-spacing: .02em;
}
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import { api } from "../api";
import type { ModelEntity } from "../types";
import "../models-page.css";
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
type Tab = "all" | "official" | "mine";
const TABS: { k: Tab; label: string }[] = [
{ k: "all", label: "全部" },
{ k: "official", label: "官方模板" },
{ k: "mine", label: "我的模特" }
];
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
export function ModelsPage() {
const [tab, setTab] = useState<Tab>("all");
const [items, setItems] = useState<ModelEntity[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
api
.listModels({ tab: tab === "all" ? undefined : tab })
.then((r) => { if (alive) setItems(r.results); })
.catch(() => { if (alive) setItems([]); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [tab]);
async function onPick(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploading(true);
try {
const form = new FormData();
form.append("file", file);
form.append("name", file.name.replace(/\.[^.]+$/, ""));
const created = await api.uploadModel(form);
setItems((list) => (tab === "official" ? list : [created, ...list]));
} finally {
setUploading(false);
}
}
async function remove(id: string) {
try {
await api.deleteModel(id);
setItems((list) => list.filter((m) => m.id !== id));
} finally {
setConfirmId(null);
}
}
return (
<div className="models-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 团队级可复用 · 形象图 + 三视图</span>
<span>·</span>
<span></span>
</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? "上传中…" : "真人上传"}
</button>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPick} />
</div>
</div>
<div className="tabs">
{TABS.map((t) => (
<button key={t.k} type="button" className={`tab${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
{t.label}
</button>
))}
</div>
{loading ? (
<div className="placeholder" style={{ minHeight: 200 }}><span className="ph-frame">// 加载中…</span></div>
) : items.length === 0 ? (
<div className="placeholder models-empty">
<span className="ph-frame">// 还没有模特</span>
<span className="models-empty-hint mono">,/</span>
</div>
) : (
<div className="models-grid">
{items.map((m) => (
<div className="model-card" key={m.id}>
<div
className={`placeholder model-portrait${m.portrait ? " has-mock-media" : ""}`}
style={m.portrait ? mediaStyle(m.portrait) : undefined}
>
{!m.portrait && <span className="ph-frame"></span>}
{m.is_official && <span className="pill info model-official"></span>}
{!m.is_official &&
(confirmId === m.id ? (
<div className="model-confirm">
<button className="btn btn-sm model-del" type="button" onClick={() => void remove(m.id)}></button>
<button className="btn btn-sm" type="button" onClick={() => setConfirmId(null)}></button>
</div>
) : (
<button className="model-del-btn" type="button" title="删除模特" onClick={() => setConfirmId(m.id)}>×</button>
))}
</div>
<div className="model-body">
<div className="model-name" title={m.name}>{m.name}</div>
<div
className={`placeholder model-triview${m.triview ? " has-mock-media" : ""}`}
style={m.triview ? mediaStyle(m.triview) : undefined}
>
{!m.triview && <span className="ph-frame mono">// 无三视图</span>}
</div>
<div className="model-tags mono">
<span>{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
<span>·</span>
<span>{m.triview ? "三视图 ✓" : "三视图 —"}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+7
View File
@@ -7,6 +7,7 @@ import {
MessageSquare,
Package,
Settings,
UserRound,
Users,
Wallet,
WandSparkles
@@ -17,6 +18,7 @@ export type Page =
| "products"
| "productDetail"
| "productCreateUpload"
| "models"
| "projects"
| "projectWizard"
| "pipeline"
@@ -57,6 +59,7 @@ export type NavItem = { page: Page; label: string; icon: typeof Home; badge?: st
export const mainNav: NavItem[] = [
{ page: "dashboard", label: "工作台", icon: Home },
{ page: "products", label: "商品库", icon: Package },
{ page: "models", label: "模特库", icon: UserRound },
{ page: "projects", label: "视频项目", icon: FolderKanban },
{ page: "pipeline", label: "生产管线", icon: WandSparkles },
{ page: "library", label: "资产库", icon: Library },
@@ -76,6 +79,7 @@ export const routeLabels: Record<Page, string> = {
products: "商品库",
productDetail: "商品详情",
productCreateUpload: "上传创建商品",
models: "模特库",
projects: "视频项目",
projectWizard: "新建视频项目",
pipeline: "生产管线",
@@ -129,6 +133,7 @@ export function resolveRoute(): ResolvedRoute {
if (path.startsWith("/products/")) {
return { page: "productDetail", authMode: "login", productId: decodeURIComponent(path.slice("/products/".length)), hash };
}
if (path === "/models") return { page: "models", authMode: "login", hash };
if (path === "/projects") return { page: "projects", authMode: "login", hash };
if (path === "/projects/new") return { page: "projectWizard", authMode: "login", hash };
if (path === "/pipeline") {
@@ -163,6 +168,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return "/products/new";
case "productDetail":
return options.productId ? `/products/${encodeURIComponent(options.productId)}` : "/products";
case "models":
return "/models";
case "projects":
return "/projects";
case "projectWizard":
+18
View File
@@ -230,6 +230,24 @@ export type Asset = {
updated_at: string;
};
// 模特库实体(顶级,团队级可复用)= 形象图 + 三视图 + 声线(声线尾期)。官方预制打 is_official 标签。
export type ModelEntity = {
id: string;
name: string;
is_official: boolean;
source: "ai" | "upload";
description: string;
metadata?: Record<string, unknown>;
portrait_asset: string | null;
triview_asset: string | null;
voice_asset: string | null;
portrait: string; // 形象图缩略图直链
triview: string; // 三视图缩略图直链
has_voice: boolean;
created_at: string;
updated_at: string;
};
export type ProjectStage = {
id: string;
stage: string;
+54
View File
@@ -0,0 +1,54 @@
// 期1 模特库走查:登录 → /models → 断言卡片/官方标签/tab,逐态截图,抓 console/pageerror。
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
const BASE = process.env.BASE || "http://localhost:5188";
const OUT = path.resolve("../../../_qa_shots/models-p1");
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ headless: true });
const r = { consoleErrors: [], steps: {} };
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(m.text()); });
p.on("pageerror", (e) => r.consoleErrors.push("PAGEERR:" + e.message));
// 登录
await p.goto(BASE + "/login", { waitUntil: "load" });
await p.waitForTimeout(400);
await p.fill("#auth-username", "airshelf");
await p.fill("#auth-pwd", "Restraint2026");
await p.click("button.btn-cta");
await p.waitForFunction(() => !location.pathname.startsWith("/login"), { timeout: 12000 });
// 左菜单是否有「模特库」入口
r.steps.navHasModels = await p.locator("nav a, aside a, .sidebar a, button").filter({ hasText: "模特库" }).count();
// 进模特库
await p.goto(BASE + "/models", { waitUntil: "load" });
await p.waitForTimeout(2800);
r.steps.h1 = await p.locator("h1").first().innerText().catch(() => "");
r.steps.tabCount = await p.locator(".tabs .tab").count();
r.steps.cardCountAll = await p.locator(".model-card").count();
r.steps.officialPill = await p.locator(".model-official").count();
r.steps.triviewThumbs = await p.locator(".model-triview").count();
r.steps.uploadBtn = await p.locator(".actions button", { hasText: /上传/ }).count();
await p.screenshot({ path: path.join(OUT, "models-all.png"), fullPage: true });
// 官方模板 tab
await p.locator(".tabs .tab", { hasText: "官方模板" }).click();
await p.waitForTimeout(2200);
r.steps.cardCountOfficial = await p.locator(".model-card").count();
r.steps.officialPillInTab = await p.locator(".model-official").count();
await p.screenshot({ path: path.join(OUT, "models-official.png"), fullPage: true });
// 我的模特 tab
await p.locator(".tabs .tab", { hasText: "我的模特" }).click();
await p.waitForTimeout(2200);
r.steps.cardCountMine = await p.locator(".model-card").count();
await p.screenshot({ path: path.join(OUT, "models-mine.png"), fullPage: true });
await browser.close();
console.log(JSON.stringify(r, null, 2));
fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(r, null, 2));
+23 -1
View File
@@ -133,7 +133,29 @@
---
# ⛔ Wave 4(后端分类改造)— 待你拍 2 个板才动
# ★★ 资产模型重定义(2026-06-20 用户盘点,W4 以此为准 —— 已不止是"改分类")
这不是简单给 category 改名,是把「模特」从一张图升级成**可复用的实体/库**。三大块:
**① 模特(顶级独立类 = 模特库)**
- 一个完整模特 = **形象图 + 三视图 + 声线**(三件套合成一个模特)
- 模特库**独立成块**(像商品库),**图片线 + 视频线都能调**(=演员库);商家**真人模特可上传**入库
- 现状问题:模特库现在埋在 model-photo.html 的深层嵌套弹窗里(actor-library),UI 当初没想清楚 → 要提到顶级
**② 图片趴(3 类,均为「组」)**
- **模特上身图**(组):模特+商品宣传图,一组多张
- **平台套图**(组):电商头图/轮播主图,各平台尺寸不同,一组 N 张(平台规则后续加)
- **自由创作**:杂图都归这
**③ 视频趴**
- **模特图**:关联模特库 → 引用库中某模特的脸 → 生成符合本脚本剧情的演员**定妆照** → 再出三视图;也可直接 AI 生成角色
- **分镜图**(组):每个视频任务脚本 = 一组分镜图 + 一组视频素材
**结构含义(待规划):** 模特要从「项目内的 person 基础资产」升级为「团队级、跨流程复用的模特实体」;上身图/套图/分镜/视频都是「组」;视频流程的模特图引用模特库。→ 这是**多阶段功能**,不是一次 relabel。审核范围不变(视频趴人脸:模特定妆照/三视图/分镜送审)。
---
# ⛔ Wave 4(原"后端分类改造")— 已升级为「资产模型重定义」,需先出设计稿
摸完代码发现:**火山人像内容审核(P0 法务红线)绑死在 `category=person`**(assets/review.py:64、adminpanel 审核队列)。现在三视图算 person → **会送审**。若按新分类把三视图/模特上身图从 person 拆走,它们就**不再过内容审核**。这是合规决策,不能我自己定。
@@ -0,0 +1,116 @@
# 模特库 + 资产模型重构 · 设计稿
> 2026-06-20 · 与用户对齐后产出 · **先拍这版图纸,再分 3 期施工**
> 现状全是测试数据、平台未上线 → 数据可随意动(归类 / 删错图)。
---
## 0. 一句话目标
把「模特」从"一张图"升级成**团队级、可复用的实体(模特库)**;图片生成线 + 视频项目线都能调它;资产按"图片趴 / 视频趴"两大块清晰分类。
---
## 1. 资产模型(实体定义)
### ① 模特(顶级实体 = 模特库)
一个完整模特 = **形象图 + 三视图 + 声线**(声线最后做)。
- **团队级、可复用**,不绑某个项目;图片线 + 视频线都能引用。
- 来源:**官方预制**(打「官方模板」标签)/ **商家自建**(AI 生成或上传真人)。
- 模特库 = **左侧顶级菜单**(像商品库),不再埋在弹窗里。
### ② 图片趴(3 类,均为「组」)
| 类 | 说明 | 形态 |
|---|---|---|
| 模特上身图 | 模特 + 商品 宣传图(引用模特库的模特) | 一组多张 |
| 平台套图 | 电商头图/轮播主图,各平台尺寸不同 | 一组 N 张(平台规则后续加) |
| 自由创作 | 杂图都归这 | 单张/多张 |
### ③ 视频趴
| 类 | 说明 | 形态 |
|---|---|---|
| 角色(原「人物」改名) | 见下方"角色链路" | 项目内,引用模特库 |
| 分镜图 | 每个视频任务脚本 = 一组分镜图 + 一组视频素材 | 组 |
**角色链路(视频趴):**
- **A 引用模特库**:选库里某模特当参考图 + 叠加**符合剧情的服装样貌描述** → 生成该模特的**定妆照** → 再出三视图。
- **B 直接新生成**:不用库里的,新生成一个 → **自动入模特库**(要删去模特库删)。
- → "角色"卡需小改以承载这两条来法。
---
## 2. 后端模型设计(提案,期 1 细化)
**新增 `Model`(模特)实体 · 团队级:**
```
Model: team, name, is_official(官方模板), source(ai/upload),
portrait_asset(形象图 FK), triview_asset(三视图 FK),
voice_asset(声线 FK, 可空·后做), metadata, created_by
```
**`Asset.Category` 枚举一次加全(逐期接线):**
- model_portrait(模特形象图)· tri_view(三视图)· voice(声线占位)
- model_tryon(模特上身图)· platform_kit(平台套图)· free_create(自由创作)
- storyboard(分镜图)
- 保留:product_image / scene / video_clip / final_video / upload / uncategorized
**「组」落库:** 复用/扩展现有 BaseAssetGroup 或加 group_id+task 关联(期 2/3 定);上身图组/套图组/分镜组/视频组同源思路。
**审核范围(不变,已拍板):** 送审 = 视频趴人脸 = **{模特定妆照(person), tri_view(三视图), storyboard(分镜图)}**;图片趴(上身图/套图/创作)**不送审**。`review.py` + adminpanel 队列过滤 `person → person+tri_view+storyboard`
---
## 3. 页面 / 导航
- **新增顶级「模特库」**(左菜单):官方模板 tab / 我的模特 tab / 真人上传入口。
- **图片生成页**:三类入口(上身图/套图/自由创作),上身图接模特库。
- **视频项目·资产趴**:"人物"→"角色",角色卡承载 A/B 两条来法。
- **深埋的 actor-library 弹窗**:本轮先不动(逻辑顺),做到对应页时再收编。
---
## ★ 施工纪律(每一块/每个 Phase 都严格走这一圈,缺一不可)
> 这是硬流程,不是口号。任何一块没走完整圈,不算完、不 commit、不进下一块。
1. **写(开发)** — 实现这一块;后端先于前端;独立子项可派 sub-agent 并行(worktree 隔离防冲突)。
2. **审(自审)** — 回头**批判性**读自己刚写的 diff(或派 sub-agent 当审查者):逻辑 / 边界(空值·0·越权·并发)/ 安全(密钥不回传·权限不漏)/ **回归风险** / 有没有跑题。发现问题 → 修 → 再审,**审干净才往下**。
3. **改(修)** — 上面审出来的、下面测出来的,全修掉。
4. **测(三层全绿,缺一不可)**:
- **后端单测**:`DB_ENGINE=sqlite python manage.py test apps.<相关app>`;每个新端点 ≥ 正常 + 权限拒绝 + 边界。
- **无头浏览器 e2e**:Playwright 真点击走完整流程 + locator 断言 + **抓 console/pageerror 必须为 0**;按需起 worker 真跑异步(出图/审核)。
- **类型/构建**:`npx tsc --noEmit && npm run build` 全绿。
5. **无头 + 截图核对** — 关键画面**截图**,逐张对照设计稿 / 预期,**视觉达标**才算过;不达标 → 回第 3 步继续迭代。
6. **直到完美验收** — 三层全绿 + 截图达标 + 该块业务链路走通,才算这块完成。
7. **全绿才本地 commit**(消息规范);写进度;**进下一块**。
8. **回归红线**:开工前记下进场既有失败基线,只保证**不新增** fail;既有链路零回归。
9. **全程本地、绝不 push**;不碰 master/main、不 `--no-verify`、不绕 hook。
— 即「**写 → 审 → 改 → 测 → 无头 → 截图 → 不达标继续迭代 → 完美验收 → commit**」,一圈不少。
## 4. 分 3 期开发(每期都走上面这一圈;不 push)
### 期 1 · 模特库地基
- 后端:`Model` 实体 + 迁移;`Asset.Category` 枚举加全;审核过滤改 person+tri_view+storyboard;后端单测。
- 数据:把**历史已成套模特(形象图+三视图)归集成 Model 实体**当测试数据;**删早期错图**理干净。
- 前端:左菜单 +「模特库」页(官方模板标签 / 我的模特 / 真人上传)。
- 验收:模特库页能看到归好的成套模特、官方标签;无头截图 + 0 console error。
### 期 2 · 图片趴三类
- 模特上身图(组,引用模特库)/ 平台套图(组)/ 自由创作 —— 落库 + 图片生成页接线。
- 前端标签表全对(上身图/套图/创作各归各类)。
- 验收:三类各生成→归类正确;截图核对。
### 期 3 · 视频趴(角色 + 分镜组)
- "人物"→"角色"改名 + 角色卡小改(承载 A 引用库 / B 新生成自动入库)。
- 分镜图成组(每脚本一组分镜 + 一组视频素材),理顺现有链路。
- 资产库"成片"→"素材";最终成片类隐藏。
- 验收:角色两条来法走通、分镜组正确;截图 + 链路零回归。
### 尾 · 声线(最后)
- 模特声线生成 + 模特实体挂声线 + 参考元素加"声音"选项。
---
## 5. 待定 / 后续
- 平台套图各平台尺寸规则(逐平台加)。
- 角色卡 UI 细节(做到期 3 再逐稿对)。
- 声线打包方式(挂在模特实体下)。
- 这些页用户还要逐页再过一遍。