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:
co-authored by
Claude Opus 4.8
parent
01d4f235ed
commit
5aab8a568f
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user