> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# 为什么 retaintive 需要 Tenant:从 user-store 到 customer boundary 的决策说明

:::tip 核心结论
当前 `rc_stores.user_id + store_members` 是一个好的 **store access model**,它回答"某个 user 能不能访问某家 store"。但它不是 **customer ownership model**,回答不了"这些 stores 共同属于哪个客户/公司/付费主体"。当系统开始支持一个客户多家店、多个用户、一个账单、多 RingCentral accounts、套餐等级、quota、owner transfer 和跨店 dashboard 时,`tenant` 就是分水岭。
:::

:::info 当前落地口径(2026-07-01 验证)
V1 是 **IDENTITY ONLY**,已落地的是 control plane(独立 Neon project `retaintive-identity`)的三张表:`tenants` / `tenant_members` / `tenant_store_mappings`(外加过渡期兼容表 `tenant_store_overrides`)。provider connections、DB placement、provisioning job ledger 全部 DEFERRED。

旧文档描述的"6 张表 + `getDbForTenant` + SQS webhook"方案没有按原样落地,已归档。schema 细节以 [`callytics-common/src/control-plane/schema/tenants.ts`](https://github.com/retaintive/callytics-common/blob/main/src/control-plane/schema/tenants.ts) 为准,本文不复制 DDL。

**当前铺开进度和已知缺口见 [`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md)** — 目前还没正式上 prod(pre-launch 阶段),control plane 只对 test 数据面生效;设计承诺和机制现状之间的差距(fallback 退役、对账、status 语义等)都在那份文档里,读本文时请配对读。
:::

## 一、这次讨论最终在回答什么

| 问题                           | 答案                                                                                                                                                   |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 当前 user-store 设计是不是错的?       | 不是。它适合做 store-level access:owner / editor / viewer。                                                                                                  |
| tenant 是不是替代 store\_members? | 不是。tenant 在上层定义客户边界;store\_members 在下层定义某个 user 能看哪些 stores。                                                                                         |
| tenant 真正解决什么?               | 客户级 ownership、billing、quota、plan tier、跨店 dashboard、owner transfer、audit;provider ownership 和 Pool/Silo routing 属于 future extension,不能混进当前 V1 schema。 |
| 什么情况下 tenant 是多余的?           | 如果永远是一人一店、没有公司级 billing、没有 team、没有跨店、没有 quota,user-store 就够。                                                                                         |

### 行业共识:B2B SaaS 的标准分层

:::info 结论
有 `tenant` 这一层不是 retaintive 自己发明的复杂化,而是 B2B SaaS 的主流建模方式。成熟系统通常把 `User`(登录身份)、`Tenant/Organization`(客户主体)、`Membership`(某个 user 在某个客户里的角色)、`Resource/Location`(客户拥有的业务资源)分开。这样才能把 billing、quota、SSO、team role、audit 和数据隔离放在稳定的客户边界上。
:::

| 行业对象                    | 它解决的问题                                                      | retaintive 对应                           |
| ----------------------- | ----------------------------------------------------------- | --------------------------------------- |
| `User`                  | 谁登录系统。它是人,不是公司;可能离职、换邮箱、同时服务多个组织。                           | `Cognito user_id`                       |
| `Tenant / Organization` | 哪个客户/公司拥有资源、签合同、付费、配置 SSO/品牌。                               | `tenants`                               |
| `Membership`            | 某个 user 在某个 tenant 里的角色;role 必须有 scope,不能只挂在 user 全局。       | `tenant_members`                        |
| `Resource / Location`   | 客户名下的具体业务对象,可以是一家店、一个 site、一个 group。                        | `rc_stores.id` today;future `locations` |
| `Resource Ownership`    | 资源属于哪个 tenant;billing count、dashboard boundary、guard 都从这里算。 | `tenant_store_mappings`                 |

外部产品命名不同,但模式相似:Auth0 叫 `Organizations`,WorkOS 叫 `Organizations`,AWS SaaS Lens 讨论 `tenant isolation`,Stripe 的 `Customer` 是付款侧客户对象。我们的 `tenant` 是产品/数据模型里的客户主体,不等于 RingCentral account,也不等于 Stripe Customer,但会和它们建立映射。

参考官方资料:[AWS SaaS Lens: Tenant isolation](https://docs.aws.amazon.com/wellarchitected/latest/saas-lens/tenant-isolation.html)、[Auth0 Organizations](https://auth0.com/docs/manage-users/organizations)、[WorkOS Organizations](https://workos.com/docs/reference/organization)、[Stripe Customer object](https://docs.stripe.com/api/customers/object)。

## 二、当前模型:user-store access model

:::note
当前 schema 的强项是:从登录 user 出发,算出这个 user 能访问哪些 stores。它不是差的设计,只是抽象层级停在 store access。
:::

```mermaid
flowchart TB
  U[Cognito User（登录身份）<br>user_id]
  UC[User Connection（OAuth 连接）<br>user_connections]
  PA[Provider Account（电话商账号）<br>provider_account_id]
  PT[Provider Topology（RC 分组来源）<br>sites / groups / ungrouped]
  IS[Internal Store（内部门店）<br>rc_stores.id = store_id]
  SP[Phone Lines（号码）<br>rc_store_phones]
  SM[Store Membership（门店授权）<br>store_members]
  BD[Business Data（业务数据）<br>calls / messages / contacts / tasks]

  U -->|拥有 OAuth 连接<br>owns token| UC --> PA --> PT -->|同步生成门店<br>sync stores| IS --> SP
  U -->|当前隐式 owner<br>legacy owner link| IS
  U -->|共享访问<br>EDITOR / VIEWER| SM --> IS
  IS -->|当前隔离键<br>store_id scope| BD
```

| 表/字段                | 当前职责                                             | 它不能表达什么                                      |
| ------------------- | ------------------------------------------------ | -------------------------------------------- |
| `rc_stores.user_id` | store owner,隐式 OWNER                             | 不能表达"公司主体";owner 换人不等于 store 换公司。            |
| `store_members`     | 某个 user 对某家 store 的 EDITOR/VIEWER 权限             | 不能表达 tenant-level billing/admin/SSO 权限。      |
| `user_connections`  | 某个 user 连接的 RingCentral OAuth account            | 不能表达 provider account 属于哪个客户主体。              |
| `store_config`      | store-level alias、activation、lead emails、pricing | 不适合放 Stripe Customer、plan tier、tenant quota。 |

## 三、真正的分水岭

:::info 分水岭
系统里是否存在一个业务对象,它不是某个 user,也不是某家 store,但它拥有多家 stores、多个 users、一个账单、一个套餐、一组 quota 和一套客户级配置。如果存在,这个对象就是 `tenant`。
:::

```mermaid
flowchart LR
  Simple[Simple Access Case（简单访问）<br>one user / one store]
  Access[Store Access Model（门店访问模型）]
  Complex[Customer Boundary Case（客户边界）<br>multi-store / multi-user / one bill / quota]
  Tenant[Tenant Boundary Model（租户/客户主体模型）]

  Simple -->|够用<br>sufficient| Access
  Complex -->|需要稳定客户对象<br>requires customer object| Tenant

  Access --> A1[回答的问题<br>Can this user access this store?]
  Tenant --> T1[回答的问题<br>Which customer owns these stores?]
  Tenant --> T2[Billing / Plan / Quota<br>放在哪一层?]
  Tenant --> T3[Customer Admin<br>谁管理这个客户账号?]
```

| 能力/场景                          | 只有 user-store                             | 有 tenant                                                                                   |
| ------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------ |
| 单店 owner 看数据                   | 支持                                        | 支持                                                                                         |
| manager 看指定几家店                 | 支持,靠 `store_members`                      | 支持,仍靠 `store_members` 或未来 `location_members`                                               |
| 一个公司 8 家店一张账单                  | 需要把 payer/tier 复制到每家 store 或绑到某个 user     | 挂在 tenant 一份即可                                                                             |
| owner transfer                 | 批量改 `rc_stores.user_id`,语义像 data transfer | 只改 `tenant_members`,公司和 stores 不变                                                          |
| 套餐限制最多 5 users / 10 stores     | 容易按 user 分散计数,被多个 admin 绕过                | 按 tenant 统一计数                                                                              |
| 多个 RingCentral accounts 属于一个客户 | 只能通过 user connections 间接推断                | V1 先用 tenant\_store\_mappings 表达 stores 归 tenant;future 再加显式 tenant\_provider\_connections |
| 未来 Pool/RLS/Silo routing       | 缺客户级 routing key                          | `tenant_id` 是 routing/isolation/audit key                                                  |

## 四、没有 tenant 时,复杂度会散落到 user/store/provider

```mermaid
flowchart TB
  U[Cognito User（Alan）<br>登录身份,不是公司]
  S1[Store 1（门店）<br>payer_id / tier copied]
  S2[Store 2（门店）<br>payer_id / tier copied]
  S3[Store 3（门店）<br>payer_id / tier copied]
  PA1[Provider Account A（RC 账号 A）<br>owned by user]
  PA2[Provider Account B（RC 账号 B）<br>owned by user]
  Stripe[Stripe Customer（付款对象）<br>implicit / inferred]
  Risk[Design Risk（设计风险）<br>客户身份靠推断<br>billing/quota 被复制<br>owner transfer 要改 stores]

  U --> S1
  U --> S2
  U --> S3
  U --> PA1
  U --> PA2
  S1 --> Stripe
  S2 --> Stripe
  S3 --> Stripe
  S1 --> Risk
  S2 --> Risk
  S3 --> Risk
  PA1 --> Risk
  PA2 --> Risk
```

:::note
这个模型的关键问题不是"查不出数据",而是公司主体没有一行稳定记录。payer、tier、quota、provider ownership、owner transfer、company audit 都只能靠 user/store/provider 的组合推断。
:::

## 五、目标模型:tenant 是客户主体,store 是业务地点

```mermaid
flowchart TB
  T[Tenant（客户主体）<br>tenants]
  TM[Tenant Member（客户层成员）<br>tenant_members<br>owner / admin / billing / viewer]
  TSM[Tenant Store Mapping（门店归属）<br>tenant_store_mappings<br>one active tenant per store]
  IS1[Internal Store 1（内部门店）<br>rc_stores.id]
  IS2[Internal Store 2（内部门店）<br>rc_stores.id]
  IS3[Internal Store 3（内部门店）<br>rc_stores.id]
  SM[Store Membership（门店级授权）<br>store_members]
  EA[Effective Access（有效访问范围）<br>tenant role + store grants]
  BD[Business Data（业务数据）<br>V1 query by store_id]
  BQ[Billing / Plan / Quota（商业规则）<br>tenant.tier now; Stripe later]

  T --> TM --> EA
  T --> TSM
  T --> BQ
  TSM --> IS1 --> BD
  TSM --> IS2 --> BD
  TSM --> IS3 --> BD
  SM --> EA
  EA --> IS1
  EA --> IS2
```

各层类型的用法 — 每一层只回答一个问题:

| 层级               | 回答的问题                             | 典型字段/表                                                                             |
| ---------------- | --------------------------------- | ---------------------------------------------------------------------------------- |
| Tenant           | 谁付钱、谁签合同、谁拥有客户级配置                 | tenants, tier, status;billing fields / Stripe mapping 是 future billing integration |
| Tenant member    | 这个 user 在客户层是什么角色                 | `tenant_members(user_id, tenant_id, role)`                                         |
| Store / Location | 具体业务地点或 RingCentral group/site    | `rc_stores.id` today, future `locations`                                           |
| Store member     | 这个 user 能看/编辑哪些具体 stores          | `store_members(user_id, store_id, role)`                                           |
| Phone line       | 具体号码、extension、provider native id | `rc_store_phones`, future `phone_lines`                                            |

## 六、Billing、Plan Tier 和 Quota 为什么必须按 tenant 算

```mermaid
flowchart TB
  T[Tenant（客户主体）<br>付费/合同/套餐边界]
  Plan[Plan Tier（套餐等级）<br>Plus / Premium / Diamond]
  Stripe[Stripe Customer（付款对象）<br>future billing integration]
  Ent[Entitlements（权益限制）<br>max_members / max_stores / credits]
  MU[Member Usage（成员用量）<br>count active tenant_members]
  PT[Provider Topology（RC 分组来源）<br>groups/sites + ungrouped bucket]
  Sync["Store Sync(门店同步)<br>provider topology -> internal stores"]
  SU[Store Usage（门店用量）<br>count active tenant_store_mappings]
  G1[Create Member Guard（成员上限）<br>check tenant quota]
  G2[Store Sync Guard（门店上限）<br>check tenant quota]
  Qty[Billing Quantity（收费数量）<br>active store count]

  T --> Plan
  T --> Stripe
  T --> Ent
  Ent --> MU --> G1
  PT --> Sync --> SU
  Ent --> SU --> G2
  SU --> Qty --> Stripe
```

| Quota/收费项                | 没有 tenant 的问题                                     | tenant 下的正确计数                                           |
| ------------------------ | ------------------------------------------------- | ------------------------------------------------------- |
| 最多 5 个 users             | Alan 创建 5 个,Mary 还能再创建 5 个;按 user 计数会被拆散          | `count(active tenant_members)`                          |
| 最多 10 家 stores           | 不同 user 或多个 RC accounts sync 出来的 stores 难以合并计数    | `count(active tenant_store_mappings)`                   |
| 按 stores 收费              | payer\_id 复制到 stores,升级/降级要批量改                    | Stripe Customer 挂 tenant,quantity 来自 active store count |
| Plus / Premium / Diamond | 挂 user 会跟人走;挂 store 会复制 N 份;挂 provider 会被 RC 拓扑绑死 | `tenant.tier` 是客户级套餐                                    |

## 七、Provider/RingCentral 关系:不要画成两个 source of truth

:::warning
正确语义:RingCentral 是 provider topology 和 provisioning input;`tenant_store_mappings` 是我们内部的 customer ownership/index。它们不是两套并列真相。
:::

```mermaid
flowchart TB
  U[Cognito User（当前连接发起人）<br>user_id]
  UC[User Connection（当前 OAuth 存储）<br>user_connections]
  PA[Provider Account（RingCentral 账号）<br>provider_account_id]
  PT[Provider Topology（RC 结构真相）<br>sites / groups / ungrouped phones]
  Sync[Store Sync Job（同步任务）<br>discover sites/groups]
  IS[Internal Store（内部门店身份）<br>rc_stores.id = store_id]
  PL[Phone Lines（号码归属）<br>rc_store_phones]
  T[Tenant（客户主体）<br>tenants]
  TSM[Tenant Store Mapping（内部归属索引）<br>tenant_store_mappings]
  Meter[Billing Meter（计费口径）<br>active store count]
  TPC[Future: Tenant Provider Connection<br>未来显式 provider ownership]

  U --> UC --> PA --> PT --> Sync --> IS --> PL
  T --> TSM --> IS
  TSM --> Meter
  T -.-> TPC
  TPC -.-> PA
```

| 对象                                | source of truth                      | 用途                                                             |
| --------------------------------- | ------------------------------------ | -------------------------------------------------------------- |
| RingCentral group/site/unassigned | RingCentral API                      | 生成/同步 stores 和 phones 的输入。                                     |
| `rc_stores`                       | Data Plane                           | 我们内部稳定 store identity,业务数据用 `store_id` 归属。                     |
| `tenant_store_mappings`           | Control Plane                        | 某个 store 属于哪个 tenant;做 guard、billing count、dashboard boundary。 |
| Stripe quantity                   | 由 tenant 下 active stores/metering 计算 | 收钱数量,不定义客户主体。                                                  |

## 八、权限模型:老板看全部,manager 看部分

```mermaid
flowchart TB
  U[Cognito User（登录身份）<br>V1: one active tenant]
  TM[Tenant Membership（客户层角色）<br>tenant_members]
  Owner[Owner（老板）<br>all stores + billing]
  Admin[Admin（管理员）<br>all stores by policy]
  Billing[Billing（付款角色）<br>billing only unless store grant]
  Viewer[Manager / Viewer（经理/店员）<br>store-scoped access]
  TSM[Tenant Stores（客户名下门店）<br>tenant_store_mappings]
  SM[Store Grants（门店授权）<br>store_members]
  Scoped[Effective Store Scope（最终可见门店）<br>tenant stores ∩ store grants]
  AllStores[All Tenant Stores（全部门店）<br>Store 1..8]
  BillOnly[Billing Console（账单配置）<br>no business data by default]

  U --> TM
  TM --> Owner --> AllStores
  TM --> Admin --> AllStores
  TM --> Billing --> BillOnly
  TM --> Viewer --> Scoped
  TSM --> AllStores
  TSM --> Scoped
  SM --> Scoped
```

:::note
Owner/admin 的默认边界是 tenant 下所有 stores;viewer/manager 类用户再用 `store_members` 缩小到指定 stores。未来如果需要"区域"这个业务对象,再加 `store_groups` 或 capability,不要把区域经理升成 tenant owner。
:::

注意两个容易混的命名:tenant 层的 `viewer`(客户层角色,默认不带任何业务数据可见范围)和 store 层的 `VIEWER`(`store_members` 里对某家店的只读授权)是两个不同的东西,详见 [`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md) 的命名对照。

## 九、如何从当前状态 establish tenant

```mermaid
flowchart LR
  S1[Step 1<br>Backfill/Create Tenant<br>创建客户主体]
  S2[Step 2<br>Add Tenant Members<br>添加成员角色]
  S3[Step 3<br>Map Existing Stores<br>建立门店归属]
  S4[Step 4<br>Enforce Tenant Guard<br>校验 tenant + store]
  S5[Step 5<br>Count Usage<br>统计 active users/stores]
  S6[Step 6<br>Wire Billing + Quota<br>接 Stripe/套餐限制]
  S7[Future<br>Provider Connections + DB Placement<br>显式电话商归属/数据库路由]

  S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
```

| 阶段              | 现在可做的最小落地                                                                             | 以后再做                                                  |
| --------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| Identity        | `tenants` + `tenant_members`                                                          | multi-tenant user switcher / platform super admin     |
| Store ownership | `tenant_store_mappings`,active store 全局唯一归属                                           | `locations` rename、store groups                       |
| Provider        | 当前通过 `user_connections` + store sync 间接归属                                             | `tenant_provider_connections` 显式挂 tenant              |
| Billing         | V1 可先 manual billing;Stripe Customer mapping 属于 future billing integration,仍应挂 tenant | Stripe webhook、metered billing、plan limit enforcement |
| Isolation       | V1 继续用 `store_id` 查询,入口加 tenant guard                                                 | Pool/RLS 时业务表强制 `tenant_id`                           |

各 step 的实际铺开进度(哪些做完了、哪些卡在半路)见 [`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md)。

## 十、最终给团队的一句话

:::info
**不要把 user 当公司。**`user_id` 是登录身份,会离职、换邮箱、被禁用、成为多个角色;`store_id` 是业务地点,会新增、关闭、重组;`provider_account_id` 是外部连接,会重连、换供应商。tenant\_id 是稳定客户主体,承载 billing、quota、tier、SSO、cross-store dashboard、audit 和 future isolation;provider ownership 可以在 future 通过显式 mapping 接进来。
:::

## 十一、未来演进:只加,不重做

按本文设计演进,**control plane 基本只加不重做;data plane 不需要重新设计,但 Final 阶段有一次"改名 + 收紧约束"的工程**。分三层:

**第一层:纯加(deferred 的 3 张表 + 客户级配置)**

原 6 表方案里没落地的 3 张表,终态仍然需要,各有明确触发条件——触发前不建(空转纯开销),触发后加表挂在 `tenant_id` 上即可,不动现有表:

| 表                             | 解决什么                                    | 触发条件                                                                                               |
| ----------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `tenant_database_placements`  | 这个客户的数据在哪个 DB(routing)                  | 第一个 Silo(独享库)客户 / 第 2 个 pool / 客户在库间搬家                                                             |
| `tenant_provider_connections` | provider 账号显式归属客户(不再通过 user 的 OAuth 推断) | 一客户多 RC 账号合并计费 / webhook 按 provider account 反查 tenant / 接第二家电话商 / owner 离职导致 `user_connections` 悬空 |
| `tenant_provisioning_jobs`    | onboarding 多步流程账本(幂等、断点续跑)              | onboarding 自动化 / self-serve;Silo 时代"给客户建库"是多步异步操作                                                  |

> 加回来时不要照抄归档 DDL(`docs/archive/multi-tenant-v1-plan/rollout.md` 只作起点参考),按当时现实重推。

**第二层:要改,但是改名/收紧,不是重新设计**(即 [`final/migration-plan.md`](/system-design/multi-tenant/final/migration-plan.md) 的 Final 阶段)

| 动作                                                                                               | 性质                                                                                          |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `rc_stores` → `locations`、`rc_store_phones` → `phone_lines`、`store_members` → `location_members` | 改名,形状/主键/数据不动,把"店"中性化(4 repo lockstep)                                                      |
| 业务表 `tenant_id` NULLABLE → NOT NULL + 开 RLS                                                      | 收紧约束——列已提前加好,就是为了避免将来重做                                                                     |
| `rc_stores.user_id`(隐式店主)退役                                                                      | 删一列,前提是 derived fallback 先退役(见 [`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md) 缺口 2) |

**第三层:唯一一个设计时标注的 revisit 点**

`tenant_members` 上"一个 user 只属一个 active tenant"的 partial unique index。将来出现 agency/BPO 代管多客户时放开——`tenants.ts` 注释原话:"THIS index is the one decision to revisit"。也只是删一个索引,不是重做表。

这个"只加不重做"的保证来自三个设计选择:① 归属放 `tenant_store_mappings` 独立行而不是塞列进 `rc_stores`(改归属模型 = 改 mapping,业务数据不动);② `tenant_id` 列提前加、启用推迟,两个动作解耦;③ 改名和拆职责是两件独立小事,不是一次 SPLIT。

:::warning 前提
"只加不重做"成立的前提是 [`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md) 缺口 2-4 的机制先补上。fallback 不退役、mapping 不对账,`rc_stores.user_id` 的旧语义就一直赖着,Final 阶段就要同时处理"改名 + 退役 + 补账"三件事,工程量不再是改名那么简单。
:::

## Source of Truth

- [`callytics-common/src/control-plane/schema/tenants.ts`](https://github.com/retaintive/callytics-common/blob/main/src/control-plane/schema/tenants.ts):**live schema 唯一来源**。V1 是 IDENTITY ONLY:tenants、tenant\_members、tenant\_store\_mappings 已落地;provider connections / DB placement / provisioning 仍是 DEFERRED。
- [`callytics-common/src/control-plane/client/routing.ts`](https://github.com/retaintive/callytics-common/blob/main/src/control-plane/client/routing.ts):store→tenant / user→tenant 的解析逻辑(mapping 优先,过渡期保留 derived fallback)。
- [`callytics-common/src/db/schema/rc-stores.ts`](https://github.com/retaintive/callytics-common/blob/main/src/db/schema/rc-stores.ts):当前 store identity、owner user、provider account、phone mapping。
- [`callytics-common/src/db/schema/user-stores.ts`](https://github.com/retaintive/callytics-common/blob/main/src/db/schema/user-stores.ts):当前 `store_members` 是 store-level shared access,不存 OWNER。
- [`callytics-common/src/db/schema/user-connections.ts`](https://github.com/retaintive/callytics-common/blob/main/src/db/schema/user-connections.ts):当前 user 到 RingCentral OAuth account 的连接。
- [`overview.md`](/system-design/multi-tenant/overview.md):multi-tenant 文档集入口 — tenant 是付费实体,location/store 是 tenant 内业务单位。
- [`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md):V1 已知缺口与补齐路线(生产实测证据)。
