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"] read_only_fields = ["id", "status"] 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)