Files
yingqing/core/backend/apps/assets/views.py
T
zycandClaude Fable 5 5ec69ad91f fix: YYX测试清单16条收口——资产库弹窗层级/批次多选删/商品筛选/模特卡单卡化/垃圾桶清空等
- 资产库:批次弹窗降层(under-confirm 990<999)修单图删除确认弹窗被压;编辑态批次卡多选批删;facets 新增 products 关联商品筛选(前后端,+2 测试);视频成品 tab 补搜索/排序/分页
- 平台套图工作台:「更多」气泡与 icon 左对齐+鼠标移出自动收起;模特卡改单卡(空态「+」占位,点卡进模特库单选回填)
- 工作台:删余额条,冻结/共计移至小标题行右侧(.dash-funds-mono)
- 模特库:点模特卡弹详情(形象图/标签/三视图+灯箱放大)
- 垃圾桶:新增「全部恢复」/「清空垃圾桶」(清空二次确认)
- 视频项目页:字重/阴影等轻量对齐设计规范
- api.assetFacets 返回体逐键兜底:旧后端缺 products 键会致资产库整页 undefined.find 白屏(前后端部署时序防护,浏览器已复现验证)

验证:tsc --noEmit / vite build / apps.assets 测试全过;暂存树已独立编译验证,不依赖工作区其余在途改动

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:03:26 +08:00

560 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from pathlib import Path
import re
import uuid
import requests
from django.db import transaction
from django.db.models import Q
from django.db.models.deletion import ProtectedError
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, 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, Model
from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer
from .storage import TosStorage
class AssetPagination(PageNumberPagination):
"""允许前端用 ?page_size= 覆盖(默认 20,上限 200)。前端「取全部资产」一次大页拉完,
不再逐页串行(原来 20/页 → 资产多时每次刷新要串十几个请求,整页很慢)。"""
page_size_query_param = "page_size"
max_page_size = 200
# 资产库 tab → 查询条件。与前端 assetTab(library.tsx)完全一致,供服务端按页过滤/计数。
# 图片趴三类(期2):tryon=模特上身图 / kits=平台套图 / creations=自由创作。
_KNOWN_CATS = [
"person", "scene", "product_image", "final_video", "upload",
"model_tryon", "platform_kit", "free_create",
]
def _tab_q(tab: str) -> Q:
if tab == "people":
return Q(category="person")
if tab == "scenes":
return Q(category="scene")
if tab == "products":
return Q(category="product_image")
if tab == "tryon": # 模特上身图(图片趴)
return Q(category="model_tryon")
if tab == "kits": # 平台套图(图片趴)
return Q(category="platform_kit")
if tab == "creations": # 自由创作(图片趴)
return Q(category="free_create")
if tab == "uploads":
return Q(category="upload")
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
return ~Q(category="final_video") & Q(asset_type="video")
if tab == "others": # 其他(资产库成品化):我的上传 + 未归类非视频(兜底)
return Q(category="upload") | (~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video"))
if tab == "unclassified": # 未归类且非视频(也不含最终成片)
return ~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video")
return Q()
# 图片趴三类按「生成批次」成组展示:同一次提交的 N 张图共享 metadata.batch_id。
BATCH_TABS = {"tryon", "kits", "creations"}
# 资产名形如「AI 生成 · 模特上身图 · 4」,去掉尾部的「· 序号」得到批次名「AI 生成 · 模特上身图」。
_BATCH_NAME_RE = re.compile(r"\s*·\s*\d+\s*$")
def _batch_key(batch_id, origin_task_id, asset_id) -> str:
"""资产归批:优先 metadata.batch_id;旧图无 batch_id 时回落 origin_task,再无则自身成单图批。"""
if batch_id:
return f"batch:{batch_id}"
if origin_task_id:
return f"task:{origin_task_id}"
return f"asset:{asset_id}"
def _batch_name(name: str) -> str:
cleaned = _BATCH_NAME_RE.sub("", name or "").strip()
return cleaned or (name or "")
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
# select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)。
# ★ defer 掉 AITask 的两个巨型 JSON 列(request/response payload):列表序列化只需 project.product_id,
# 不 defer 的话 select_related 会把每个资产关联 AITask 的完整 AI 请求/响应 payload 全拖出来,
# 200 个资产实测 100s+(数据越多越慢);defer 后 <1s、product 仍正常解析、无额外查询。
queryset = (
Asset.objects.prefetch_related("files")
.select_related("origin_task__project")
.defer("origin_task__request_payload", "origin_task__response_payload")
.all()
)
serializer_class = AssetSerializer
pagination_class = AssetPagination
search_fields = ["name", "description"]
ordering_fields = ["created_at", "updated_at", "name"]
def perform_destroy(self, instance):
"""R108:资产删除 = 回收站软删(is_deleted 置位),不再物理删行(原默认 hard delete 不可恢复)。
资产库列表/批次/任务中心批次弹窗/工作台记录/对话历史引用的都是同一 Asset 查询(均过滤
is_deleted),软删后各处即联动消失(R109「生成记录与资产库数据绑定」)。TOS 文件保留,可恢复。"""
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
def get_queryset(self):
"""服务端过滤,支持各页按需懒加载(不再前端取全量后客户端切片)。
参数:tab(资产库分桶)/category/asset_type/source/product/q(搜索)/m_<key>(metadata 过滤)/ordering。
默认按 -created_at 排序——无 ORDER BY 时分页会重/漏。"""
qs = super().get_queryset()
# R108 垃圾桶:trash/restore/purge 只看软删资产(镜像商品垃圾桶的写法)。
# perform_destroy 软删时 update updated_at → -updated_at ≈ 删除时间倒序;不过滤 in_library
# (未入库的工作台图删了也得能恢复)。基类 queryset 的 defer(AITask 巨型 payload)保持生效。
if self.action in ("trash", "restore", "purge"):
return qs.filter(is_deleted=True).order_by("-updated_at")
qs = qs.filter(is_deleted=False) # 软删资产不出现在资产库
p = self.request.query_params
# 资产库列表/批次默认只展示「已加入资产库」的资产(in_library=True);未加入的工作台生成图不出现在这里。
# 仅对列表型 action 过滤——retrieve / set-library / submit-review 等仍要能取到未加入的资产。
# 任务中心要看「全部生成图」(含未入库),传 ?in_library=all 旁路;?in_library=false 只看未入库。
if self.action in ("list", "batches"):
lib = (p.get("in_library") or "").lower()
if lib == "all":
pass
elif lib in ("false", "0"):
qs = qs.filter(in_library=False)
else:
qs = qs.filter(in_library=True)
if p.get("tab"):
qs = qs.filter(_tab_q(p["tab"]))
if p.get("category"):
qs = qs.filter(category=p["category"])
if p.get("asset_type"):
qs = qs.filter(asset_type=p["asset_type"])
if p.get("source"):
qs = qs.filter(source=p["source"])
if p.get("origin_task"):
qs = qs.filter(origin_task_id=p["origin_task"])
if p.get("product"):
# 资产归属商品:独立生图写 metadata.product_id;项目内生成回溯 origin_task→project→product;
# 上传的商品图经 ProductImage 关联。三路并集 = 前端 ProductDetail 的 belongsToProduct。
# ★ ZWQ#5:项目里同时会生成「角色立绘/三视图(person/tri_view)、场景图(scene)、分镜图(storyboard)」,
# 这些都挂在同一 project 上但**不属于商品**。若 origin_task→project 这一路不限类目,商品库的
# 「AI 生成三视图」会把项目里的角色三视图当成商品三视图错显。故 project 回溯路只认「确属商品」的类目
# (商品图/模特上身图/平台套图/自由创作);角色/场景/分镜资产仍可经 metadata.product_id 显式归属命中。
pid = p["product"]
product_categories = (
Asset.Category.PRODUCT_IMAGE,
Asset.Category.MODEL_TRYON,
Asset.Category.PLATFORM_KIT,
Asset.Category.FREE_CREATE,
)
qs = qs.filter(
Q(metadata__product_id=pid)
| (Q(origin_task__project__product_id=pid) & Q(category__in=product_categories))
| Q(product_images__product_id=pid)
).distinct()
if p.get("q"):
qs = qs.filter(Q(name__icontains=p["q"]) | Q(description__icontains=p["q"]))
for key, val in p.items():
if key.startswith("m_") and val:
qs = qs.filter(**{f"metadata__{key[2:]}": val})
ordering = p.get("ordering") or "-created_at"
return qs.order_by(ordering)
@action(detail=False, methods=["get"])
def summary(self, request):
"""资产库 tab 计数,供 tab 徽标——不必取全量。
图片趴三类(模特上身图/平台套图/自由创作)按「生成批次」计数(与卡片成组展示一致);
其他(我的上传 + 兜底)仍按资产张数计。"""
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
out = {}
for t in BATCH_TABS: # 图片趴:数批次
seen = set()
rows = base.filter(_tab_q(t)).values("id", "origin_task_id", "metadata")
for row in rows:
seen.add(_batch_key((row["metadata"] or {}).get("batch_id"), row["origin_task_id"], row["id"]))
out[t] = len(seen)
out["others"] = base.filter(_tab_q("others")).count()
return Response(out)
@action(detail=False, methods=["get"])
def batches(self, request):
"""图片趴(模特上身图/平台套图/自由创作)按生成批次成组:同一次提交的 N 张图
共享 metadata.batch_id = 一批;无 batch_id 的旧图回落 origin_task / 自身成单图批。
复用 get_queryset 的 tab/source/q/m_* 过滤 + ordering,按「批」分页返回,每批含封面 + 整批资产。"""
from apps.assets.serializers import _asset_preview
qs = self.get_queryset() # team scope + tab/source/q/meta 过滤 + ordering + defer
groups: dict[str, list] = {}
order: list[str] = [] # 保留资产 ordering 决定的批次先后(尊重最近/最早排序)
for asset in qs:
key = _batch_key((asset.metadata or {}).get("batch_id"), asset.origin_task_id, asset.id)
bucket = groups.get(key)
if bucket is None:
groups[key] = bucket = []
order.append(key)
bucket.append(asset)
try:
page = max(1, int(request.query_params.get("page", 1)))
except (TypeError, ValueError):
page = 1
try:
page_size = min(200, max(1, int(request.query_params.get("page_size", 20))))
except (TypeError, ValueError):
page_size = 20
total = len(order)
start = (page - 1) * page_size
ctx = self.get_serializer_context()
results = []
for key in order[start:start + page_size]:
assets = groups[key]
first = assets[0]
results.append({
"batch_id": key,
"name": _batch_name(first.name),
"count": len(assets),
"cover": _asset_preview(first),
"created_at": first.created_at,
"items": AssetSerializer(assets, many=True, context=ctx).data,
})
return Response({"count": total, "results": results})
@action(detail=True, methods=["post"], url_path="submit-review")
def submit_review(self, request, pk=None):
"""手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核 —— 用户点灰盾「待审核」时触发。
正常走 on_commit 自动送审;此口给万一漏提交时补提交。非送审类拒绝。"""
from apps.assets.review import submit_asset_for_review
asset = self.get_object() # team-scoped + is_deleted 过滤
if asset.category not in Asset.REVIEW_CATEGORIES:
return Response({"detail": "该素材无需审核"}, status=status.HTTP_400_BAD_REQUEST)
started = submit_asset_for_review(asset)
asset.refresh_from_db()
# 没真正送出去(审核服务未启用/未配置 SDK/火山未回 Id/调用异常),且素材也不是已终态:
# 如实回报 503,别返回空 review_status —— 否则前端会把「没送出去」误显示成「审核中」,刷新就打回原形。
if not started and asset.review_status not in ("processing", "active", "failed"):
return Response(
{"detail": "审核服务暂不可用,请稍后重试", "review_status": asset.review_status or ""},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return Response({"review_status": asset.review_status, "review_error": asset.review_error or ""})
@action(detail=True, methods=["post"], url_path="set-library")
def set_library(self, request, pk=None):
"""单图「加入资产库 / 取消加入」:置 in_library。加入 → 出现在资产库列表;取消 → 从列表移出。
注:资产在生成时已落库并扣费,此口只控制是否进「资产库」展示列表,不增删真实资产、不二次扣费。"""
asset = self.get_object() # team-scoped;此 action 不过滤 in_library,故未加入的图也能取到
in_lib = bool(request.data.get("in_library", True))
if asset.in_library != in_lib:
asset.in_library = in_lib
asset.save(update_fields=["in_library"])
return Response({"id": str(asset.id), "in_library": asset.in_library})
@action(detail=False, methods=["get"], url_path="trash")
def trash(self, request):
"""垃圾桶:列出本团队已软删(is_deleted=True)的资产 —— R108 删除弹窗承诺
「删除的图片将回收到垃圾桶」,此口兑现承诺(与商品垃圾桶 /api/products/trash/ 同构)。"""
qs = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(qs)
if page is not None:
return self.get_paginated_response(self.get_serializer(page, many=True).data)
return Response(self.get_serializer(qs, many=True).data)
@action(detail=True, methods=["post"], url_path="restore")
def restore(self, request, pk=None):
"""从垃圾桶恢复:is_deleted → False。资产库/批次/商品素材区引用的都是同一 Asset 查询
(均过滤 is_deleted),恢复后各处即联动复现;in_library 保持删除前原样,不额外置位。"""
asset = self.get_object() # trash 态 queryset:只有软删资产可恢复
asset.is_deleted = False
asset.save(update_fields=["is_deleted", "updated_at"])
return Response(self.get_serializer(asset).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["delete"], url_path="purge")
def purge(self, request, pk=None):
"""彻底删除(仅垃圾桶内软删资产可彻底删,不可恢复)。与商品 purge 语义对齐:
硬删 DB 行(AssetFile 记录级联删除),TOS 对象不动(商品 purge 同样不触碰对象存储)。
分镜/视频片段/商品图等以 PROTECT 引用资产:被引用的资产硬删会炸 ProtectedError,
如实回 409(留在垃圾桶,仍可恢复),别让它 500。"""
asset = self.get_object()
try:
asset.delete()
except ProtectedError:
return Response(
{"detail": "该资产仍被商品图/分镜/视频片段等引用,无法彻底删除;可恢复后先解除引用"},
status=status.HTTP_409_CONFLICT,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["get"], url_path="video-packs")
def video_packs(self, request):
"""视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源:
取 VideoSegment.adopted_version 的真实视频资产)。片段给 TOS 直链 url 供 <video> 播放/取首帧。"""
from apps.assets.serializers import _asset_preview
from apps.projects.models import Project
team = self.get_team()
projects = (
Project.objects.filter(team=team)
.select_related("product__cover_asset")
.prefetch_related("product__cover_asset__files")
.order_by("-updated_at")
)
out = []
for proj in projects:
segs = (
proj.video_segments.filter(adopted_version__isnull=False)
.select_related("adopted_version__asset")
.prefetch_related("adopted_version__asset__files")
.order_by("sort_order")
)
clips = []
for seg in segs:
a = getattr(seg.adopted_version, "asset", None)
if a is None or a.is_deleted:
continue
clips.append({"id": str(a.id), "name": a.name, "url": _asset_preview(a)})
if not clips:
continue
product = proj.product
out.append({
"project_id": str(proj.id),
"project_name": proj.name,
"product_cover": _asset_preview(getattr(product, "cover_asset", None)) if product else "",
"clips": clips,
})
out.sort(key=lambda p: len(p["clips"]), reverse=True)
return Response(out)
@action(detail=False, methods=["get"])
def facets(self, request):
"""某 tab 下真实存在的筛选项(来源/类型/关联商品/指定 metadata 键的取值),供下拉「只列真有的」。
参数:tab、meta_keys=gender,age,role,...(逗号分隔)。"""
base = Asset.objects.filter(team=self.get_team(), is_deleted=False, in_library=True)
if request.query_params.get("tab"):
base = base.filter(_tab_q(request.query_params["tab"]))
sources = sorted(s for s in base.values_list("source", flat=True).distinct() if s)
kinds = sorted(k for k in base.values_list("asset_type", flat=True).distinct() if k)
meta = {}
for key in (k for k in request.query_params.get("meta_keys", "").split(",") if k):
vals = base.values_list(f"metadata__{key}", flat=True)
meta[key] = sorted({str(v) for v in vals if v not in (None, "")})
# 关联商品:本 tab 下真实关联到的商品(供「关联商品」下拉只列真有的),只回 id/title
products = self._facet_products(base)
return Response({"sources": sources, "kinds": kinds, "metadata": meta, "products": products})
def _facet_products(self, base):
"""收集 base(已 team+tab 过滤)下真实关联的商品 id → 解析成 [{id, title}]。
三路与 get_queryset(?product=)完全对齐:① metadata.product_id ② origin_task→project→product
(仅「确属商品」的类目,不误纳角色/场景/分镜)③ ProductImage 关联。
只 values 取需要的列(product_id / title),不 select_related AITask,不拖巨型 payload。"""
from apps.products.models import Product
product_ids: set[str] = set()
# 路① 独立生图写 metadata.product_id:JSON 键在 Python 侧取,避开 MySQL JSON 抽取比较坑
for md in base.values_list("metadata", flat=True):
if isinstance(md, dict):
pid = md.get("product_id")
if pid:
product_ids.add(str(pid))
# 路② 项目内生成回溯,仅「确属商品」的类目(与 ZWQ#5 一致)
product_categories = (
Asset.Category.PRODUCT_IMAGE,
Asset.Category.MODEL_TRYON,
Asset.Category.PLATFORM_KIT,
Asset.Category.FREE_CREATE,
)
pid2 = base.filter(
category__in=product_categories, origin_task__project__product__isnull=False
).values_list("origin_task__project__product_id", flat=True)
product_ids.update(str(x) for x in pid2 if x)
# 路③ 上传商品图经 ProductImage 关联
pid3 = base.filter(product_images__isnull=False).values_list("product_images__product_id", flat=True)
product_ids.update(str(x) for x in pid3 if x)
if not product_ids:
return []
# 团队隔离(即便某 product_id 越权也过滤掉);只取 id/title
rows = Product.objects.filter(team=self.get_team(), id__in=product_ids).values("id", "title")
products = [{"id": str(r["id"]), "title": r["title"]} for r in rows]
products.sort(key=lambda x: x["title"] or "")
return products
@action(detail=True, methods=["get"], url_path="raw")
def raw(self, request, pk=None):
"""同源流式代理资产主文件。TOS 桶未配 CORS,浏览器 JS 读不到跨域媒体数据——
编辑器抽视频缩略图(canvas 会被 taint)/解码音频波形(fetch 被拦)都需要走这里。"""
asset = self.get_object()
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
if primary is None:
return Response({"detail": "asset has no file"}, status=status.HTTP_404_NOT_FOUND)
url = TosStorage().presigned_get_url(object_key=primary.object_key, expires_in=600)
upstream_headers = {}
if request.META.get("HTTP_RANGE"):
upstream_headers["Range"] = request.META["HTTP_RANGE"]
upstream = requests.get(url, headers=upstream_headers, stream=True, timeout=120)
response = StreamingHttpResponse(
upstream.iter_content(chunk_size=256 * 1024),
status=upstream.status_code,
content_type=primary.content_type or "application/octet-stream",
)
for header in ("Content-Length", "Content-Range", "Accept-Ranges"):
if header in upstream.headers:
response[header] = upstream.headers[header]
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)
@action(detail=False, methods=["post"], url_path="enroll-asset")
def enroll_asset(self, request):
"""图片创作「加入模特库」:把一张已生成的图复用为模特形象图建 Model 条目。
资产生成时已落库扣过费,此处只引用、不重新上传/不二次扣费。同一资产已建过则幂等返回。"""
team = self.get_team()
asset_id = request.data.get("asset_id")
asset = Asset.objects.filter(id=asset_id, team=team, is_deleted=False).first() if asset_id else None
if asset is None:
raise ValidationError({"asset_id": "资产不存在或不属于本团队"})
existing = Model.objects.filter(team=team, portrait_asset=asset, is_deleted=False).first()
if existing is not None:
return Response(ModelLibrarySerializer(existing).data, status=status.HTTP_200_OK)
name = (request.data.get("name") or (asset.name or "模特").split("·")[0].strip() or "模特")[:255]
model = Model.objects.create(
team=team,
created_by=request.user,
name=name,
source=Model.Source.AI,
portrait_asset=asset,
metadata={"source": "enroll_from_asset", "asset_id": str(asset.id)},
)
return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED)
class AssetUploadView(APIView):
parser_classes = [MultiPartParser, FormParser]
@transaction.atomic
def post(self, request):
serializer = AssetUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
team = get_current_team(request.user)
upload = serializer.validated_data["file"]
suffix = Path(upload.name).suffix.lower()
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "application/octet-stream",
)
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=request.user,
name=serializer.validated_data.get("name") or upload.name,
asset_type=serializer.validated_data["asset_type"],
source=Asset.Source.UPLOAD,
category=serializer.validated_data["category"],
description=serializer.validated_data.get("description", ""),
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
return Response(AssetSerializer(asset).data, status=status.HTTP_201_CREATED)