优化后台用户管理和发积分
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0009_team_price_multiplier"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="team",
|
||||
name="is_personal",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -40,6 +40,9 @@ class Team(TimeStampedModel):
|
||||
# 视频类按实结算用**下单时的快照系数**(request_payload.price_multiplier),中途改价不影响在途任务。
|
||||
# 仅平台超管可改(admin 团队定价端点,0.10~10.00 边界)。
|
||||
price_multiplier = models.DecimalField(max_digits=5, decimal_places=2, default=1)
|
||||
# 个人团队:后台直建用户时自动配的一人队,只当这个用户的积分钱包(积分账户是 OneToOne 挂 Team 的,
|
||||
# 所以「给用户发积分」在数据层仍然是给团队发)。团队体系一行没改,只是这类队不进后台团队列表。
|
||||
is_personal = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@@ -98,20 +98,57 @@ class AdminTeamMemberSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
def wallet_membership(user):
|
||||
"""用户的钱包归属:第一个 active 成员关系(与 common.api.get_current_team 同口径,消费从这个团队扣)。
|
||||
列表页依赖 prefetch 好的 team_memberships,这里只在内存里挑,不要改成 .filter() 否则每行多一次查询。"""
|
||||
actives = [m for m in user.team_memberships.all() if m.status == TeamMember.Status.ACTIVE]
|
||||
return min(actives, key=lambda m: m.created_at) if actives else None
|
||||
|
||||
|
||||
class AdminUserSerializer(serializers.ModelSerializer):
|
||||
teams = serializers.SerializerMethodField()
|
||||
# 后台按「用户」视角管积分,但账户实际挂在团队上。这里把钱包团队拍平给前端:
|
||||
# wallet_shared=True 表示这是个多人共享池(老用户),发积分弹窗要明确提示钱会进整个团队。
|
||||
balance = serializers.SerializerMethodField()
|
||||
wallet_team = serializers.SerializerMethodField()
|
||||
wallet_team_name = serializers.SerializerMethodField()
|
||||
wallet_shared = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "status", "is_platform_admin", "date_joined", "teams"]
|
||||
fields = [
|
||||
"id", "username", "status", "is_platform_admin", "date_joined", "teams",
|
||||
"balance", "wallet_team", "wallet_team_name", "wallet_shared",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_teams(self, obj):
|
||||
return [
|
||||
{"team_id": str(m.team_id), "team_name": m.team.name, "role": m.role}
|
||||
for m in obj.team_memberships.select_related("team").all()
|
||||
for m in obj.team_memberships.all()
|
||||
if not m.team.is_personal # 个人团队是钱包实现细节,不当「所属团队」展示
|
||||
]
|
||||
|
||||
def get_balance(self, obj):
|
||||
m = wallet_membership(obj)
|
||||
acct = getattr(m.team, "credit_account", None) if m else None
|
||||
return str(acct.balance) if acct is not None else "0"
|
||||
|
||||
def get_wallet_team(self, obj):
|
||||
m = wallet_membership(obj)
|
||||
return str(m.team_id) if m else None
|
||||
|
||||
def get_wallet_team_name(self, obj):
|
||||
m = wallet_membership(obj)
|
||||
return m.team.name if m else None
|
||||
|
||||
def get_wallet_shared(self, obj):
|
||||
m = wallet_membership(obj)
|
||||
if m is None:
|
||||
return False
|
||||
count = getattr(m, "team_member_count", None)
|
||||
return bool((count if count is not None else m.team.members.count()) > 1)
|
||||
|
||||
|
||||
class AdminTaskSerializer(serializers.ModelSerializer):
|
||||
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework.test import APIClient
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import AdminAuditLog, Invitation, Team, TeamMember, User
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
|
||||
|
||||
class AdminInvitationApiTests(TestCase):
|
||||
@@ -850,3 +851,102 @@ class AdminGovernanceTests(TestCase):
|
||||
self.assertGreaterEqual(checks["teams_without_account"], 1) # GovTeam 无 credit_account
|
||||
self.assertIn("totals", r.data)
|
||||
self.assertGreaterEqual(r.data["totals"]["projects"], 2)
|
||||
|
||||
|
||||
class AdminUserProvisioningTests(TestCase):
|
||||
"""后台直建用户 + 按用户发积分。积分账户仍挂 Team(计费链路不变),
|
||||
新用户配一个 is_personal 的一人团队当专属钱包;老的多人团队用户发积分则进共享池。"""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(username="padmin", password="x", is_platform_admin=True)
|
||||
self.normal = User.objects.create_user(username="normal", password="x")
|
||||
self.team = Team.objects.create(name="Legacy Team", owner=self.normal)
|
||||
TeamMember.objects.create(team=self.team, user=self.normal, role=TeamMember.Role.OWNER)
|
||||
self.mate = User.objects.create_user(username="mate", password="x")
|
||||
TeamMember.objects.create(team=self.team, user=self.mate, role=TeamMember.Role.MEMBER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("100"))
|
||||
self.ac = APIClient()
|
||||
self.ac.force_authenticate(self.admin)
|
||||
self.nc = APIClient()
|
||||
self.nc.force_authenticate(self.normal)
|
||||
|
||||
def _create(self, **overrides):
|
||||
payload = {"username": "created-user", "password": "strong-pass-1", "initial_credits": "500"}
|
||||
payload.update(overrides)
|
||||
return self.ac.post("/api/admin/users/", payload, format="json")
|
||||
|
||||
def test_create_user_provisions_personal_wallet(self):
|
||||
r = self._create()
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.assertEqual(r.data["balance"], "500.0000")
|
||||
self.assertFalse(r.data["wallet_shared"])
|
||||
self.assertEqual(r.data["teams"], []) # 个人团队是钱包实现细节,不当「所属团队」露出
|
||||
|
||||
user = User.objects.get(username="created-user")
|
||||
team = Team.objects.get(owner=user)
|
||||
self.assertTrue(team.is_personal)
|
||||
self.assertEqual(team.credit_account.balance, Decimal("500"))
|
||||
ledger = CreditLedger.objects.get(team=team, ledger_type=CreditLedger.Type.RECHARGE)
|
||||
self.assertEqual(ledger.metadata["target_user_id"], str(user.id))
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="user.create").exists())
|
||||
|
||||
def test_created_user_can_login(self):
|
||||
self._create()
|
||||
r = APIClient().post("/api/auth/login/", {"username": "created-user", "password": "strong-pass-1"}, format="json")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_create_user_validation_and_permission(self):
|
||||
self.assertEqual(self.nc.post("/api/admin/users/", {}, format="json").status_code, 403)
|
||||
self.assertEqual(self._create(username="").status_code, 400)
|
||||
self.assertEqual(self._create(password="short").status_code, 400)
|
||||
self.assertEqual(self._create(initial_credits="-5").status_code, 400)
|
||||
self.assertEqual(self._create(username="normal").status_code, 400) # 重名
|
||||
self.assertEqual(User.objects.filter(username="created-user").count(), 0)
|
||||
|
||||
def test_create_user_without_initial_credits(self):
|
||||
r = self._create(initial_credits="")
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.assertEqual(r.data["balance"], "0.0000")
|
||||
team = Team.objects.get(owner__username="created-user")
|
||||
self.assertFalse(CreditLedger.objects.filter(team=team).exists()) # 0 积分不落空流水
|
||||
|
||||
def test_adjust_user_credit(self):
|
||||
user_id = self._create().data["id"]
|
||||
r = self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "250", "reason": "活动赠送"}, format="json")
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.assertEqual(r.data["balance"], "750.0000")
|
||||
ledger = CreditLedger.objects.filter(ledger_type=CreditLedger.Type.ADJUSTMENT).latest("created_at")
|
||||
self.assertEqual(ledger.reason, "活动赠送")
|
||||
self.assertEqual(ledger.metadata["target_user_id"], user_id)
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="user.credit_adjust").exists())
|
||||
|
||||
# 扣减合法,但不能扣成负数
|
||||
self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "-50"}, format="json").data["balance"], "700.0000")
|
||||
self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "-9999"}, format="json").status_code, 400)
|
||||
self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "0"}, format="json").status_code, 400)
|
||||
self.assertEqual(self.nc.post(f"/api/admin/users/{user_id}/credits/", {"amount": "1"}, format="json").status_code, 403)
|
||||
|
||||
def test_legacy_multi_member_user_uses_shared_pool(self):
|
||||
r = self.ac.get("/api/admin/users/?search=mate")
|
||||
row = next(u for u in r.data["results"] if u["username"] == "mate")
|
||||
self.assertEqual(row["balance"], "100.0000")
|
||||
self.assertTrue(row["wallet_shared"])
|
||||
self.assertEqual(row["wallet_team_name"], "Legacy Team")
|
||||
|
||||
self.ac.post(f"/api/admin/users/{row['id']}/credits/", {"amount": "40"}, format="json")
|
||||
self.team.credit_account.refresh_from_db()
|
||||
self.assertEqual(self.team.credit_account.balance, Decimal("140")) # 进的是团队共享池
|
||||
|
||||
def test_platform_admin_without_team_has_no_wallet(self):
|
||||
r = self.ac.get("/api/admin/users/?search=padmin")
|
||||
row = next(u for u in r.data["results"] if u["username"] == "padmin")
|
||||
self.assertIsNone(row["wallet_team"])
|
||||
self.assertEqual(row["balance"], "0")
|
||||
|
||||
def test_personal_teams_hidden_from_team_list(self):
|
||||
self._create()
|
||||
names = {t["name"] for t in self.ac.get("/api/admin/teams/").data["results"]}
|
||||
self.assertIn("Legacy Team", names)
|
||||
self.assertNotIn("created-user", names)
|
||||
all_names = {t["name"] for t in self.ac.get("/api/admin/teams/?include_personal=1").data["results"]}
|
||||
self.assertIn("created-user", all_names)
|
||||
|
||||
@@ -31,6 +31,7 @@ from .views import (
|
||||
admin_team_pricing,
|
||||
admin_team_toggle,
|
||||
admin_teams,
|
||||
admin_user_credits,
|
||||
admin_user_reset_password,
|
||||
admin_user_toggle,
|
||||
admin_users,
|
||||
@@ -47,6 +48,7 @@ urlpatterns = [
|
||||
path("users/", admin_users, name="admin-users"),
|
||||
path("users/<uuid:user_id>/toggle/", admin_user_toggle, name="admin-user-toggle"),
|
||||
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
|
||||
path("users/<uuid:user_id>/credits/", admin_user_credits, name="admin-user-credits"),
|
||||
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("prompt-templates/", admin_prompt_templates, name="admin-prompt-templates"),
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -113,7 +113,7 @@ def _enforce_quota_policy(*, team, project, amount: Decimal) -> None:
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None) -> CreditLedger:
|
||||
def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None, metadata: dict | None = None) -> CreditLedger:
|
||||
"""平台超管手动调额(争议 / 补偿)。amount 可正可负;落 ADJUSTMENT 流水并更新余额。
|
||||
调额后余额不得为负(否则拒绝)。"""
|
||||
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
|
||||
@@ -129,7 +129,7 @@ def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None) ->
|
||||
amount=amount,
|
||||
balance_after=new_balance,
|
||||
reason=reason or "平台手动调额",
|
||||
metadata={"kind": "admin_adjust"},
|
||||
metadata={"kind": "admin_adjust", **(metadata or {})},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1216,6 +1216,12 @@ export const adminApi = {
|
||||
const q = qs.toString();
|
||||
return request<Paginated<AdminUser>>(`/api/admin/users/${q ? `?${q}` : ""}`);
|
||||
},
|
||||
createUser(payload: { username: string; password: string; initial_credits?: string }) {
|
||||
return request<AdminUser>("/api/admin/users/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
adjustUserCredit(id: string, payload: { amount: string; reason?: string }) {
|
||||
return request<AdminUser>(`/api/admin/users/${id}/credits/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
toggleUser(id: string) {
|
||||
return request<AdminUser>(`/api/admin/users/${id}/toggle/`, { method: "POST" });
|
||||
},
|
||||
|
||||
@@ -1238,6 +1238,12 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
.field-label .req { color: var(--accent-crimson); margin-left: 2px; }
|
||||
.field-hint { font-size: 12px; color: var(--black-alpha-48); }
|
||||
.field-hint.is-error { color: var(--accent-crimson); }
|
||||
/* 标签内联注解(取值范围 / 单位 / 可留空):跟 .req 一样附在 .field-label 里,mono 灰弱化 */
|
||||
.field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); }
|
||||
/* 数字输入:去掉浏览器上下箭头 + 等宽数字,避免输入时字宽跳动 */
|
||||
.num-input::-webkit-outer-spin-button,
|
||||
.num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.num-input { -moz-appearance: textfield; font-variant-numeric: tabular-nums; }
|
||||
.input, .textarea, .select {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Building, KeyRound, Percent, X } from "lucide-react";
|
||||
import { Building, Coins, KeyRound, Percent, UserPlus, X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { Pager } from "../../components/pager";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
@@ -247,6 +247,17 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
const [pwdTarget, setPwdTarget] = useState<AdminUser | null>(null);
|
||||
const [pwd, setPwd] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
// 直接开户:不发邀请码,后端建号时自动配一个个人团队当钱包(团队体系保留,后台只按用户视角管)
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newPwd, setNewPwd] = useState("");
|
||||
const [newCredits, setNewCredits] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
// 发积分:金额可正可负,落一条调额流水
|
||||
const [creditTarget, setCreditTarget] = useState<AdminUser | null>(null);
|
||||
const [creditAmt, setCreditAmt] = useState("");
|
||||
const [creditReason, setCreditReason] = useState("");
|
||||
const [creditSaving, setCreditSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -293,12 +304,68 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
}
|
||||
}
|
||||
|
||||
function closeCreate() {
|
||||
setCreateOpen(false);
|
||||
setNewName("");
|
||||
setNewPwd("");
|
||||
setNewCredits("");
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
if (creating) return;
|
||||
const name = newName.trim();
|
||||
if (!name) { notify("error", "请填写用户名"); return; }
|
||||
if (newPwd.trim().length < 8) { notify("error", "密码至少 8 位"); return; }
|
||||
const credits = newCredits.trim();
|
||||
if (credits && (!Number.isFinite(Number(credits)) || Number(credits) < 0)) { notify("error", "初始积分需为非负数"); return; }
|
||||
setCreating(true);
|
||||
try {
|
||||
await adminApi.createUser({ username: name, password: newPwd.trim(), initial_credits: credits || "0" });
|
||||
notify("success", `已创建用户 ${name}`);
|
||||
closeCreate();
|
||||
setPage(1);
|
||||
await load();
|
||||
} catch (e) {
|
||||
notify("error", e instanceof Error ? e.message : "创建失败");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCredit() {
|
||||
setCreditTarget(null);
|
||||
setCreditAmt("");
|
||||
setCreditReason("");
|
||||
}
|
||||
|
||||
async function doCredit() {
|
||||
if (!creditTarget || creditSaving) return;
|
||||
const amt = Number(creditAmt);
|
||||
if (!Number.isFinite(amt) || amt === 0) { notify("error", "请填写非 0 的积分数量"); return; }
|
||||
setCreditSaving(true);
|
||||
try {
|
||||
await adminApi.adjustUserCredit(creditTarget.id, { amount: creditAmt.trim(), reason: creditReason.trim() });
|
||||
notify("success", `已为 ${creditTarget.username} ${amt > 0 ? "发放" : "扣减"} ${Math.abs(amt)} 积分`);
|
||||
closeCredit();
|
||||
await load();
|
||||
} catch (e) {
|
||||
notify("error", e instanceof Error ? e.message : "调额失败");
|
||||
} finally {
|
||||
setCreditSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>用户</h1>
|
||||
<div className="sub"><span className="mono">{count} 个用户</span> · 跨团队 · 启停 / 强制改密</div>
|
||||
<div className="sub"><span className="mono">{count} 个用户</span> · 直接开户 · 发积分 / 启停 / 强制改密</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary" type="button" onClick={() => setCreateOpen(true)}>
|
||||
<UserPlus size={15} />新建用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -319,7 +386,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
<div className="admin-table-wrap">
|
||||
<table className="t admin-table">
|
||||
<thead>
|
||||
<tr><th>用户名</th><th>所属团队</th><th>状态</th><th>注册时间</th><th className="col-actions">操作</th></tr>
|
||||
<tr><th>用户名</th><th>积分余额</th><th>所属团队</th><th>状态</th><th>注册时间</th><th className="col-actions">操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
@@ -328,6 +395,9 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
{u.username}
|
||||
{u.is_platform_admin && <span className="pill info admin-inline-pill"><span className="dot" />超管</span>}
|
||||
</td>
|
||||
<td className="num mono">
|
||||
{u.wallet_team ? `${pts(u.balance)} 积分` : <span className="muted">—</span>}
|
||||
</td>
|
||||
<td>
|
||||
{u.teams.length === 0
|
||||
? <span className="muted">—</span>
|
||||
@@ -340,6 +410,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
<span className="muted">—</span>
|
||||
) : (
|
||||
<>
|
||||
{u.wallet_team && <button className="btn btn-sm btn-ghost" type="button" onClick={() => setCreditTarget(u)}>发积分</button>}
|
||||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setPwdTarget(u)}>改密</button>
|
||||
<button className={`btn btn-sm btn-ghost${u.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggle(u)}>
|
||||
{u.status === "active" ? "停用" : "启用"}
|
||||
@@ -355,6 +426,71 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createOpen && (
|
||||
<div className="modal-bg show">
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-label="新建用户">
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><UserPlus size={16} /></div>
|
||||
<div className="ti">新建用户</div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={closeCreate}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
<p className="admin-modal-desc">直接开户,不用发邀请码。建好即可用该用户名密码登录,并拥有一个独立的积分钱包。</p>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="new-username">用户名 <span className="req">*</span></label>
|
||||
<input id="new-username" className="input" type="text" placeholder="登录用的用户名" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="new-password">初始密码 <span className="req">*</span> <span className="lbl-note">(至少 8 位)</span></label>
|
||||
<input id="new-password" className="input" type="text" placeholder="告知用户后建议其自行修改" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="new-credits">初始积分 <span className="lbl-note">(可留空,默认 0)</span></label>
|
||||
<input id="new-credits" className="input num-input" type="number" inputMode="numeric" min="0" placeholder="例: 1000" value={newCredits} onChange={(e) => setNewCredits(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-f">
|
||||
<button className="btn" type="button" onClick={closeCreate}>取消</button>
|
||||
<button className="btn btn-primary" type="button" disabled={creating} onClick={() => void doCreate()}>{creating ? "创建中…" : "创建用户"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creditTarget && (
|
||||
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) closeCredit(); }}>
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-label="发积分">
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h">
|
||||
<div className="ic-m"><Coins size={16} /></div>
|
||||
<div className="ti">发积分<span>{creditTarget.username}</span></div>
|
||||
<button className="x modal-x" type="button" aria-label="关闭" onClick={closeCredit}><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
<p className="admin-modal-desc">
|
||||
当前余额 <b style={{ color: "var(--accent-black)" }}>{pts(creditTarget.balance)} 积分</b>。填正数为发放,负数为扣减;扣减后余额不得为负。会落一条调额流水。
|
||||
</p>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="credit-amount">积分数量 <span className="req">*</span> <span className="lbl-note">(正数发放 / 负数扣减)</span></label>
|
||||
<input id="credit-amount" className="input num-input" type="number" inputMode="numeric" placeholder="例: 1000 或 -200" value={creditAmt} onChange={(e) => setCreditAmt(e.target.value)} />
|
||||
{creditTarget.wallet_shared && (
|
||||
<div className="field-hint">⚠ 该用户属于多人团队「{creditTarget.wallet_team_name}」,这笔积分会进团队共享池,同队成员都能使用。</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="credit-reason">备注 <span className="lbl-note">(选填,写进流水便于对账)</span></label>
|
||||
<input id="credit-reason" className="input" type="text" placeholder="例: 活动赠送 / 争议补偿" value={creditReason} onChange={(e) => setCreditReason(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-f">
|
||||
<button className="btn" type="button" onClick={closeCredit}>取消</button>
|
||||
<button className="btn btn-primary" type="button" disabled={creditSaving} onClick={() => void doCredit()}>{creditSaving ? "提交中…" : "确认调额"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pwdTarget && (
|
||||
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setPwdTarget(null); }}>
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-label="重置密码">
|
||||
|
||||
@@ -389,19 +389,13 @@
|
||||
绝不复用 styles.css 旧暖米调色板的同名类(.role-choice/.cred-card 等)。
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* 共用:字段必填星标 / 标签副注 */
|
||||
.invite-modal .field-label .req { color: var(--accent-crimson); margin-left: 2px; }
|
||||
.invite-modal .field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); }
|
||||
/* .req / .lbl-note / .num-input 已提到 design-restraint.css 作共享类,这里不再重写 */
|
||||
|
||||
/* 共用:输入框 + 行内随机生成按钮 */
|
||||
.invite-modal .input-with-gen { display: flex; gap: 8px; }
|
||||
.invite-modal .input-with-gen .input { flex: 1; min-width: 0; }
|
||||
.invite-modal .input-with-gen .btn-sm { height: 38px; padding: 0 12px; flex: 0 0 auto; }
|
||||
.invite-modal .pw-input { letter-spacing: .04em; }
|
||||
/* 数字输入去掉 spinner(对齐设计稿) */
|
||||
.invite-modal .num-input::-webkit-outer-spin-button,
|
||||
.invite-modal .num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.invite-modal .num-input { -moz-appearance: textfield; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ─── 角色双卡(创建账户 / 编辑成员共用)─── */
|
||||
.invite-modal .role-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
|
||||
@@ -83,6 +83,11 @@ export type AdminUser = {
|
||||
is_platform_admin: boolean;
|
||||
date_joined: string;
|
||||
teams: { team_id: string; team_name: string; role: string }[];
|
||||
// 钱包:积分账户挂在团队上,后台按用户视角拍平。wallet_shared=true 表示这是多人共享池
|
||||
balance: string;
|
||||
wallet_team: string | null;
|
||||
wallet_team_name: string | null;
|
||||
wallet_shared: boolean;
|
||||
};
|
||||
export type AdminQualityWord = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user