优化后台用户管理和发积分

This commit is contained in:
Azmat@qq.com
2026-09-20 16:30:45 +08:00
parent fb3b2904ae
commit 690eb3d843
12 changed files with 470 additions and 22 deletions
+151 -8
View File
@@ -3,21 +3,23 @@
import logging
from decimal import Decimal, ROUND_HALF_UP
from django.db.models import Case, CharField, Count, F, Q, Value, When
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import Case, CharField, Count, F, Prefetch, Q, Value, When
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.models import Invitation, Team, TeamMember, User
from apps.accounts.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer
from apps.ai.model_catalog import invalidate_model_catalog_cache
from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
from apps.assets.models import Asset
from apps.assets.review import poll_asset_review, submit_asset_for_review
from apps.billing.models import BillingConfig, CreditLedger, QuotaPolicy
from apps.billing.models import BillingConfig, CreditAccount, CreditLedger, QuotaPolicy
from apps.billing.pricing import get_billing_config, invalidate_billing_config_cache
from apps.billing.services.ledger import adjust_credit
from apps.common.pagination import DefaultPagination
@@ -63,6 +65,48 @@ def _team_qs():
)
def _admin_user_qs():
"""用户列表:把成员关系连同团队钱包一次性 prefetch 出来,序列化器才能免查询地拍出余额。
team_member_count 用来判断钱包是不是多人共享池(决定发积分弹窗的提示文案)。"""
memberships = (
TeamMember.objects.select_related("team", "team__credit_account")
.annotate(team_member_count=Count("team__members", distinct=True))
.order_by("created_at")
)
return User.objects.prefetch_related(Prefetch("team_memberships", queryset=memberships))
def _wallet_team(user):
"""用户的钱包团队:第一个 active 成员关系(与 get_current_team 同口径)。无团队返回 None。"""
membership = (
user.team_memberships.filter(status=TeamMember.Status.ACTIVE)
.select_related("team")
.order_by("created_at")
.first()
)
return membership.team if membership else None
def _parse_points(raw, *, field: str, allow_negative: bool):
"""解析后台传来的积分数量。积分全站按整数流通,这里统一 HALF_UP 取整(与计价引擎同口径)。
返回 (Decimal, None) 或 (None, Response)。"""
from decimal import InvalidOperation
try:
value = Decimal(str(raw))
except (InvalidOperation, TypeError, ValueError):
return None, Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST)
# Decimal("NaN")/("Infinity") 构造不抛,进库才炸 500 → 显式拦
if not value.is_finite():
return None, Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST)
value = value.quantize(Decimal("1"), rounding=ROUND_HALF_UP)
if not allow_negative and value < 0:
return None, Response({field: ["积分不能为负"]}, status=status.HTTP_400_BAD_REQUEST)
if abs(value) > Decimal("10000000"):
return None, Response({field: ["单次不能超过 1,000 万积分"]}, status=status.HTTP_400_BAD_REQUEST)
return value, None
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_invitations(request):
@@ -127,8 +171,11 @@ def admin_revoke_invitation(request, invite_id):
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_teams(request):
"""列所有团队(跨团队,可按 status/search 过滤,分页)。"""
"""列所有团队(跨团队,可按 status/search 过滤,分页)。
默认隐藏个人团队(后台直建用户的钱包容器,只在用户页以「余额」形态出现),?include_personal=1 可看全。"""
qs = _team_qs().order_by("-created_at")
if str(request.query_params.get("include_personal") or "") not in {"1", "true"}:
qs = qs.filter(is_personal=False)
st = request.query_params.get("status")
if st in dict(Team.Status.choices):
qs = qs.filter(status=st)
@@ -209,11 +256,14 @@ def admin_team_pricing(request, team_id):
return Response(AdminTeamSerializer(_team_qs().get(id=team.id)).data)
@api_view(["GET"])
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_users(request):
"""列所有用户(跨团队,可按 status/search 过滤,分页)。"""
qs = User.objects.prefetch_related("team_memberships__team").order_by("-date_joined")
"""GET 列所有用户(可按 status/search 过滤,分页,每行带钱包余额);
POST 平台超管直接开户(不走邀请码),顺带配一个个人团队当钱包。"""
if request.method == "POST":
return _admin_create_user(request)
qs = _admin_user_qs().order_by("-date_joined")
st = request.query_params.get("status")
if st in dict(User.Status.choices):
qs = qs.filter(status=st)
@@ -225,6 +275,99 @@ def admin_users(request):
return paginator.get_paginated_response(AdminUserSerializer(page, many=True).data)
def _admin_create_user(request):
"""后台直建用户:{username, password, initial_credits?}。
积分账户是 OneToOne 挂 Team 的,所以这里给新用户配一个 is_personal 的一人团队当专属钱包 ——
计费链路(预留/实扣/流水/额度)一行不用改,后台却能按「用户」视角管人和钱。
后续要恢复多人协作,把人拉进同一个团队即可,不需要迁数据。"""
username = str(request.data.get("username") or "").strip()
password = str(request.data.get("password") or "").strip()
if not username:
return Response({"username": ["请填写用户名"]}, status=status.HTTP_400_BAD_REQUEST)
if len(username) > 150:
return Response({"username": ["用户名不能超过 150 字"]}, status=status.HTTP_400_BAD_REQUEST)
try:
for validator in User._meta.get_field("username").validators:
validator(username)
except DjangoValidationError as exc:
return Response({"username": list(exc.messages)}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({"username": ["该用户名已存在"]}, status=status.HTTP_400_BAD_REQUEST)
if len(password) < 8:
return Response({"password": ["密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST)
initial, err = _parse_points(request.data.get("initial_credits") or 0, field="initial_credits", allow_negative=False)
if err is not None:
return err
with transaction.atomic():
user = User.objects.create_user(username=username, password=password)
team = Team.objects.create(name=username, owner=user, is_personal=True)
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(team=team, balance=initial)
if initial > 0:
# 开户额度必须进流水,否则这笔钱在账单里查无凭证、无法对账(与注册赠送同口径)
CreditLedger.objects.create(
team=team,
user=request.user,
ledger_type=CreditLedger.Type.RECHARGE,
amount=initial,
balance_after=initial,
reason="后台开户初始积分",
metadata={"kind": "admin_create_user", "target_user_id": str(user.id)},
)
log_admin_action(
request,
"user.create",
target_type="user",
target_id=user.id,
target_name=user.username,
after={"initial_credits": str(initial), "wallet_team": str(team.id)},
)
return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data, status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_user_credits(request, user_id):
"""给用户发/扣积分:{amount, reason?}。amount 可正可负,落 ADJUSTMENT 流水。
钱实际进的是这个用户的钱包团队 —— 个人团队即专属钱包;老用户若在多人团队里,
这笔会进团队共享池(前端弹窗已明确提示,后端流水的 metadata 记下目标用户备查)。"""
user = User.objects.filter(id=user_id).first()
if user is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
team = _wallet_team(user)
if team is None:
return Response({"detail": "该用户没有可用的积分钱包(无生效团队)"}, status=status.HTTP_400_BAD_REQUEST)
amount, err = _parse_points(request.data.get("amount"), field="amount", allow_negative=True)
if err is not None:
return err
if amount == 0:
return Response({"amount": ["调额金额不能为 0"]}, status=status.HTTP_400_BAD_REQUEST)
reason = str(request.data.get("reason") or "").strip()
try:
ledger = adjust_credit(
team=team,
amount=amount,
reason=reason or f"平台给 {user.username} 调额",
operator=request.user,
metadata={"target_user_id": str(user.id), "target_username": user.username},
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
log_admin_action(
request,
"user.credit_adjust",
target_type="user",
target_id=user.id,
target_name=user.username,
after={"amount": str(amount), "balance_after": str(ledger.balance_after), "team": str(team.id), "reason": reason},
)
return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data, status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_user_toggle(request, user_id):
@@ -248,7 +391,7 @@ def admin_user_toggle(request, user_id):
before={"status": before},
after={"status": user.status},
)
return Response(AdminUserSerializer(user).data)
return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data)
@api_view(["POST"])