Files
yingqing/core/backend/apps/accounts/serializers.py
T
seaislee1209andClaude Opus 4.8 443023a1a9 feat(admin): Phase 0 平台超管基础 — is_platform_admin + IsPlatformAdmin + create_platform_admin(admin/admin123) + AdminAuditLog + Admin 后台外壳与路由 gating
后端:User.is_platform_admin + migration;权限类 IsPlatformAdmin;管理命令建 admin/admin123(幂等);
AdminAuditLog 模型 + log_admin_action() helper;me/login 对无团队超管优雅返回 team=null;UserSerializer 暴露标志。
前端:routes/admin 后台外壳(分组侧栏 + 概览 + 占位)、/admin 路由解析与 gating(超管直落、非超管纠回)、
侧栏平台入口、admin-page.css(仅 token)、IconKitSvg 补图标。
测试:accounts 11/11 单测过;无头 e2e _admin-p0.mjs 全断言过 + 0 console error;tsc+build 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:49:34 +08:00

171 lines
7.1 KiB
Python

from rest_framework import serializers
from apps.billing.models import CreditAccount, CreditLedger
from .models import Invitation, LoginSession, Team, TeamMember, User, UserPreference
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = [
"id", "username", "first_name", "last_name", "email", "phone",
"avatar_url", "status", "is_platform_admin",
]
read_only_fields = ["id", "status", "is_platform_admin"]
class UserPreferenceSerializer(serializers.ModelSerializer):
class Meta:
model = UserPreference
fields = ["notify", "two_factor_enabled", "creation_defaults", "display", "updated_at"]
read_only_fields = ["updated_at"]
class LoginSessionSerializer(serializers.ModelSerializer):
is_current = serializers.SerializerMethodField()
class Meta:
model = LoginSession
fields = ["id", "user_agent", "ip_address", "last_seen_at", "created_at", "is_current"]
read_only_fields = fields
def get_is_current(self, obj) -> bool:
ctx = self.context or {}
return bool(obj.ip_address and obj.ip_address == ctx.get("current_ip") and obj.user_agent == ctx.get("current_ua"))
class TeamSerializer(serializers.ModelSerializer):
class Meta:
model = Team
fields = ["id", "name", "status", "owner", "created_at", "updated_at"]
read_only_fields = ["id", "status", "owner", "created_at", "updated_at"]
class TeamMemberSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
# 本月已消费(自然月,按 CreditLedger CHARGE 流水按人聚合);由 view 经 context 注入 charged_map
month_charged = serializers.SerializerMethodField()
class Meta:
model = TeamMember
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit", "month_charged"]
read_only_fields = ["id", "team", "user", "status"]
def get_month_charged(self, obj):
charged_map = self.context.get("charged_map") or {}
return str(charged_map.get(obj.user_id, 0))
class RegisterSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
password = serializers.CharField(min_length=8, write_only=True)
email = serializers.EmailField(required=False, allow_blank=True)
team_name = serializers.CharField(max_length=128, required=False, allow_blank=True)
# 邀请码(可选):有效码 → 加入已有团队;空 → 开新团队当超管。认证定调:不用邮箱注册,username 为主标识。
invite_code = serializers.CharField(max_length=32, required=False, allow_blank=True)
def validate_username(self, value):
if User.objects.filter(username=value).exists():
raise serializers.ValidationError("username already exists")
return value
def validate_invite_code(self, value):
code = (value or "").strip().upper()
if not code:
return ""
from django.utils import timezone
from .models import Invitation
inv = Invitation.objects.filter(code=code).first()
if inv is None:
raise serializers.ValidationError("邀请码不存在")
if inv.status != Invitation.Status.PENDING:
raise serializers.ValidationError("邀请码已被使用或已撤销")
if inv.expires_at <= timezone.now():
raise serializers.ValidationError("邀请码已过期")
return code
def create(self, validated_data):
from decimal import Decimal
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from .models import Invitation
invite_code = (validated_data.get("invite_code") or "").strip().upper()
with transaction.atomic():
user = User.objects.create_user(
username=validated_data["username"],
password=validated_data["password"],
email=validated_data.get("email", ""),
)
if invite_code:
# 凭码加入已有团队:幂等加成员(避开 unique_together 撞 IntegrityError)、标邀请 used、
# 整段跳过 CreditAccount + ledger(团队级 OneToOne 已存在,二次建必报错、额度也不重发)。
inv = Invitation.objects.select_for_update().filter(code=invite_code).first()
if inv is None or inv.status != Invitation.Status.PENDING or inv.expires_at <= timezone.now():
raise serializers.ValidationError({"invite_code": "邀请码已失效"})
team = inv.team
role = inv.role
if role == TeamMember.Role.OWNER:
role = TeamMember.Role.ADMIN # 不允许 OWNER(Team.owner 唯一)
TeamMember.objects.get_or_create(
team=team,
user=user,
defaults={
"role": role,
"status": TeamMember.Status.ACTIVE,
"monthly_credit_limit": inv.monthly_credit_limit,
},
)
inv.status = Invitation.Status.USED
inv.used_by = user
inv.used_at = timezone.now()
inv.save(update_fields=["status", "used_by", "used_at", "updated_at"])
else:
# 无码:开新团队当 owner + 发试用额度(原逻辑)
team = Team.objects.create(
name=validated_data.get("team_name") or f"{user.username}'s Team",
owner=user,
)
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
trial = Decimal(str(settings.DEFAULT_TRIAL_CREDITS))
CreditAccount.objects.create(team=team, balance=trial)
# 开户赠送额度必须进流水,否则这笔钱在账单里查无凭证、无法对账(余额从第一条起就可追溯)
if trial > 0:
CreditLedger.objects.create(
team=team,
user=user,
ledger_type=CreditLedger.Type.RECHARGE,
amount=trial,
balance_after=trial,
reason="新用户试用额度赠送",
metadata={"kind": "trial_grant"},
)
return {"user": user, "team": team}
class InvitationSerializer(serializers.ModelSerializer):
used_by_username = serializers.CharField(source="used_by.username", read_only=True, default=None)
register_url = serializers.SerializerMethodField()
class Meta:
model = Invitation
fields = [
"id", "code", "role", "email", "monthly_credit_limit", "status",
"expires_at", "used_by", "used_by_username", "used_at", "created_at", "register_url",
]
read_only_fields = fields
def get_register_url(self, obj):
return f"/register?invite={obj.code}"
class LoginSerializer(serializers.Serializer):
username = serializers.CharField()
password = serializers.CharField(write_only=True)