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

# Multi-Tenant 架构总览(从这里开始)

这份是 multi-tenant 这套文档的入口。读完能回答 3 件事:**为什么要补 tenant 这一层 / 现在打算怎么做 / 以后会演进成什么样**。

## 一句话讲清楚问题

retaintive 现在的数据库里只有"店"这一层(`store_id`),没有"客户"这一层。

健身房单店没事。客户复杂起来立刻分裂:

> Glow Beauty 公司开了 3 家店(SoHo / Brooklyn / Queens)。**总部老板的 Stripe 信用卡 / SSO 配置 / 合同 tier 应该挂在哪?** 现在只能复制 3 份到每家店,改 1 个字段要同步 3 次,还可能漏。

业界把"谁付钱的"那层叫 **tenant**(租户)。Slack 叫 workspace,Linear 叫 workspace,Salesforce 叫 Account — 名字不一样,概念是同一个。**我们要补的就是这层**。

## 这套设计分两步走,不是一次性大重构

| 阶段                             | 我们做什么                                                                  | 业务代码                      | 详细看哪份                                                                                                                              |
| ------------------------------ | ---------------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| **V1 上线方案**(已落地 identity-only) | 加一层"客户档案"和"门店归属表",业务代码基本不动                                             | 不改                        | 决策说明 [`why-tenant.md`](/system-design/multi-tenant/why-tenant.md) + 铺开进度与缺口 [`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md) |
| **Final 长期演进**(以后做)            | 把业务表里的 `store_id` 这种带"店"字的列改成中性的 `location_id`,加 Stripe / SSO / 多电话商支持 | 4 个 repo lockstep 改 3-4 周 | [`final/migration-plan.md`](/system-design/multi-tenant/final/migration-plan.md)                                                   |
| **通用方法论**(架构师查)                | Pool / Silo / Hybrid / RLS / 自动化 / Stripe / 业界对照                       | —                         | [`best-practices.md`](/system-design/multi-tenant/best-practices.md)                                                               |

### V1 上线方案用人话讲

```
现在:
   登录 → user_id → 查询时直接 WHERE store_id IN (...)
                    没有"客户"这一层概念

V1 之后:
   登录 → user_id → 查 tenant_members 表 → 拿到 tenant_id(客户 ID)
                  → 查 tenant_store_mappings 表 → 拿到这个客户名下哪些 store
                  → 业务查询继续 WHERE store_id IN (...)
                  ↑ 业务 SQL 不变,但多了一层"客户边界"
```

3 件事 V1 实际做了(**identity-only**,完整决策说明见 [`why-tenant.md`](/system-design/multi-tenant/why-tenant.md)):

1. **加 3 张核心表**(客户档案 `tenants` / 用户跟客户关系 `tenant_members` / 客户名下有哪些店 `tenant_store_mappings`),放在独立的 control plane Neon project(`retaintive-identity`),另有 1 张过渡期兼容表 `tenant_store_overrides`
2. **加 1 个解析层**(`callytics-common/src/control-plane/client/routing.ts`)— 回答"这个 user 属于哪个客户 / 这家店属于哪个客户"
3. **业务表加 NULLABLE `tenant_id` 列**(test 已 apply,prod 待铺,见 [`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md))

**V1 不做**(留给 Final;其中前 3 条是最初 6 表方案的一部分,未按原样落地,旧方案已归档到 `docs/archive/`):

1. ~~DB routing~~(`getDbForTenant` / `tenant_database_placements`)— Silo/Pool 分库时才需要
2. ~~provider 归属表~~(`tenant_provider_connections`)+ ~~onboarding job ledger~~ + ~~SQS webhook routing~~
3. ~~把 `rc_stores` 表改名叫 `locations`~~ — 这是 4 repo lockstep,3-4 周工作量
4. ~~业务表 `store_id` 改名 `location_id`~~ — Hybrid 模式才必须(下方解释)
5. ~~每个客户单独一个 Neon 数据库~~ — V1 全部留在当前 shared DB,叫 `pool_orange_theory`

### 业务表加列 vs 改名 — 两个独立动作

> 这是新人最容易搞混的地方,先讲清楚再往下读。

`store_id` 单字段同时背了两个职责 — "**哪家店**"(业务划分)+ "**哪个客户**"(隔离)。Final 阶段用 **2 个独立 ALTER** 解决,**时机不同**(不是 1 个 SPLIT 把单字段拆成 2 字段,是 2 件独立小事):

| 动作                                              | V1 做不做                                                                                         | Final 触发                                                                  |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| **业务表加 `tenant_id` 列**(NULLABLE,跟 store\_id 并存) | **V1 就加** — 加列是 0 风险动作(不动现有 query),并且为未来 Pool/Hybrid 铺路。新写入数据 backfill 后 NOT NULL 仍是 Final 阶段做 | 第 1 个 Hybrid / Pool 客户来时把 NULLABLE → NOT NULL,业务 query 改用 `tenant_id` 做隔离 |
| **`store_id` 改名为 `location_id`**                | **不做**                                                                                         | 第 1 个客户要 Pool 共享 DB 时必须做(4 repo lockstep)                                 |
| **业务表全面用 `tenant_id` 替代 `store_id` 做隔离**        | **不做** — V1 仍用 store\_id 隔离,只是字段提前加好                                                           | 第 1 个 Hybrid / Pool 客户来时必须做                                               |

判定标准 1 句话:**只要我们还是"每个客户一个独立数据库"(Silo),业务表的 `store_id` 单字段够用**。一旦有 2 个以上客户共享同一个 Neon 数据库,必须有 `tenant_id` 列才能做 RLS 隔离。

→ 详细工程拆分见 [`final/migration-plan.md`](/system-design/multi-tenant/final/migration-plan.md) 阶段 1-4。

读完这份文档能回答的 8 个问题(分 3 类):

- **生命周期**(客户自己变化):扩张 1→N、缩小 N→1、搬家 Pool↔Silo、tier 升降、改付款人、改 vertical、M\&A 合并
- **平台接入**(外部对接):3 方 CPaaS、BPO 转售
- **2026 潮流**(新增能力):对外 API、AI 介入、Hybrid 部署、Dashboard 老板视角(聚合 / 单店 / 对比)

***

## 一、几个核心词,先讲人话

读后面之前,先把这几个词搞清楚。**所有章节都假设你懂下面这几个词**。

| 词                   | 人话                                                                                                                                             | retaintive 例子                                               |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| **tenant**          | 租户。1 个付费实体 = 1 个 tenant。隔离边界:跨 tenant 数据绝对不串。                                                                                                  | Glow Beauty 公司 = 1 tenant                                   |
| **location**        | tenant 内的物理执行单位 (一家店 / 一个 call center site)。同 tenant 内 location 之间允许互看。                                                                        | Glow 的 SoHo / Brooklyn / Queens = 3 个 location              |
| **phone\_line**     | 具体电话号码,挂在 location 下。                                                                                                                          | SoHo 店的 +1-555-1234                                         |
| **Pool**            | 多 tenant 共享同一个数据库。成熟 Pool 通常靠 `tenant_id` / RLS 隔离；V1 的 Orange Theory pool 先靠 Control Plane + `tenant_store_mappings` + current `store_id` 边界。 | `pool_orange_theory` = 当前 shared Neon DB                    |
| **Silo**            | 1 个 tenant 独占一个数据库 (物理隔离)。贵但合规。                                                                                                                | United Airlines 单开一个 Neon project                           |
| **Hybrid**          | 同一套系统里 Pool + Silo 共存。常见组合是低价 / trial 走 Pool，高价值 / 合规客户走 Silo。                                                                                 | Optional future；当前 Final 可以保持 Silo-first / all-Silo default |
| **Stripe Customer** | Stripe 那边的付款实体。默认 1 个 tenant 对应 1 个 Stripe Customer；V1 可先 manual billing,字段预留。                                                                 | Glow Beauty 公司 = 1 个 Stripe Customer                        |
| **CPaaS**           | 电话能力服务商。Twilio / RingCentral / Genesys 这些。                                                                                                     | 我们现在用 RingCentral                                           |
| **BPO**             | call center 外包公司,自己服务 N 个 end-client。                                                                                                          | TaskUs 这种公司 (我们暂时没接)                                        |

**关键认知**: `tenant` 是设计层的概念,UI 上可以叫 "Account" / "Organization" / "Workspace" 都行。但代码 / DB / API 内部统一叫 `tenant`。

***

## 二、为什么要改

跨店看数据已经能做,不是 UX 问题。**真问题在架构里少了一层**:只有"店",没有"客户"。

`store_id` 一个字段背了两件事 — **哪家店** + **属于谁付钱的**。客户复杂起来 (3 店 1 老板 / 10 个 call center 1 公司 / 跨行业) 立刻分裂出 4 个痛点:

| # | 痛点         | 一句话                                                           |
| - | ---------- | ------------------------------------------------------------- |
| 1 | 换行业进不来     | "店"装不下 call center / 跨 vertical / 总部                          |
| 2 | 客户级东西没地方挂  | Stripe / SSO / tier / 品牌只能复制 N 份到每家店                          |
| 3 | 跨店要写 2 套代码 | 单店一套,聚合一套 (现在 `dashboard-summary` vs `dashboard-multi-store`) |
| 4 | 边缘场景扭曲     | 1 电话挂多店变多对多,本该是客户级归属                                          |

业界把"谁付钱的"那层叫 `tenant` (租户)。**我们要补的就是这层**。

`franchise_id` 字段不是 legacy,是降级 — 终态当"品牌标签"用 (聚合分析 OrangeTheory 全美加盟商整体表现),不再当隔离。详见 [`final/migration-plan.md`](/system-design/multi-tenant/final/migration-plan.md) §六 franchise 归位。

***

## 三、现状 → 终态:一张图看完

读图前先把两个时间点分清楚：

| 时间点                       | schema 重点                                                                                                                                                                                                                              | 为什么这样做                                                       |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| **V1**(已落地 identity-only) | 新增 Control Plane 表：`tenants` / `tenant_members` / `tenant_store_mappings`(DB placement / provider connections / provisioning jobs 已 DEFERRED)。Data Plane 继续用当前 `rc_stores` / `store_id`。                                               | 少改业务代码，先把客户边界和权限做对;DB routing / webhook routing 推迟到有真实分库需求时。 |
| **Final**                 | 把业务词汇中性化:`rc_stores -> locations`、`rc_store_phones -> phone_lines`、`store_members -> location_members`。**当第 1 个 Hybrid / Pool 客户来时**(2 个以上 tenant 共享同一个 DB),业务表 `store_id` 改名为 `location_id` + 新加 `tenant_id` 列(2 个独立 ALTER,不是 SPLIT)。 | 为非 Orange Theory、非 RingCentral、Stripe/SSO/API/AI 等长期平台能力铺路。  |

下面这张表讲的是 **Final 业务模型**，不是 V1 上线清单。V1 只先落地其中的 Control Plane 和 routing 部分。

按层级排序 — 顶层 (客户) 在上,字段拆分在中,降级字段在底。**最后一列用 Glow Beauty (3 店连锁) 套一遍**,看完表就知道实际数据长啥样:

| 终态 (新的)                           | 定义 (人话)                            | 现状 (原来)                                     | 怎么变                             | 例子 (Glow Beauty)                                     |
| --------------------------------- | ---------------------------------- | ------------------------------------------- | ------------------------------- | ---------------------------------------------------- |
| **客户表** (`tenants`)               | 谁付钱给我们 / 合同 / 总公司主体                | 没有这张表                                       | **全新增**                         | Glow Beauty 公司 = 1 行                                 |
| 店表 (`locations`)                  | 一家实体店 / 一个 call center site / 一个工厂 | 现在叫 `rc_stores`                             | 改名 (现在表名绑死了 RC,换中性词)            | SoHo / Brooklyn / Queens = 3 行                       |
| 电话表 (`phone_lines`)               | 具体一根电话号码                           | 现在叫 `rc_store_phones`                       | 改名 + 加字段 (以后要支持 Twilio 等其他厂商)   | SoHo 店的 3 个号码 = 3 行                                  |
| 数据表里的"客户"字段 (`tenant_id`)         | 每条记录属于哪个客户 — **隔离边界,跨客户绝对不串**      | 数据表里的 `store_id` 字段同时背了"是哪家店" + "是哪个客户" 两件事 | 把"客户"那部分职责单独拿出来一列               | Glow 所有联系人这一列都填 "Glow"                               |
| 数据表里的"店"字段 (`location_id`)        | 每条记录属于哪家店 — **业务划分,同客户内可看**        | `store_id` 的另一半职责                           | 把"店"那部分职责也拿出来,独立成一列             | SoHo 店的联系人这一列填 "SoHo"                                |
| 客户表里的"品牌"字段 (`tenants.franchise`) | 加盟品牌名 (跨客户做整体分析用)                  | `franchise_id` 散在每张数据表里,每条记录都要填             | 把所有数据表里的"品牌"列**全部删掉**,只在客户表里留一个 | Glow Beauty = null;OrangeTheory 加盟商 = "orangeTheory" |

**1 个客户 → N 家店**是弹性的,不动 schema:

- **Sarah** 单店 → 1 客户,1 店 (Devon)
- **Glow Beauty** → 1 客户,3 店 (SoHo / Brooklyn / Queens)
- **United Airlines** → 1 客户,10 店 (10 个 call center site)

**关键**:Glow Beauty **公司本身**(客户)和 **SoHo 这家店**(店)以前混在 `store_id` 一个字段里,现在分开。**谁付钱 / SSO / 合同等级** 挂在客户层,**电话 / 员工 / 营业时间** 挂在店层。

**现状一句话**:单层 `store_id` 隔离 + 只接 RingCentral + 没接 Stripe + 没有客户层概念。详细 schema 数字 (22 张表分类 / 67 文件) → 见 [`final/migration-plan.md`](/system-design/multi-tenant/final/migration-plan.md) §四 现状。

**终态**:

```
╔═══════════════════════════════════════════════════════════════════╗
║                       Final State (2027)                          ║
╠═══════════════════════════════════════════════════════════════════╣
║                                                                   ║
║  ┌────────────┐         ┌──────────────┐        ┌──────────────┐ ║
║  │  Stripe 侧 │   ←→    │ retaintive 侧│   ←→   │ Provider 侧  │ ║
║  ├────────────┤         ├──────────────┤        ├──────────────┤ ║
║  │ Customer   │ ──────→ │  tenants     │        │ RingCentral  │ ║
║  │ Subscription│ webhook│   (合同主体) │        │ Twilio       │ ║
║  │ Price      │         │       │      │        │ Genesys      │ ║
║  └────────────┘         │       ↓ 1:N  │        │ Five9        │ ║
║                         │  locations   │ ←───── │ Amazon Connect│ ║
║                         │   (业务划分) │  sync  └──────────────┘ ║
║                         │       │      │            ↑            ║
║                         │       ↓ 1:N  │            │            ║
║                         │  phone_lines │            │ 这些都是   ║
║                         │  (具体号码)  │ ←──────────┘ 数据源     ║
║                         │              │   每个 provider 一套    ║
║                         │ (BPO 可选)   │   ID, 我们不在顶层用    ║
║                         │ tenant_clients│   他们的词              ║
║                         └──────────────┘                          ║
║                                                                   ║
║  对外:                                                            ║
║   • Public REST API (给客户 dev 自己集成)                         ║
║   • Webhook (我们 → 客户)                                         ║
║   • AI Agent SDK / MCP server (AI 调用)                           ║
║                                                                   ║
╚═══════════════════════════════════════════════════════════════════╝
```

这张图里的 `tenants` 不只是某个业务 DB 里的普通表。终态会有一个中心的 **Control Plane** 管这些平台级数据：tenant 是谁、谁能访问、Stripe / SSO / custom domain 绑定到谁、每个 tenant 的数据在 Pool 还是 Silo。业务数据本身放在 Data Plane，也就是 Pool DB 或 tenant 专属 Silo DB / Neon branch。

V1 实际先上线的是这套终态里的**身份层**(identity-only):Control Plane 建了 3 张表(`tenants` / `tenant_members` / `tenant_store_mappings`),DB placement / provider connections / provisioning jobs 推迟;Orange Theory 现有数据先保留在 `pool_orange_theory`,不急着全量 rename 业务表(`rc_stores` → `locations` 等)。业务表的 NULLABLE `tenant_id` 列已在 schema 加好(prod migration 待铺,见 [`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md))。

下面所有章节都在解释这张图的细节。

***

## 四、3 个真实客户怎么映射

举例用 3 个虚构客户贯穿全文。下面是 Final 模型的概念例子；V1 里的 Orange Theory tenants 可以先映射到 `pool_orange_theory`，并通过 `tenant_store_mappings` 约束能访问哪些 current `rc_stores`。

### 客户 A:Sarah — 单店健身房加盟商

```
Sarah (Cognito 账号)
   │
   ├─ tenants: 1 行
   │    name="Sarah's OTF Devon"
   │    vertical="gym"
   │    franchise="orangeTheory"     ← 品牌字段保留
   │    tier="smb"
   │    placement="pool"
   │    billing_provider_customer_id="cus_AAA"
   │
   ├─ locations: 1 行
   │    name="Devon"
   │
   ├─ phone_lines: 5 行
   │    (5 个 RC 号码)
   │
   └─ Stripe: Sarah 自己刷信用卡,月付
```

Sarah 的查询: `WHERE tenant_id = 'A'` — 因为只有 1 个 location,聚合视图 = 单店视图。

### 客户 B:Glow Beauty Corp — 3 店连锁

```
Glow 总部 (Cognito 账号)
   │
   ├─ tenants: 1 行
   │    name="Glow Beauty Corp"
   │    vertical="beauty"
   │    franchise=null                ← 独立品牌,不是加盟体系
   │    tier="pro"
   │    placement="pool"
   │    billing_provider_customer_id="cus_BBB"
   │
   ├─ locations: 3 行
   │    SoHo / Brooklyn / Queens
   │
   ├─ phone_lines: 9 行 (每店 3 个)
   │
   └─ Stripe: 总部 1 张卡付全部,月付
```

Glow 老板的 3 种 dashboard 视图都能轻松实现:

```
聚合视图     WHERE tenant_id = 'B'                          → 75 条 (3 店合计)
单店视图     WHERE tenant_id = 'B' AND location_id = 'soho' → 32 条
对比视图     WHERE tenant_id = 'B' GROUP BY location_id     → SoHo 32, BK 18, Queens 25
```

**痛点解决了**: 老板 1 次登录,聚合 / 切店 / 对比都通。

### 客户 C:United Airlines — 10 个 call center

```
United 公司合同
   │
   ├─ tenants: 1 行
   │    name="United Airlines"
   │    vertical="airline"
   │    franchise=null
   │    tier="platinum"
   │    placement="silo"          ← 关键: 走独立 DB
   │    region="us-east-1+eu-west-1"
   │    billing_provider_customer_id="cus_CCC"
   │
   ├─ locations: 10 行
   │    Tampa CC / SLC CC / CVG / DFW / ATL / SEA / 4 个 UK site
   │
   ├─ phone_lines: ~5000 行
   │    每 site ~500 DID + tollfree
   │
   └─ Stripe: 公司合同,invoice / annual contract
```

United 的数据**物理上**不在主 Neon DB,在专属 Neon project `proj-united`。代码读 `tenants.placement` 字段路由过去。

***

## 五、Pool / Silo / Hybrid 是啥 (初学者第一图)

新人最容易困惑的地方。用最简单的图建立直觉。

先记住当前决策:这节是概念解释,不是 V1 默认方案。V1 是 `pool_orange_theory` + Control Plane routing；Final default 可以继续 Silo-first / all-Silo。Pool/Hybrid 只有当低价客户很多、free trial 很多、或 branch-per-tenant 的 compute / migration / secret / monitoring 成本开始吃掉毛利时才考虑。

```
                   ┌──── Pool 区 (共享 Neon DB) ────┐
                   │                                  │
                   │  contacts 表                     │
                   │  ┌─────────────────────────────┐│
                   │  │ tenant_id │ phone │ name ...││
                   │  ├───────────┼───────┼─────────┤│
                   │  │ Sarah-A   │ +555  │ Anna    ││
                   │  │ Sarah-A   │ +666  │ Bob     ││
                   │  │ Glow-B    │ +777  │ Carol   ││  ← 都在一张表
                   │  │ Glow-B    │ +888  │ Dave    ││  ← 靠 tenant_id 隔离
                   │  │ ...       │ ...   │ ...     ││
                   │  └─────────────────────────────┘│
                   │  WHERE tenant_id = 'A' 只看 Sarah│
                   │  WHERE tenant_id = 'B' 只看 Glow │
                   │                                  │
                   │  成本: 共享 compute,小客户摊薄低  │
                   │  适合: 大量低价 / trial tenants   │
                   └──────────────────────────────────┘

                   ┌──── Silo 区 (独立 Neon project) ────┐
                   │                                       │
                   │  proj-united (United 专用)            │
                   │  ┌──────────────────────────────────┐│
                   │  │ contacts 表 (只有 United 数据)    ││
                   │  │ phone │ name │ ...                ││
                   │  │ +999  │ Eve  │ ...                ││  ← 物理隔离
                   │  └──────────────────────────────────┘│
                   │                                       │
                   │  成本: 独立 endpoint / secret / compute│
                   │        / migration / monitoring        │
                   │  适合: 高价值 / 合规 / enterprise      │
                   │  好处: 物理隔离 / 性能 / blast radius  │
                   └───────────────────────────────────────┘

                   Hybrid = 上面两个区同时存在
                           tenants.placement 字段决定走哪个
```

**怎么选**:

- 当前 Orange Theory:保留在 `pool_orange_theory`,但通过 Control Plane 明确 tenant / store / placement 边界。
- 高价值客户或合规客户:可以 promote 到 Silo branch/project。
- 大量低价 self-serve / free trial:以后才考虑成熟 Pool / Hybrid,用 `tenant_id` / RLS 做共享 DB 隔离。

> **Silo-first 的硬前提:必须把 onboarding / migration / monitoring 自动化**。手工管 5 个客户的 Neon project 还行,10 个就 human error 频出。详细需要自动化的 7 件事(Stripe / Neon provision / secret / migration fan-out / smoke test / provider rebind / churn delete)见 [`best-practices.md`](/system-design/multi-tenant/best-practices.md) §Silo 自动化必备的 7 件事。第 6 个 Silo 客户来之前 4 件必须做完,第 10 个之前 7 件全做完。

***

## 六、Hybrid 的真实形态:不是 DB 二选一,是 3 面分别决定

上面那张图是直觉。但**真实的 Hybrid 不是"DB 二选一"** — 它是 3 个面 (数据 / 接入 / 运维) **各自**决定 Pool 或 Silo。

```
                    Sarah (SMB)    Glow (Pro)     United (Platinum)
                  ──────────────  ──────────────  ──────────────────
数据面            共享 Neon DB    共享 Neon DB    ★ 独立 Neon project
  (DB / 存储)     共享 S3 前缀    共享 S3 前缀    ★ 独立 S3 bucket

接入面            共享 Cognito    共享 Cognito    ☆ 独立 SAML SSO
  (登录 / API)    共享 API host   共享 API host   ☆ 独立 endpoint

运维面            共享监控        共享监控        ★ 独立 CloudWatch
  (监控 / 审计)   共享 audit      共享 audit      ★ 独立 audit 流

  ★ = 必须独立 (合规硬要求)
  ☆ = 按需独立 (客户选,默认共享)
```

**3 个关键认知**:

1. **Hybrid 不是开关,是每个面逐项决定**。
2. **Platinum tier ≠ 全部独立**。AWS Account 通常仍共享 (太贵),Neon / 监控才必须独立。
3. **`tenants.placement` 单字段不够** — 真实落地是 `placement_profile` (预定义组合)。

→ 完整 8 维度细节 (Cognito / SAML / DynamoDB / S3 prefix / Rate limit / sub-account / namespace / audit 流) 和 placement\_profile 字段设计 → 见 [`best-practices.md`](/system-design/multi-tenant/best-practices.md)。

***

## 七、9 个常见生命周期变化

每个客户的真实变化我们怎么处理。**80% 是改 1 个字段**,只有 1 个真贵。

| # | 客户的变化                     | 我们做什么                                                            | 改动级别 |
| - | ------------------------- | ---------------------------------------------------------------- | ---- |
| 1 | Sarah 新开 1 店              | 加 1 行 location                                                   | ⭐    |
| 2 | Sarah 关 1 店               | 标记关闭(数据保留)                                                       | ⭐    |
| 3 | Sarah 改 vertical (健身房加美容) | 加 1 行 location,类型标 "beauty"                                      | ⭐    |
| 4 | Sarah 改付款人 (转给老婆)         | 改 1 个字段(Stripe Customer ID)                                      | ⭐    |
| 5 | Sarah 拆品牌账单               | 新建 tenant,把对应店转过去                                                | ⭐⭐   |
| 6 | 升 tier smb→pro            | Stripe 那边改套餐,我们 webhook 自动同步                                     | ⭐    |
| 7 | 单个 tenant promote 到 Silo  | 把该 tenant 的 store-scoped 数据搬到独立 DB,先可 maintenance window,以后再做无停机 | ⭐⭐⭐⭐ |
| 8 | Sarah 取消订阅 (churn)        | 软删,30/60/90 天保留期,期满删干净                                           | ⭐    |
| 9 | M\&A 合并 (2 tenant 合 1)    | 把店全转到留下的那个 tenant                                                | ⭐⭐⭐  |

**第 7 项 promote-to-silo 是最贵的**:开独立 Neon branch/project / 历史数据回填 / 切读 / 切写 / 校验。早期可以用 maintenance window；要做到无停机时才需要双写期和 reconciliation。

→ 完整工程细节 + 函数实现 + GDPR delete → 见 [`final/migration-plan.md`](/system-design/multi-tenant/final/migration-plan.md) 的 long-term migration sections。

***

## 八、Dashboard:老板想"聚合看 / 分开看 / 对比看"

Glow Beauty 老板 (3 店) 想看 3 种视图:今天 3 店一共多少电话 / SoHo 店本周怎样 / 3 店对比谁好。

终态:1 次登录,顶部切换器选视角,后端自动按 `tenant_id` 或 `location_id` 过滤。

```
              老板 (User) 登录
                    │
                    ├─→ 拥有 tenant=tnt_002 (Glow Beauty Corp)
                    │     │
                    │     └─→ 3 个 location: SoHo, Brooklyn, Queens
                    │
                    └─→ Dashboard 顶部有个切换器:

               ┌────────────────────────────────┐
               │ [Glow Beauty Corp ▼]           │  ← tenant 级
               │   • All Locations (聚合)       │  ← 默认: 跨所有 location
               │   • SoHo                       │  ← 单 location 视角
               │   • Brooklyn                   │
               │   • Queens                     │
               │   • Compare: SoHo vs BK        │  ← 对比视角
               └────────────────────────────────┘
```

3 种视图怎么查:

| 视图      | 过滤条件                                   |
| ------- | -------------------------------------- |
| 聚合 (默认) | 按 `tenant_id` 拿 Glow 的所有数据             |
| 单店      | 按 `tenant_id` + `location_id` 拿 SoHo 的 |
| 对比      | 按 `tenant_id` 拿全部,然后按 `location_id` 分组 |

→ 底层数据带"客户 + 店"两层维度,前端切换器决定怎么过滤。1 套数据服务 3 种视角(对比 [二、为什么要改](#二为什么要改) 痛点 3 的"写 2 套代码"问题)。

***

## 九、Provider 怎么对接 — 我们用统一词,他们各家不同

我们要从 RingCentral / Twilio / Genesys / Five9 / Amazon Connect 等服务商拿电话数据。他们各家命名都不一样。

**核心做法**:我们**不跟任何一家对齐**。内部统一用 `tenant / location / phone_line`,中间加一层 adapter 翻译到各家。

```
        我们的内部 model (统一)             Provider 各家的 model (各自)
        ─────────────────────             ──────────────────────────

        tenants
          │                                 RingCentral:
          │                                   Account → Site → Extension
          ↓
        locations  ←──── adapter ─────┬──  Twilio:
          │              (映射表)        │   Account → Subaccount → Phone
          │                              │
          ↓                              ├──  Genesys:
        phone_lines ←──── adapter ──────┤   Org → Site → Queue
                                        │
                                        ├──  Five9:
                                        │   Domain → Campaign → Skill
                                        │
                                        └──  Amazon Connect:
                                            Instance → Routing Profile → Queue
```

**怎么实现**:加 2 张新表 —

- **凭证表**:存 OAuth / API key,记哪个 tenant 接了哪家 provider
- **映射表**:把我们的 location 映射到 provider 那边的 site / subaccount / queue

→ 同一个 location 可以**同时挂多个 provider** (例:United Tampa 用 Genesys 当主路由,Amazon Connect 当溢出备份)。

→ 完整表结构 DDL + adapter 接口设计 + 多 provider 数据合并策略 → 见 [`best-practices.md`](/system-design/multi-tenant/best-practices.md) §六。

**3 个客户怎么映射**:

- **Sarah** 用 RingCentral → tenant 下 1 个 location (Devon),location 挂到 RC 的某个 site,拉 5 个电话
- **Glow Beauty** 用 RingCentral → tenant 下 3 个 location,各自挂到 RC 的 3 个 site,共拉 9 个电话
- **United** 用 Genesys + Amazon Connect 混搭 → tenant 下 10 个 location,每个 location **同时挂 2 个 provider** (主备切换),拉 \~5000 个电话

### 5 大主流 CPaaS 命名速查

| 平台                 | 顶层 (相当于我们 tenant) | 子层 (相当于我们 location)            |
| ------------------ | ----------------- | ------------------------------ |
| **RingCentral**    | Account           | Site → Extension → Phone       |
| **Twilio**         | Account           | Subaccount → Phone             |
| **Genesys Cloud**  | Organization      | Site → Queue → Agent           |
| **Five9**          | Domain            | Campaign → Skill → Agent       |
| **Amazon Connect** | Instance          | Routing Profile → Queue → User |

**核心**:所有家顶层用的都是中性词 (Account / Organization / Domain / Instance)。**没有任何一家**用业务词 (store / merchant)。我们用 `tenant` 跟业界对齐。

**前提**:别把 provider 的词漏到主表。现在 `franchise_id` 也是历史包袱要清 (见 §二)。

***

## 十、BPO 是啥?未来场景

BPO = Business Process Outsourcing,call center 外包公司。我们暂时不做,但设计上要预留。

```
                  ┌─────────────────────────────────────────────┐
                  │  retaintive (我们)                          │
                  └─────────────────────────────────────────────┘
                          ↑
                          │ 谁付 retaintive 的钱? = tenant
                          │
        ┌─────────────────┼──────────────────┐
        │                 │                  │
   Sarah 健身房      United Airlines     TaskUs (BPO 外包)
   tenant=Sarah    tenant=United        tenant=TaskUs
                                            │
                                            │ TaskUs 自己干嘛?
                                            │ 给别人做 call center
                                            │
                                ┌───────────┼───────────┐
                                │           │           │
                              Airbnb     Stripe      Doordash
                            (TaskUs 服务的) (TaskUs 服务的)

                          这 3 个是 TaskUs 的 "client"
                          但他们不付钱给 retaintive
                          → 不是 tenant
                          → 但 TaskUs 想在 dashboard 里
                             按 client 分别看报表
                          → 这就是 tenant_clients
```

**所以 `tenant_clients` 是**: BPO 客户**自己内部**的细分,我们给他们一个**业务字段**支持 (`contacts.client_id`),不是隔离边界。

**如果不做 BPO 这门生意**: `tenant_clients` 表永远是空,Sarah / Glow / United 完全不用,留 NULL。

***

## 十一、2026 潮流 — API + AI 能撑住吗?

未来想做:

- **公开 API**:让客户自己开发集成 (类似 Stripe 给开发者用的 API)
- **AI 介入**:客户的 AI Agent (Claude / GPT 等) 通过 MCP 直接调用我们的能力

这套设计**专门**为 API + AI 准备 (见 §三 final state 图右下角的"对外"层)。

### API platform 怎么挂

每个客户拿到 1 个 API key,所有 endpoint 自动 scope 到该 tenant (拿不到别人的数据)。

```
公开 REST API (api.retaintive.ai)
  │
  ├─→ 客户带 API key 请求
  │       ↓
  │   服务端从 key 解出 tenant,再路由到该 tenant 的数据库(future 的 getDbForTenant,尚未落地)
  │   Pool 模式才需要业务表 WHERE tenant_id / RLS
  │
  ├─→ 提供联系人 / 通话 / 任务 / 消息等 endpoint
  │
  ├─→ 每个 tenant 独立的 rate limit (按 tier 不同)
  │
  └─→ Webhook 反向推数据给客户 (新联系人 / 通话分析完成 / 新任务)
```

→ **核心**:API 看到的 tenant 就是数据库里的 tenant,**不需要重新设计**。Stripe / Twilio / Slack 都是这套。

### AI / MCP 怎么挂

客户的 AI Agent 通过 MCP 协议连过来,调用我们提供的 tool (比如 "搜联系人 / 看通话纪要 / 创建任务")。

```
客户的 AI Agent (Claude / GPT / Custom)
  │
  │ 通过 MCP server 连接 (带 API key)
  ▼
retaintive MCP server
  │
  │ Tool 名字 (自然语言):
  │   - search_contacts(query)
  │   - get_call_summary(call_id)
  │   - create_task(...)
  │
  ▼ 每个 tool 自动注入 tenant 边界 (来自 API key)
   AI 看不到 tenant 参数, server 强制注入
```

→ **核心**:AI 想越界访问别人 tenant 也做不到 — 因为 tenant 参数它根本碰不到,是 server 强制塞进去的。

**为什么 `tenant` 命名对 AI 友好**:AI 训练数据里 Stripe / Slack / Salesforce 都用 tenant 概念,AI 一看就懂。如果叫 `store_id`,AI 还得猜"是零售 store 还是 SaaS 用语"。

→ 2026 潮流:**中性词 = AI 友好**,业务词 = AI 困惑。
→ 完整 MCP 工具列表 + tenant 注入实现细节 → 见 [`best-practices.md`](/system-design/multi-tenant/best-practices.md) §七。

***

## 十二、5 条硬规则 + 8 治理项 (速查)

### 5 条硬规则 (不可破)

1. **隔离**: 跨 tenant 数据绝对不串。同 tenant 内跨 location 允许互看。
2. **计费**: 默认 1 tenant = 1 Stripe Customer；V1 可 manual billing,但 Stripe 字段要预留。
3. **BPO**: BPO 公司是 tenant,end-clients 是 `tenant_clients` (业务字段,不是隔离层)。
4. **扩张**: tenant ↔ location 是 1:N,N 改字段不动 schema。
5. **Placement ↔ 部署**: 部署位置由 Control Plane 的 placement 决定。V1 默认 `pool_orange_theory`;Final default 可以 Silo-first;Pool/Hybrid 只是 future option。

### 8 治理项 (要承认存在,不一定立刻做)

| # | 项                       | 啥意思                                            | 现在有吗 |
| - | ----------------------- | ---------------------------------------------- | ---- |
| 1 | Lifecycle 状态机           | trial → active → suspended → churned → deleted | ❌    |
| 2 | GDPR delete             | 一键导出 + cascade delete 整个 tenant                | ❌    |
| 3 | Audit log per tenant    | 谁看了谁的数据,航空业必须有                                 | ❌    |
| 4 | Admin impersonate       | 我们员工怎么 debug 客户问题                              | ❌    |
| 5 | Per-tenant feature flag | 不同 tier 开不同功能                                  | ⚠️ 半 |
| 6 | Region failover         | Platinum 客户的 DR                                | ❌    |
| 7 | Migration tool          | promote-to-silo / churn 删除 / future Pool↔Silo  | ❌    |
| 8 | Provider unbind         | 客户换 CPaaS 不掉数据                                 | ❌    |

***

## 十三、想深入看哪份?

| 想看什么                                                                         | 去哪                                                                               |
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| 业界怎么想 multi-tenant,通用方法论,完整 8 维度 Hybrid,DDL 表结构,MCP 实现细节                     | [`best-practices.md`](/system-design/multi-tenant/best-practices.md)             |
| Final / long-term refactor 怎么从 V1 演进到中性 schema、Silo-first、future Pool/Hybrid | [`final/migration-plan.md`](/system-design/multi-tenant/final/migration-plan.md) |
| 当前 store\_id 隔离设计 (legacy, 已 ship)                                           | [`store-level-isolation.md`](/system-design/store-level-isolation.md)            |
