feat(admin): Phase 5 火山人像审核队列 — 跨团队 person 资产审核(列/筛/批量送审/轮询)

后端:adminpanel asset-reviews(列跨团队 person + 状态筛分页)+ submit(批量送审/重试)+ poll(轮询 processing),
复用 assets/review.py,IsPlatformAdmin + 审计;AdminReviewAssetSerializer;修 N+1(prefetch 缓存取首图,列表 5s→1s)。
前端:adminApi 审核系列;Admin 资产审核页(状态筛 + 多选表格 + 绿/红/灰状态盾 + 重试 + 吸底 bulk-bar 批量送审 + 刷新状态)。
测试:adminpanel 28 单测过(mock 火山:队列只列 person/状态筛/批量送审计数/poll 只轮 processing);
无头 e2e _admin-p5.mjs 5 断言过 + 0 console error(只读 poll,不点真送审);tsc+build 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 22:07:11 +08:00
co-authored by Claude Opus 4.8
parent 01d4f235ed
commit 5aab8a568f
11 changed files with 494 additions and 0 deletions
@@ -2,6 +2,7 @@ from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import QualityWord
from apps.assets.models import Asset
class QualityWordSerializer(serializers.ModelSerializer):
@@ -11,6 +12,25 @@ class QualityWordSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "created_at"]
class AdminReviewAssetSerializer(serializers.ModelSerializer):
"""跨团队人像审核队列行:复用 AssetFileSerializer 的首图 preview_url。"""
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
preview_url = serializers.SerializerMethodField()
class Meta:
model = Asset
fields = ["id", "name", "category", "review_status", "review_error", "team", "team_name", "preview_url", "created_at"]
read_only_fields = fields
def get_preview_url(self, obj):
from apps.assets.serializers import AssetFileSerializer
# 用 prefetch 缓存(list(...))而非 .first():后者会另发查询,列表页 N+1 拖慢到数秒
files = list(obj.files.all())
return AssetFileSerializer(files[0]).data.get("preview_url", "") if files else ""
class AdminTeamSerializer(serializers.ModelSerializer):
owner_username = serializers.CharField(source="owner.username", read_only=True, default=None)
member_count = serializers.SerializerMethodField()
+66
View File
@@ -1,3 +1,5 @@
from unittest.mock import patch
from rest_framework.test import APIClient
from django.test import TestCase
@@ -232,3 +234,67 @@ class AdminQualityWordTests(TestCase):
words = quality_words("video")
self.assertIn("启用词", words)
self.assertNotIn("停用词", words)
class AdminAssetReviewTests(TestCase):
"""Phase 5:跨团队人像审核队列(列/筛/批量送审/轮询)+ 权限。火山调用全 mock。"""
def setUp(self):
from apps.assets.models import Asset
self.admin = User.objects.create_user(username="padmin5", password="x", is_platform_admin=True)
self.normal = User.objects.create_user(username="normal5", password="x")
self.teamA = Team.objects.create(name="TeamA", owner=self.normal)
TeamMember.objects.create(team=self.teamA, user=self.normal, role=TeamMember.Role.OWNER)
self.user2 = User.objects.create_user(username="u2-5", password="x")
self.teamB = Team.objects.create(name="TeamB", owner=self.user2)
self.a_proc = Asset.objects.create(team=self.teamA, name="proc", asset_type="image", category=Asset.Category.PERSON, review_status="processing")
self.a_active = Asset.objects.create(team=self.teamA, name="active", asset_type="image", category=Asset.Category.PERSON, review_status="active")
self.a_failed = Asset.objects.create(team=self.teamB, name="failed", asset_type="image", category=Asset.Category.PERSON, review_status="failed")
self.a_none = Asset.objects.create(team=self.teamA, name="none", asset_type="image", category=Asset.Category.PERSON, review_status="")
# 非 person 资产不该进队列
Asset.objects.create(team=self.teamA, name="scene", asset_type="image", category=Asset.Category.SCENE, review_status="")
self.ac = APIClient()
self.ac.force_authenticate(self.admin)
self.nc = APIClient()
self.nc.force_authenticate(self.normal)
def test_list_permission_and_only_person_cross_team(self):
self.assertEqual(self.nc.get("/api/admin/asset-reviews/").status_code, 403)
r = self.ac.get("/api/admin/asset-reviews/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 4) # 4 person 资产(跨 2 团队),scene 排除
self.assertEqual({row["team_name"] for row in r.data["results"]}, {"TeamA", "TeamB"})
def test_filter_by_status(self):
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=processing").data["count"], 1)
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=active").data["count"], 1)
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=failed").data["count"], 1)
self.assertEqual(self.ac.get("/api/admin/asset-reviews/?review_status=none").data["count"], 1)
def test_submit_calls_review_and_audit(self):
with patch("apps.adminpanel.views.submit_asset_for_review") as mock_submit:
r = self.ac.post(
"/api/admin/asset-reviews/submit/",
{"asset_ids": [str(self.a_none.id), str(self.a_failed.id)]},
format="json",
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["submitted"], 2)
self.assertEqual(mock_submit.call_count, 2)
self.assertTrue(AdminAuditLog.objects.filter(action="asset_review.submit").exists())
def test_submit_requires_admin(self):
with patch("apps.adminpanel.views.submit_asset_for_review") as mock_submit:
r = self.nc.post("/api/admin/asset-reviews/submit/", {"asset_ids": [str(self.a_none.id)]}, format="json")
self.assertEqual(r.status_code, 403)
mock_submit.assert_not_called()
def test_poll_only_processing(self):
with patch("apps.adminpanel.views.poll_asset_review", return_value="active") as mock_poll:
r = self.ac.post("/api/admin/asset-reviews/poll/", {}, format="json")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["polled"], 1) # 仅 a_proc 处于 processing
mock_poll.assert_called_once()
+6
View File
@@ -1,6 +1,9 @@
from django.urls import path
from .views import (
admin_asset_reviews,
admin_asset_reviews_poll,
admin_asset_reviews_submit,
admin_invitations,
admin_quality_word_detail,
admin_quality_words,
@@ -25,4 +28,7 @@ urlpatterns = [
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
path("quality-words/", admin_quality_words, name="admin-quality-words"),
path("quality-words/<uuid:word_id>/", admin_quality_word_detail, name="admin-quality-word-detail"),
path("asset-reviews/", admin_asset_reviews, name="admin-asset-reviews"),
path("asset-reviews/submit/", admin_asset_reviews_submit, name="admin-asset-reviews-submit"),
path("asset-reviews/poll/", admin_asset_reviews_poll, name="admin-asset-reviews-poll"),
]
+64
View File
@@ -11,9 +11,12 @@ from apps.accounts.models import Invitation, Team, User
from apps.accounts.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer
from apps.ai.models import QualityWord
from apps.assets.models import Asset
from apps.assets.review import poll_asset_review, submit_asset_for_review
from apps.common.pagination import DefaultPagination
from .serializers import (
AdminReviewAssetSerializer,
AdminTeamMemberSerializer,
AdminTeamSerializer,
AdminUserSerializer,
@@ -262,3 +265,64 @@ def admin_quality_word_detail(request, word_id):
after={"text": obj.text},
)
return Response(QualityWordSerializer(obj).data)
# ─────────────────────────── 火山人像审核队列 ───────────────────────────
_REVIEW_STATUSES = {"", "processing", "active", "failed"}
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_asset_reviews(request):
"""跨团队真人(person)资产审核队列。?review_status=none|processing|active|failed 过滤(none=未送审)。"""
qs = (
Asset.objects.filter(category=Asset.Category.PERSON, is_deleted=False)
.select_related("team")
.prefetch_related("files")
.order_by("-created_at")
)
rs = request.query_params.get("review_status")
if rs == "none":
qs = qs.filter(review_status="")
elif rs in {"processing", "active", "failed"}:
qs = qs.filter(review_status=rs)
search = (request.query_params.get("search") or "").strip()
if search:
qs = qs.filter(team__name__icontains=search)
paginator = DefaultPagination()
page = paginator.paginate_queryset(qs, request)
return paginator.get_paginated_response(AdminReviewAssetSerializer(page, many=True).data)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_asset_reviews_submit(request):
"""批量送审(也用于失败重试):对给定 person 资产逐个 submit_asset_for_review。"""
ids = request.data.get("asset_ids") or []
assets = list(Asset.objects.filter(id__in=ids, category=Asset.Category.PERSON, is_deleted=False))
for asset in assets:
submit_asset_for_review(asset)
statuses = {str(a.id): a.review_status for a in Asset.objects.filter(id__in=ids)}
log_admin_action(
request,
"asset_review.submit",
target_type="asset",
target_name=f"{len(assets)} assets",
after={"count": len(assets)},
)
return Response({"submitted": len(assets), "statuses": statuses})
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_asset_reviews_poll(request):
"""轮询审核中(processing)资产的最新状态。给 asset_ids 则只轮询这些,否则轮询全平台 processing。"""
ids = request.data.get("asset_ids")
qs = Asset.objects.filter(category=Asset.Category.PERSON, review_status="processing", is_deleted=False)
if ids:
qs = qs.filter(id__in=ids)
statuses = {}
for asset in qs:
statuses[str(asset.id)] = poll_asset_review(asset)
return Response({"polled": len(statuses), "statuses": statuses})