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
+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 条目。