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:
seaislee1209
2026-06-19 21:07:03 +08:00
co-authored by Claude Opus 4.8
parent 443023a1a9
commit 3f8547564c
12 changed files with 684 additions and 172 deletions
@@ -0,0 +1,24 @@
# Generated by Django 5.1.15 on 2026-06-19 12:54
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_user_is_platform_admin_adminauditlog'),
]
operations = [
migrations.AddField(
model_name='invitation',
name='kind',
field=models.CharField(choices=[('join_team', 'Join Team'), ('create_team', 'Create Team')], default='join_team', max_length=16),
),
migrations.AlterField(
model_name='invitation',
name='team',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='accounts.team'),
),
]
+11 -2
View File
@@ -118,7 +118,10 @@ def _default_invite_expiry():
class Invitation(TimeStampedModel):
"""团队邀请码:超管/团管生成,新用户凭码注册即加入该团队(幂等加成员、不重复发额度)。"""
"""邀请码 · 两类(kind):
- join_team(团管/超管发):绑定某团队,新用户凭码注册即加入该团队当成员(幂等、不重复发额度)。
- create_team(平台超管发):不绑团队,新用户凭码注册 = 开新团队 + 当 owner(发试用额度)。
注册必须有码(取消「空码开团队」),前端先验码再展开对应表单。"""
class Status(models.TextChoices):
PENDING = "pending", "Pending"
@@ -126,7 +129,13 @@ class Invitation(TimeStampedModel):
REVOKED = "revoked", "Revoked"
EXPIRED = "expired", "Expired"
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="invitations")
class Kind(models.TextChoices):
JOIN_TEAM = "join_team", "Join Team"
CREATE_TEAM = "create_team", "Create Team"
kind = models.CharField(max_length=16, choices=Kind.choices, default=Kind.JOIN_TEAM)
# join_team 必绑团队;create_team 开团队前无团队,故可空。
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="invitations", null=True, blank=True)
code = models.CharField(max_length=32, unique=True, default=_gen_invite_code)
role = models.CharField(max_length=24, choices=TeamMember.Role.choices, default=TeamMember.Role.MEMBER)
email = models.EmailField(blank=True)
+41 -31
View File
@@ -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
+153 -7
View File
@@ -4,10 +4,15 @@ from django.conf import settings
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.accounts.models import Invitation, Team, TeamMember, User
from apps.billing.models import CreditAccount, CreditLedger
def make_create_team_code():
"""铸一个 create_team 邀请码(平台超管发的「开团队码」)。本期注册必须有码。"""
return Invitation.objects.create(kind=Invitation.Kind.CREATE_TEAM).code
class AuthApiTests(TestCase):
def test_register_creates_user_team_member_credit_account_and_token(self):
client = APIClient()
@@ -18,6 +23,7 @@ class AuthApiTests(TestCase):
"password": "strong-password",
"email": "owner@example.com",
"team_name": "Launch Team",
"invite_code": make_create_team_code(),
},
format="json",
)
@@ -35,7 +41,12 @@ class AuthApiTests(TestCase):
client = APIClient()
client.post(
"/api/auth/register/",
{"username": "ledger-owner", "password": "strong-password", "team_name": "Ledger Team"},
{
"username": "ledger-owner",
"password": "strong-password",
"team_name": "Ledger Team",
"invite_code": make_create_team_code(),
},
format="json",
)
team = Team.objects.get(name="Ledger Team")
@@ -57,10 +68,8 @@ class InvitationFlowTests(TestCase):
)
def test_invite_join_same_team_no_duplicate_credits(self):
from apps.accounts.models import Invitation
owner_client = APIClient()
r = self._register(owner_client, "inv-owner", team_name="Owner Team")
r = self._register(owner_client, "inv-owner", team_name="Owner Team", invite_code=make_create_team_code())
self.assertEqual(r.status_code, 201)
team = Team.objects.get(name="Owner Team")
accounts_before = CreditAccount.objects.filter(team=team).count()
@@ -104,7 +113,7 @@ class InvitationFlowTests(TestCase):
def test_invite_generate_requires_manage_permission(self):
owner_client = APIClient()
ro = self._register(owner_client, "perm-owner", team_name="Perm Team")
ro = self._register(owner_client, "perm-owner", team_name="Perm Team", invite_code=make_create_team_code())
owner_client.credentials(HTTP_AUTHORIZATION=f"Token {ro.data['token']}")
gen = owner_client.post("/api/auth/team/invitations/", {"role": "member"}, format="json")
code = gen.data["code"]
@@ -193,9 +202,146 @@ class PlatformAdminTests(TestCase):
client = APIClient()
r = client.post(
"/api/auth/register/",
{"username": "regular-owner", "password": "strong-password", "team_name": "Reg Team"},
{
"username": "regular-owner",
"password": "strong-password",
"team_name": "Reg Team",
"invite_code": make_create_team_code(),
},
format="json",
)
self.assertEqual(r.status_code, 201)
self.assertFalse(r.data["user"]["is_platform_admin"])
class RegisterInviteCodeTests(TestCase):
"""Phase 1:注册必须有码,kind 分流 create_team / join_team;取消「空码开团队」。"""
def _register(self, username, **extra):
return APIClient().post(
"/api/auth/register/",
{"username": username, "password": "strong-password", **extra},
format="json",
)
def test_register_requires_invite_code(self):
r = self._register("no-code-user", team_name="No Code Team")
self.assertEqual(r.status_code, 400)
self.assertIn("invite_code", r.data)
self.assertFalse(User.objects.filter(username="no-code-user").exists())
self.assertFalse(Team.objects.filter(name="No Code Team").exists())
def test_register_blank_invite_code_rejected(self):
r = self._register("blank-code-user", team_name="Blank", invite_code="")
self.assertEqual(r.status_code, 400)
self.assertFalse(User.objects.filter(username="blank-code-user").exists())
def test_create_team_code_opens_new_team_with_trial(self):
code = make_create_team_code()
r = self._register("ct-owner", team_name="Created Team", invite_code=code)
self.assertEqual(r.status_code, 201)
user = User.objects.get(username="ct-owner")
team = Team.objects.get(name="Created Team")
self.assertEqual(team.owner, user)
self.assertTrue(TeamMember.objects.filter(team=team, user=user, role=TeamMember.Role.OWNER).exists())
# 试用额度发放
trial = Decimal(str(settings.DEFAULT_TRIAL_CREDITS))
self.assertEqual(CreditAccount.objects.get(team=team).balance, trial)
# 邀请标 used + 回填团队
inv = Invitation.objects.get(code=code)
self.assertEqual(inv.status, Invitation.Status.USED)
self.assertEqual(inv.used_by, user)
self.assertEqual(inv.team, team)
def test_join_team_code_joins_existing_no_new_team(self):
# 先用 create_team 码开团队 + 团管发 join 码
owner_client = APIClient()
ro = owner_client.post(
"/api/auth/register/",
{"username": "jt-owner", "password": "strong-password", "team_name": "Host Team", "invite_code": make_create_team_code()},
format="json",
)
owner_client.credentials(HTTP_AUTHORIZATION=f"Token {ro.data['token']}")
gen = owner_client.post("/api/auth/team/invitations/", {"role": "member"}, format="json")
self.assertEqual(gen.status_code, 201)
self.assertEqual(gen.data["kind"], Invitation.Kind.JOIN_TEAM)
code = gen.data["code"]
host_team = Team.objects.get(name="Host Team")
teams_before = Team.objects.count()
r = self._register("jt-joiner", invite_code=code)
self.assertEqual(r.status_code, 201)
joiner = User.objects.get(username="jt-joiner")
self.assertTrue(TeamMember.objects.filter(team=host_team, user=joiner).exists())
self.assertFalse(Team.objects.filter(owner=joiner).exists()) # 没开新团队
self.assertEqual(Team.objects.count(), teams_before)
def test_register_with_used_code_rejected(self):
code = make_create_team_code()
self._register("first-use", team_name="T1", invite_code=code)
r2 = self._register("second-use", team_name="T2", invite_code=code)
self.assertEqual(r2.status_code, 400)
self.assertFalse(User.objects.filter(username="second-use").exists())
def test_register_with_expired_code_rejected(self):
from datetime import timedelta
from django.utils import timezone
inv = Invitation.objects.create(kind=Invitation.Kind.CREATE_TEAM)
inv.expires_at = timezone.now() - timedelta(days=1)
inv.save(update_fields=["expires_at"])
r = self._register("expired-user", team_name="Exp", invite_code=inv.code)
self.assertEqual(r.status_code, 400)
self.assertFalse(User.objects.filter(username="expired-user").exists())
class ValidateInviteTests(TestCase):
"""Phase 1:免登录验码端点 —— 注册页「填码 → 验过才展开」。"""
def test_validate_create_team_code(self):
code = make_create_team_code()
r = APIClient().post("/api/auth/validate-invite/", {"code": code}, format="json")
self.assertEqual(r.status_code, 200)
self.assertTrue(r.data["valid"])
self.assertEqual(r.data["kind"], Invitation.Kind.CREATE_TEAM)
self.assertIsNone(r.data["team_name"])
def test_validate_join_team_code_returns_team_name(self):
owner_client = APIClient()
ro = owner_client.post(
"/api/auth/register/",
{"username": "vi-owner", "password": "strong-password", "team_name": "Visible Team", "invite_code": make_create_team_code()},
format="json",
)
owner_client.credentials(HTTP_AUTHORIZATION=f"Token {ro.data['token']}")
gen = owner_client.post("/api/auth/team/invitations/", {"role": "member"}, format="json")
code = gen.data["code"]
r = APIClient().post("/api/auth/validate-invite/", {"code": code}, format="json")
self.assertEqual(r.status_code, 200)
self.assertTrue(r.data["valid"])
self.assertEqual(r.data["kind"], Invitation.Kind.JOIN_TEAM)
self.assertEqual(r.data["team_name"], "Visible Team")
def test_validate_unknown_code_invalid(self):
r = APIClient().post("/api/auth/validate-invite/", {"code": "TEAM-ZZZZ-ZZZZ"}, format="json")
self.assertEqual(r.status_code, 200)
self.assertFalse(r.data["valid"])
def test_validate_blank_code_invalid(self):
r = APIClient().post("/api/auth/validate-invite/", {"code": ""}, format="json")
self.assertEqual(r.status_code, 200)
self.assertFalse(r.data["valid"])
def test_validate_used_code_invalid(self):
code = make_create_team_code()
APIClient().post(
"/api/auth/register/",
{"username": "vi-consumer", "password": "strong-password", "team_name": "Consumed", "invite_code": code},
format="json",
)
r = APIClient().post("/api/auth/validate-invite/", {"code": code}, format="json")
self.assertEqual(r.status_code, 200)
self.assertFalse(r.data["valid"])
+2
View File
@@ -16,11 +16,13 @@ from .views import (
team_member_password,
team_members,
update_avatar,
validate_invite,
)
urlpatterns = [
path("register/", register, name="auth-register"),
path("validate-invite/", validate_invite, name="auth-validate-invite"),
path("login/", login, name="auth-login"),
path("logout/", logout, name="auth-logout"),
path("me/", me, name="auth-me"),
+29
View File
@@ -83,6 +83,34 @@ def register(request):
return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([])
def validate_invite(request):
"""免登录验码:注册页「填码 → 验过才展开表单」用。
返回 {valid, kind, team_name?, role?};无效返回 {valid:false, detail}(始终 200,不暴露是否存在以外的细节)。"""
from django.utils import timezone
code = str(request.data.get("code") or request.data.get("invite_code") or "").strip().upper()
if not code:
return Response({"valid": False, "detail": "请填写邀请码"})
inv = Invitation.objects.select_related("team").filter(code=code).first()
if inv is None:
return Response({"valid": False, "detail": "邀请码不存在"})
if inv.status != Invitation.Status.PENDING:
return Response({"valid": False, "detail": "邀请码已被使用或已撤销"})
if inv.expires_at <= timezone.now():
return Response({"valid": False, "detail": "邀请码已过期"})
return Response(
{
"valid": True,
"kind": inv.kind,
"role": inv.role,
# join_team 回团队名(只读展示);create_team 无团队
"team_name": inv.team.name if inv.team_id else None,
}
)
@api_view(["POST"])
@permission_classes([])
def login(request):
@@ -265,6 +293,7 @@ def team_invitations(request):
role = TeamMember.Role.ADMIN # 邀请不允许 OWNER(Team.owner 唯一)
invite = Invitation.objects.create(
team=team,
kind=Invitation.Kind.JOIN_TEAM, # 团管发的码恒为「加入本团队」
role=role,
email=str(request.data.get("email") or "").strip(),
monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or 0,