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:
seaislee1209
2026-06-19 12:31:14 +08:00
co-authored by Claude Opus 4.8
parent 9643f9ba5e
commit ba855cc219
6 changed files with 282 additions and 17 deletions
+41 -1
View File
@@ -12,8 +12,9 @@ from rest_framework.response import Response
from apps.common.api import get_current_team
from .models import LoginSession, TeamMember, User, UserPreference
from .models import Invitation, LoginSession, TeamMember, User, UserPreference
from .serializers import (
InvitationSerializer,
LoginSerializer,
LoginSessionSerializer,
RegisterSerializer,
@@ -238,6 +239,45 @@ def team_members(request):
return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED)
@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def team_invitations(request):
"""团队邀请码:GET 列本团队邀请;POST 生成新码。权限=超管/团管(复用 can_manage_team)。"""
team = get_current_team(request.user)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
if request.method == "GET":
invites = team.invitations.select_related("used_by").order_by("-created_at")
return Response(InvitationSerializer(invites, many=True).data)
role = normalize_member_role(request.data.get("role"))
if role == TeamMember.Role.OWNER:
role = TeamMember.Role.ADMIN # 邀请不允许 OWNER(Team.owner 唯一)
invite = Invitation.objects.create(
team=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,
created_by=request.user,
)
return Response(InvitationSerializer(invite).data, status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def revoke_invitation(request, invite_id):
"""撤销一个待用邀请码。权限=超管/团管。"""
team = get_current_team(request.user)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
invite = Invitation.objects.filter(team=team, id=invite_id).first()
if invite is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if invite.status == Invitation.Status.PENDING:
invite.status = Invitation.Status.REVOKED
invite.save(update_fields=["status", "updated_at"])
return Response(InvitationSerializer(invite).data)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsAuthenticated])
def team_member_detail(request, member_id):