Files
yingqing/core/backend/apps/adminpanel/views.py
T
seaislee1209andClaude Opus 4.8 5aab8a568f 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>
2026-06-19 22:07:11 +08:00

329 lines
13 KiB
Python

"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。"""
from django.db.models import Count, Q
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from apps.accounts.audit import log_admin_action
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,
QualityWordSerializer,
)
def _team_qs():
return (
Team.objects.select_related("owner", "credit_account")
.annotate(member_count_anno=Count("members", distinct=True))
)
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_invitations(request):
"""GET 列所有邀请码(跨团队,可按 kind/status/search 过滤,分页);
POST 平台超管发「开团队码」(create_team,不绑团队,新用户凭码开新团队当 owner)。"""
if request.method == "GET":
qs = Invitation.objects.select_related("team", "used_by").order_by("-created_at")
kind = request.query_params.get("kind")
if kind in {Invitation.Kind.JOIN_TEAM, Invitation.Kind.CREATE_TEAM}:
qs = qs.filter(kind=kind)
st = request.query_params.get("status")
if st in dict(Invitation.Status.choices):
qs = qs.filter(status=st)
search = (request.query_params.get("search") or "").strip()
if search:
qs = qs.filter(Q(code__icontains=search) | Q(team__name__icontains=search))
paginator = DefaultPagination()
page = paginator.paginate_queryset(qs, request)
return paginator.get_paginated_response(InvitationSerializer(page, many=True).data)
email = str(request.data.get("email") or "").strip()
invite = Invitation.objects.create(
kind=Invitation.Kind.CREATE_TEAM,
team=None,
email=email,
created_by=request.user,
)
log_admin_action(
request,
"invite.issue_create_team",
target_type="invitation",
target_id=invite.id,
target_name=invite.code,
after={"kind": invite.kind, "email": email},
)
return Response(InvitationSerializer(invite).data, status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_revoke_invitation(request, invite_id):
"""撤销一个待用邀请码(任意团队)。已用/已撤销/已过期则原样返回(幂等)。"""
invite = Invitation.objects.select_related("team").filter(id=invite_id).first()
if invite is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if invite.status == Invitation.Status.PENDING:
invite.status = Invitation.Status.REVOKED
invite.save(update_fields=["status", "updated_at"])
log_admin_action(
request,
"invite.revoke",
target_type="invitation",
target_id=invite.id,
target_name=invite.code,
)
return Response(InvitationSerializer(invite).data)
# ─────────────────────────── 团队管理 ───────────────────────────
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_teams(request):
"""列所有团队(跨团队,可按 status/search 过滤,分页)。"""
qs = _team_qs().order_by("-created_at")
st = request.query_params.get("status")
if st in dict(Team.Status.choices):
qs = qs.filter(status=st)
search = (request.query_params.get("search") or "").strip()
if search:
qs = qs.filter(name__icontains=search)
paginator = DefaultPagination()
page = paginator.paginate_queryset(qs, request)
return paginator.get_paginated_response(AdminTeamSerializer(page, many=True).data)
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_team_detail(request, team_id):
team = _team_qs().filter(id=team_id).first()
if team is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
members = team.members.select_related("user").order_by("created_at")
data = AdminTeamSerializer(team).data
data["members"] = AdminTeamMemberSerializer(members, many=True).data
return Response(data)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_team_toggle(request, team_id):
"""启停团队。团队停用后其成员登录会被拒(login 校验团队状态)。"""
team = Team.objects.filter(id=team_id).first()
if team is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
before = team.status
team.status = Team.Status.ACTIVE if team.status == Team.Status.DISABLED else Team.Status.DISABLED
team.save(update_fields=["status", "updated_at"])
log_admin_action(
request,
"team.toggle_status",
target_type="team",
target_id=team.id,
target_name=team.name,
before={"status": before},
after={"status": team.status},
)
return Response(AdminTeamSerializer(_team_qs().get(id=team.id)).data)
# ─────────────────────────── 用户管理 ───────────────────────────
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_users(request):
"""列所有用户(跨团队,可按 status/search 过滤,分页)。"""
qs = User.objects.prefetch_related("team_memberships__team").order_by("-date_joined")
st = request.query_params.get("status")
if st in dict(User.Status.choices):
qs = qs.filter(status=st)
search = (request.query_params.get("search") or "").strip()
if search:
qs = qs.filter(username__icontains=search)
paginator = DefaultPagination()
page = paginator.paginate_queryset(qs, request)
return paginator.get_paginated_response(AdminUserSerializer(page, many=True).data)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_user_toggle(request, user_id):
"""启停用户。停用即清 token 强制下线;不允许停用平台超管(防自锁)。"""
user = User.objects.filter(id=user_id).first()
if user is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if user.is_platform_admin:
return Response({"detail": "不能停用平台超管"}, status=status.HTTP_400_BAD_REQUEST)
before = user.status
user.status = User.Status.ACTIVE if user.status == User.Status.DISABLED else User.Status.DISABLED
user.save(update_fields=["status"])
if user.status == User.Status.DISABLED:
Token.objects.filter(user=user).delete()
log_admin_action(
request,
"user.toggle_status",
target_type="user",
target_id=user.id,
target_name=user.username,
before={"status": before},
after={"status": user.status},
)
return Response(AdminUserSerializer(user).data)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_user_reset_password(request, user_id):
"""平台超管强制改用户密码(改后清 token 强制重登)。"""
user = User.objects.filter(id=user_id).first()
if user is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
password = str(request.data.get("password") or "").strip()
if len(password) < 8:
return Response({"password": ["新密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(password)
user.save(update_fields=["password"])
Token.objects.filter(user=user).delete()
log_admin_action(
request,
"user.reset_password",
target_type="user",
target_id=user.id,
target_name=user.username,
)
return Response(status=status.HTTP_204_NO_CONTENT)
# ─────────────────────────── 质量词(平台单层)───────────────────────────
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_quality_words(request):
"""GET 列全部质量词(配置量小,不分页,前端按 stage 分组);POST 新增一条。"""
if request.method == "GET":
qs = QualityWord.objects.all().order_by("stage", "sort", "created_at")
return Response(QualityWordSerializer(qs, many=True).data)
serializer = QualityWordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
log_admin_action(
request,
"quality_word.create",
target_type="quality_word",
target_id=obj.id,
target_name=f"{obj.stage}:{obj.text}",
after={"stage": obj.stage, "text": obj.text},
)
return Response(QualityWordSerializer(obj).data, status=status.HTTP_201_CREATED)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsPlatformAdmin])
def admin_quality_word_detail(request, word_id):
obj = QualityWord.objects.filter(id=word_id).first()
if obj is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if request.method == "DELETE":
log_admin_action(
request,
"quality_word.delete",
target_type="quality_word",
target_id=obj.id,
target_name=f"{obj.stage}:{obj.text}",
before={"stage": obj.stage, "text": obj.text},
)
obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
before_text = obj.text
serializer = QualityWordSerializer(obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
log_admin_action(
request,
"quality_word.update",
target_type="quality_word",
target_id=obj.id,
target_name=f"{obj.stage}:{obj.text}",
before={"text": before_text},
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})