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:
@@ -3,6 +3,7 @@ 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
|
||||
@@ -27,6 +28,26 @@ class AssetPagination(PageNumberPagination):
|
||||
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):
|
||||
# select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1)。
|
||||
# ★ defer 掉 AITask 的两个巨型 JSON 列(request/response payload):列表序列化只需 project.product_id,
|
||||
@@ -43,6 +64,53 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
search_fields = ["name", "description"]
|
||||
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")
|
||||
def raw(self, request, pk=None):
|
||||
"""同源流式代理资产主文件。TOS 桶未配 CORS,浏览器 JS 读不到跨域媒体数据——
|
||||
|
||||
@@ -17,7 +17,12 @@ from .serializers import ProductSerializer
|
||||
|
||||
|
||||
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
|
||||
search_fields = ["title", "brand", "category"]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
|
||||
Reference in New Issue
Block a user