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_idNULLABLE 列(铺路,新写入由 worker 自动填),但不要求 backfill 历史、不开 RLS、不依赖此列做 isolation。不做:全量 rename / 每个 customer 独立 branch /tenant_idNOT 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 - V1 实际落地口径(identity-only Control Plane):
why-tenant.md;铺开进度与缺口:v1-gaps.md;原上线方案(历史参考):archive/multi-tenant-v1-plan/launch-plan.md - 通用方法论 (Pool/Silo/Hybrid/8 维度/DDL):
best-practices.md - 当前 store_id 隔离设计 (PR #226 已 ship):
store-level-isolation.md
注意:这份迁移计划描述的是更完整的最终形态重构。当前 V1 决策是先做 Control Plane,把 Orange Theory 现有数据保留在
pool_orange_theory,并通过tenant_store_mappings桥接 tenant 到 currentrc_stores。V1 业务表加tenant_idNULLABLE 列(铺路),但不以全量 rename、tenant_idNOT NULL backfill、或 Pool/Hybrid 为上线前置条件。
一、要去哪 (Final State)
一句话讲清
Optional Future: Pool / Hybrid
Pool / Hybrid 是 Control Plane 能支持的未来 placement 形态,不是当前 Final 必须完成的默认目标。
当前最顺的 Final 路径可以是:
也就是说,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:
完整 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 不要求一次性做完。
Phase 2 DoD (阶段 6+,按业务触发,不在当前 V1 / Final refactor 窗口内):
二、3 个客户怎么映射
客户 A:Sarah — Orange Theory Devon 加盟商
Sarah 的查询全部 WHERE tenant_id = 'A'。她只有 1 个 location,聚合视图 = 单店视图。
客户 B:Glow Beauty Corp — 3 店连锁
Glow 老板的 3 种 dashboard 视图都是 SQL 自然支持:
客户 C:United Airlines — 10 个 call center
United 数据物理上不在主 Neon DB,在专属 Neon project proj-united。代码读 tenants.placement 字段路由。
三、字段怎么变 (终态 → 现状)
终态 5 张核心表速查 (你先记住这 5 个)
⚠️ 跟
tenant_clients别混:tenant_members是用户成员关系 (Cognito 用户在哪个 tenant 是啥角色)。tenant_clients是 BPO 场景的 end-clients 业务字段 (例:TaskUs 服务的 Airbnb / Stripe / Doordash) — 见overview.md§十、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§权限 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 个字段:终态长啥样、做啥用、原来在哪、怎么变过来。
四、现状
我们在哪。
隔离 + 命名
Schema 22 张表分类
按 storeId 字段状态精确分类 (grep verify, 2026-06-03):
命名 3 时代叠加
3 个时代的命名同时存在,谁都没全干掉谁:
Frontend bridge 不是"全切了" — 是 2 个 Pinia store 并存,一半 composable 直接调 currentStoreId,一半调 currentOrgId (alias)。UI 用户看到"Organization",API URL 还是 storeId。
写入端 vs 读取端 (这是工作量真正在哪)
之前以为只改 67 个 routes (读取端) 就行 — 错了。真实影响面跨 4 个 repo:
→ 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:写规则 + 全栈 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 天)
3 个客户在阶段 1 结束时:
- Sarah:tenants 表 1 行,业务表 tenant_id 列填好但还没 WHERE 用
- Glow / United:还在测试环境 mock,实际客户进来再手动建
阶段 2:改写入端共享 helper (3-4 天) — 最关键一步
之前漏的最贵的一步。改完 callytics-common 中心 helper,4 个 Lambda 写入端自动连带改。
测试:老 pipeline 仍能跑,新写入数据 tenant_id 填充率 100%。Backfill 历史数据复用 storeid-coverage-monitor Lambda 同款 pattern。
阶段 3:改读取端 + frontend (1 周)
3 个客户视角:
- Sarah 完全无感(查询语义等价)
- Glow 老板 dashboard 一次登录看全 3 店(从"逐店切换"到"自然聚合")
- United(测试 mock) 验证 silo placement 路由
阶段 4:收尾 (3-4 天)
核心:把 store_id 单字段的两个职责分到两列,通过 2 个独立 ALTER 实现 — 不是 1 个 SPLIT 把单字段拆成 2 字段。
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 周)
详细 webhook flow + 签名验证见 best-practices.md。
阶段 5.5:DoD 收口 — 治理面 design + 观测最低限度 (2-3 天)
DoD (§一 line 70-81) 要求"8 治理项至少有 design 文档" + "Per-tenant CloudWatch dimension 上线"。这步集中兑现 — 只写 design + 加 metric dimension,不实现完整功能。
→ 这步做完所有 DoD hard requirements 都兑现 (除了第 1 个 Platinum 客户 Pool→Silo 迁移走通过 — 那个等真实客户触发)。
阶段 6+:长期 (按业务触发,不一次性)
不一次性做的原因:没有具体业务触发时,做了就是 over-engineering。Definition of Done 只要求"有 design",不要求"全实现"。
六、franchise 归位
详细历史 + 演变 + 迁移工程。Overview 给的是 1 句话。
历史演变
当前在哪里填
历史踩坑 (说明 franchise 不是无害包袱)
2026-04 message-processor 曾写 franchiseId = "orangeTheory-{site_id}" 拼串,导致 Neon PK 3-way 分裂、1709 客户重复 contact 记录。PR #632 加 parseFranchiseFromClientId() 化解。
→ franchise 写错过会造成 data corruption,所以 schema 留着 + NOT NULL。
终态归位
两个概念终态共存,职责彻底分开:
OrangeTheory 加盟商场景:
跨 franchise 品牌聚合 query:
→ 这是 retaintive 平台的运营分析视角,不是单 tenant 视角。 → 单个 tenant (Sarah) 的 dashboard 不允许看到其他加盟商数据 (规则 1)。 → 跨 franchise 聚合只有 platform admin 能看,通过专门 endpoint + audit log。
franchise 迁移 4 阶段 (跟主迁移并行做)
关键:业务表 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)每小时跑一次,告警条件:
阶段 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 真迁:
- 受影响 row count by table — 总数 vs 单 tenant 的占比,异常时停下来排查
- 孤儿行检测(关键 gate) — 该 tenant 对应
store_id[]在所有业务表里,是否有tenant_id IS NULL的行?孤儿行 > 0 → backfill 没跑完 → STOP,先补 backfill 再 dryRun。否则迁移会漏搬数据。- 估计耗时 — 按当时 Pool DB 大小 + Neon project 创建时间(~2 min)+ logical replication catch-up 时间
- logical replication slot 当前状态 — Pool DB 是否已 enable
wal_level=logical、max_replication_slots 是否够、有无 stale slot- 跨 tenant 数据残留检测 —
SELECT DISTINCT tenant_id FROM <each_table> WHERE store_id IN (<this_tenant_stores>)看是否真的只有该 tenant 的行,防止 backfill bug 把别人数据带过去dryRun 通过(5 项全绿)才能 trigger 真迁。第一个 Silo 客户来时必须先 dryRun。
关键技术点
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
场景 B:客户取消 (churn)
→ 关键:Silo project 是 retaintive 名下的 Neon project,客户不直接拥有。我们删 Neon project,客户合同上的 "data deletion" 责任就履行了。
→ 这是治理面第 2 条 (GDPR delete) 和第 7 条 (migration tool) 的具体含义。
一份代码,覆盖所有场景
→ 覆盖 4 种场景:
- Pool↔Silo (tier 升降)
- Silo↔Silo 跨 region (合规迁移)
- churn delete (取消订阅)
- M&A 合并 (UPDATE tenant_id 全改一边,然后 archive 旧 tenant)
Reconciliation 监控
八、风险 + 回退
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_idRENAME COLUMN(同一列改 column name,内容不变)。
风险清单
阶段间回退策略
每个阶段都是可回退的,因为 store_id 直到阶段 4 才删:
- 阶段 1-3 出问题:tenant_id 列还是 NULLABLE,直接停用新代码,回退到老 query
- 阶段 4 删 store_id 前:必须确认阶段 2-3 数据填充率 100% + 自动化测试全绿
- 阶段 5 Stripe 出问题:webhook 暂停 + 手动同步 tier 兜底
九、引用
- 不懂 multi-tenant?先看
overview.md - 通用方法论 (Pool/Silo/Hybrid/8 维度/DDL):
best-practices.md - 当前 store_id 隔离设计 (PR #226 已 ship):
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 - Codex 咨询 brief (命名选项):
.claude/specs/2026-06-03-codex-naming-options.md