- /api/assets/ 列表 select_related('origin_task__project') 为解析资产归属商品,
会把每个资产关联 AITask 的 request_payload/response_payload(完整 AI 请求/响应 JSON)
整行拖出 → 取全部 200 资产实测 >60s 超时,数据越多越慢(用户报的「刷新越来越卡」)。
加 .defer 这两列后 0.76s,product 仍正常解析、序列化 0 额外查询。
- billing/ledgers 同模式补 .defer(task payload)。ai/billing.summary 早已 defer,assets/ledgers 本是漏网。
- 附 qa/function-audit/perf-probe.mjs 性能验证闸 + PERF-GATE.md(API 确定性断言+浏览器功能守护)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
107 lines
4.6 KiB
Python
107 lines
4.6 KiB
Python
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)
|