from pathlib import Path import uuid import requests from django.db import transaction 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, 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() 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 get_queryset(self): """服务端过滤,支持各页按需懒加载(不再前端取全量后客户端切片)。 参数:tab(资产库分桶)/category/asset_type/source/product/q(搜索)/m_(metadata 过滤)/ordering。 默认按 -created_at 排序——无 ORDER BY 时分页会重/漏。""" qs = super().get_queryset().filter(is_deleted=False) # 软删资产不出现在资产库 p = self.request.query_params 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("product"): # 资产归属商品:独立生图写 metadata.product_id;项目内生成回溯 origin_task→project→product; # 上传的商品图经 ProductImage 关联。三路并集 = 前端 ProductDetail 的 belongsToProduct。 pid = p["product"] qs = qs.filter( Q(metadata__product_id=pid) | Q(origin_task__project__product_id=pid) | 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) # 资产库成品化:对外只数成品三类 + 其他(角色/场景/商品图各回各库,视频成品走 video-packs) tabs = ["tryon", "kits", "creations", "others"] return Response({t: base.filter(_tab_q(t)).count() for t in tabs}) @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) submit_asset_for_review(asset) asset.refresh_from_db() return Response({"review_status": asset.review_status, "review_error": asset.review_error or ""}) @action(detail=False, methods=["get"], url_path="video-packs") def video_packs(self, request): """视频成品按「项目素材包」打包:每个项目的已采用视频片段 = 一个包(与「导出全部」同源: 取 VideoSegment.adopted_version 的真实视频资产)。片段给 TOS 直链 url 供