08 · 团队与成员关系:一个用户为什么可以有多个角色#
设小陈在甲公司负责客服,在乙公司的试用团队里只是客户。如果把 role: ADMIN 放在用户资料上,很容易把“某个团队的管理权”误当成“全站管理权”。本章用数据库关系解决这个问题。
1. 用户、团队、成员关系分别表达什么#
先读 schema.prisma 中的 User、Team、Membership:
User 小陈 ── Membership(甲团队,AGENT)── Team 甲
└─ Membership(乙团队,CUSTOMER)─ Team 乙User表示一个人的账号身份。Team表示一组共享业务数据的工作空间。本项目用它表达租户边界。Membership表示某个用户在某个团队里的资格和角色。
用户与团队是多对多关系。中间这张成员关系表除了连接两端,还保存 role 和加入时间,所以它是有业务意义的实体。
| 用户 | 团队 | 成员角色 | 能做的事 |
|---|---|---|---|
| 小陈 | 甲 | AGENT |
处理甲团队工单 |
| 小陈 | 乙 | CUSTOMER |
创建并查看自己在乙的工单 |
| 小林 | 乙 | ADMIN |
管理乙团队成员 |
小陈在甲的资格不能借到乙使用;退出甲团队也不会删除小陈的全站账号。
2. 联合唯一约束如何表达业务规则#
模型的核心字段为:
// 源码节选:省略其他关系字段
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 使用嵌套写入:
// 源码节选: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 不查询所有团队再让浏览器筛选,而是从成员关系出发:
// 源码节选: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. 为什么升级后旧工单没有自动出现在新团队#
读 第二份迁移,找这三个步骤:
- 为旧工单先增加可空的
teamId。 - 有旧数据时建立“第一阶段历史工单归档”团队,把旧记录迁入。
- 补齐后将
teamId改为非空,并添加外键与索引。
归档团队没有自动加入任何成员,旧工单的创建者也不会凭空被认领。直接把所有旧工单分给首个注册用户,会泄露原本无法确定归属的数据。
当前没有历史数据认领入口,也没有通用的团队删除界面。迁移保留数据,不代表所有数据都立即能在普通工作台看到。不要为“找回旧工单”直接清空数据库或随意修改归属。
拓展阅读为什么变更字段要分步骤?
空数据库可以直接增加必填列;已有记录的表要先解决“过去的数据填什么”。这里采用“先允许缺失 → 回填归属 → 再收紧约束”的顺序。生产迁移还要考虑表规模、锁等待与备份;这份学习迁移不能直接代表所有线上大表方案。
7. 动手验证模型#
使用两个已注册的本地测试账号。A 创建甲,B 创建乙,再由 B 将 A 加入乙并设为客户。
检查 A 的团队选择器,应能看到两个团队且角色不同。在 Prisma Studio 查看两条 Membership:userId 相同、teamId 和 role 不同。只查看模型,不通过数据库工具直接调整权限来替代业务验收。
练习:移除 A 在乙的成员关系,是否应删除 A 在甲的关系和用户账号?
动手练习查看参考答案
参考答案:不应。当前移除操作删除的是指定团队与用户对应的 Membership;User 与其他团队关系仍保留。业务中的“移出团队”与“删除账号”是不同动作,不能混用。
下一章用这些关系回答:谁能看成员、谁能改角色、谁能改工单状态。