优化后台用户管理和发积分
This commit is contained in:
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user