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") 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", "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_(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) ) return trash_qs.exclude(owned_by_deleted_free_creation).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 自动送审;此口给万一漏提交时补提交。非送审类拒绝。""" 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.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 供