feat(admin): Phase 1 注册邀请码逻辑 — 码必填 + kind 分流 + validate-invite + 注册页码优先重做
后端:Invitation.kind(join_team/create_team)+ team 可空 + migration;RegisterSerializer 邀请码必填, create_team 开新团队当 owner+发试用额度、join_team 幂等入伙不重复发额度,删「空码开团队」; validate-invite 免登录验码端点;团管发码恒 join_team;InvitationSerializer 增 kind/team_name。 前端:注册页码优先(验过才展开;create_team 填团队名 / join_team 只读团队名,不暴露团队列表)、 邀请链接 ?invite= 自动验码、api.validateInvite、验码行/状态提示样式(仅 token)。 测试:accounts 22/22 单测过(RegisterInviteCodeTests + ValidateInviteTests); 无头 e2e _admin-p1.mjs 全断言过 + 0 console error;tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
443023a1a9
commit
3f8547564c
@@ -62,8 +62,9 @@ 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)
|
||||
# 邀请码必填(本期定调):create_team 码 → 开新团队当 owner;join_team 码 → 加入该团队当成员。
|
||||
# 取消「空码 = 任何人开新团队」。认证定调:不用邮箱注册,username 为主标识。
|
||||
invite_code = serializers.CharField(max_length=32, required=True, allow_blank=False)
|
||||
|
||||
def validate_username(self, value):
|
||||
if User.objects.filter(username=value).exists():
|
||||
@@ -73,7 +74,7 @@ class RegisterSerializer(serializers.Serializer):
|
||||
def validate_invite_code(self, value):
|
||||
code = (value or "").strip().upper()
|
||||
if not code:
|
||||
return ""
|
||||
raise serializers.ValidationError("请填写邀请码")
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Invitation
|
||||
@@ -98,38 +99,20 @@ class RegisterSerializer(serializers.Serializer):
|
||||
|
||||
invite_code = (validated_data.get("invite_code") or "").strip().upper()
|
||||
with transaction.atomic():
|
||||
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": "邀请码已失效"})
|
||||
|
||||
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 + 发试用额度(原逻辑)
|
||||
|
||||
if inv.kind == Invitation.Kind.CREATE_TEAM:
|
||||
# 开团队码:开新团队 + 当 owner + 发试用额度(原「无码开团队」逻辑,改为凭 create_team 码触发)
|
||||
team = Team.objects.create(
|
||||
name=validated_data.get("team_name") or f"{user.username}'s Team",
|
||||
name=(validated_data.get("team_name") or "").strip() or f"{user.username}'s Team",
|
||||
owner=user,
|
||||
)
|
||||
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
|
||||
@@ -146,18 +129,45 @@ class RegisterSerializer(serializers.Serializer):
|
||||
reason="新用户试用额度赠送",
|
||||
metadata={"kind": "trial_grant"},
|
||||
)
|
||||
# 回填邀请所属团队,便于审计「这个开团队码开出了哪个团队」
|
||||
inv.team = team
|
||||
else:
|
||||
# join_team 码:幂等加入已有团队当成员(避开 unique_together 撞 IntegrityError)、
|
||||
# 整段跳过 CreditAccount + ledger(团队级 OneToOne 已存在,二次建必报错、额度也不重发)。
|
||||
team = inv.team
|
||||
if team is None:
|
||||
raise serializers.ValidationError({"invite_code": "邀请码未绑定团队"})
|
||||
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", "team", "updated_at"])
|
||||
return {"user": user, "team": team}
|
||||
|
||||
|
||||
class InvitationSerializer(serializers.ModelSerializer):
|
||||
used_by_username = serializers.CharField(source="used_by.username", read_only=True, default=None)
|
||||
team_name = serializers.CharField(source="team.name", 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",
|
||||
"id", "code", "kind", "role", "email", "monthly_credit_limit", "status",
|
||||
"team", "team_name", "expires_at", "used_by", "used_by_username",
|
||||
"used_at", "created_at", "register_url",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
Reference in New Issue
Block a user