from pathlib import Path import uuid import requests from django.db import transaction from django.http import StreamingHttpResponse from rest_framework import status from rest_framework.decorators import action from rest_framework.pagination import PageNumberPagination from rest_framework.parsers import FormParser, 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 from .serializers import AssetSerializer, AssetUploadSerializer from .storage import TosStorage class AssetPagination(PageNumberPagination): """允许前端用 ?page_size= 覆盖(默认 20,上限 200)。前端「取全部资产」一次大页拉完, 不再逐页串行(原来 20/页 → 资产多时每次刷新要串十几个请求,整页很慢)。""" page_size_query_param = "page_size" max_page_size = 200 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"] @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 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)