Files
yingqing/core/backend/apps/assets/views.py
T

772 lines
39 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.conf import settings
from django.db import transaction
from django.db.models import Q
from django.http import StreamingHttpResponse
from django.utils import timezone
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",
# 模特资料、项目角色三视图/分镜是功能性引用资产,只在模特库或项目中管理,
# 不应落入 /library 的「其他」形成第二个人物入口。
"model_portrait", "tri_view", "storyboard", "voice",
]
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 == "image_creations": # 图片自由创作
return Q(category="free_create", asset_type="image")
if tab == "video_creations": # 视频自由创作
return Q(category="free_create", asset_type="video") & ~Q(metadata__feature="omni_create")
if tab == "creations": # 自由创作(兼容旧入口:图片+视频)
return Q(category="free_create") & ~Q(metadata__feature="omni_create")
if tab == "uploads":
return Q(category="upload")
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
return ~Q(category="final_video") & Q(asset_type="video") & ~Q(metadata__feature="omni_create")
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", "image_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"):
trash_qs = qs.filter(is_deleted=True, purged_at__isnull=True)
# 自由创作父记录已进垃圾桶时,生成 Asset 由父记录统一恢复/彻底隐藏,
# 不在“资产”分类重复展示。单独从资产库删除时父记录仍为正常态,仍会出现在这里。
owned_by_deleted_free_creation = (
Q(origin_task__task_type="free_video", origin_task__is_deleted=True)
| Q(origin_task__conversation__is_deleted=True)
)
# 图片异常批次由任务批次垃圾桶统一恢复/彻底删除;其成功图不能再作为独立资产
# 重复出现在这里。全成功图片批次不会软删任务,仍维持既有的逐图资产垃圾桶行为。
owned_by_deleted_image_exception_batch = Q(
origin_task__project__isnull=True,
origin_task__task_type__in=["person_image", "product_image"],
origin_task__is_deleted=True,
)
return trash_qs.exclude(owned_by_deleted_free_creation | owned_by_deleted_image_exception_batch).order_by("-updated_at")
qs = qs.filter(is_deleted=False, purged_at__isnull=True) # 软删资产不出现在资产库
p = self.request.query_params
tab = p.get("tab")
# 视频自由创作的显示标题来自关联任务的原始 prompt;只在该专用 tab 取回 JSON,避免普通资产列表
# 重新加载巨型请求/响应 payload。
if tab == "video_creations":
# Django 没有 QuerySet.undefer();先清除默认延迟字段,再仅保留不需要的 response_payload 延迟加载。
qs = qs.defer(None).defer("origin_task__response_payload")
# 资产库列表/批次默认只展示「已加入资产库」的资产(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 tab:
qs = qs.filter(_tab_q(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"):
query = p["q"]
title_q = Q(name__icontains=query) | Q(description__icontains=query)
# display_name 不落在 Asset 表;视频自由创作搜索需同时匹配关联任务的完整提示词,
# 才能找到历史被截为前 50 字符的标题后半段。
if tab == "video_creations":
title_q |= Q(origin_task__task_type="free_video", origin_task__request_payload__prompt__icontains=query)
qs = qs.filter(title_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, purged_at__isnull=True, 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["video_creations"] = base.filter(_tab_q("video_creations")).count()
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 自动送审;此口给万一漏提交时补提交。非送审类拒绝。
for_reference=true:素材要被拿去当生成参考(自由创作 @引用三库)。此时不再看类目白名单——
白名单只是「别为无关素材白跑一趟审核」的优化,而参考素材里有没有真人脸我们无从判断,
一律登记一次。放宽的是送审范围,不是放行范围,不会削弱审核。"""
from apps.assets.review import submit_asset_for_review
asset = self.get_object() # team-scoped + is_deleted 过滤
for_reference = bool(request.data.get("for_reference"))
if not for_reference and asset.category not in Asset.REVIEW_CATEGORIES:
return Response({"detail": "该素材无需审核"}, status=status.HTTP_400_BAD_REQUEST)
started = submit_asset_for_review(asset, force=for_reference)
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.purged_at = None
asset.save(update_fields=["is_deleted", "purged_at", "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):
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录和 TOS 文件保留。"""
asset = self.get_object()
asset.purged_at = timezone.now()
asset.save(update_fields=["purged_at", "updated_at"])
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 or a.purged_at is not None:
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, purged_at__isnull=True, 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)
.order_by("-created_at")
.values("id", "title")
)
return [{"id": str(r["id"]), "title": r["title"]} for r in rows]
@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()
if self.action in ("trash", "restore", "purge"):
return (
Model.objects.filter(team=team, is_official=False, is_deleted=True, purged_at__isnull=True)
.prefetch_related("portrait_asset__files", "triview_asset__files")
.order_by("-updated_at")
)
qs = (
Model.objects.filter(Q(team=team) | Q(is_official=True), is_deleted=False, purged_at__isnull=True)
.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 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))
# 官方模板靠前,再按新→旧
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_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("官方模特不可删除")
if instance.team_id != self.get_team().id:
raise PermissionDenied("无权删除其他团队的模特")
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()
from apps.ai.generation_errors import public_error_for_task
public_error = public_error_for_task(task, operation="triview_generate")
return Response(
{
"task_id": str(task.id),
"status": task.status,
"estimated_cost": str(task.estimated_cost),
"actual_cost": str(task.actual_cost),
"error": public_error.as_dict() if public_error else None,
"error_message": public_error.fallback_message if public_error else "",
"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):
"""垃圾桶:列出本团队已软删且未彻底隐藏的自建模特。"""
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):
"""从垃圾桶恢复模特。"""
model = self.get_object()
model.is_deleted = False
model.purged_at = None
model.save(update_fields=["is_deleted", "purged_at", "updated_at"])
return Response(self.get_serializer(model).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["delete"], url_path="purge")
def purge(self, request, pk=None):
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录和关联资产保留。"""
model = self.get_object()
model.purged_at = timezone.now()
model.save(update_fields=["purged_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@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=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 条目。
资产生成时已落库扣过费,此处只引用、不重新上传/不二次扣费。同一资产已建过则幂等返回。"""
team = self.get_team()
asset_id = request.data.get("asset_id")
asset = Asset.objects.filter(id=asset_id, team=team, is_deleted=False, purged_at__isnull=True).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, purged_at__isnull=True).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)