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
+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,