feat(core): 资产端点加服务端过滤/计数/facets + 商品嵌 preview_url(为前端按页懒加载铺路)

- /api/assets/ get_queryset 支持 tab(对齐前端 assetTab 分桶)/category/asset_type/source/
  product(metadata.product_id 或 origin_task→project→product)/q 搜索/m_<key> metadata 过滤,
  默认 -created_at 排序(无 ORDER BY 分页会重/漏)。
- 新增 /api/assets/summary/(各 tab 计数,供 tab 徽标)+ /api/assets/facets/
  (某 tab 下真实存在的 来源/类型/指定 metadata 键取值,供下拉「只列真有的」)。
- ProductSerializer 内嵌 cover_preview_url + images[].preview_url,products/项目向导/pipeline
  缩略图不再依赖全局 assets 全量数组反查;product 视图 prefetch 图片/封面资产文件防 N+1。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 18:03:07 +08:00
co-authored by Claude Opus 4.8
parent 200871847a
commit ce809b2d32
2 changed files with 74 additions and 1 deletions
+68
View File
@@ -3,6 +3,7 @@ import uuid
import requests import requests
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.http import StreamingHttpResponse from django.http import StreamingHttpResponse
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
@@ -27,6 +28,26 @@ class AssetPagination(PageNumberPagination):
max_page_size = 200 max_page_size = 200
# 资产库 tab → 查询条件。与前端 assetTab(library.tsx)完全一致,供服务端按页过滤/计数。
_KNOWN_CATS = ["person", "scene", "product_image", "final_video", "upload"]
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 == "uploads":
return Q(category="upload")
if tab == "finals": # final_video,或「未归类但是视频」
return Q(category="final_video") | (~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): class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
# select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)。 # select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)。
# ★ defer 掉 AITask 的两个巨型 JSON 列(request/response payload):列表序列化只需 project.product_id, # ★ defer 掉 AITask 的两个巨型 JSON 列(request/response payload):列表序列化只需 project.product_id,
@@ -43,6 +64,53 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
search_fields = ["name", "description"] search_fields = ["name", "description"]
ordering_fields = ["created_at", "updated_at", "name"] ordering_fields = ["created_at", "updated_at", "name"]
def get_queryset(self):
"""服务端过滤,支持各页按需懒加载(不再前端取全量后客户端切片)。
参数:tab(资产库分桶)/category/asset_type/source/product/q(搜索)/m_<key>(metadata 过滤)/ordering。
默认按 -created_at 排序——无 ORDER BY 时分页会重/漏。"""
qs = super().get_queryset()
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
qs = qs.filter(Q(metadata__product_id=p["product"]) | Q(origin_task__project__product_id=p["product"]))
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())
tabs = ["people", "scenes", "products", "finals", "uploads", "unclassified"]
return Response({t: base.filter(_tab_q(t)).count() for t in tabs})
@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())
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, "")})
return Response({"sources": sources, "kinds": kinds, "metadata": meta})
@action(detail=True, methods=["get"], url_path="raw") @action(detail=True, methods=["get"], url_path="raw")
def raw(self, request, pk=None): def raw(self, request, pk=None):
"""同源流式代理资产主文件。TOS 桶未配 CORS,浏览器 JS 读不到跨域媒体数据—— """同源流式代理资产主文件。TOS 桶未配 CORS,浏览器 JS 读不到跨域媒体数据——
+6 -1
View File
@@ -17,7 +17,12 @@ from .serializers import ProductSerializer
class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet): class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = Product.objects.prefetch_related("images", "selling_points").all() # 预取 图片资产文件 + 封面资产文件:序列化器内嵌 preview_url 时不再 N+1(也让前端无需全量 assets 反查)
queryset = (
Product.objects.select_related("cover_asset")
.prefetch_related("images__asset__files", "cover_asset__files", "selling_points")
.all()
)
serializer_class = ProductSerializer serializer_class = ProductSerializer
search_fields = ["title", "brand", "category"] search_fields = ["title", "brand", "category"]
ordering_fields = ["created_at", "updated_at", "title"] ordering_fields = ["created_at", "updated_at", "title"]