角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
815 lines
42 KiB
Python
815 lines
42 KiB
Python
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 _not_omni_create_q() -> Q:
|
||
"""「不是全能创作产出」。不能只写 ~Q(metadata__feature="omni_create")。
|
||
|
||
metadata 里没有 feature 键时取值是 SQL NULL,NOT(NULL = 'x') 还是 NULL,整行会被筛掉 ——
|
||
历史资产(没写过 feature)会从「视频自由创作」「自由创作」「素材」这些分桶里整批消失。
|
||
"""
|
||
return Q(metadata__feature__isnull=True) | ~Q(metadata__feature="omni_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 == "image_creations": # 图片自由创作
|
||
return Q(category="free_create", asset_type="image")
|
||
if tab == "video_creations": # 视频自由创作
|
||
return Q(category="free_create", asset_type="video") & _not_omni_create_q()
|
||
if tab == "creations": # 自由创作(兼容旧入口:图片+视频)
|
||
return Q(category="free_create") & _not_omni_create_q()
|
||
if tab == "uploads":
|
||
return Q(category="upload")
|
||
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
|
||
return ~Q(category="final_video") & Q(asset_type="video") & _not_omni_create_q()
|
||
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))
|
||
# 标签筛选: ?tag=男人 或 ?tag=男人&tag=警察(多标签 AND)
|
||
raw_tags = self.request.query_params.getlist("tag") or []
|
||
if not raw_tags:
|
||
joined = (self.request.query_params.get("tags") or "").strip()
|
||
if joined:
|
||
raw_tags = [t.strip() for t in joined.split(",") if t.strip()]
|
||
for tag in raw_tags:
|
||
qs = qs.filter(metadata__tags__contains=[tag])
|
||
# 官方模板靠前,再按新→旧
|
||
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=["get"], url_path="tags")
|
||
def tags(self, request):
|
||
"""当前可见模特里出现过的标签(供前端筛选条)· 只返回后台标记为显示的。"""
|
||
from .model_library_tags import public_tag_rows
|
||
|
||
tab = (request.query_params.get("tab") or "").strip() or None
|
||
if tab not in (None, "official", "mine"):
|
||
tab = None
|
||
team = self.get_team()
|
||
items = public_tag_rows(tab=tab, team_id=getattr(team, "id", None))
|
||
return Response({"results": items})
|
||
|
||
@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,
|
||
)
|
||
raw_tags = request.data.get("tags")
|
||
tags: list[str] = []
|
||
if isinstance(raw_tags, str) and raw_tags.strip():
|
||
import json
|
||
try:
|
||
parsed = json.loads(raw_tags)
|
||
if isinstance(parsed, list):
|
||
tags = [str(t).strip() for t in parsed if str(t).strip()]
|
||
else:
|
||
tags = [t.strip() for t in raw_tags.split(",") if t.strip()]
|
||
except Exception:
|
||
tags = [t.strip() for t in raw_tags.split(",") if t.strip()]
|
||
elif isinstance(raw_tags, list):
|
||
tags = [str(t).strip() for t in raw_tags if str(t).strip()]
|
||
model = Model.objects.create(
|
||
team=team,
|
||
created_by=request.user,
|
||
name=name,
|
||
source=Model.Source.UPLOAD,
|
||
portrait_asset=portrait,
|
||
metadata={"source": "upload", "tags": tags},
|
||
)
|
||
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)
|