TTeamDesk 学习手册
本章目录 展开导航 ↓

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:

  1. 在事务中锁定目标团队行。
  2. 用会话中的操作者身份执行 requireAdmin。
  3. 按目标邮箱查 User;不存在返回“请对方先注册”。
  4. 查询目标人在该团队是否已有 Membership;重复添加返回 CONFLICT。
  5. 新建成员关系,写入目标角色,提交事务并返回 { success: true }。

服务端选择用户记录后才获得目标 userId,前端不通过邮箱冒充操作者身份。当前所有已登录团队成员都可查看成员列表;添加、变更和移除只允许管理员。

没有邮件发送、邀请链接或待接受状态。不能把“邮箱输入框 + 添加按钮”讲成已经完成了一套邀请系统。

拓展阅读邀请系统还需要哪些状态?

真正的邀请通常还要区分待接受、已接受、过期、撤销,验证接收身份,并处理重复接受与权限变化。当前采用直接添加已注册账号,简化了学习路径;这些能力应在真实需求出现时独立设计,不能只补一封邮件就当成闭环。

3. 为什么成员变更要锁团队行#

源码里的这句位于事务开头:

ts
// 源码节选:用户输入作为参数绑定,不拼接 SQL 字符串
await tx.$queryRaw`SELECT id FROM "Team" WHERE id = ${input.teamId}::uuid FOR UPDATE`;

模板插值由 Prisma 参数化处理;不要把它改成字符串拼接。FOR UPDATE 在事务内锁定团队行,其他需要取得同一行锁的事务要等待。

当前 add、change 和相关工单写流程采用这把团队锁。成员变更必须在拿锁后再检查管理员资格,不能先在事务外检查完,然后带着旧权限等锁。

锁的范围是同一个团队的相关写流程;不是把全部用户、全部团队都停住。没有遵守这把锁约定的代码也不会自动受到完整保护。

4. 最后一位管理员规则怎样检查#

在 change 中定位:

ts
// 源码节选: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 校验登录和目标参数 → 团队锁内确认操作者管理员资格 → 检查目标及最后管理员规则 → 原子变更成员与相关工单 → 返回结果并刷新缓存。

把知识变成自己的理解

完成本章练习,再继续下一站。

TEAMDESK HANDBOOK · 从一张工单,走进全栈开发

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。