10 · 成员管理:为什么最后一位管理员不能离开#
本章沿“管理员添加同事,再调整其角色”走一遍。重点是分清调用人与目标成员,并理解为什么成员变更要在事务里重新检查权限,以及最后管理员规则如何应对并发。
1. 先区分调用人与操作目标#
查看 router.ts 的团队接口:
| 接口 | 服务方法 | 谁提供目标信息 |
|---|---|---|
team.addMember |
add |
表单提供邮箱和目标角色 |
team.changeRole |
change |
表格选择目标 userId 和新角色 |
team.removeMember |
change |
表格提供目标 userId,不传 role |
服务工厂 createTeamService(db, userId) 的 userId 来自会话,是操作者。input.userId 则是被操作的人。这两个名字相似,阅读时一定要区分。
删除与改角色共用 change 方法;input.role 有值表示调整角色,无值表示移除。路由分别使用不同 schema,不允许客户端仅靠遗漏关键字段就绕过输入合同。
2. 添加成员走的是“已注册邮箱”,不是邮件邀请#
Members 的表单提交 email、role 和当前 teamId。Zod 校验邮箱与角色,服务层开始执行 add:
- 在事务中锁定目标团队行。
- 用会话中的操作者身份执行
requireAdmin。 - 按目标邮箱查
User;不存在返回“请对方先注册”。 - 查询目标人在该团队是否已有 Membership;重复添加返回
CONFLICT。 - 新建成员关系,写入目标角色,提交事务并返回
{ success: true }。
服务端选择用户记录后才获得目标 userId,前端不通过邮箱冒充操作者身份。当前所有已登录团队成员都可查看成员列表;添加、变更和移除只允许管理员。
没有邮件发送、邀请链接或待接受状态。不能把“邮箱输入框 + 添加按钮”讲成已经完成了一套邀请系统。
拓展阅读邀请系统还需要哪些状态?
真正的邀请通常还要区分待接受、已接受、过期、撤销,验证接收身份,并处理重复接受与权限变化。当前采用直接添加已注册账号,简化了学习路径;这些能力应在真实需求出现时独立设计,不能只补一封邮件就当成闭环。
3. 为什么成员变更要锁团队行#
源码里的这句位于事务开头:
// 源码节选:用户输入作为参数绑定,不拼接 SQL 字符串
await tx.$queryRaw`SELECT id FROM "Team" WHERE id = ${input.teamId}::uuid FOR UPDATE`;模板插值由 Prisma 参数化处理;不要把它改成字符串拼接。FOR UPDATE 在事务内锁定团队行,其他需要取得同一行锁的事务要等待。
当前 add、change 和相关工单写流程采用这把团队锁。成员变更必须在拿锁后再检查管理员资格,不能先在事务外检查完,然后带着旧权限等锁。
锁的范围是同一个团队的相关写流程;不是把全部用户、全部团队都停住。没有遵守这把锁约定的代码也不会自动受到完整保护。
4. 最后一位管理员规则怎样检查#
在 change 中定位:
// 源码节选:src/server/services/team.ts
if (
target.role === 'ADMIN' &&
input.role !== 'ADMIN' &&
(await tx.membership.count({
where: { teamId: input.teamId, role: 'ADMIN' },
})) <= 1
)
throw new TRPCError({
code: 'BAD_REQUEST',
message: '团队必须保留至少一位管理员',
});目标当前是管理员,而且这次要把他降级或移除时,服务才统计该团队管理员数量。移除时 input.role 是 undefined,也满足“不再是管理员”的判断。
这条规则允许管理员在存在其他管理员时降级自己,但不允许团队失去管理入口。它是业务约束,不是邮箱密码验证的一部分。
5. 为什么“先 count 再 update”在并发下可能失效#
设团队有 A、B 两位管理员,二人同时把自己降为客服:
| 步骤 | 没有协调的风险 | 当前团队锁的作用 |
|---|---|---|
| 两个请求统计管理员 | 都可能读到数量 2 | 只有一个先进入同团队写流程 |
| 第一个请求降级并提交 | 数量变 1 | 释放锁,让后一个继续 |
| 第二个请求再次检查 | 若沿用旧结果就会降到 0 | 重新授权并统计,拒绝破坏规则 |
现有集成测试让两位管理员分别降级自己,断言恰好一个请求成功,数据库仍保留一位管理员。不要把测试描述成总是 A 赢,等待和执行顺序不应该成为业务假设。
如果场景换成两位管理员同时降级对方,等待者拿锁后也必须重新验证自己是否仍为管理员,不能沿用请求开始时的角色。
拓展阅读事务、锁、约束各自负责什么?
事务保证多次写入一起提交或回滚;行锁协调相互竞争的流程;唯一约束等数据库规则守住结构边界。“至少留一位管理员”是跨多条成员记录的条件,不会因为普通 role 枚举或一个事务就自动成立。服务要在合适的并发控制下检查它。
6. 当前成员变更还会影响工单分配#
这部分是第三、四阶段扩展,阅读当前代码会遇到,但本章只追踪其原因。
被移除或降为客户的人不能继续承担客服工单,所以服务在同一事务中:
- 按
teamId + assigneeMembershipId批量清空负责人、递增工单version,由updateManyAndReturn返回受影响的工单 ID 与新版本。 - 批量写入
ASSIGNEE_RELEASED操作记录。 - 再调整或删除 Membership。
用成员关系 ID 指定负责人,能明确表示“此人在这个团队承担处理责任”。工单版本递增后,旧详情页提交修改会察觉数据已变化。
如果操作记录写入失败,成员与工单变更也应回滚。移除成员不是删除其全部历史工单、回复或全站账号,不能为了省事级联清空业务历史。
7. 返回成功后前端为什么刷新不止一张表#
Members.refresh 让团队列表、成员列表、工单列表、详情和操作记录缓存失效,因为:
- 目标角色变了,当前人的角色也可能变了。
- 成员被移除或降为客户,负责人可能被清空。
- 工单版本和记录可能已经更新。
页面设置 busy 禁用相关操作,减少重复点击;Popconfirm 让用户确认移除意图。这些是交互保护,服务端仍要重新授权、检查目标和管理员数量。
在一个浏览器里操作成功,不代表另一个浏览器界面立即实时刷新。后者下一次请求仍受服务端规则约束;当前系统没有承诺通过 WebSocket 推送每次权限变更。
8. 本章验收与练习#
在本地测试团队中添加已注册同事,验证重复添加被拒绝;再用客户身份尝试成员管理,应由服务端拒绝。让团队只剩一位管理员,尝试降级或移除自己,应得到明确错误。
并发场景优先用 tickets.integration.test.ts 的“并发管理员降级不会产生无人管理的团队”验证,避免人工点击顺序让测试偶然成功。
练习:如果把 requireAdmin 挪到获取团队锁之前,会有什么问题?
动手练习查看参考答案
参考答案:请求等待锁期间,操作者可能已经被另一事务降级或移除。如果后续继续使用旧检查结果,就可能以失效权限修改成员。授权检查应与关键业务写入的并发策略配套,当前做法是在拿锁后的事务中查询资格。
完整链路是:成员表单 → tRPC 校验登录和目标参数 → 团队锁内确认操作者管理员资格 → 检查目标及最后管理员规则 → 原子变更成员与相关工单 → 返回结果并刷新缓存。