V1 上线后的长期演进路径

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

先读这个边界:

  • V1 实际落地口径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。

配套文档:

注意:这份迁移计划描述的是更完整的最终形态重构。当前 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 路径可以是:

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:

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 §完整 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,铺开验收见 v1-gaps.md(原 DoD 成文于已归档的 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设备层 — 具体电话号码,可挂多 providerrc_store_phones 改名 + 扩字段
tenant_members成员层 — tenant 内的人 (老板、前台、客服)Control Plane 新加
location_memberslocation/store 级共享权限store_members 改名

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

⚠️ tenant_memberslocation_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 §权限 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.placementPool 还是 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电话号码 — 可挂多 providerrc_store_phones改名 + 扩字段
phone_lines.location_id (FK)属哪家店rc_store_phones.store_id改名跟随 rc_stores → locations
phone_lines.e164E.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_idprovider 那边的原生 IDrc_store_phones.extension_id改名 + 通用化 (RC 叫 ext,Twilio 叫 SID)
tenant_memberstenant 内的成员 (老板 / 前台 / 客服 / 经理)(无)Control Plane 新加 — 不从 store-level membership 直接 rename
tenant_members.tenant_id (FK)属哪个客户(无)新加
tenant_members.roletenant 级角色 (owner / admin / viewer)store_members.role 可作为 seed 参考不能机械照搬,需要人工确认
location_memberslocation/store 级成员关系store_membersFinal rename — 继续表达 store/location 级共享权限
tenant_clients 表 (可选, BPO 才用)BPO 服务的 end-clients (例:Airbnb / Stripe 之于 TaskUs)(无)新加,跟 tenant_members 是不同概念
tenant_provider_connectionsprovider 顶层连接 (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 (强隔离)8blackout-periods / contacts / operating-overrides / operating-schedules / phone-numbers / staff / task-progress-events / user-stores.ts (actual table store_members)
NULLABLE (弱隔离, backfill 漏洞)6calls / contact-timeline / leads / messages / store-config / tasks
rc-stores 自己 + 引用2rc-stores (PK 是 id 不是 storeId) / sub-accounts (只引用不带列)
合理无 storeId (enum / OAuth / metadata)4name-trust / oauth-states / task-ui / user-connections
GAP1playbook-feedback — 应有 storeId 但没有
index re-export hub1index.ts (不是表)
合计22

命名 3 时代叠加

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

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

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-trackingLead 入口 (从 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 §十二。


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

本节不是 V1 上线计划。Current -> V1 的迁移路径原成文于已归档的 rollout.md §5(未按原样执行,实际落地口径见 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

阶段 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 flagstier-based gating1 周
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:

-- "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_idtenants.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)每小时跑一次,告警条件:

-- 孤儿行检测
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 边界:tenantstenant_memberstenant_store_mappingsgetDbForTenant(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 的行?
    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) 的具体含义。

一份代码,覆盖所有场景

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_idcallytics-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 兜底

九、引用