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

08 · 团队与成员关系:一个用户为什么可以有多个角色#

设小陈在甲公司负责客服,在乙公司的试用团队里只是客户。如果把 role: ADMIN 放在用户资料上,很容易把“某个团队的管理权”误当成“全站管理权”。本章用数据库关系解决这个问题。

1. 用户、团队、成员关系分别表达什么#

先读 schema.prisma 中的 User、Team、Membership:

text
User 小陈 ── Membership(甲团队,AGENT)── Team 甲
         └─ Membership(乙团队,CUSTOMER)─ Team 乙
  • User 表示一个人的账号身份。
  • Team 表示一组共享业务数据的工作空间。本项目用它表达租户边界。
  • Membership 表示某个用户在某个团队里的资格和角色。

用户与团队是多对多关系。中间这张成员关系表除了连接两端,还保存 role 和加入时间,所以它是有业务意义的实体。

用户 团队 成员角色 能做的事
小陈 甲 AGENT 处理甲团队工单
小陈 乙 CUSTOMER 创建并查看自己在乙的工单
小林 乙 ADMIN 管理乙团队成员

小陈在甲的资格不能借到乙使用;退出甲团队也不会删除小陈的全站账号。

2. 联合唯一约束如何表达业务规则#

模型的核心字段为:

prisma
// 源码节选:省略其他关系字段
model Membership {
  id        String     @id @default(uuid()) @db.Uuid
  teamId    String     @db.Uuid
  userId    String
  role      MemberRole @default(CUSTOMER)
  createdAt DateTime   @default(now())
  team      Team       @relation(fields: [teamId], references: [id], onDelete: Cascade)
  user      User       @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([teamId, userId])
  @@index([userId])
}

@@unique([teamId, userId]) 表示“同一人在同一团队只能有一条成员关系”,不阻止他加入其他团队。数据库约束是最后一道防线,不能只靠前端按钮防止重复添加。

Prisma 据此生成 teamId_userId 组合查询入口。你在 requireMembership 里看到它时,可以直接读成:“查询这个人在这个团队的那一条关系。”

@@index([userId]) 为按用户查团队提供索引;联合唯一索引则从团队与用户组合识别成员。索引有查询收益,也增加写入维护成本,并不是列越多越好。

用户 ID 在当前认证模型里是字符串;团队 ID、成员关系 ID 和工单 ID 使用 UUID。不要因为看到很多 UUID,就给所有 userId 输入也强行添加 UUID 校验。接口约束要与实际模型一致。

拓展阅读外键与授权有什么区别?

外键保证引用的用户或团队存在,唯一约束保证不会重复加入;它们不会判断“调用人是不是管理员”。真实存在的团队也可能属于别人。数据库关系保证结构成立,业务授权保证当前人有资格操作。

3. 创建团队时为什么同时创建首位管理员#

team.create 使用嵌套写入:

ts
// 源码节选:src/server/services/team.ts
const team = await db.team.create({
  data: { name, members: { create: { userId, role: 'ADMIN' } } },
});
return { id: team.id, name: team.name };

这里的 userId 已由会话确认,role 固定为 ADMIN,不是创建表单任意指定的角色。

Prisma 嵌套写入使团队与首位管理员一起成功或回滚。如果先建团队、再单独建管理员,第二步失败就可能留下无人管理的团队。

任何已登录用户都可以创建自己的团队。即使小陈在乙只是客户,也可以创建丙并成为丙的管理员;这不影响乙的权限。

这条链路是:创建团队表单 → tRPC 验证登录与名称 → 服务使用可信用户 ID → 数据库原子创建团队与 Membership → 返回团队 ID → 列表失效并切换团队。

4. 为什么“我的团队”从成员表开始查#

team.list 不查询所有团队再让浏览器筛选,而是从成员关系出发:

ts
// 源码节选:src/server/services/team.ts
const members = await db.membership.findMany({
  where: { userId },
  include: { team: true },
  orderBy: { createdAt: 'asc' },
});

结果只包含用户所属的团队,服务再投影为 { id, name, role }。include 表示随成员关系读取关联团队,不代表把整张用户表公开出去。

团队选择器因此展示“用户可进入的工作空间”,而不是“全站公司名录”。但后续请求仍要独立验权,不能因为团队 ID 曾出现在列表里,就永远认为用户有权限。

5. 工单怎样归属团队和创建者#

工单的两个关键字段是 teamId 与 creatorId:

  • 团队决定这张工单属于哪一组业务数据。
  • 创建者让服务端判断客户是否在查看本人提交的工单。

新建工单前,服务检查成员关系,再写入所选团队与会话用户。当前 Ticket.creatorId 可空,是为兼容历史工单和用户关联删除等情况;业务创建入口不会把新工单创建者留空。

“数据库允许 null”与“某个业务用例允许 null”不是同一个约束。模型需要容纳数据生命周期,接口还要定义当前动作的边界。

6. 为什么升级后旧工单没有自动出现在新团队#

读 第二份迁移,找这三个步骤:

  1. 为旧工单先增加可空的 teamId。
  2. 有旧数据时建立“第一阶段历史工单归档”团队,把旧记录迁入。
  3. 补齐后将 teamId 改为非空,并添加外键与索引。

归档团队没有自动加入任何成员,旧工单的创建者也不会凭空被认领。直接把所有旧工单分给首个注册用户,会泄露原本无法确定归属的数据。

当前没有历史数据认领入口,也没有通用的团队删除界面。迁移保留数据,不代表所有数据都立即能在普通工作台看到。不要为“找回旧工单”直接清空数据库或随意修改归属。

拓展阅读为什么变更字段要分步骤?

空数据库可以直接增加必填列;已有记录的表要先解决“过去的数据填什么”。这里采用“先允许缺失 → 回填归属 → 再收紧约束”的顺序。生产迁移还要考虑表规模、锁等待与备份;这份学习迁移不能直接代表所有线上大表方案。

7. 动手验证模型#

使用两个已注册的本地测试账号。A 创建甲,B 创建乙,再由 B 将 A 加入乙并设为客户。

检查 A 的团队选择器,应能看到两个团队且角色不同。在 Prisma Studio 查看两条 Membership:userId 相同、teamId 和 role 不同。只查看模型,不通过数据库工具直接调整权限来替代业务验收。

练习:移除 A 在乙的成员关系,是否应删除 A 在甲的关系和用户账号?

动手练习查看参考答案

参考答案:不应。当前移除操作删除的是指定团队与用户对应的 Membership;User 与其他团队关系仍保留。业务中的“移出团队”与“删除账号”是不同动作,不能混用。

下一章用这些关系回答:谁能看成员、谁能改角色、谁能改工单状态。

把知识变成自己的理解

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

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

搜索教程

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

阅读设置

外观
正文字号

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

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