feat: 解耦角色与模特并完善模特库
This commit is contained in:
@@ -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、项目、垃圾桶或文件。"))
|
||||
@@ -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 二级软删。"""
|
||||
|
||||
@@ -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 条目。
|
||||
|
||||
Reference in New Issue
Block a user