feat: 解耦角色与模特并完善模特库
This commit is contained in:
@@ -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