feat: 解耦角色与模特并完善模特库

This commit is contained in:
hh
2026-07-13 11:02:30 +08:00
parent 564215dc53
commit fc0f0ca370
22 changed files with 1328 additions and 314 deletions
@@ -0,0 +1,86 @@
"""审计“我的演员”收口为模特前的历史数据;只读,不写库。"""
from django.core.management.base import BaseCommand
from django.db.models import Q
from apps.assets.models import Asset, Model
from apps.projects.models import BaseAssetGroup
class Command(BaseCommand):
help = "只读审计旧 person 资产,输出可安全迁移为 Model 的候选和排除原因。"
def add_arguments(self, parser):
parser.add_argument("--team-id", help="仅审计指定团队 UUID")
parser.add_argument("--limit", type=int, default=50, help="最多打印多少条候选(默认 50)")
def handle(self, *args, **options):
team_id = options.get("team_id")
limit = max(0, options["limit"])
active_people = Asset.objects.filter(
category=Asset.Category.PERSON,
is_deleted=False,
purged_at__isnull=True,
)
if team_id:
active_people = active_people.filter(team_id=team_id)
# 项目基础资产组引用的 person(包含采用版和候选版)一律视为项目角色,不自动迁移。
project_role_asset_ids = set(
Asset.objects.filter(
Q(adopted_base_groups__kind=BaseAssetGroup.Kind.PERSON)
| Q(candidate_base_groups__kind=BaseAssetGroup.Kind.PERSON)
)
.values_list("id", flat=True)
.distinct()
)
if team_id:
project_role_asset_ids &= set(active_people.values_list("id", flat=True))
existing_model_portraits = set(
Model.objects.filter(
is_deleted=False,
purged_at__isnull=True,
portrait_asset__isnull=False,
)
.values_list("portrait_asset_id", flat=True)
)
if team_id:
existing_model_portraits &= set(active_people.values_list("id", flat=True))
saved_people = active_people.filter(in_library=True)
project_generated_ids = set(
active_people.filter(origin_task__project__isnull=False).values_list("id", flat=True)
)
eligible = (
saved_people.exclude(id__in=project_role_asset_ids)
.exclude(id__in=project_generated_ids)
.exclude(id__in=existing_model_portraits)
.order_by("created_at")
)
trash_people = Asset.objects.filter(
category=Asset.Category.PERSON,
is_deleted=True,
purged_at__isnull=True,
)
purged_people = Asset.objects.filter(category=Asset.Category.PERSON, purged_at__isnull=False)
if team_id:
trash_people = trash_people.filter(team_id=team_id)
purged_people = purged_people.filter(team_id=team_id)
self.stdout.write(self.style.WARNING("=== audit_actor_model_migration · DRY-RUN / READ-ONLY ==="))
self.stdout.write(f"正常 person 资产: {active_people.count()}")
self.stdout.write(f" 已有 Model 形象图: {len(existing_model_portraits)}")
self.stdout.write(f" 项目角色引用: {len(project_role_asset_ids)}")
self.stdout.write(f" 项目生成角色: {len(project_generated_ids)}")
self.stdout.write(f" 旧已保存人物候选: {eligible.count()}")
self.stdout.write(f"垃圾桶 person(不迁移): {trash_people.count()}")
self.stdout.write(f"彻底隐藏 person(不迁移): {purged_people.count()}")
self.stdout.write("\n可迁移候选(只打印,不创建 Model):")
for asset in eligible[:limit]:
self.stdout.write(f" {asset.id} | team={asset.team_id} | {asset.name}")
if eligible.count() > limit:
self.stdout.write(f" … 其余 {eligible.count() - limit} 条未打印(可用 --limit 调整)")
self.stdout.write(self.style.WARNING("审计结束:未创建、更新、删除任何记录或文件。"))
@@ -0,0 +1,80 @@
"""把符合规则的历史“已保存演员”补为 Model;默认 dry-run。"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from apps.assets.models import Asset, Model
from apps.projects.models import BaseAssetGroup
def eligible_legacy_actor_assets(team_id=None):
"""只返回正常状态、已保存、非项目角色且尚未拥有 Model 的旧 person 资产。"""
people = Asset.objects.filter(
category=Asset.Category.PERSON,
in_library=True,
is_deleted=False,
purged_at__isnull=True,
)
if team_id:
people = people.filter(team_id=team_id)
project_role_ids = Asset.objects.filter(
Q(adopted_base_groups__kind=BaseAssetGroup.Kind.PERSON)
| Q(candidate_base_groups__kind=BaseAssetGroup.Kind.PERSON)
).values_list("id", flat=True)
existing_model_ids = Model.objects.filter(
is_deleted=False,
purged_at__isnull=True,
portrait_asset__isnull=False,
).values_list("portrait_asset_id", flat=True)
return (
people.exclude(origin_task__project__isnull=False)
.exclude(id__in=project_role_ids)
.exclude(id__in=existing_model_ids)
.order_by("created_at")
)
class Command(BaseCommand):
help = "迁移正常状态的历史已保存演员为 Model;默认 dry-run,--apply 才写库。"
def add_arguments(self, parser):
parser.add_argument("--team-id", help="仅迁移指定团队 UUID")
parser.add_argument("--apply", action="store_true", help="真正创建 Model(默认只打印)")
parser.add_argument("--limit", type=int, default=50, help="最多打印多少条候选(默认 50)")
def handle(self, *args, **options):
apply = options["apply"]
candidates = eligible_legacy_actor_assets(options.get("team_id"))
count = candidates.count()
limit = max(0, options["limit"])
self.stdout.write(self.style.WARNING(f"=== migrate_saved_actors_to_models · {'APPLY' if apply else 'DRY-RUN'} ==="))
self.stdout.write(f"可迁移旧保存人物: {count}")
for asset in candidates[:limit]:
self.stdout.write(f" {asset.id} | team={asset.team_id} | {asset.name}")
if not apply:
self.stdout.write(self.style.WARNING("DRY-RUN 结束,未写库。加 --apply 才会创建 Model。"))
return
created = 0
with transaction.atomic():
for asset in candidates.select_for_update():
# select_for_update 后再次确认,保证并发/重复执行都不会造成重复 Model。
if Model.objects.filter(
portrait_asset=asset,
is_deleted=False,
purged_at__isnull=True,
).exists():
continue
Model.objects.create(
team_id=asset.team_id,
created_by_id=asset.created_by_id,
name=(asset.name or "模特")[:255],
source=Model.Source.UPLOAD if asset.source == Asset.Source.UPLOAD else Model.Source.AI,
portrait_asset=asset,
metadata={"migrated_from_legacy_actor": True},
)
created += 1
self.stdout.write(self.style.SUCCESS(f"完成:创建 {created} 个 Model;未修改任何 Asset、项目、垃圾桶或文件。"))
+165 -1
View File
@@ -1,7 +1,8 @@
from io import BytesIO
from io import BytesIO, StringIO
from types import SimpleNamespace
from unittest.mock import patch
from django.core.management import call_command
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase, TestCase
from rest_framework.test import APIClient
@@ -44,10 +45,60 @@ class ModelLibraryApiTests(TestCase):
mine = {m["id"] for m in self.client.get("/api/models/?tab=mine").json()["results"]}
self.assertEqual(mine, {str(self.mine.id)})
def test_filter_by_portrait_asset(self):
portrait = Asset.objects.create(team=self.teamA, name="角色立绘", asset_type="image", source="ai_generated")
self.mine.portrait_asset = portrait
self.mine.save(update_fields=["portrait_asset"])
rows = self.client.get(f"/api/models/?portrait_asset={portrait.id}").json()["results"]
self.assertEqual([row["id"] for row in rows], [str(self.mine.id)])
missing = self.client.get("/api/models/?portrait_asset=00000000-0000-0000-0000-000000000000").json()["results"]
self.assertEqual(missing, [])
def test_enroll_asset_creates_model_once_and_reuses_it(self):
portrait = Asset.objects.create(
team=self.teamA, name="AI 模特候选", asset_type="image", source="ai_generated", category=Asset.Category.PERSON,
)
first = self.client.post("/api/models/enroll-asset/", {"asset_id": str(portrait.id), "name": "通勤模特"}, format="json")
self.assertEqual(first.status_code, 201)
self.assertEqual(first.json()["name"], "通勤模特")
self.assertEqual(first.json()["portrait_asset"], str(portrait.id))
second = self.client.post("/api/models/enroll-asset/", {"asset_id": str(portrait.id), "name": "重复名称不应覆盖"}, format="json")
self.assertEqual(second.status_code, 200)
self.assertEqual(second.json()["id"], first.json()["id"])
self.assertEqual(Model.objects.filter(team=self.teamA, portrait_asset=portrait, is_deleted=False).count(), 1)
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_update_own_model_profile_does_not_rewrite_portrait_asset(self):
portrait = Asset.objects.create(team=self.teamA, name="原始形象资产名", asset_type="image", source="upload")
self.mine.portrait_asset = portrait
self.mine.save(update_fields=["portrait_asset"])
res = self.client.patch(
f"/api/models/{self.mine.id}/",
{"name": "更新后的模特名", "description": "通勤风格"},
format="json",
)
self.assertEqual(res.status_code, 200)
self.mine.refresh_from_db()
portrait.refresh_from_db()
self.assertEqual(self.mine.name, "更新后的模特名")
self.assertEqual(self.mine.description, "通勤风格")
self.assertEqual(portrait.name, "原始形象资产名")
def test_official_model_cannot_be_edited(self):
res = self.client.patch(f"/api/models/{self.official.id}/", {"name": "不应更新"}, format="json")
self.assertEqual(res.status_code, 403)
self.official.refresh_from_db()
self.assertEqual(self.official.name, "官方小美")
def test_delete_is_soft(self):
res = self.client.delete(f"/api/models/{self.mine.id}/")
self.assertEqual(res.status_code, 204)
@@ -129,6 +180,103 @@ class ModelLibraryApiTests(TestCase):
self.assertIsNotNone(model.portrait_asset)
self.assertEqual(model.portrait_asset.category, Asset.Category.MODEL_PORTRAIT)
def test_upload_portrait_replaces_current_model_only_and_stays_out_of_library(self):
old_portrait = Asset.objects.create(
team=self.teamA, name="旧形象", asset_type="image", source="upload", category=Asset.Category.MODEL_PORTRAIT,
)
self.mine.portrait_asset = old_portrait
self.mine.save(update_fields=["portrait_asset"])
stored = SimpleNamespace(object_key="teams/x/models/new.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
image = SimpleUploadedFile("new.png", b"img", content_type="image/png")
res = self.client.post(
f"/api/models/{self.mine.id}/upload-portrait/",
{"file": image, "name": "一次保存的新模特名", "description": "一次保存的新描述"},
format="multipart",
)
self.assertEqual(res.status_code, 200)
self.mine.refresh_from_db()
self.assertNotEqual(self.mine.portrait_asset_id, old_portrait.id)
self.assertEqual(self.mine.name, "一次保存的新模特名")
self.assertEqual(self.mine.description, "一次保存的新描述")
new_portrait = self.mine.portrait_asset
self.assertEqual(new_portrait.category, Asset.Category.MODEL_PORTRAIT)
self.assertFalse(new_portrait.in_library)
self.assertTrue(Asset.objects.filter(id=old_portrait.id).exists())
self.assertEqual(Model.objects.filter(team=self.teamA).count(), 1)
self.assertEqual(
self.mine.metadata["portrait_versions"],
[str(old_portrait.id), str(new_portrait.id)],
)
library_ids = {row["id"] for row in self.client.get("/api/assets/?page_size=200").json()["results"]}
self.assertNotIn(str(new_portrait.id), library_ids)
def test_official_model_cannot_replace_portrait(self):
with patch("apps.assets.views.TosStorage") as Tos:
image = SimpleUploadedFile("new.png", b"img", content_type="image/png")
res = self.client.post(f"/api/models/{self.official.id}/upload-portrait/", {"file": image}, format="multipart")
self.assertEqual(res.status_code, 403)
Tos.assert_not_called()
class ActorModelMigrationAuditTests(TestCase):
"""审计命令只能分类和输出,绝不能改写旧资产、项目或模特记录。"""
def setUp(self):
from apps.products.models import Product
from apps.projects.models import BaseAssetGroup, Project
self.user, self.team = _mk_team("audit-u", "Audit")
self.eligible = Asset.objects.create(
team=self.team, name="旧保存演员", asset_type="image", source="upload", category=Asset.Category.PERSON, in_library=True,
)
existing = Asset.objects.create(
team=self.team, name="已有模特形象", asset_type="image", source="upload", category=Asset.Category.PERSON, in_library=True,
)
Model.objects.create(team=self.team, name="已有模特", portrait_asset=existing)
role = Asset.objects.create(
team=self.team, name="项目角色", asset_type="image", source="ai_generated", category=Asset.Category.PERSON, in_library=True,
)
product = Product.objects.create(team=self.team, title="测试商品")
project = Project.objects.create(team=self.team, name="测试项目", product=product)
BaseAssetGroup.objects.create(project=project, kind=BaseAssetGroup.Kind.PERSON, adopted_asset=role)
Asset.objects.create(
team=self.team, name="垃圾桶演员", asset_type="image", source="upload", category=Asset.Category.PERSON, is_deleted=True,
)
def test_audit_is_read_only_and_excludes_project_and_existing_models(self):
before_assets = Asset.objects.count()
before_models = Model.objects.count()
out = StringIO()
call_command("audit_actor_model_migration", "--team-id", str(self.team.id), stdout=out)
report = out.getvalue()
self.assertIn("旧已保存人物候选: 1", report)
self.assertIn(str(self.eligible.id), report)
self.assertIn("垃圾桶 person(不迁移): 1", report)
self.assertIn("未创建、更新、删除任何记录或文件", report)
self.assertEqual(Asset.objects.count(), before_assets)
self.assertEqual(Model.objects.count(), before_models)
def test_apply_migrates_only_eligible_saved_actor_and_is_idempotent(self):
call_command("migrate_saved_actors_to_models", "--team-id", str(self.team.id), "--apply")
model = Model.objects.get(team=self.team, portrait_asset=self.eligible)
self.assertEqual(model.name, self.eligible.name)
self.assertEqual(model.metadata.get("migrated_from_legacy_actor"), True)
self.eligible.refresh_from_db()
self.assertEqual(self.eligible.category, Asset.Category.PERSON) # 历史分类不改
self.assertFalse(self.eligible.is_deleted)
self.assertEqual(Model.objects.filter(team=self.team, portrait_asset=self.eligible).count(), 1)
self.assertFalse(Model.objects.filter(team=self.team, portrait_asset__name="项目角色").exists())
call_command("migrate_saved_actors_to_models", "--team-id", str(self.team.id), "--apply")
self.assertEqual(Model.objects.filter(team=self.team, portrait_asset=self.eligible).count(), 1)
class AssetSoftDeleteTests(TestCase):
"""资产库不展示软删资产(is_deleted=True):列表 + tab 计数都排除。"""
@@ -148,6 +296,22 @@ class AssetSoftDeleteTests(TestCase):
def test_summary_excludes_soft_deleted(self):
self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 1)
def test_functional_model_and_project_assets_do_not_appear_in_others(self):
model_portrait = Asset.objects.create(
team=self.team, name="模特形象", asset_type="image", source="upload", category=Asset.Category.MODEL_PORTRAIT,
)
tri_view = Asset.objects.create(
team=self.team, name="角色三视图", asset_type="image", source="ai_generated", category=Asset.Category.TRI_VIEW,
)
other = Asset.objects.create(
team=self.team, name="其他图片", asset_type="image", source="upload", category=Asset.Category.UNCATEGORIZED,
)
ids = {row["id"] for row in self.client.get("/api/assets/?tab=others&page_size=50").json()["results"]}
self.assertIn(str(other.id), ids)
self.assertNotIn(str(model_portrait.id), ids)
self.assertNotIn(str(tri_view.id), ids)
class AssetTrashApiTests(TestCase):
"""R108 资产垃圾桶:trash 只列本团队软删资产;restore 后资产库重新可见;purge 二级软删。"""
+145
View File
@@ -3,6 +3,7 @@ import re
import uuid
import requests
from django.conf import settings
from django.db import transaction
from django.db.models import Q
from django.http import StreamingHttpResponse
@@ -36,6 +37,9 @@ class AssetPagination(PageNumberPagination):
_KNOWN_CATS = [
"person", "scene", "product_image", "final_video", "upload",
"model_tryon", "platform_kit", "free_create",
# 模特资料、项目角色三视图/分镜是功能性引用资产,只在模特库或项目中管理,
# 不应落入 /library 的「其他」形成第二个人物入口。
"model_portrait", "tri_view", "storyboard", "voice",
]
@@ -445,6 +449,10 @@ class ModelLibraryViewSet(ModelViewSet):
qs = qs.filter(is_official=True)
elif tab == "mine":
qs = qs.filter(team=team, is_official=False)
# 项目角色详情用形象资产精确判断「是否已升级为模特」;不靠前端拉全量模特列表猜测,
# 避免模特数量超过单页上限时把已升级角色误显示成可重复升级。
if portrait_asset_id := self.request.query_params.get("portrait_asset"):
qs = qs.filter(portrait_asset_id=portrait_asset_id)
if self.request.query_params.get("q"):
q = self.request.query_params["q"]
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q))
@@ -454,6 +462,14 @@ class ModelLibraryViewSet(ModelViewSet):
def perform_create(self, serializer):
serializer.save(team=self.get_team(), created_by=self.request.user)
def perform_update(self, serializer):
instance = serializer.instance
if instance.is_official:
raise PermissionDenied("官方模特不可编辑")
if instance.team_id != self.get_team().id:
raise PermissionDenied("无权编辑其他团队的模特")
serializer.save()
def perform_destroy(self, instance):
if instance.is_official:
raise PermissionDenied("官方模特不可删除")
@@ -462,6 +478,77 @@ class ModelLibraryViewSet(ModelViewSet):
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
@action(detail=True, methods=["get"], url_path="triview-quote")
def triview_quote(self, request, pk=None):
"""返回当前团队实际三视图价格;标准价 20 积分,含团队价格系数。"""
model = self.get_object()
if model.is_official:
raise PermissionDenied("官方模特不可生成三视图")
from apps.ai.services import quote_model_triview
try:
_config, quote = quote_model_triview(model=model)
except ValueError as exc:
raise ValidationError({"detail": str(exc)}) from exc
return Response({"points": str(quote.points)}, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"], url_path="generate-triview")
def generate_triview(self, request, pk=None):
"""提交团队模特三视图任务;9.4A 仅供接口与自动化测试,前端按钮尚未启用。"""
if not getattr(settings, "MODEL_TRIVIEW_GENERATION_ENABLED", False):
return Response(
{"detail": "模特三视图生成尚未启用,请先同步部署 worker"},
status=status.HTTP_409_CONFLICT,
)
model = self.get_object()
if model.is_official:
raise PermissionDenied("官方模特不可生成三视图")
from apps.ai.services import generate_model_triview
try:
task, created = generate_model_triview(model=model, user=request.user)
except ValueError as exc:
raise ValidationError({"detail": str(exc)}) from exc
return Response(
{
"task_id": str(task.id),
"status": task.status,
"estimated_cost": str(task.estimated_cost),
"reused": not created,
},
status=status.HTTP_202_ACCEPTED if created else status.HTTP_200_OK,
)
@action(detail=True, methods=["get"], url_path="triview-status")
def triview_status(self, request, pk=None):
"""轮询指定模特三视图任务,只允许读取本团队且 payload 归属当前模特的任务。"""
model = self.get_object()
task_id = request.query_params.get("task_id")
if not task_id:
raise ValidationError({"task_id": "缺少任务 id"})
from apps.ai.models import AITask
task = AITask.objects.filter(
id=task_id,
team=self.get_team(),
task_type=AITask.Type.MODEL_TRIVIEW,
request_payload__model_id=str(model.id),
).first()
if task is None:
raise ValidationError({"task_id": "任务不存在或不属于当前模特"})
model.refresh_from_db()
return Response(
{
"task_id": str(task.id),
"status": task.status,
"estimated_cost": str(task.estimated_cost),
"actual_cost": str(task.actual_cost),
"error_message": task.error_message,
"model": ModelLibrarySerializer(model).data if task.status == AITask.Status.SUCCEEDED else None,
},
status=status.HTTP_200_OK,
)
@action(detail=False, methods=["get"], url_path="trash")
def trash(self, request):
"""垃圾桶:列出本团队已软删且未彻底隐藏的自建模特。"""
@@ -533,6 +620,64 @@ class ModelLibraryViewSet(ModelViewSet):
)
return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="upload-portrait", parser_classes=[MultiPartParser, FormParser])
def upload_portrait(self, request, pk=None):
"""显式维护已有模特的形象图:上传新 model_portrait 并切换当前引用;不新建模特、不回写项目角色。"""
model = self.get_object()
if model.is_official:
raise PermissionDenied("官方模特不可编辑")
team = self.get_team()
if model.team_id != team.id:
raise PermissionDenied("无权编辑其他团队的模特")
upload = request.FILES.get("file")
if upload is None:
raise ValidationError({"file": "请上传一张模特形象图"})
next_name = str(request.data.get("name", model.name)).strip()[:255]
if not next_name:
raise ValidationError({"name": "模特名称不能为空"})
next_description = str(request.data.get("description", model.description or "")).strip()
asset_id = uuid.uuid4()
suffix = Path(upload.name).suffix.lower() or ".png"
object_key = f"teams/{team.id}/models/{model.id}/portraits/{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"{next_name}·形象图",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.MODEL_PORTRAIT,
in_library=False,
metadata={"kind": "model", "view": "frontal", "source": "upload", "model_id": str(model.id)},
)
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,
)
versions = [str(value) for value in (model.metadata or {}).get("portrait_versions", []) if value]
for value in (model.portrait_asset_id, portrait.id):
if value and str(value) not in versions:
versions.append(str(value))
metadata = dict(model.metadata or {})
metadata["portrait_versions"] = versions
model.name = next_name
model.description = next_description
model.portrait_asset = portrait
model.metadata = metadata
model.save(update_fields=["name", "description", "portrait_asset", "metadata", "updated_at"])
return Response(ModelLibrarySerializer(model).data, status=status.HTTP_200_OK)
@action(detail=False, methods=["post"], url_path="enroll-asset")
def enroll_asset(self, request):
"""图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目。