2025年7月28日·阅读约2分钟

保持可懂的 B2B 组织与团队数据库模式

B2B 组织与团队数据库模式:关于邀请、成员状态、角色继承和审计就绪变更的实用关系型范式。

保持可懂的 B2B 组织与团队数据库模式

这个模式解决了什么问题

大多数 B2B 应用并不是简单的“用户账户”应用。它们是共享工作区,人们属于某个组织,分成不同团队,并根据岗位获得不同权限。销售、支持、财务和管理员需要不同的访问权,而且这些访问会随时间变化。

过于简单的模型会很快崩溃。如果只有一个 users 表和单个 role 列,你就无法表达“同一个人在一个 org 是 Admin,但在另一个 org 是 Viewer”。你也无法处理常见情形,比如只应看到某个团队的合同工,或者离开项目但仍属于公司的人。

邀请是另一个常见的错误来源。如果邀请只是一个邮箱行,就会变得不清楚这个人是否已经“在”组织里、他们应该加入哪个团队,以及如果他们用不同邮箱注册会怎样。这里的小不一致往往会变成安全问题。

这个模式有四个目标:

  • 安全:权限来自明确的 membership,而不是假设。
  • 清晰:org、team 和 role 各自有唯一的真实来源。
  • 一致性:邀请和成员关系遵循可预测的生命周期。
  • 历史:你可以解释谁授予了访问、谁更改了角色或谁移除了某人。

承诺是一个单一的关系模型,随着功能增长仍保持可理解:每个用户可属于多个 org、每个 org 可有多个 team、角色继承可预测、变更对审计友好。这是可以今天实现并在以后扩展而不用重写一切的结构。

关键术语:org、team、user 和 membership

如果你想要一个六个月后仍然可读的 schema,先定义几个词。大多数混淆来自把“某人是谁”与“他们可以做什么”混在一起。

一个 Organization (org) 是最高的租户边界。它代表拥有数据的客户或业务账户。如果两个用户属于不同 org,默认情况下他们不应该看到彼此的数据。这个规则能防止大量意外的跨租户访问。

一个 Team 是 org 内更小的分组。Teams 模拟真实的工作单元:Sales、Support、Finance,或“Project A”。Teams 不替代 org 边界;它们生活在 org 之下。

一个 User 是身份。它是登录和个人资料:邮箱、姓名、密码或 SSO ID,可能还有 MFA 设置。一个用户可以存在但暂时没有任何访问权。

一个 Membership 是访问记录。它回答:“这个 user 属于这个 org(可选地也属于这个 team),具有什么状态和角色。”把身份(User)与访问(Membership)分开,让合同工、离职和多 org 访问的建模更容易。

可以在代码和 UI 中使用的简单含义:

  • Member:在 org 或 team 中有活跃 membership 的用户。
  • Role:一组命名的权限(例如 Org Admin、Team Manager)。
  • Permission:单一允许的操作(例如“查看发票”)。
  • Tenant boundary:数据按 org 作用域的规则。

把 membership 当成一个小的 状态机,而不是布尔值。典型状态有 invited、active、suspended 和 removed。这能让邀请、审批和离职保持一致且可审计。

单一关系模型:核心表和关系

一个良好的多租户 schema 从一个想法开始:把“谁属于哪里”存到一个地方,其他都作为辅助表。这样你可以回答基本问题(谁在 org、谁在 team、他们能做什么)而不需要跳过不相关模型。

通常需要的核心表:

  • organizations:每个客户账户一行(租户)。保存名称、状态、计费字段和不可变 id。
  • teams:组织内的分组(Support、Sales、Admin)。总是属于一个 organization。
  • users:每个人一行。这是全球级的,不是按组织的。
  • memberships:表示“这个 user 属于这个 organization”,可选地“也属于这个 team”。
  • role_grants(或 role_assignments):记录某个 membership 在 org 级、team 级或两者的角色。

保持键和约束严格。对每个表使用代理主键(UUID 或 bigint)。添加外键,例如 teams.organization_id -> organizations.idmemberships.user_id -> users.id。然后加几个唯一约束,在问题进入生产前阻止重复。

能早期捕获大多数坏数据的规则:

  • 一个 org slug 或外部键:unique(organizations.slug)
  • 每个 org 内的 team 名称:unique(teams.organization_id, teams.name)
  • 不允许重复 org membership:unique(memberships.organization_id, memberships.user_id)
  • 不允许重复 team membership(如果你单独建了 team_memberships):unique(team_memberships.team_id, team_memberships.user_id)

决定哪些是追加式不可变的,哪些是可更新的。organizations、teams 和 users 是可更新的。memberships 通常在当前状态上可更新(active、suspended),但变更也应该写到追加式的访问日志里,以便将来审计。

保持一致的邀请与 membership 状态

保持访问清晰的最简单方法是把邀请当作独立记录,而不是半成的 membership。membership 表示“这个用户当前属于”。邀请表示“我们提出了访问,但还不是真正的”。把它们分开可以避免幽灵成员、半创建的权限以及“谁邀请了此人?”的谜题。

一个简单且可靠的状态模型

对于 memberships,使用一小组人人都能理解的状态:

  • active:用户可以访问 org(以及他们所属的任何 team)
  • suspended:暂时被阻止,但历史保留完整
  • removed:不再是成员,为审计和报告保留

很多团队避免把“invited”作为 membership 的状态,而是严格把“invited”放在 invitations 表里。这通常更干净:只有实际有访问的用户才有 membership 行(active),或曾经有过访问(suspended/removed)。

在账号不存在时用邮箱邀请

B2B 应用经常在没有用户账号时通过邮箱发邀请。在邀请记录上存邮箱,以及邀请适用的位置(org 或 team)、预期角色和发起人。如果该人在之后用该邮箱注册,可以匹配到待处理邀请并让他们接受。

接受邀请时,在一个事务中处理:将邀请标记为 accepted,创建 membership,并写入一条审计条目(谁接受、何时、使用哪个邮箱)。

定义清晰的邀请结束状态:

  • expired:超过截止不能被接受
  • revoked:被管理员取消不能接受
  • accepted:已转换为 membership

通过强制“每个 org 或 team 每个邮箱只有一个待处理邀请”来防止重复邀请。如果支持重新邀请,要么延长现有待处理邀请的到期时间,要么撤销旧的并发新 token。

角色与继承:不让访问变得混乱

以后再加原生应用
把相同的成员和角色模型扩展到 iOS 和 Android 原生应用。
构建移动端

大多数 B2B 应用需要两级访问:某人在组织整体能做什么,以及在具体团队里能做什么。把这些合并到一个 role 列就是应用变得不一致的起点。

Org 级角色回答诸如:此人能否管理计费、邀请他人或查看所有团队?Team 级角色回答:他们能否编辑 Team A 的条目、批准 Team B 的请求,还是只能查看?

当继承遵循一条规则时最容易理解:org 角色默认适用于所有地方,除非某个 team 明确另有设置。这样行为可预测并减少重复数据。

一种干净的建模方式是把角色分配与作用域一起存储:

  • role_assignmentsuser_id, org_id, 可选 team_id(NULL 表示 org 级别),role_id, created_at, created_by

如果想“每个作用域一个角色”,对 (user_id, org_id, team_id) 加唯一约束。

然后某个团队的实际权限获取逻辑是:

  1. 先查找特定团队的分配(team_id = X)。如果存在,使用它。

  2. 否则回退到 org 范围的分配(team_id IS NULL)。

为了最小权限原则,选择一个最小的 org 默认角色(通常是“Member”),不要赋予它隐藏的管理员权限。新用户不应隐式获得团队访问权限。如果要自动授予,请通过创建显式的 team membership 来实现,而不是悄悄扩大 org 角色的权限。

覆盖(override)应当少见且显而易见。例如:Maria 在 org 中是“Manager”(可邀请、查看报表),但在 Finance 团队中应为“Viewer”。你存一条 org 级别的分配,再存一条 Finance 的 team 作用域覆盖。不要复制权限,例外应可见。

当角色名称能覆盖常见模式时效果最好。只有在真正的特殊需求(例如“可导出但不可编辑”)或合规需要明确允许操作清单时,才使用显式权限。即便如此,也要保持相同的作用域思想,让心智模型一致。

审计友好的变更:记录谁更改了访问

如果你的应用只在 membership 行上记录当前角色,你会丢失故事线。当有人问“谁在上周二把 Alex 设为 admin?”时,你就无法可靠回答。你需要变更历史,而不仅是当前状态。

最简单的做法是专门的审计日志表,记录访问事件。把它当成追加式日记:永远不修改旧的审计行,只添加新行。

一个实用的审计表通常包含:

  • actor_user_id(谁做的变更)
  • subject_typesubject_id(membership、team、org)
  • action(invite_sent、role_changed、membership_suspended、team_deleted)
  • occurred_at(何时发生)
  • reason(可选自由文本,例如 “contractor offboarding”)

为了捕获“之前”和“之后”,存你关心字段的小快照。把它限定为访问控制数据,而不要存完整用户资料。例如:before_roleafter_rolebefore_stateafter_statebefore_team_idafter_team_id。如果偏好灵活性,可以用两个 JSON 列(beforeafter),但保持载荷小且一致。

对于 memberships 和 teams,软删除通常优于硬删除。不要删除行,而是用 deleted_atdeleted_by 标记禁用。这样外键保持完整,也更容易解释过去的访问。硬删除仍可用于真正临时的记录(比如过期邀请),但只有在确定以后不需要时才这样做。

有了这些,你可以快速回答合规常见问题:

  • 谁授予或移除了访问,什么时候?
  • 具体变更是什么(角色、团队、状态)?
  • 访问是否作为正常离职流程被移除?

逐步:在关系数据库中设计 schema

保持数据模型清晰
使用一个随着团队、覆盖规则和审计历史增长仍然可读的数据模型。
设计数据库

从简单开始:一处声明谁属于什么,以及为什么。小步构建,并逐步添加规则以防数据漂移到“近似正确”。

在 PostgreSQL 和其他关系数据库中一个实用的顺序:

  1. 创建 organizationsteams,每个带稳定主键(UUID 或 bigint)。添加 teams.organization_id 外键,早些决定团队名在 org 内是否必须唯一。

  2. users 与 membership 分开。把身份字段放在 users(email、status、created_at)。把“属于 org/team”放在 memberships 表,包含 user_idorganization_id、可选 team_id(如果你这么建)、以及 state 列(active、suspended、removed)。

  3. invitations 作为独立表,不是 membership 的一列。存 organization_id、可选 team_idemailtokenexpires_ataccepted_at。强制“每个 org + 邮箱 + team 只有一个开放邀请”的唯一性,防止重复。

  4. 用显式表建模角色。简单做法是 roles(admin、member 等)加上指向 org 范围(无 team_id)或 team 范围(设定 team_id)的 role_assignments

  5. 从第一天起添加审计轨迹。用 access_events 表,包含 actor_user_idtarget_user_id(或邀请时的邮箱)、action(invite_sent、role_changed、removed)、scope(org/team)和 created_at

这些表建好后,跑几个基本管理员查询来验证现实:“谁有 org 级访问?”,“哪些团队没有管理员?”,“哪些邀请过期但仍然打开?”这些问题通常能早期暴露缺失的约束。

防止脏数据的规则和约束

用真实流程测试模型
在数小时内原型化模式和关键流程,然后安全地通过重生成迭代。
开始原型

当数据库而不仅仅是代码强制租户边界时,schema 就会保持整洁。最简单的规则是:每个租户作用域表都带 org_id,每次查询都包含它。即便有人在应用中忘了过滤器,数据库也应该阻止跨 org 的连接。

保持数据清洁的护栏

从那些总是指向“同一 org”的外键开始。例如,如果你单独存 team membership,team_memberships 行应该引用 team_iduser_id,还要带 org_id。通过复合键可以强制被引用的 team 属于同一个 org。

防止常见问题的约束示例:

  • 每个用户在每个 org 只有一个活跃 membership:在支持的数据库上对活跃行做 (org_id, user_id) 的唯一约束或部分索引。
  • 每个 org 或 team 每个邮箱只有一个待处理邀请:对 (org_id, team_id, email)state = 'pending' 的情况下做唯一约束。
  • 邀请 token 全局唯一且不重用:对 invite_token 做唯一约束。
  • team 必属于且只属于一个 org:teams.org_id NOT NULL 并有外键到 orgs(id)
  • 结束 membership 而不是删除:存 ended_at(和可选的 ended_by)以保护审计历史。

针对常用查询的索引

对应用经常执行的查询建索引:

  • (org_id, user_id) 用于“这个用户属于哪些 org?”
  • (org_id, team_id) 用于“列出某团队的成员”
  • (invite_token) 用于“接受邀请”
  • (org_id, state) 用于“待处理邀请”和“活跃成员”

保持 org 名可改动。到处使用不可变的 orgs.id,把 orgs.name(和 slug)当可编辑字段。重命名只影响一行。

移动团队到另一个 org 通常是策略问题。最安全的做法是禁止(或克隆团队),因为 memberships、roles 和审计历史都是 org 范围的。如果必须允许移动,在一个事务内完成并更新所有携带 org_id 的子行。

为了防止用户离开后出现孤立记录,避免硬删除。禁用用户、结束他们的 memberships,并对父表使用 ON DELETE RESTRICT,除非你确实希望级联删除。

示例场景:一个 org、两个团队,安全地变更访问

设想一个叫 Northwind Co 的公司,只有一个 org 和两个团队:Sales 和 Support。他们雇了合同工 Mia 在 Support 处理工单一个月。模型应保持可预测:一个人,一个 org membership,可选的 team membership,清晰的状态。

一个 org 管理员(Ava)通过邮箱邀请 Mia。系统创建一条与 org 关联的 invitation 行,状态为 pending 并有过期时间。此时没别的变化,因此不会出现“半成用户”的模糊访问。

当 Mia 接受时,邀请被标记为 accepted,并创建一个 org membership 行,状态为 active。Ava 把 Mia 的 org 角色设为 member(不是 admin)。然后 Ava 给 Mia 加入 Support 团队并分配 team 角色比如 support_agent

现在加一个转折:Ben 是全职员工,org 角色是 admin,但他不应该看到 Support 的数据。你可以用 team 级覆盖来处理:显式在 Support 存一个降低权限的分配,同时保留他在 org 设置上的管理员能力。

一周后,Mia 违反政策被暂停。Ava 把 Mia 的 org membership 状态设置为 suspended,而不是删除行。team memberships 可以保留,但因 org membership 非活跃而失效。

审计历史保持清晰,因为每次变更都是一条事件:

  • Ava 邀请了 Mia(谁、什么、何时)
  • Mia 接受了邀请
  • Ava 把 Mia 加入 Support 并分配 support_agent
  • Ava 为 Ben 设置了 Support 的覆盖
  • Ava 暂停了 Mia

用这个模型,UI 能显示清晰的访问摘要:org 状态(active 或 suspended)、org 角色、带角色和覆盖的团队列表,以及解释某人能否看到 Sales 或 Support 的“最近访问变更”时间线。

常见错误与陷阱

集中化访问管理
用一个应用替代分散的脚本,干净地处理邀请、角色和访问变更。
试用 AppMaster

大多数访问相关的 bug 来自“差不多正确”的数据模型。起初 schema 看起来没问题,然后边缘情况堆积:重新邀请、团队移动、角色更改和离职。

常见陷阱是把邀请和 membership 混在一行。如果你在同一记录里既存“invited”又存“active”而含义不清,你就会问出诸如“如果某人从未接受,他们算不算成员?”之类的无法回答的问题。要么把 invitations 和 memberships 分开,要么把状态机做得明确且一致。

另一个常见错误是在 user 表上放一个全局 role 就算完事。角色几乎总是有作用域(org 级、team 级、项目级)。全局角色会迫使你做各种 hack,比如“某用户对一个客户是 admin,对另一个客户是只读”,这会打破多租户预期并带来支持难题。

通常会伤到后期的陷阱包括:

  • 意外允许跨 org 的 team membership(team_id 指向 org A,而 membership 指向 org B)。
  • 硬删除 membership 导致丢失“上周谁有访问?”的线索。
  • 缺失唯一性规则导致用户通过重复行获得重复访问。
  • 让继承默默叠加(org admin + team member + override),以至于没人能解释为什么有权限。
  • 把“邀请被接受”当作 UI 事件而不是数据库事实。

举个快速例子:一个合同工被邀请到 org,加到 Team Sales,随后被移除并在一个月后重新邀请。如果你覆盖旧行,你会丢失历史;如果允许重复,你可能出现两个活跃的 membership。清晰的状态、作用域化角色和正确的约束能防止两者。

快速检查与把它纳入应用的下一步

在编码前,快过一遍你的模型,看看纸面上是否仍然说得通。一个好的多租户访问模型应该显得无聊:相同规则到处适用,特殊情况很少。

一个快速清单来捕捉常见缺口:

  • 每个 membership 指向确切的一个 user 和一个 org,并有唯一约束防止重复。
  • 邀请、membership 和移除状态是显式的(不要用 null 来隐含状态),且转换有限制(例如不能接受已过期邀请)。
  • 角色统一存放并且有效访问一致计算(包含继承规则,如果使用的话)。
  • 删除 org/team/user 不会抹去历史(需要审计痕迹时使用软删除或归档字段)。
  • 每次访问变更都会发出一条审计事件,记录 actor、target、scope、时间戳和理由/来源。

用真实问题对设计施压测试。如果你不能用一个查询和清晰规则回答下列问题,你可能需要一个约束或额外状态:

  • 如果某人被邀请两次然后邮箱变了,会怎样?
  • team 管理员能否把 org 所有者从该 team 中移除?
  • 如果 org 角色授予对所有团队的访问,某个团队能否覆盖它?
  • 如果邀请在角色变更后才被接受,哪一个角色生效?
  • 当支持询问“谁移除了访问”时,你能否迅速证明?

把管理员和支持人员必须理解的事项写下来:membership 状态(以及触发条件)、谁能邀请/移除、角色继承在通俗语言下意味着什么,以及在事件中查看审计日志的位置。

先实现约束(唯一性、外键、允许的转换),再围绕它们构建业务逻辑,让数据库帮助你保持诚实。把策略决策(是否启用继承、默认角色、邀请到期)放在配置表中,而不是代码常量里。

如果你想不手写每个后端和管理界面就搭建它,AppMaster (appmaster.io) 可以帮助你在 PostgreSQL 中建模这些表,并把邀请与 membership 转换实现为显式业务流程,同时为生产部署生成真实源码。

常见问题

为什么不应该在 users 表上只存一个全局角色?

使用单独的 membership 记录,让角色和访问与某个 org(以及可选的 team)绑定,而不是跟全局用户身份绑在一起。这样同一个人在一个 org 是 Admin,在另一个 org 可以是 Viewer,就不需要各种 hack。

邀请应该立刻创建一个 membership 行吗?

把两者分开:invitation 是带有邮箱、作用域和到期时间的邀请,而 membership 表示用户真实拥有访问权。这样可以避免“幽灵成员”、状态不清和邮箱变更带来的安全问题。

我应该使用哪些 membership 状态?

大多数 B2B 场景用一小组状态即可:activesuspendedremoved。如果把“invited”仅放在 invitations 表里,membership 就始终代表当前或曾经的访问,而不是待定的访问。

如何在不混淆的情况下建模 org 角色与 team 角色?

把 org 角色和 team 角色作为带作用域的分配存储:当 team_idNULL 时表示 org 作用域,为特定值时表示 team 作用域。检查某个团队的权限时,优先使用 team 特定的分配,否则回退到 org 级别的分配。

角色继承的最简单规则是什么?

从一个可预测的规则开始:org 角色默认适用于所有地方,只有在显式设置时 team 角色才覆盖。让覆盖尽量少且清晰可见,这样任何人都能解释为什么某人有某个权限。

如何防止重复邀请并干净地重新邀请?

用唯一约束强制“每个 org/team 每个邮箱只能有一个待处理邀请”,并对邀请状态定义清晰的生命周期(pending/accepted/revoked/expired)。如果需要重新邀请,要么延长现有待处理邀请的到期时间,要么先撤销旧邀请再发新 token。

如何在数据库层强制租户边界?

每个租户作用域的行都应该带有 org_id,并通过外键/约束防止混 org 的情况(例如 membership 引用的 team 必须属于相同的 org)。这样能降低应用层忘记过滤器时的影响范围。

如何让访问变更便于审计?

保持一个追加式的访问事件日志,记录谁对谁做了什么、在哪个作用域(org 或 team)、何时发生以及理由。记录关键字段的前/后快照(role、state、team),这样可以可靠回答“谁在上周二授予了 admin 权限?”之类的问题。

我应该对 memberships 和 invites 做硬删除吗?

不要对 membership 和 team 做硬删除;用结束/禁用字段保留历史,这样外键不会被破坏且历史可查询。对 invites 来说,保留过期记录也能完整保留安全痕迹,但至少不要重复使用 token。

这个模式里最重要的索引有哪些?

为常用路径建索引:(org_id, user_id) 用于 org membership 检查,(org_id, team_id) 用于列出 team 成员,(invite_token) 用于接受邀请,(org_id, state) 用于管理界面(例如“活跃成员”或“待处理邀请”)。索引应反映真实查询而不是每列都建索引。

容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧