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

# V1 上线后的长期演进路径

这份文档讲 **V1 之后** 怎么从 current Orange Theory pool + Control Plane 演进到更中性的 Final 架构。

先读这个边界:

- **V1 实际落地口径**看 [`why-tenant.md`](/system-design/multi-tenant/why-tenant.md)(原 6 表方案已归档到 `docs/archive/multi-tenant-v1-plan/`)。V1 是 identity-only Control Plane(`tenants` / `tenant_members` / `tenant_store_mappings`),Silo-ready routing 已 DEFERRED。V1 **加** 业务表 `tenant_id` NULLABLE 列(铺路,新写入由 worker 自动填),但不要求 backfill 历史、不开 RLS、不依赖此列做 isolation。不做:全量 rename / 每个 customer 独立 branch / `tenant_id` NOT NULL 强制。
- **本文是 Final / long-term refactor plan**。它描述未来的 provider-neutral schema、Stripe/SSO/custom domain、以及完整的 tenant lifecycle automation。Final 默认仍可以保持 Silo-first / all-Silo;Pool / Hybrid 只是 optional future。
- Final 仍然需要中心 **Control Plane**。Final 不是删除 Control Plane,而是让 Control Plane 从 V1 的 placement routing 扩展到 billing、SSO、domain、provider、migration 和 placement orchestration。

**配套文档**:

- 不懂 multi-tenant 是啥?先看 [`overview.md`](/system-design/multi-tenant/overview.md)
- V1 实际落地口径(identity-only Control Plane):[`why-tenant.md`](/system-design/multi-tenant/why-tenant.md);铺开进度与缺口:[`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md);原上线方案(历史参考):[`archive/multi-tenant-v1-plan/launch-plan.md`](../../../../archive/multi-tenant-v1-plan/launch-plan.md)
- 通用方法论 (Pool/Silo/Hybrid/8 维度/DDL):[`best-practices.md`](/system-design/multi-tenant/best-practices.md)
- 当前 store\_id 隔离设计 (PR #226 已 ship):[`store-level-isolation.md`](/system-design/store-level-isolation.md)

> 注意:这份迁移计划描述的是更完整的最终形态重构。当前 V1 决策是先做 Control Plane,把 Orange Theory 现有数据保留在 `pool_orange_theory`,并通过 `tenant_store_mappings` 桥接 tenant 到 current `rc_stores`。V1 业务表加 `tenant_id` NULLABLE 列(铺路),但不以全量 rename、`tenant_id` NOT NULL backfill、或 Pool/Hybrid 为上线前置条件。

***

## 一、要去哪 (Final State)

### 一句话讲清

```
Control Plane (中心平台层,长期存在)
   ├─ tenants                          客户 / 付费 / 合同主体
   ├─ tenant_members                   Cognito user 属于哪个 tenant
   ├─ tenant_database_placements       tenant 数据在 Pool / Silo / branch 哪里
   ├─ tenant_provider_connections      RingCentral / Twilio / Genesys 等连接 (future deferred)
   ├─ billing_accounts                 Stripe / invoice / entitlement (future billing integration)
   └─ tenant_domains                   custom domain / routing

Data Plane (客户业务数据)
   ├─ locations                  店 / call center site / facility
   │    └─ phone_lines           具体电话号码 / provider line
   │
   ├─ Silo DB / Neon branch      默认路径:一个 tenant 独占
   │    └─ 业务表不靠 tenant_id 隔离,但可保留 tenant_id 做审计 / 迁移
   │
   └─ Optional Pool / Hybrid     未来如果低价 tier / free trial / 大量小客户需要
        └─ 多 tenant 共用时,业务表必须有 tenant_id + 可选 location_id
```

### Optional Future: Pool / Hybrid

Pool / Hybrid 是 **Control Plane 能支持的未来 placement 形态**，不是当前 Final 必须完成的默认目标。

当前最顺的 Final 路径可以是：

```text
Control Plane 长期存在
   └─ 每个 tenant 通过 tenant_database_placements 找到自己的 Data Plane

Data Plane 当前可以先很简单:
   ├─ pool_orange_theory       当前 Orange Theory shared DB
   └─ silo_<tenant>            高价值 / 合规 tenant 的独立 branch 或 project
```

也就是说，V1 不先做成熟 Pool，不先做 Pool -> Silo live migration，也不把 `tenant_id` 加进所有业务表作为上线前置。Final 也可以先保持 Silo-first / all-Silo，只要我们的客户数量、客单价和自动化能力还支撑这个模型。

什么时候才把 Pool / Hybrid 变成真实产品能力：

- 出现大量低价小客户或 free trial，每个 tenant 都独立 branch/project 的固定工作量吃掉毛利。
- migration 变成 fan-out 问题：一次 schema migration 要跑几百上千个 placements，失败重试和版本漂移开始难管。
- secrets / endpoints / connection pools / observability 的 per-tenant 维度太多，oncall 和支持成本开始明显上升。
- 业务上明确要 tier differentiation：低价 self-serve 共享 Pool，高价 enterprise 独立 Silo。

所以 Pool/Hybrid 不是“更高级”。它只是当 tenant 数量和价格结构变了以后，用共享 compute 和共享运维面降低单位成本的一种选择。

如果未来需要 Hybrid，Control Plane 不需要推倒重来，只是 `tenant_database_placements` 里出现更多 placement：

```text
tnt_west_harlem  -> shared_pool  pool_orange_theory
tnt_enterprise_x -> neon_branch  silo_enterprise_x
tnt_trial_001    -> shared_pool  self_serve_pool
```

完整 8 维度 Hybrid 细节（Cognito / SAML / DynamoDB / S3 / 监控等）见 [`best-practices.md`](/system-design/multi-tenant/best-practices.md) §完整 Hybrid 8 维度图。那是 future option，不是 V1 / 当前 Final default。

**字段演进路径**：当前 V1 identity-only 已把 DB placement 整体 DEFERRED,没有 `tenant_database_placements`。首个需要 DB routing(Silo / Pool 分库)的客户出现时,再按 `tenant_database_placements.placement_type` / `placement_key` 这类 routing 字段落地。等客户出现“数据 Silo 但 SSO 共享”这类组合需求时,再升级成 `placement_profile` 预定义组合（见 best-practices §五 placement\_profile 字段设计）。这是阶段 6+ 触发动作。

### Definition of Done

DoD 分两层:

**Final Refactor DoD (旧阶段 0-5.5,不是 V1 Launch DoD)**:

如果当前目标是 V1,不要按下表当上线清单。V1 实际落地口径见 [`why-tenant.md`](/system-design/multi-tenant/why-tenant.md),铺开验收见 [`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md)(原 DoD 成文于已归档的 [`rollout.md`](../../../../archive/multi-tenant-v1-plan/rollout.md) §9,仅历史参考)。下表是未来进入 provider-neutral / Pool-Hybrid refactor 时的完成标准;all-Silo Final 不要求一次性做完。

| 检查项                                                             | 在哪个阶段做                                                   |
| --------------------------------------------------------------- | -------------------------------------------------------- |
| 顶层 `tenants` 表存在,所有业务表 FK 指向它                                   | 阶段 1                                                     |
| 业务表新加 `tenant_id` 列 + `store_id` 改名 `location_id`(2 个独立 ALTER)  | 阶段 1 (加 tenant\_id) + 阶段 4 (store\_id → location\_id 改名) |
| `rc_stores` / `rc_store_phones` / `store_members` 3 张表改名        | 阶段 4                                                     |
| JWT 携带 `tenant_id` (不只 `user_id` + localStorage)                | 阶段 3                                                     |
| `getDbForTenant(tenantId)` escape hatch 函数存在                    | 阶段 1                                                     |
| Stripe webhook `customer.subscription.*` 接好,tier → placement 同步 | 阶段 5                                                     |
| Per-tenant CloudWatch dimension 上线 (tenant\_id 进 metric)        | 阶段 5.5                                                   |
| 5 硬规则锁进 PR 模板,CI 强制跑跨 tenant 查询测试                               | 阶段 0                                                     |
| 8 治理项至少有 design 文档                                              | 阶段 5.5                                                   |

**Phase 2 DoD (阶段 6+,按业务触发,不在当前 V1 / Final refactor 窗口内)**:

| 检查项                                                         | 触发条件                        |
| ----------------------------------------------------------- | --------------------------- |
| 第 1 个 tenant promote-to-silo 迁移走通过 (验证 migrateTenant() 真能跑) | 第 1 个需要 Silo 的客户来时          |
| 第 1 个非 RC 客户接入 (验证 provider adapter)                        | 第 1 个 Twilio / Genesys 客户来时 |
| Public API + MCP server 上线                                  | 客户问起 / 销售提需求                |

***

## 二、3 个客户怎么映射

### 客户 A:Sarah — Orange Theory Devon 加盟商

```
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 行
   │
   └─ 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 视图都是 SQL 自然支持:

```
聚合     WHERE tenant_id = 'B'                          → 跨 3 店合计
单店     WHERE tenant_id = 'B' AND location_id = 'soho'
对比     WHERE tenant_id = 'B' GROUP BY location_id
```

### 客户 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 行 (TPA / SLC / CVG / DFW / ATL / SEA / 4 UK site)
   ├─ phone_lines: ~5000 行
   └─ Stripe: 公司合同,invoice / annual contract
```

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

***

## 三、字段怎么变 (终态 → 现状)

### 终态 5 张核心表速查 (你先记住这 5 个)

| 终态表                | 职责 (做什么用)                                | 从哪来                        |
| ------------------ | ---------------------------------------- | -------------------------- |
| `tenants`          | 客户层 — 谁付钱、合同 tier、Stripe Customer、SSO、品牌 | **新加**(以前没这层)              |
| `locations`        | 店层 — 物理执行单位 (店 / call center site / 工厂)  | `rc_stores` 改名             |
| `phone_lines`      | 设备层 — 具体电话号码,可挂多 provider                | `rc_store_phones` 改名 + 扩字段 |
| `tenant_members`   | 成员层 — tenant 内的人 (老板、前台、客服)              | Control Plane 新加           |
| `location_members` | location/store 级共享权限                     | `store_members` 改名         |

> ⚠️ **跟 `tenant_clients` 别混**:`tenant_members` 是**用户成员关系** (Cognito 用户在哪个 tenant 是啥角色)。`tenant_clients` 是 BPO 场景的 **end-clients 业务字段** (例:TaskUs 服务的 Airbnb / Stripe / Doordash) — 见 [`overview.md`](/system-design/multi-tenant/overview.md) §十、[`best-practices.md`](/system-design/multi-tenant/best-practices.md) §二 命名与层级。两者**不是同一张表**。

> ⚠️ **`tenant_members` 跟 `location_members` 关系**:两张表都存在 — `tenant_members` 管"tenant 内是否有权限 + 角色 (owner/admin/viewer)",`location_members` 管"viewer 限制到具体 location"。每个请求按 3 步 resolve:tenant\_members 校验 → tenant\_role >= admin 跨 location 放行 / viewer 必须 location\_members 授权 → 业务表 query 自动注入 tenant\_id。详细 resolve 顺序 + 3 种 user 实例对照见 [`best-practices.md`](/system-design/multi-tenant/best-practices.md) §权限 resolve 顺序。

→ V1 里 `tenants` / `tenant_members` / `tenant_store_mappings` 是 Control Plane 新表;DB placement / provider connections / provisioning jobs 都是 DEFERRED。Final 的 `locations` / `phone_lines` / `location_members` 才是对现有 `rc_stores` / `rc_store_phones` / `store_members` 的中性化演进。

### 详细字段映射

每行讲 1 个字段:终态长啥样、做啥用、原来在哪、怎么变过来。

| 终态 (新的)                                | 职责 (做什么用)                                                                                           | 现状 (原来)                                                   | 怎么变                                                                    |
| -------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------- |
| `tenants` 表                            | 客户 / 付费 / 合同主体                                                                                      | (无)                                                       | **新加** — 顶层真正的新东西                                                      |
| `tenants.id` (PK)                      | routing key / customer key。Pool 模式下也是业务表 `tenant_id` 隔离键；Silo 模式下主要用于 Control Plane routing 和 audit | (无)                                                       | 新加                                                                     |
| `tenants.tier`                         | 合同等级 smb / pro / platinum                                                                           | (无)                                                       | 新加,从 Stripe webhook 同步                                                 |
| `tenants.placement`                    | Pool 还是 Silo (路由用)                                                                                  | (无)                                                       | 新加                                                                     |
| `tenants.billing_provider_customer_id` | 默认 1 tenant 对 1 Stripe Customer；manual invoice / enterprise contract 可以先为空或通过 billing 表表达           | (无)                                                       | 新加,Stripe 接入时填                                                         |
| `tenants.owner_user_id`                | 谁是 tenant 的拥有者                                                                                      | `rc_stores.user_id`                                       | 挪位置 — 所有者属客户层                                                          |
| `tenants.franchise` (nullable)         | **品牌标签** — 跨 tenant 聚合 ("OrangeTheory 全美加盟商总体表现")                                                   | 业务表 `franchise_id` (NOT NULL 在 5+ 张表)                     | 挪 + 降级 — 从业务表 NOT NULL 移到 tenants 单列,不再当隔离用                            |
| `locations` 表                          | 店 / call center site / 工厂 — 物理执行单位                                                                  | `rc_stores` 表                                             | 改名 — 表名绑死 RC,改中性词                                                      |
| `locations.id` (PK)                    | 业务划分 — `WHERE location_id` 限制单店视图                                                                   | `rc_stores.id`                                            | 保留 PK 不变                                                               |
| `locations.tenant_id` (FK)             | 属哪个客户                                                                                               | (无)                                                       | 新加,1 个 tenant 对 N 个 location                                           |
| `locations.name`                       | 店名                                                                                                  | `rc_stores.name`                                          | 保留                                                                     |
| `locations.timezone`                   | 时区                                                                                                  | `rc_stores.timezone`                                      | 保留                                                                     |
| `locations.type`                       | 类型 (store / site / facility / group)                                                                | `rc_stores.type` (只有 store\|group)                        | 扩值 — 加 call center / 工厂类型                                              |
| `phone_lines` 表                        | 电话号码 — 可挂多 provider                                                                                 | `rc_store_phones` 表                                       | 改名 + 扩字段                                                               |
| `phone_lines.location_id` (FK)         | 属哪家店                                                                                                | `rc_store_phones.store_id`                                | 改名跟随 rc\_stores → locations                                            |
| `phone_lines.e164`                     | E.164 格式号码                                                                                          | `rc_store_phones.phone_number`                            | 改名 — 用电信标准词                                                            |
| `phone_lines.line_type`                | 类型 (main / direct / tollfree / did)                                                                 | `rc_store_phones.phone_type`                              | 改名                                                                     |
| `phone_lines.provider_id` (FK)         | 哪个 CPaaS 提供的                                                                                        | (无)                                                       | **新加** — 支持多 provider                                                  |
| `phone_lines.provider_native_id`       | provider 那边的原生 ID                                                                                   | `rc_store_phones.extension_id`                            | 改名 + 通用化 (RC 叫 ext,Twilio 叫 SID)                                       |
| `tenant_members` 表                     | tenant 内的成员 (老板 / 前台 / 客服 / 经理)                                                                     | (无)                                                       | Control Plane 新加 — 不从 store-level membership 直接 rename                 |
| `tenant_members.tenant_id` (FK)        | 属哪个客户                                                                                               | (无)                                                       | 新加                                                                     |
| `tenant_members.role`                  | tenant 级角色 (owner / admin / viewer)                                                                 | `store_members.role` 可作为 seed 参考                          | 不能机械照搬,需要人工确认                                                          |
| `location_members` 表                   | location/store 级成员关系                                                                                | `store_members` 表                                         | Final rename — 继续表达 store/location 级共享权限                               |
| `tenant_clients` 表 (可选, BPO 才用)        | BPO 服务的 end-clients (例:Airbnb / Stripe 之于 TaskUs)                                                   | (无)                                                       | **新加,跟 `tenant_members` 是不同概念**                                        |
| `tenant_provider_connections` 表        | provider 顶层连接 (OAuth 凭证) — future deferred 表                                                        | 部分散在 `rc_stores.provider_account_id` / `user_connections` | 首个需要显式 provider ownership 的客户来时新建,再扩 provider 枚举(加 Twilio / Genesys 等) |
| `location_provider_mappings` 表         | 把我们的 location 映射到 provider 的 site/queue                                                             | 部分散在 `rc_stores.rc_site_id`                               | **新加** + 挪位置 — 多 provider 必备                                           |
| 业务表 (16 张) `tenant_id`                 | 隔离键(新职责)                                                                                            | (业务表无此列)                                                  | **新加列** — backfill 已有数据,跟 store\_id 并存                                 |
| 业务表 `location_id`                      | 业务划分 (单店视图)                                                                                         | 业务表 `store_id` (隔离 + 店双职责)                                | **改名 + 减负** — store\_id → location\_id,职责从"店 + 客户"变回"只管店"              |
| (业务表 `franchise_id` 删除)                | —                                                                                                   | 业务表 `franchise_id` NOT NULL (5+ 张)                        | 删字段,挪到 `tenants.franchise`,见上方                                         |

***

## 四、现状

我们在哪。

### 隔离 + 命名

| 维度           | 现状                                                |
| ------------ | ------------------------------------------------- |
| 隔离键          | `store_id` (UUID),16 张业务表使用                       |
| 顶层 tenant 概念 | 无                                                 |
| Stripe 集成    | 无 (0 个文件引用)                                       |
| Provider     | 只接 RingCentral,`rc_stores` 表名直接绑 RC               |
| 隔离设计文档       | `store-level-isolation.md` PR #226 (2026-04 ship) |

### Schema 22 张表分类

按 `storeId` 字段状态精确分类 (grep verify, 2026-06-03):

| 类别                                        | 张数     | 文件                                                                                                                                                                     |
| ----------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **NOT NULL** (强隔离)                        | 8      | blackout-periods / contacts / operating-overrides / operating-schedules / phone-numbers / staff / task-progress-events / user-stores.ts (actual table `store_members`) |
| **NULLABLE** (弱隔离, backfill 漏洞)           | 6      | calls / contact-timeline / leads / messages / store-config / tasks                                                                                                     |
| **rc-stores 自己 + 引用**                     | 2      | rc-stores (PK 是 `id` 不是 `storeId`) / sub-accounts (只引用不带列)                                                                                                             |
| **合理无 storeId** (enum / OAuth / metadata) | 4      | name-trust / oauth-states / task-ui / user-connections                                                                                                                 |
| **GAP**                                   | 1      | playbook-feedback — 应有 storeId 但没有                                                                                                                                     |
| **index re-export hub**                   | 1      | index.ts (不是表)                                                                                                                                                         |
| **合计**                                    | **22** |                                                                                                                                                                        |

### 命名 3 时代叠加

3 个时代的命名同时存在,谁都没全干掉谁:

| 时代   | 命名                                        | 文件数                                   | 状态                                   |
| ---- | ----------------------------------------- | ------------------------------------- | ------------------------------------ |
| 时代 1 | `franchise_id` / `account_id` / `site_id` | 7 个 API route 文件                      | 降级 (NOT NULL 但只 1 个值 "orangeTheory") |
| 时代 2 | `store_id` (state C 当前主隔离键)               | 67 个 API route 文件 + 124 个 frontend 文件 | 主流                                   |
| 时代 3 | `useOrganizationStore` (frontend bridge)  | 半数 composable 用                       | UI 已经叫 "Organization",底层桥接到 store    |
| 未来   | `tenant_id`                               | 0 个文件                                 | 要补的                                  |

Frontend bridge 不是"全切了" — 是 2 个 Pinia store 并存,一半 composable 直接调 `currentStoreId`,一半调 `currentOrgId` (alias)。UI 用户看到"Organization",API URL 还是 storeId。

### 写入端 vs 读取端 (这是工作量真正在哪)

之前以为只改 67 个 routes (读取端) 就行 — **错了**。真实影响面跨 4 个 repo:

| Repo / 模块                                      | 角色                                        | 改动量                                  |
| ---------------------------------------------- | ----------------------------------------- | ------------------------------------ |
| studio-website-monorepo `apps/api/src/routes/` | 读取端 67 文件 + 写入端 (OAuth sync)              | 机械改 SQL WHERE                        |
| studio-website-monorepo `apps/web/src/`        | Frontend 124 文件 + 2 Pinia store           | 改 store + composable                 |
| callytics-infrastructure (5 Lambda)            | 写入端 (AI 分析、消息、转录、lead)                    | 共享 helper 改一次连带改                     |
| callytics-common `src/phone-identity`          | **写入端中心 helper** `resolvePhoneIdentity()` | 改 return type,4 个 Lambda lockstep 升级 |
| lead-tracking                                  | Lead 入口 (从 DDB 拿 storeId 不是 Neon)         | 这里还在用 DDB,要先迁 Neon 再改命名              |

→ 4 repo lockstep 改,这是为什么真实工作量 3-4 周而不是"改 67 文件 1 周"。

### 治理面缺失

8 项 control plane 全 ❌ 没做:lifecycle 状态机 / GDPR delete / audit log / admin impersonate / per-tenant config / region failover / migration tool / provider unbind。

详细影响见 [`overview.md`](/system-design/multi-tenant/overview.md) §十二。

***

## 五、Final / Pool-Hybrid refactor route (not V1)

本节不是 V1 上线计划。Current -> V1 的迁移路径原成文于已归档的 [`rollout.md`](../../../../archive/multi-tenant-v1-plan/rollout.md) §5(未按原样执行,实际落地口径见 [`why-tenant.md`](/system-design/multi-tenant/why-tenant.md))。

本节只在我们决定做下面任一事项时才启用:

- 把 current `rc_stores` / `rc_store_phones` / `store_members` 全面中性化成 `locations` / `phone_lines` / `location_members`。
- 把业务表全面加 `tenant_id`。
- 做成熟 Pool / Hybrid,让多个 tenants 共用同一套 business tables。
- 做无停机 Pool \<-> Silo migration。

### 阶段总览

```
                        阶段 0   ── 1-2 天 ──▶ 写规则 + 全栈影响面 audit
                          │
                        阶段 1   ── 3-4 天 ──▶ 加 tenants 表 + 路由层 escape hatch
                          │
                        阶段 2   ── 3-4 天 ──▶ 改写入端共享 helper ★ 最关键
                          │
                        阶段 3   ── 1 周 ────▶ 改读取端 + frontend
                          │
                        阶段 4   ── 3-4 天 ──▶ 收尾:store_id 改名 location_id + 3 张表改名
                          │
       "tenant_id 全栈打通" ◀─── 总计约 3 周 ───
       (仅适用于成熟 Pool/Hybrid refactor,不是 V1)
                          │
                        阶段 5   ── 1 周 ────▶ Stripe 接入
                          │
                        阶段 5.5 ── 2-3 天 ──▶ DoD 收口 (8 治理项 design + 观测)
                          │
       "DoD 全部兑现"      ◀─── 总计约 4-4.5 周 ───
                          │
                        阶段 6+  ── 长期 ───▶ provider adapter / migration tool / API platform
                                              (按业务触发)
```

### 阶段 0:写规则 + 全栈 audit (1-2 天)

5 硬规则全员签字,锁进 PR 模板和 CI。Audit 输出完整写入入口清单 (§四 写入端列表 + 任何遗漏的)。

**为什么先做这步**:改名不是真工作,定规则才是。跳过这步 = 把错的概念固化进新名字,3 个月后又要再改一遍 (Codex adversarial review 反复强调)。

产出物:

- `PR_TEMPLATE.md` 加 multi-tenant checklist
- CI 加跨 tenant 查询自动化测试 (任何业务表 SELECT 无 `WHERE tenant_id = ?` → CI fail)
- 完整写入入口清单 doc

### 阶段 1:加 tenants 表 + escape hatch (3-4 天)

```
新建 callytics-common/src/db/schema/tenants.ts:
  id, name, vertical, tier, billing_provider_customer_id, placement, region, status

回填脚本: 1 rc_store → 1 tenant (Sarah 这种单店客户)
       Glow / United 这种多 location 客户后续手动合并

业务表加 tenant_id NULLABLE 列 (NOT NULL 在阶段 4 改)
   Migration 跟 callytics-common 0014/0016 同款 pattern

新加 getDbForTenant(tenantId) 路由函数 (永远返回 'pool' 主 DB)
  → 这是 escape hatch, 阶段 6+ Platinum 客户来时不会改 API

新加 resolveTenantContext(storeId) helper
  → 旧代码传 storeId, helper 自动反查 tenant_id (过渡兼容)
```

**3 个客户在阶段 1 结束时**:

- Sarah:tenants 表 1 行,业务表 tenant\_id 列填好但还没 WHERE 用
- Glow / United:还在测试环境 mock,实际客户进来再手动建

### 阶段 2:改写入端共享 helper (3-4 天) — 最关键一步

**之前漏的最贵的一步**。改完 callytics-common 中心 helper,4 个 Lambda 写入端自动连带改。

```
1. callytics-common phone-identity helper:
   resolvePhoneIdentity() return type 加 tenantId + locationId
   原 storeId 保留作过渡
   
2. 所有 INSERT/UPSERT 双写 tenant_id + store_id
   过渡期同时写,直到阶段 4 删 store_id
   
3. 4 个 Lambda 同步发版 (lockstep):
   ai-analysis-processor / contacts-analyzer /
   message-processor / lead-processor / transcribe-processor
```

测试:老 pipeline 仍能跑,新写入数据 tenant\_id 填充率 100%。Backfill 历史数据复用 `storeid-coverage-monitor` Lambda 同款 pattern。

### 阶段 3:改读取端 + frontend (1 周)

```
studio-api 67 routes:
   WHERE store_id = $1  →  WHERE tenant_id = $1
   (Sarah 等价,Glow 自动跨 3 店聚合,United 自动跨 10 site 聚合)
   单 location 视角:加 AND location_id = $2

Frontend:
   stores/stores.ts → 改名 tenants.ts
   useStoresStore → useTenantsStore
   organization.ts 整文件删除 (alias 转正)
   currentStoreId localStorage key → currentTenantId (加 migration)

JWT payload 加 tenant_id claim (不只 user_id)
   所有 API 从 JWT 拿 tenant, 不再从 URL/body 拿 (更安全)

自动化测试:
   跨 tenant 查询返回 0 行
   写测试 WHERE tenant_id = 'A' 查任意业务表 → 不返回 tenant-B 数据
   CI 锁死
```

**3 个客户视角**:

- Sarah 完全无感(查询语义等价)
- Glow 老板 dashboard 一次登录看全 3 店(从"逐店切换"到"自然聚合")
- United(测试 mock) 验证 silo placement 路由

### 阶段 4:收尾 (3-4 天)

**核心**:把 `store_id` 单字段的两个职责分到两列,通过 2 个独立 ALTER 实现 — 不是 1 个 SPLIT 把单字段拆成 2 字段。

```
业务表 (16 张) — 2 个独立 ALTER:
  ALTER #1 (阶段 1 已完成): 加 tenant_id NULLABLE 列, backfill 已有数据
                            阶段 2-3 双写填充 → 现在 NULLABLE → NOT NULL
  ALTER #2 (阶段 4 本步骤): store_id 改名 → location_id (RENAME COLUMN)

  → 完成后业务表有 2 列:
       tenant_id   (隔离用,新加的)
       location_id (业务划分,从 store_id 改名,职责减少为只管"店")

  → 关键澄清:这不是 "把 store_id 1 个字段 split 成 2 个字段"。
       tenant_id 是新加列(原本没有,backfill 出来的)
       location_id 是 store_id 改名(同一列,内容不变,只是 column name 改了)

业务表 franchise_id:
  从 calls/messages/contacts 删除 (跟随 §六 franchise 归位 4 阶段)

3 张表改名 (用 PostgreSQL view 做兼容 alias 给老 code 1 个 release 缓冲):
  rc_stores         → locations
  rc_store_phones   → phone_lines      ← 之前漏写
  store_members     → location_members ← tenant_members 是 Control Plane 新表,不是 store_members 机械改名

phone_lines 表加新字段 (支持多 provider):
  provider_id (FK to provider_connections, 阶段 6+ 才填,先留 NULLABLE)
  provider_native_id (改名自 extension_id)
  line_type (改名自 phone_type)
  e164 (改名自 phone_number)
```

**3 个客户视角验证**:

- Sarah: contacts 表里她的行 → tenant\_id="A" (新 backfill), location\_id="devon" (= 原 store\_id 改名)
- Glow: contacts 表里她的行 → tenant\_id="B" (新 backfill), location\_id="soho" / "brooklyn" / "queens" (= 原 store\_id 改名,Glow 3 店各自一行)
- 所有业务表 SELECT 必须带 `WHERE tenant_id`,CI 强制

### 阶段 5:Stripe 接入 (1 周)

```
tenants.billing_provider_customer_id 字段 (阶段 1 已建)

Stripe webhook handler in studio-api routes/webhooks/stripe.ts:
   customer.subscription.created  → 创建 tenant
   customer.subscription.updated  → 同步 tier
   customer.subscription.deleted  → 标记 status='suspended'
   
Tier → placement 映射示例
   V1 / all-Silo Final 不 hardcode smb/pro = pool, platinum = silo
   如果未来支持 Hybrid,再用映射表决定哪些 tier 走 shared_pool / neon_branch
   
双向同步测试:
   Stripe portal 改 plan → tenants 表自动更新
   客户欠费 → status 自动 suspended
```

详细 webhook flow + 签名验证见 [`best-practices.md`](/system-design/multi-tenant/best-practices.md)。

### 阶段 5.5:DoD 收口 — 治理面 design + 观测最低限度 (2-3 天)

DoD (§一 line 70-81) 要求"8 治理项至少有 design 文档" + "Per-tenant CloudWatch dimension 上线"。这步集中兑现 — **只写 design + 加 metric dimension,不实现完整功能**。

```
8 治理项 design 文档 (每项 1 张 design doc 半页, 不实现):
  1. Lifecycle 状态机    — 状态图 trial→active→suspended→churned→deleted + 转换条件
  2. GDPR delete         — flow 已经在 §七 写好 (cascade delete + drop silo project)
  3. Audit log           — 表 schema 草图 + 写入入口列表 (谁看了谁的数据)
  4. Admin impersonate   — 流程: 员工申请 → audit log → JWT 注入 → 30min TTL
  5. Per-tenant feature flag — 表 schema 草图 + 跟 tier 关联规则
  6. Region failover     — Platinum DR runbook 草稿 (us-east-1 → eu-west-1 切换步骤)
  7. Migration tool      — 已经在 §七 写好 (migrateTenant function)
  8. Provider unbind     — 客户换 CPaaS 数据保留策略 (provider_connections.status='revoked')

观测最低限度 (实施):
  • CloudWatch metric 加 tenant_id dimension (跨 5 Lambda + studio-api)
  • Logger appendKeys 自动注入 tenant_id (跟现有 user_id 同款)
  • 1 个 sample dashboard: "per-tenant call volume" 验证 dimension 真的能 group by
```

→ 这步做完所有 DoD hard requirements 都兑现 (除了第 1 个 Platinum 客户 Pool→Silo 迁移走通过 — 那个等真实客户触发)。

### 阶段 6+:长期 (按业务触发,不一次性)

| 项                                                           | 触发条件                     | 工作量   |
| ----------------------------------------------------------- | ------------------------ | ----- |
| Provider adapter (Twilio / Genesys)                         | 第 1 个非 RC 客户来时           | 2-3 周 |
| Migration tool `migrateTenant()`                            | 第 1 个 Platinum 来时        | 2 周   |
| Public API platform (per-tenant API key + endpoint scoping) | 客户问起 / 销售提需求             | 4-6 周 |
| Webhook (我们 → 客户)                                           | 第 1 个客户要 push 通知时        | 1-2 周 |
| MCP server (AI tool 自动注入 tenant)                            | 客户 AI 集成需求               | 2-3 周 |
| Per-tenant rate limit (tier-aware)                          | 客户滥用 / 跨 tenant 公平性问题    | 1 周   |
| Per-tenant audit log                                        | 安全合规需求 (Platinum / 银行医疗) | 1 周   |
| Per-tenant feature flags                                    | tier-based gating        | 1 周   |
| Per-tenant observability (深度,超出阶段 5.5 基础 dimension)         | 大客户 SLA 监控               | 3-5 天 |
| Admin impersonate (实施 design)                               | debug 客户问题频繁时            | 3-5 天 |
| Region failover (实施 design)                                 | 第 1 个 Platinum 客户要 DR 时  | 1-2 周 |
| Provider unbind (实施 design)                                 | 第 1 个客户换 CPaaS 时         | 3-5 天 |

**不一次性做**的原因:没有具体业务触发时,做了就是 over-engineering。Definition of Done 只要求"有 design",不要求"全实现"。

***

## 六、franchise 归位

详细历史 + 演变 + 迁移工程。Overview 给的是 1 句话。

### 历史演变

```
最早 (State A-B)        franchise + site 当 isolation key
                        PK = (phone, franchise_id, site_id)

State C (PR #226, 2026-04-14 ship)
                        storeId (UUID) 接管 isolation
                        franchise 降级为 "审计 + 预留多品牌"
                        contacts PK 改为 (phone, store_id)
                        字段没删,所有 writer 还填

现在 (2026-06)          franchise 在 5+ 主表 NOT NULL
                        实际只有 1 个值 "orangeTheory"
                        infra CLAUDE.md 明确:"非隔离键,预留多品牌扩展"
```

### 当前在哪里填

```
Schema 层 (NOT NULL):
  contacts / calls / messages / staff / task-progress-events / ...
  
Schema 层 (NULLABLE,backfill 中):
  leads (in progress)

写入端 (Lambda):
  message-processor 用 parseFranchiseFromClientId() 从 webhook 拿
  ai-analysis-processor / contacts-analyzer / lead-processor 从 DDB 拿

读取端 (API):
  tasks/note,reopen,postpone,close 的 RETURNING 都带 franchise_id
```

### 历史踩坑 (说明 franchise 不是无害包袱)

2026-04 message-processor 曾写 `franchiseId = "orangeTheory-{site_id}"` 拼串,导致 Neon PK 3-way 分裂、**1709 客户重复 contact 记录**。PR #632 加 `parseFranchiseFromClientId()` 化解。

→ franchise 写错过会造成 data corruption,所以 schema 留着 + NOT NULL。

### 终态归位

两个概念终态共存,职责彻底分开:

| 字段                             | 终态位置      | 作用                      |
| ------------------------------ | --------- | ----------------------- |
| `tenants.tenant_id` (PK)       | tenants 表 | 隔离 + 计费 + 合同 + SSO 全挂这层 |
| `tenants.franchise` (nullable) | tenants 表 | 品牌聚合维度 (跨 tenant 分析)    |

**OrangeTheory 加盟商场景**:

```
Sarah 加盟商     tenant_id=tnt_001   tenants.franchise="orangeTheory"
Ardmore 加盟商   tenant_id=tnt_002   tenants.franchise="orangeTheory"
                                    ↑ 不同 tenant, 同 franchise
Glow Beauty 总部 tenant_id=tnt_003   tenants.franchise=NULL (独立品牌)
United Airlines  tenant_id=tnt_004   tenants.franchise=NULL
```

**跨 franchise 品牌聚合 query**:

```sql
-- "OrangeTheory 全美所有加盟商本月总通话数"
SELECT COUNT(*)
FROM calls c
JOIN tenants t ON t.id = c.tenant_id
WHERE t.franchise = 'orangeTheory'
  AND c.start_time >= NOW() - INTERVAL '30 days';
```

→ 这是 **retaintive 平台的运营分析**视角,不是单 tenant 视角。
→ 单个 tenant (Sarah) 的 dashboard **不允许**看到其他加盟商数据 (规则 1)。
→ 跨 franchise 聚合**只有 platform admin 能看**,通过专门 endpoint + audit log。

### franchise 迁移 4 阶段 (跟主迁移并行做)

```
阶段 0 (准备)
  tenants 表加 franchise TEXT NULLABLE 列
  回填: UPDATE tenants SET franchise = ? (现状全是 'orangeTheory')

阶段 1 (双填期)
  Writer 同时填 业务表.franchiseId + tenants.franchise
  业务表 franchiseId 保留 NOT NULL

阶段 2 (读取迁移)
  跨 franchise 聚合 query 改读 tenants.franchise
  业务表 franchiseId 不再被 query 引用 (仍写入)
  
阶段 3 (清理)
  业务表 franchiseId 改 NULLABLE
  Writer 停止填业务表 franchiseId
  监控无 query 引用 ~1 月

阶段 4 (删字段)
  DROP COLUMN franchiseId 从所有业务表
  索引 idx_*_franchise 全删
  Writer helper 不再返回 franchiseId

总时长: 2-3 周, 跟 tenant 主迁移并行
```

**关键**:业务表 `franchiseId` **不直接删** — 先双填 → 改读 → 停写 → 删字段,4 阶段防 data loss。

### franchise 孤儿行监控 (踩坑回顾)

**问题**:franchise 双填期(阶段 1)→ 改读(阶段 2)期间,如果某些行 `franchise_id` 和 `tenants.franchise` 都是 NULL(rc-stores OAuth sync 漏写 / backfill 没覆盖到),阶段 2 读 tenants.franchise 时这些行返回 0 → dashboard 显示数据丢失。

**2026-04 message-processor 实际踩过**:写 `franchiseId = "orangeTheory-{site_id}"` 拼串导致 Neon PK 3-way 分裂、**1709 客户重复 contact 记录**。PR #632 加 `parseFranchiseFromClientId()` 化解。

**Reconciliation job**:迁移期间(阶段 1-3)每小时跑一次,告警条件:

```sql
-- 孤儿行检测
SELECT COUNT(*) FROM contacts
WHERE franchise_id IS NULL OR franchise_id = '';
-- ↑ 应该 = 0,> 0 触发 Sentry alert

-- tenants.franchise 跟业务表 franchise_id 不一致检测
SELECT c.franchise_id, t.franchise, COUNT(*)
FROM contacts c
JOIN tenants t ON t.id = c.tenant_id
WHERE c.franchise_id != t.franchise
GROUP BY c.franchise_id, t.franchise;
-- ↑ 应该 = 空集,出现行触发告警 + 人工 reconcile

-- backfill 完成度
SELECT
  (SELECT COUNT(*) FROM contacts WHERE tenant_id IS NULL) AS missing_tenant,
  (SELECT COUNT(*) FROM contacts) AS total;
-- ↑ missing_tenant / total < 0.1% 才能进阶段 3 停写
```

**阶段 3 (停写 franchise\_id) 的 entry gate**:reconciliation 3 项全绿 + 监控连续 1 周无告警。否则停在阶段 2 不要往下走。

***

## 七、Pool↔Silo 迁移工程

Overview 提到第 7 项生命周期是最贵的。这里记录的是 **future full migration tool** 的工程形态，不是 V1 必须实现的上线清单。

当前 V1 只保留 identity / ownership 边界：`tenants`、`tenant_members`、`tenant_store_mappings`。`getDbForTenant(tenant_id)`、`tenant_database_placements` 和 migration runner MVP 都是 future placement layer 的触发项；真正的无停机 Pool -> Silo migration 可以等第一个客户明确需要时再做。早期 promote-to-silo 可以先用 maintenance window。

### 5 步迁移流程 (Pool → Silo,无 downtime)

> **顺序很关键**:Step 2 snapshot 回填必须在 Step 3 双写之前。否则双写已经写了一段时间后再灌历史 snapshot,silo 里可能已有同一批行,容易撞主键、overwrite 或造成 reconciliation 噪声。

> **前置:必须先跑 dryRun mode**。任何 Pool→Silo 迁移**真跑之前**必须先 `migrateTenant(tenantId, ..., { dryRun: true })`,输出 5 项检查全绿才能 trigger 真迁:
>
> 1. **受影响 row count by table** — 总数 vs 单 tenant 的占比,异常时停下来排查
> 2. **孤儿行检测(关键 gate)** — 该 tenant 对应 `store_id[]` 在所有业务表里,是否有 `tenant_id IS NULL` 的行?
>    ```sql
>    SELECT table_name, COUNT(*) FROM (
>      SELECT 'contacts' AS table_name FROM contacts
>        WHERE store_id = ANY(:tenant_stores) AND tenant_id IS NULL
>      UNION ALL
>      SELECT 'calls' FROM calls WHERE store_id = ANY(:tenant_stores) AND tenant_id IS NULL
>      -- ... 所有业务表
>    ) GROUP BY table_name;
>    ```
>    孤儿行 > 0 → backfill 没跑完 → **STOP**,先补 backfill 再 dryRun。否则迁移会漏搬数据。
> 3. **估计耗时** — 按当时 Pool DB 大小 + Neon project 创建时间(\~2 min)+ logical replication catch-up 时间
> 4. **logical replication slot 当前状态** — Pool DB 是否已 enable `wal_level=logical`、max\_replication\_slots 是否够、有无 stale slot
> 5. **跨 tenant 数据残留检测** — `SELECT DISTINCT tenant_id FROM <each_table> WHERE store_id IN (<this_tenant_stores>)` 看是否真的只有该 tenant 的行,防止 backfill bug 把别人数据带过去
>
> dryRun 通过(5 项全绿)才能 trigger 真迁。第一个 Silo 客户来时必须先 dryRun。

```
现状: Sarah 在主 Neon shared DB
       ↓
       客户签合同 Platinum, Stripe webhook 触发
       ↓
┌─────────────────────────────────────────────────┐
│  Pool → Silo 迁移流程 (无 downtime)             │
└─────────────────────────────────────────────────┘

Step 1: 创建独立 Neon project + 准备 schema
  • Neon API: 创建 project "tnt_001-silo"
  • 部署同一份 schema (drizzle migrate)
  • 留空 DB,记录此时主 DB 的 logical replication slot 位置 (LSN_T0)
    → 这是后续 CDC catch-up 的起点

Step 2: 历史快照回填 (snapshot @ LSN_T0)
  • tenants.placement = 'migrating-to-silo' (但还没开双写)
  • 用 COPY (SELECT * FROM <table> WHERE tenant_id='tnt_001') TO STDOUT
    | psql silo -c "COPY <table> FROM STDIN"
    逐表导出 + 导入 (pg_dump --table 是按表名 pattern 选表, 不支持 WHERE 行筛, 
    改用 COPY 是正确的行筛法)
  • 校验 row count + sample 一致
  • 此时 silo 有 LSN_T0 那一刻的完整 tenant 数据

Step 3: 启动双写 + CDC catch-up
  • INSERT/UPDATE/DELETE 同时写主 DB + silo DB (按行 idempotent: 
    silo 侧用 INSERT ... ON CONFLICT DO UPDATE,处理 Step 2 重叠的边界行)
  • 同时启动 logical replication 从 LSN_T0 重放到当下,catch up snapshot 之后的写
  • 持续 1-7 天,期间监控 reconciliation job (主 DB / silo row count diff)
  • 读还在主 DB

Step 4: 切换读
  • tenants.placement = 'silo'
  • getDbForTenant() 路由根据 placement 切 connection
  • 读走 silo,写还双写
  • 观察 24-48 小时,确认 silo 数据完整

Step 5: 切换写 + 清理
  • 写只走 silo,关掉双写 + CDC
  • 24 小时后,从主 DB DELETE WHERE tenant_id = 'tnt_001' (cascade)
  • 完成
```

### 关键技术点

- **`getDbForTenant(tenantId)`** 是 escape hatch 的具体实现 — 这函数现在(阶段 1)就写,以后业务代码每行不再需要知道 tenant 在哪个 DB。
- Step 2-5 跟 schema migration "expand → backfill → contract" 同款思路 — 搬数据不是搬字段。
- **跨 DB 一致性** — 双写期任何一边 fail,要有 reconciliation job 兜底 (定时 diff Pool 和 Silo row count,不一致告警)。

### Silo → Pool (降级 / 取消)

**场景 A:Platinum 客户降级回 Pro**

```
(合同没续, 不想付 Silo 钱了)
─────────────────────────────────────
反向走 Step 1-5 (主库变 silo, 目标库变主)
警告客户: PCI/SOC2 隔离强度降低
Audit log 永久标记 "降级时间"
```

**场景 B:客户取消 (churn)**

```
方式 1 (软删 + 保留窗口期):
  tenants.status = 'churned'
  placement 不变, 数据保留 30/60/90 天 (合同/法规定)
  客户期间可申请导出
  期满: cascade delete + drop silo Neon project

方式 2 (GDPR right-to-be-forgotten):
  立即 cascade delete 所有 tenant_id = X 的行
  drop silo Neon project
  保留 audit log 中"已删除"的指针 (不留数据)
  给客户出删除证明
```

→ **关键**:Silo project 是 retaintive 名下的 Neon project,客户不直接拥有。我们删 Neon project,客户合同上的 "data deletion" 责任就履行了。

→ 这是治理面第 2 条 (GDPR delete) 和第 7 条 (migration tool) 的具体含义。

### 一份代码,覆盖所有场景

```typescript
async function migrateTenant(
  tenantId: string,
  from: 'pool-main' | 'silo-X',
  to:   'pool-main' | 'silo-Y' | 'archive'
): Promise<MigrationResult> {
  // Step 1-5 通用流程, from/to 决定具体动作
  
  switch (`${from}→${to}`) {
    case 'pool-main→silo-Y':  return upgradeToSilo(tenantId, to);
    case 'silo-Y→pool-main':  return downgradeToPool(tenantId, from);
    case 'silo-X→silo-Y':     return moveSilo(tenantId, from, to);    // 跨 region
    case 'pool-main→archive': return cascadeDelete(tenantId);          // SMB churn
    case 'silo-Y→archive':    return dropSiloProject(tenantId, from);  // Platinum churn
    default:
      throw new Error(`Unsupported migration: ${from} → ${to}`);
  }
}
```

→ 覆盖 4 种场景:

1. **Pool↔Silo** (tier 升降)
2. **Silo↔Silo 跨 region** (合规迁移)
3. **churn delete** (取消订阅)
4. **M\&A 合并** (UPDATE tenant\_id 全改一边,然后 archive 旧 tenant)

### Reconciliation 监控

```
双写期 + 切读期必须有 reconciliation job:

  每 1h 跑一次
  COUNT rows by tenant_id in 主 DB 和 silo DB
  不一致 → Discord + Sentry alert
  alert 后人工或自动 replay 差异

监控指标:
  write_lag_ms (主 DB 写完到 silo DB 写完的延迟)
  dual_write_fail_count
  row_count_diff
  read_route_distribution (切读期观察流量分布)
```

***

## 八、风险 + 回退

### Codex Adversarial Review 关键提醒

> 改 67 文件不是 1 周的事。风险是 **semantic backfill** (storeId 语义正确映射到 tenant\_id + location\_id,Sarah 1:1 但 Glow 1:N) 和 **auth/query isolation regressions** (漏改 1 个 route 就是跨 tenant 泄漏) — 不是机械改名。

> **澄清**:"semantic backfill" 不是把 `store_id` 单字段 split 成 2 个值,而是 — 阶段 1 新加 `tenant_id` 列时,要 backfill 已有数据(每行的 `store_id` 反查它属于哪个 `tenant`,然后写入 `tenant_id`)。Sarah 单店 1 个 store → 1 个 tenant(1:1 简单);Glow 3 店 → 1 个 tenant(3:1,3 个 store\_id 反查同一个 tenant\_id)。`location_id` 不是 backfill 出来的,是阶段 4 直接 `store_id` RENAME COLUMN(同一列改 column name,内容不变)。

### 风险清单

| 风险                                                        | 缓解                                                      |
| --------------------------------------------------------- | ------------------------------------------------------- |
| 漏改 1 个 query → 跨 tenant 数据泄漏                              | 阶段 0 CI 锁死 (任何业务表 SELECT 必须带 `WHERE tenant_id`)         |
| Glow 老板 dashboard 跨店聚合 query 错 (`location_id` filter 没去掉) | E2E 测试覆盖 3 客户画像                                         |
| 历史数据 tenant\_id 回填失败 / 部分失败                               | 复用 `storeid-coverage-monitor` Lambda 同款 monitor + alert |
| 4 Lambda 同步发版漏一个 → 写入数据缺 tenant\_id                       | callytics-common bump 主版本号,4 Lambda lockstep 升级 + 灰度    |
| Frontend 2 store 切换 → localStorage key 冲突                 | 阶段 3 加 migration 逻辑,1 个 release 后清理                     |
| Stripe webhook 失败 → tier 不同步                              | webhook 失败入 SQS DLQ,人工告警                                |

### 阶段间回退策略

每个阶段都是**可回退**的,因为 store\_id 直到阶段 4 才删:

- 阶段 1-3 出问题:tenant\_id 列还是 NULLABLE,直接停用新代码,回退到老 query
- 阶段 4 删 store\_id 前:必须确认阶段 2-3 数据填充率 100% + 自动化测试全绿
- 阶段 5 Stripe 出问题:webhook 暂停 + 手动同步 tier 兜底

***

## 九、引用

- 不懂 multi-tenant?先看 [`overview.md`](/system-design/multi-tenant/overview.md)
- 通用方法论 (Pool/Silo/Hybrid/8 维度/DDL):[`best-practices.md`](/system-design/multi-tenant/best-practices.md)
- 当前 store\_id 隔离设计 (PR #226 已 ship):[`store-level-isolation.md`](/system-design/store-level-isolation.md)
- Schema SoT:`callytics-common/src/db/schema/` — 22 张表,以代码为准
- Codex 咨询 brief (文档结构):[`.claude/specs/2026-06-03-codex-multi-tenant-doc-structure.md`](../../../../../.claude/specs/2026-06-03-codex-multi-tenant-doc-structure.md)
- Codex 咨询 brief (命名选项):[`.claude/specs/2026-06-03-codex-naming-options.md`](../../../../../.claude/specs/2026-06-03-codex-naming-options.md)
