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:
co-authored by
Claude Opus 4.8
parent
1c9143ce61
commit
44e90233ff
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user