feat(core): Wave 3.5 邀请制后端 — Invitation 模型 + 端点 + register 改造(只生成迁移不apply)
- Invitation 模型:team/code(TEAM-XXXX-XXXX唯一)/role/email/月额度/created_by/expires_at(7天)/ used_by/used_at/status(pending/used/revoked/expired);迁移 accounts/0003(未 apply 远程库,rule 3) - RegisterSerializer 加 invite_code:有效码→幂等 get_or_create 成员+标used+跳过额度发放; 无码→开新团队当 owner+试用额度(原逻辑);邀请 role 不许 OWNER 降级 ADMIN;不存在/已用/过期→400 - 端点 /api/auth/team/invitations/(GET列/POST生成)+ <id>/revoke/;权限复用 can_manage_team(超管/团管) 验证: django check 0;apps.accounts 测试 5/5 OK —— 含「凭码加入同团队/角色额度对/不重复发额度/ 未知码400/普通成员生成403」。认证定调:username 为主标识、不用邮箱注册。 ⚠️ Invitation 迁移需用户低峰 apply 到远程 MySQL,前端生成码 e2e 才能跑(逻辑已被测试证)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9643f9ba5e
commit
ba855cc219
@@ -2,7 +2,7 @@ from rest_framework import serializers
|
||||
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
|
||||
from .models import LoginSession, Team, TeamMember, User, UserPreference
|
||||
from .models import Invitation, LoginSession, Team, TeamMember, User, UserPreference
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@@ -59,45 +59,109 @@ class RegisterSerializer(serializers.Serializer):
|
||||
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", ""),
|
||||
)
|
||||
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(
|
||||
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,
|
||||
ledger_type=CreditLedger.Type.RECHARGE,
|
||||
amount=trial,
|
||||
balance_after=trial,
|
||||
reason="新用户试用额度赠送",
|
||||
metadata={"kind": "trial_grant"},
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user