Multi-Tenant 架构最佳实践

本文是通用方法论。retaintive V1 实际落地口径 → 看 why-tenant.md(原上线方案已归档到 docs/archive/multi-tenant-v1-plan/)。Final / long-term refactor → 看 final/migration-plan.md


一、北极星原则

Tenant 是头等公民。Auth ≠ Isolation。Strategic 早做,Tactical 延后。

Tenant 不是某张表上的一个外键。它是整个系统的第一维度 — schema、API、queue、observability、billing、admin、deploy region 都要按它切。Auth 只证明"用户合法登录",不证明"这个用户只能看自己 tenant 的数据"。隔离必须在每一条数据访问路径上独立强制(DB query / background job / cache / log / search index)。

Strategic 决策(隔离模型、命名层级、tier 映射、provider 抽象)改起来贵 — 早。Tactical 实现(RLS policy、rate limit 数值、cache TTL)可以增量上 — 延后。

5 条硬规则

  1. 隔离:跨 tenant 绝对不串。同 tenant 内,跨 location 允许(一个 tenant 自己的多个门店 / 仓库 / 子团队可以互看)。
  2. 计费:默认 1 tenant = 1 billing customer。Stripe 接入后通常是 1 tenant = 1 Stripe Customer;manual invoice / reseller / enterprise contract 可以通过 billing_accounts 表表达(Final phase only,V1 不建,字段挂 tenants 表里),不要把 billing exception 混进数据隔离边界。升级路径见下方 Stripe 计费层升级路径 — BPO 真要分账时切 Stripe Connect Connected Accounts。
  3. BPO:BPO(Business Process Outsourcing)公司是 tenant,end-clients 是 tenant_clients(业务字段,不是隔离层)。BPO 一家代运营 50 个品牌 = 1 个 tenant + 50 个 tenant_clients;不是 50 个 tenant。
  4. 扩张:tenant ↔ location 是 1:N。N 改字段不动 schema — 加店、关店、改 vertical 全是 INSERT/UPDATE 行,不是 ALTER TABLE。
  5. Placement ↔ 部署:tier 是商业包装,placement 是基础设施路由。不要把 tier hardcode 成 Pool/Silo。少量高价值客户可以 all-Silo;大量低价 self-serve / free trial 才需要 Pool/Hybrid。

Stripe 计费层升级路径

Decision:Stripe 计费抽象分 3 层 — V1 用最简单的 Customer 层,BPO 出现后才升 Connected Accounts。

Why this:Stripe 有 3 个层次的 billing model,不同业务复杂度对应不同层。一开始就上 Connect = over-engineering,但等真有 BPO 客户再迁也是工程量。提前预留字段才能平滑升级。

3 层 Stripe 模型:

Stripe 对象适用场景retaintive 何时启用
L1: Customer1 个 Stripe Customer1 个客户付钱给 1 个 SaaS第一个 billing integration 的默认模型 — Sarah / Glow / United 都是这层
L2: Subscription with multiple Prices1 Customer + N 个 Subscription1 个客户多个 tier 混合(SMB + 加购 Pro feature)第一个客户想"基础套餐 + 加购通话分析"分开计费时
L3: Connect Connected Accounts1 Platform Account + N 个 Connected AccountBPO 平台代运营,end-client 自己付费第一个 BPO 客户想给 end-client 单独开账时

升级判定:

当前 V1 identity-only 状态:
  tenants 表没有 billing_provider_* 字段

第一个 L1 Stripe integration:
  tenants.billing_provider_customer_id = 'cus_AAA'

未来 L3 触发:
  TaskUs (BPO) 想给 Airbnb / Stripe / Doordash (end-clients) 单独开账单
  → TaskUs 升级成 Stripe Connect Platform Account
  → 每个 end-client 在 retaintive 这边是 tenant_clients 行
  → 每个 end-client 在 Stripe 那边对应一个 Connected Account
  
  schema 变化:
    tenants                — 不变 (TaskUs 仍是 1 行)
    tenant_clients          — 新表,end-client 业务字段
    tenant_clients.billing_provider_account_id  — 这里挂 Connected Account ID

字段预留:当前 V1 identity-only 没有落 billing 字段。第一个 L1 billing / Stripe integration 触发时,先在 tenants 或 future billing_accountsbilling_provider_customer_id (nullable 字段),表达默认的 1 tenant = 1 Stripe Customer。billing_provider_account_id 只属于未来 Connect/BPO 路径:若建 tenant_clientsbilling_accounts,Day 1 就预留 nullable 字段,不要把 Connect account id 混进当前 identity-only V1。

Why not 一开始就上 Connect:Connect 比 Customer 复杂很多 — 需要处理 onboarding KYC、payout、reverse transfer、platform fee 等。3 个客户都是 L1 时上 Connect = 3 倍 onboarding 复杂度换 0 业务价值。

Why not 等 BPO 来再改 flow:加字段不可怕,改 billing flow 才贵。第一个 L1 billing integration 可以只落 billing_provider_customer_id,但 webhook / invoice pipeline 要把 Customer 与 Connect 的边界留出来;一旦写成"永远假设全是 Customer",未来要插 Connect 就等于重构 webhook handler。预留扩展点是廉价保险

Reference:

Consequences:

  • V1 webhook handler 只接 customer.subscription.* events;预留 account.* event handler 的 hook 但不实现。
  • L1 → L3 升级时,单个 tenant 不能从 Customer 转 Connected Account 直接迁数据 — 必须 Stripe 端新建 Connected Account + retaintive 端 link 到 tenant_clients。这就是为什么 tenant_clients.billing_provider_account_id 是 nullable 而不是 inherits from tenants.billing_provider_customer_id

二、Strategic — 架构设计层 (改不了的)

隔离模型谱系

Pattern:用 Pool / Bridge / Silo / Hybrid 四档表示隔离强度。选择哪档不是“架构高级程度”问题,而是 business model / compliance / cost / automation 成熟度问题。

Why this

  • Pool(共享表 + tenant_id + RLS)— compute 共享,单位成本最低。代价是每条数据访问路径都必须严守 tenant scope,RLS / query audit / tenant-aware cache 必须成熟。
  • Silo(每 tenant 独立 DB / 独立 Neon branch/project)— 合规友好(PCI / HIPAA / SOC2 audit 范围小)、性能可预测、blast radius 限于单 tenant。代价是每 tenant 都有独立 endpoint / secret / migration / observability 维度。
  • Bridge(共享 cluster + 每 tenant 独立 schema)— PG 的 schema-per-tenant,介于两者之间。schema 数量上限 ~1000 后元数据查询变慢。
  • Hybrid — 同一个 SaaS 里部分 tenant 走 Pool、部分 tenant 走 Silo。通常在同时服务低价 self-serve 和高价值 enterprise 时才值得。

When all-Silo works:客户数量少、客单价高、合规叙事重要、且 branch/project provisioning / migration / secret / monitoring 自动化到位时,all-Silo 是合理默认。

硬前提:没自动化的 all-Silo 5 个客户能撑,10 个开始崩。手工 pg_dump + 手工切 secret + 手工跑 schema migration,在 3 个客户时还行,10 个就 human error 频出(漏跑 migration → schema drift / 拷错 secret → 客户连别人的 DB)。详见下方 Silo 自动化必备的 7 件事

When all-Silo hurts:客户数量很多且价格很低时,每个 tenant 独立 compute / endpoint / secret / migration / observability 的固定成本会吃掉毛利。例:大量低价 self-serve tenants 如果都要独立 always-on compute、独立 secret、独立 migration fan-out 和独立 monitoring,固定成本会比共享 Pool 高很多;这时 Pool 通过共享 compute 才有意义。

Why not 全 Pool:大客户合规 / 性能 / blast radius 要求满足不了。一个 noisy tenant 把 connection pool 占满全停服。

Reference

  • AWS SaaS Tenant Isolation Strategies whitepaper — Pool/Silo 命名来源
  • AWS Serverless SaaS Reference Architecture — basic/standard/premium = Pool, platinum = Silo
  • Azure Multitenant Solution Architecture — 同样 Pool/Silo 谱系

Consequences

  • Placement 决定 connection string。code 里必须用 tenant resolver 拿 connection,不能 hardcode DB URL。
  • 如果支持 Pool / Silo 互转,必须有自动化迁移工具(计费 tier ↔ 隔离强度)。如果产品选择 all-Silo,迁移工具可以先聚焦 createTenant / promoteTenantToSilo / archiveTenant

命名与层级

Decisiontenants → locations → phone_lines(或 users / orders / tickets 等业务实体)。需要 BPO 场景再加 tenant_clients 作为业务字段挂在 locations 或独立表。

Why this

  • tenant 是计费 + 隔离单位(通常 1:1 with Stripe Customer;manual billing 阶段可以先为空)。
  • location 是运营单位(一家店 / 一个仓库 / 一个 region office)。同 tenant 跨 location 允许互看。
  • phone_lines 是设备 / 资源单位。一个 location 多个 phone_line 很常见(前台 / 销售 / 客服分机)。
  • tenant_clients(可选)— BPO 给 end-client 提供服务时,end-client 是业务字段("这条 ticket 属于哪个 end-client"),不是隔离层(BPO 员工要能跨 end-client 查询和汇总)。

Why not franchise_id / site_id / account_id 当一级隔离:这些是某个 vertical 或某个 provider 的本地概念(franchise 是连锁概念,site 是 RingCentral 概念,account 是 OAuth 概念)。绑死了换 vertical / 换 provider 就改 schema。

Why not 把 BPO 的 end-client 当 tenant:BPO 业务模型是"一个甲方运营多个品牌",账单付一份。end-client 当 tenant 意味着每个 end-client 一个 Stripe Customer — 跟 BPO 实际付款关系不符。

Reference

  • Slack — workspace(tenant 等价)+ channel(location 等价的子粒度)
  • Notion — workspace + teamspace
  • Linear — workspace + team
  • Salesforce — Account(tenant 等价,付款方)+ Contact(end-client 等价,业务字段)
  • AWS Serverless SaaS reference — 统一用 tenantId 命名

Consequences

  • 所有 schema 字段名必须 tenant_id,不是 org_id / account_id / customer_id。混用就是混乱的开始。
  • 业务字段(tenant_client_idlocation_id)可以加 index,但不参与 RLS policy。RLS 只看 tenant_id

权限 resolve 顺序 (tenant_members vs location_members)

Decision:两张成员表都存在,但职责不同。每个请求按下面 3 步顺序判,任何一步 fail 就 reject。

Why this:Glow Beauty 老板登录看 3 家店 = tenant 级权限;SoHo 店的前台只看 SoHo = location 级权限。两张表同时存在,系统必须有明确 resolve 顺序,否则会出现 "员工是 location_members 里 SoHo 的 staff,但 tenant_members 里没他,他能不能登录?" 这类歧义。

3 层 resolve 顺序:

请求进来 (JWT → user_id, header → tenant_id, URL/body → location_id):

Step 1: tenant_members 校验 (这个 user 在这个 tenant 有 role 吗?)
  SELECT role FROM tenant_members
  WHERE user_id = $1 AND tenant_id = $2 AND status = 'active'
  → 没记录 → 403 Forbidden (这个 user 跟这个 tenant 没关系)
  → 拿到 tenant_role: 'owner' | 'admin' | 'viewer'

Step 2: location 维度过滤 (这个请求是单 location 还是跨 location?)
  if request 带 location_id:
    if tenant_role >= 'admin': 直接放行 (admin 跨 location 互看)
    if tenant_role == 'viewer':
      SELECT role FROM location_members
      WHERE user_id = $1 AND location_id = $3 AND status = 'active'
      → 没记录 → 403 (viewer 没 location-level 授权)
      → 拿到 location_role: 'manager' | 'staff'
  else (跨 location 聚合视图):
    require tenant_role >= 'admin' (viewer 不能跨 location 聚合)

Step 3: 业务表 query 自动注入隔离
  SET LOCAL app.tenant_id = $tenant_id (RLS 用)
  SELECT ... FROM contacts WHERE ... [AND location_id = $3 if 单 location]

3 种 user 实例对照:

Usertenant_memberslocation_members可见范围
Glow 老板 Alicetenant=Glow, role=owner(无)Glow 全 3 店(聚合 / 单店 / 对比都可)
SoHo 经理 Bobtenant=Glow, role=viewerlocation=SoHo, role=manager只能看 SoHo 单店;切到 Brooklyn 403
Glow 客服 Caroltenant=Glow, role=admin(无)Glow 全 3 店(admin 跨 location 但不算 owner,不能改账单)
Sarah 单店老板tenant=Sarah, role=owner(无)Sarah 唯一 location

Why not 单一张表合并 tenant + location role:tenant 内可能有跨 location 的角色(老板 / 客服 / 财务 = 跨店),也有限制到单 location 的角色(前台 / 店长)。混一张表 → 每个 user 跨 location 都要 N 行,query 变复杂,permission audit 更难。

Why not 只用 tenant_members:某些 vertical (大客户 / BPO) 需要把员工限制到具体 location,tenant_members 不够。

Why not 只用 location_members:tenant 级行为(改 Stripe 卡 / 改 tier / 改 SSO)没法挂 location,必须 tenant_members。

Consequences:

  • API endpoint 默认走 Step 1 + Step 3,跨 location query 自动走 Step 2 多一层。
  • Admin impersonate(SaaS 提供方员工 debug) → 绕过 Step 1-2,但必须记 audit log。

权限 / 访问 scope 支持矩阵 (V1 vs Final)

下表是这套设计在 V1 / Final 两个阶段能覆盖的访问场景。字段级 / 行级 / 临时权限不在 multi-tenant scope 内,真要做时单独设计。

场景V1 支持?Final 支持?怎么实现
1 个 tenant 多个 user 同时登录tenant_members,1 tenant 对多 user
不同 user 不同 tenant role(owner / admin / viewer)tenant_members.role,中间件取出来塞 request context
Owner 能改 billing,viewer 不能route handler 按 tenantRole 判断
1 个 user 跨多个 tenant(连锁加盟商管几家店)tenant_members 多行,前端 tenant switcher 切 active tenant
跨 location 限制(店员 Carol 只看 SoHo,不看 Brooklyn)V1 沿用现有 store_members 表(viewer + store_members 共同限定);Final 改名 location_members 语义更清,功能等价
字段级脱敏(店员看不到电话号码全文)业务逻辑层做,需要业务表加 pii_visible_to 字段 + UI 渲染层脱敏
行级"只看本人接的电话"业务表加 assigned_to + 业务 query 加 WHERE assigned_to = userId
临时降权 / 临时升权(代班)单独 feature,需要 tenant_member_overrides 表 + TTL
Admin impersonate(SaaS 员工 debug 客户问题)Final 加 impersonation_audit 表 + 显式切 context flow,见 §Security Admin impersonation

Why "字段级 / 行级" 不在 multi-tenant scope:multi-tenant 解决的是 "谁能看到哪个客户的数据" 这一层。客户内部不同员工看到的不同字段 / 不同行 = 业务层 RBAC,跟 tenant 隔离是正交的两件事。混在一起 design 会让 tenant 隔离变得复杂、行级权限也做不深。

Silo 自动化必备的 7 件事

Decision:如果产品方向是 all-Silo / Silo-first,这 7 件事必须自动化。每一件没做就是手工卡点 — 5 个客户能撑,10 个 human error 频出。

Why this:Silo 模式把"运维成本"从"共享一份基础设施"变成"per-tenant N 份"。手工管 N 份的临界点是 5-10 个客户。WorkOS / Northflank 2026 SaaS 产线指南原话:"automated provisioning covers the full onboarding sequence, each step needs to be idempotent"。

自动化 7 件:

#自动化项触发不做的后果
1Stripe Customer 创建 + Subscription 监听客户 sign up / 销售关单手工去 Stripe portal 建,容易漏字段;tier 升降不同步
2Neon project / branch provisiontenants.status = 'provisioning'手工 Neon UI 创建,5 分钟一个;10 个客户 1 小时
3Secret 写入 AWS Secrets ManagerNeon 创建完手工拷 DB URL,容易泄漏 / 拷错 / 落进日志
4DB schema migration fan-out每次 schema 改N 个 Silo DB 都要跑;漏一个 = schema drift,出错时排查很痛
5Smoke test + status=active 切换provision 完客户登录失败才发现没 ready;onboarding 体验差
6Provider connection rebind(OAuth / API key)tenant 换 CPaaS / 重新接入手工挪 OAuth token,容易漏 revoke 旧 token
7Churn 软删 + 期满 cascade delete + drop Neon projecttenants.status = 'churned' + 30/60/90 天数据法务风险;GDPR 不合规;旧 Neon project 一直 idle 烧钱

业界 stack 推荐:

  • Terraform / Pulumi — infra-as-code,project template 模板化(创 Neon project + IAM + secret 一起跑)
  • Stripe Webhook handler — raw body 验签 + idempotent (用 Stripe event_id 去重) + 入 SQS 异步处理
  • SQS + DLQ — webhook 入队避免 Stripe 超时;失败重试 + 死信告警
  • Provisioning job table (tenant_provisioning_jobs) — 每步 job 记 status + retry,失败可断点续传

触发条件:第 6 个 Silo 客户来之前必须 4 件做完(1, 2, 3, 4)。第 10 个之前必须 7 件全做完。否则 ops 撑不住。

Reference:

Consequences:

  • 当前 V1 identity-only 不建 tenant_provisioning_jobs;等 onboarding 自动化 / self-serve / Silo provision 真正触发时,这张表是 7 件事的执行 ledger。前 1-2 个客户可以手工建档,但进入自动化前要把 job table 和幂等流程补上 — 后期接 Stripe webhook 时只是切 trigger,不改 pipeline。
  • Future getDbForTenant()connection_secret_ref 字段是自动化的 SoT — 自动化脚本写 secret + 写这个字段是同一个 atomic op。

Anti-pattern:

  • "先手工建,等客户多了再自动化" — 客户增长不是线性的,客户 10 个来时一周内可能就崩。
  • 自动化脚本不 idempotent — provision 失败重试就会建 2 个 Neon project。
  • Stripe webhook 直接同步建 Neon project — Stripe 30s 超时,Neon provision 可能 2 min,会 timeout 重发 → 建多个 project。永远 webhook → SQS → worker

Tenant lifecycle

Decision:把 tenant 当成有状态机的实体。8 类生命周期变化都要有显式的代码路径 + audit log 记录。

Why this:默认状态 active 跑着没事。出问题都是状态转换没做好 — churn 之后数据还在被查询、M&A 之后两 tenant 的数据混了、tier 降级之后 silo 还活着没回收。

8 类生命周期变化

#变化改动级别操作
1新开 1 个 locationINSERT INTO locations 一行
2关 1 个 locationUPDATE locations SET status='closed'(软删)
3改 vertical(同 tenant 多业务线)INSERT INTO locations 带新 vertical 字段
4改付款人 / 换信用卡UPDATE tenants SET billing_provider_customer_id = ?(Stripe webhook 触发)
5拆品牌独立账单⭐⭐INSERT INTO tenants 新建 + UPDATE locations SET tenant_id = ? 转移子集
6升 tier basic→premium(同 Pool 内)UPDATE tenants SET tier = ?(Stripe webhook 触发)
7placement 变化,例如 shared pool → Silo⭐⭐⭐⭐计费 tier ↔ 隔离强度
8M&A 合并 / churn / GDPR delete⭐⭐⭐UPDATE locations.tenant_id 全改一边 + 软删 / cascade delete

状态机

provisioning → active → suspended → churned → deleted
                ↑          ↓
                └──── reactivated
  • provisioning — 新建中,Stripe Customer 已创但 DB / silo 还没准备好。API 拒访问。
  • active — 正常服务。
  • suspended — 欠费 / 政策违规 / 临时停服。读可降级(只读模式),写禁。
  • churned — 已退订。30 / 60 / 90 天保留期内可恢复。
  • deleted — GDPR 触发或保留期结束。silo project drop,pool 数据 cascade delete。不可恢复。

Why not 把状态藏在多个 boolean 列is_active / is_paying / is_deleted):状态机不显式 = 转换规则散在代码各处 = 一定有 case 漏掉。一个枚举 + 转换函数收敛所有路径。

Reference:Stripe Subscription status 模型、AWS Control Tower account lifecycle。

Consequences

  • 每个 API endpoint 进来先 load tenant,check status,不是 active 就返回对应错误(402 Payment Required / 403 Suspended / 410 Deleted)。
  • 所有 background job 也要 check status,churned tenant 的 cron 必须停。

计费 tier ↔ 隔离强度

Decision:tier 是计费/包装概念,placement 是部署/路由概念。两者用映射表关联,不是同一个字段。

Why this

  • Stripe 接入后可以是 tier 的 source of truth。V1 / enterprise contract 可以先 manual。
  • Placement(shared pool / Neon branch / Neon project / region)由 Control Plane 决定。tier 可以影响 placement,但不能在业务代码里 hardcode。
  • 升 tier 不一定触发 placement 变化。同 placement 内升降 tier 只改 entitlement / feature / rate limit。只有 placement 变化才需要迁移。

Tier ↔ placement 映射示例

TierPossible placement说明
launch / manualshared_pool 或 neon_branchV1 可先 manual 决策
standardshared_pool 或 neon_branch看价格和客户数量
premium / enterpriseneon_branch / neon_project合规、隔离、性能叙事更强
free trial / low-price self-serveshared_pool只有低价大规模时才值得

Pool ↔ Silo 迁移流程(只有支持 Pool/Hybrid 时才需要完整实现):

Pool → Silo(升 platinum):

  1. Provision 独立 Neon project(自动化脚本,~2 min)
  2. 启动 dual-write — 应用层同时写 pool DB + silo DB
  3. COPY (SELECT ... WHERE tenant_id = ?) 或 store-scoped export 回填历史数据到 silo
  4. 切读 — 该 tenant 所有 query 走 silo connection
  5. 切写 + 清理 pool DB — DELETE FROM ... WHERE tenant_id = ?,cascade 删全部 pool 数据

Silo → Pool(降级或 churn):

  • 降级:反向 Step 1-5。警告:PCI / SOC2 强度降级,需 tenant 显式同意,记 audit log。
  • Churn 软删:tenants.status = 'churned'。Silo project 保留,30 / 60 / 90 天宽限期后 cascade delete。
  • GDPR delete:立即 cascade delete + drop silo project。不走宽限期。

One function, all paths

// 伪代码,universal pattern
migrateTenant(tenantId, fromPlacement, toPlacement, options)
// 覆盖:Pool↔Silo、跨 region、churn 软删、M&A 合并、GDPR delete

所有 placement 变化都走这个 function。不允许散在各 API handler 里手写 pg_dump。

Why not Stripe webhook 直接驱动迁移:webhook 是 trigger,迁移是异步 long-running job(可能跑 1 小时)。Webhook handler 只写 tenants.tier,发 SQS message。后台 worker 跑迁移。

Reference:Stripe Subscription webhook docs、AWS DMS migration pattern、Neon project provisioning API。

Consequences

  • Placement 变化 SLA = 几小时到一天,不是即时。前端要显示"升级中"状态。
  • 必须有 dryRun mode — 不真迁,只 print 影响的 row count + 估计耗时。
  • 迁移失败要可重试 + 可回滚。

Provider adapter 层

Decision:把外部 provider(telephony / payment / messaging / CRM)抽象成一层 adapter。Core business logic 不直接调 provider SDK。

Why this

  • 行业现实 — telephony 有 RingCentral / Twilio / Genesys / Five9 / Amazon Connect,payment 有 Stripe / Adyen / Braintree。同一类 provider 概念差很多(账号 / 子账号 / org / instance)。
  • 客户带 provider 来 — enterprise 客户经常有自己的 Twilio / Genesys 合同,要求 SaaS 接入他们的实例。绑死一家就接不了。
  • AI / MCP 友好 — adapter 层用统一抽象词(PhoneNumber / Call / Message),AI agent 能跨 provider 推理。

抽象模型

概念我们的统一词Provider 各家叫法
顶层账号AccountTwilio Account / RC Account / Genesys Org / Five9 Domain / Amazon Connect Instance
子账号 / 子组织SubaccountTwilio Subaccount / RC Site / Genesys Site / Five9 Campaign / Connect Routing Profile
电话号码PhoneNumberE.164 字符串(统一)
通话Calltelephony_session_id(抽象出来)
消息Messageprovider 各自 ID(抽象出来)

Why not 直接用 RingCentral SDK 满天飞:换 provider 要改几百处。adapter 一层,换 provider = 加一个 adapter 实现。

Why not 完全抽象到不可见:abstraction leak 是常态。某些 provider-specific 字段(call recording URL / DTMF 行为)必须暴露。Adapter 允许 provider_metadata 字段透传原始 payload。

Reference:Twilio 自家也用 adapter pattern 支持多 CPaaS、Vonage 的 Provider Switch、CrushBank 的 ITSM connector pattern。

Consequences

  • Adapter interface 一旦稳定,加 provider = 实现 interface + 注册。Core 0 改动。
  • Adapter 层就是 AI / MCP 接口的天然来源 — 统一抽象词直接变 MCP tool spec。

API platform + AI / MCP

Decision:把 multi-tenant API 当 platform 设计,不是当内部 CRUD。2026 年这一层 = AI agent 的入口。

Why this

  • 抽象词稳定 = AI 友好。POST /tenants/:id/locationsPOST /createStoreForFranchise 好太多。AI agent 推理路径短。
  • Webhook + MCP 是同一类 — outbound event delivery。MCP server 暴露 tool spec 给 LLM,webhook 暴露 event spec 给客户后端。设计原则相同(idempotent、tenant-scoped、auth-required)。
  • Self-serve API key + scope = enterprise 入场券。每个 tenant 能自助生成 API key,绑 scope(read:calls / write:tasks / admin:locations)。

API 层 checklist

维度要求
路由 tenant-scoped/tenants/:tenantId/... 或 header X-Tenant-Id
Auth tenant-boundAPI key / JWT 必须绑 tenant,不能跨 tenant 用
Idempotent write写操作支持 Idempotency-Key header(Stripe pattern)
Pagination + filter所有 list 都有 cursor + filter
Rate limit per tenant见 §3.3
MCP tool spec抽象词稳定后,自动生成 MCP server tool list

Why not 等 AI agent 火了再加抽象:抽象层不是临时贴的。API shape 一旦发布出去,外部客户 / AI agent 都在用,改成本指数级涨。Day 1 就当 platform 设计。

Reference:Stripe API design、Linear GraphQL schema、Anthropic MCP spec、Twilio webhook patterns。

Consequences

  • 内部 service-to-service 也走 API(不是直接 DB query)。这样 tenant scope 强制在 API 层,DB 层 RLS 是 defense in depth。
  • 加 AI agent / MCP server 几乎 0 改动 — API 已经是抽象词层。

三、Tactical — 技术实现层 (AWS 5 pillar)

每个 pillar 写 Principle(原则)+ When to enable(何时启用)+ Anti-pattern(反模式)。Current / Delta / Owner / Next PR 在 final/migration-plan.md 里填。

Security

Principle:Defense in depth — 每一层独立强制 tenant scope。Auth → API → Service → DB,每层失效其他层仍能挡。

Auth ≠ Isolation:JWT 解出 tenant_id 之后,必须通过 tenant resolver 选对 placement / DB。光"用户登录了"不证明"用户只能看自己 tenant 的数据"。

Pool / Silo / Hybrid 三种模式下 RLS 的作用

RLS 是数据库强制每条 query 必须满足 policy 的兜底。在不同模式下用法不同:

模式RLS 角色强度为什么
Pool(共享 DB)必备 (Day 1)强制N 个 tenant 在一张表,代码漏 WHERE tenant_id 就跨 tenant 泄漏。RLS 在 DB 层兜底,挡住所有这种 bug
Silo(独立 DB / Neon project)Optional defense-in-depth推荐但不强求DB 物理只有 1 个 tenant 的数据,理论无 cross-tenant 泄漏可能。但 RLS 能挡 1 个 2nd-order bug:getDbForTenant() 路由错把 tenant_A 连到 tenant_B 的 DB → RLS policy 因 app.tenant_id 不匹配仍 deny
Hybrid(同一份代码同时跑 Pool + Silo)业务表 Day 1 写 RLS,Silo DB 上 policy 也开必备一份代码两种 DB,Pool 必须 RLS;Silo 也开 RLS 让 promote / 数据迁移时 schema 一致

Hybrid 怎么设计:一套代码,运行时按 tenant 路由

Hybrid 不是"Pool 和 Silo 两套代码",是一套代码,运行时根据 tenant 路由到不同 DB。3 个 design invariant:

1. 同一份业务 schema 跑在两种 DB 上
   ├─ Pool DB: 业务表带 tenant_id NOT NULL + RLS policy
   └─ Silo DB: 业务表也带 tenant_id NOT NULL + RLS policy (冗余但简化)

2. 每条请求进来:
   resolveTenantContext(req) → tenant_id
   getDbForTenant(tenant_id) → 拿对应 connection (Pool 或 Silo)
   db.execute("SET LOCAL app.tenant_id = '...'")   ← RLS 用这个 session var
   业务代码: SELECT * FROM contacts (RLS 自动按 tenant_id 过滤,代码无感)

3. 业务代码不知道 tenant 在 Pool 还是 Silo
   ├─ 代码读起来像 single-tenant app
   ├─ Pool 模式 RLS 强制隔离
   └─ Silo 模式 connection 自带边界 + RLS 兜底

关键设计 invariant:业务表 schema 永远带 tenant_id 字段(无论 Pool 还是 Silo)。Silo 模式下这个字段"看似冗余",但有 3 个好处:

  1. 同一份 query / migration 在两种 DB 上都跑得通(代码一份)
  2. 客户从 Pool promote 到 Silo 时数据格式不用转(直接 COPY ... WHERE tenant_id)
  3. M&A / 政策变化要把 Silo 客户合回 Pool 时,数据原生支持

不这样做的代价:Silo 业务表不带 tenant_id → Pool/Silo 两套 schema 差异 → 两套 query 代码 → 客户在 Pool/Silo 之间流动几乎不可能。这是早期 Hybrid 设计最常见的 trap,业界 RLS articles 反复警告。

RLS 实施细节

  • PG CREATE POLICY per table,policy 用 current_setting('app.tenant_id') 拿当前 session 的 tenant_id。
  • 应用层每个 connection 进来先 SET LOCAL app.tenant_id = '...'SET LOCAL 不是 SETSET 是 session 级,pgbouncer / connection pool 复用 connection 时上一次的值漏过来。SET LOCAL 是 transaction 级,跨 transaction 自动清理。
  • Bypass RLS 只给特定 superuser role(migration、admin tool)。普通 app role 不能 bypass。

GDPR delete + Admin impersonation

  • GDPR delete:cascade delete 所有 tenant 数据 + audit log 留 retention 字段("this tenant was deleted on YYYY-MM-DD by user X",不留 PII)。Silo placement 多一步 drop Neon project。
  • Admin impersonation:所有 admin(包括 SaaS 提供方员工)跨 tenant 操作必须 (1) 显式触发 impersonate flow,(2) 记 audit log(who / when / which tenant / what action),(3) 时间窗口受限(默认 1 hour)。

8 治理项中归这里的:GDPR delete / Audit log per tenant(跟 Op Excellence 共享)/ Admin impersonation。

When to enable:

时机
RLS — Pool 模式Day 1
RLS — Silo 模式客户 ≥ 3 时加上(防 resolver 路由 bug 误连别人 DB)
RLS — Hybrid 准备业务表加 tenant_id 字段时同步加 RLS policy(为未来 Pool↔Silo migration 铺路)
Audit log第一个付费客户之前
GDPR delete tool第一个 EU tenant 之前
Admin impersonation客服团队 onboard 第二个员工之前

Anti-pattern:

  • "Auth 拿到 user_id 就够了,DB 层不用过滤 tenant"。错。BOLA(OWASP API1)的标准场景。
  • "RLS 太复杂,应用层过滤就行"。错。任何 dev 写 SELECT * FROM contacts 漏掉 WHERE 就是 cross-tenant 泄漏。RLS 是兜底。
  • "Silo 模式不用 RLS,DB 已经隔离了"。半对getDbForTenant() 路由 bug → 跨 tenant 数据访问;RLS 在那种 2nd-order failure 下兜底。
  • "Silo 业务表不加 tenant_id 字段,反正是物理隔离的"。错。这把 Hybrid 路径堵死了 — Pool/Silo 之间客户流动 = 数据格式转换 = 几乎不可行。
  • "admin tool 跨 tenant 不记 log,反正只有我们自己用"。错。员工内鬼 / 凭证泄漏后这是唯一追溯路径。

Reference:

Operational Excellence

Principle:每个 tenant 看起来像独立产品。Observability、deploy、runbook、kill switch 都能 per-tenant 操作。

Per-tenant observability

  • Log / metric / trace 都带 tenant_id tag。Datadog / Sentry / CloudWatch query 能按 tenant 切。
  • Per-tenant dashboard — 客户 X 的 P50 / P99 / error rate 单独看得见,不混在全局聚合里。

Audit log per tenant

  • 所有 write / admin action / config change 入 audit log,按 tenant 分区(DB partition 或 log stream)。
  • 客户合规要求时直接导出该 tenant 的 audit log,不需要全局 grep。

Admin / migration tool

  • Pool↔Silo 迁移工具(计费 tier ↔ 隔离强度)— 一个 CLI / API,覆盖所有 placement 变化。
  • Tenant lifecycle CLI — tenant suspend <id> / tenant churn <id> / tenant restore <id> 一行命令。

Per-tenant kill switch

  • 单 tenant 出问题(DDOS attack / bug 触发 / 客户主动暂停)能 kill 单 tenant 而不影响其他人。
  • Implementation:tenants.status = 'suspended',所有 API entry point check 一下。

Lifecycle 状态机:见 Tenant lifecycle。状态机的 transition log 本身就是 audit log 的一部分。

8 治理项中归这里的

  • Lifecycle 状态机
  • Audit log per tenant(也跟 Security 共享)
  • Migration tool (Pool↔Silo)

When to enable

  • tenant_id tag — Day 1。所有 log / metric 加这个 tag 几乎 0 成本。
  • Per-tenant dashboard — 第一个客户问"我的 P99 是多少"之前。
  • Audit log — 第一个付费客户。
  • Migration tool — 第一个 platinum tenant 之前(手动 pg_dump 在客户 3 家以前能撑,第 4 家就崩了)。

Anti-pattern

  • 全局 dashboard 一锅炖,问"客户 X 慢吗" → 答不出。
  • 手工 pg_dump + manual 切换 — 第 5 次就出 human error。
  • 没有 kill switch — 一个 tenant 的 bug 触发全平台限流。

Reference:AWS Well-Architected Operational Excellence pillar、Honeycomb 的 high-cardinality observability、Stripe 的 dashboard per Connect Account。

Reliability

Principle:一个 tenant 不能把其他 tenant 拖死(noisy neighbor)。Rate limit、queue partition、circuit breaker 都按 tenant 切。

Noisy neighbor 防护

  • Per-tenant rate limit — token bucket,每 tenant 独立 quota。premium tier 更高 quota。
  • Per-tenant queue partition — SQS FIFO MessageGroupId = tenant_id,or 每 tier 一组独立 queue。
  • Connection pool quota — pool 模式下,单 tenant 占连接数有 hard cap。

DR failover

  • tenants.primary_region 挂时,自动切到 tenants.replica_region。RTO / RPO 按 tier 区分(premium 5 min RTO,basic 1 hour 也接受)。
  • Failover 必须可自动 + 可手动触发。手动触发是 region partial outage 时管理员判断的口子。

Per-tenant feature flag

  • LaunchDarkly / Unleash / 自建 — feature gate 能按 tenant 开关,不只是按 percentage。
  • 用途:beta 客户先开新 feature、出问题的 tenant 单点关一个 feature 缓解。

Provider unbind

  • 客户离开时(churn / 换 provider),所有 provider 绑定要能干净解绑 — webhook 取消、token revoke、queue stop。
  • 反向:客户来时 provider rebind 也是同一套流程,可重入。

8 治理项中归这里的

  • Per-tenant feature flags
  • Region failover
  • Provider unbind

When to enable

  • Per-tenant rate limit — 拿到第一个客户用 API 之后立刻。
  • Per-tenant feature flag — 进 closed beta 之前。
  • DR failover — 拿到第一个企业合同(一定会问 RPO / RTO)。
  • Provider unbind — 第一个 churn 之前(必然会发生)。

Anti-pattern

  • 全局 rate limit — 一个 tenant 写脚本爆调,把其他 tenant 全 throttle。
  • SQS queue 不 partition — 一个 tenant 灌 100 万 message,其他 tenant 的 message 卡 4 小时。
  • Feature flag 只支持 percentage — 想给客户 X 单独开 / 关一个 feature 做不到。
  • Churn 客户的 webhook 没取消 — 半年后还在收 event,被客户法务投诉"为什么还在调我们 API"。

Reference:AWS Well-Architected Reliability pillar、Stripe 的 tenant-aware rate limiting、Cloudflare 的 per-zone limits。

Performance Efficiency

Principle:Pool 模式下,单 tenant 的 query / cache / connection 都不能挤压其他人。Hot tenant 是异常,不是常态。

Caching strategy

  • Cache key 必须含 tenant_idSELECT * FROM contacts WHERE id = ? cache key = contact:${tenantId}:${contactId}。漏掉 tenant_id = cross-tenant cache poisoning。
  • Per-tenant cache size cap — 单 tenant 不能占 cache memory 超过 X%。
  • TTL 按 data type 分(不是全局 60s)。

Sharding(Pool 内部分片)

  • 当 Pool DB 撑不住,按 tenant_id hash 分片到多个 DB instance。
  • 一致性 hash + virtual node,加 / 减 shard 不需要全量迁移。
  • 跨 shard query 禁(admin 才允许)。

Connection pool strategy

  • Per-tenant connection limit — 单 tenant 最多占 X 个 connection(pool size 20 时 cap 5)。
  • pgbouncer transaction pooling — 多 tenant 共享 connection,配合 SET LOCAL 用。

Query plan / index

  • 所有 index 第一个字段 = tenant_id(tenant_id, created_at) 不是 (created_at)
  • PG partial index 给大 tenant 单独优化(如 WHERE tenant_id = 'big-tenant')。

When to enable

  • Cache key 含 tenant_id — Day 1。这个不补救。
  • Per-tenant cache cap — 第一次有 tenant 撑爆 cache memory。
  • Sharding — 单 DB 撑不住的那一天(通常 1000+ tenant 或单表 1 亿+ 行)。
  • Per-tenant connection limit — 第一次 noisy neighbor 把 pool 占满。

Anti-pattern

  • Cache key 不含 tenant_id — A tenant 的数据缓存里给 B tenant 看到。Cache poisoning 是教科书漏洞。
  • Index 不带 tenant_id 第一字段 — WHERE tenant_id = ? 走 full scan,pool 模型下越加 tenant 越慢。
  • 全局 connection pool 不限单 tenant — noisy tenant 直接 OOM。

Reference:PG 多租户索引最佳实践、Redis tenant-aware caching、AWS DynamoDB partition key design。

Cost Optimization

Principle:成本能按 tenant 归因。Pool 模式下大家共享基础设施,但每 tenant 的 query 数 / storage 量 / network egress 能算出来 → 才能算单 tenant 单位经济。

Per-tenant cost attribution

  • DB query 量 / storage 量按 tenant_id 聚合(PG pg_stat_statements + tenant_id tag、Neon usage API)。
  • Network egress / CDN bandwidth 按 tenant 域名 / API key 切。
  • AI / LLM token 用量按 tenant 计(每次 call OpenAI / Anthropic 记 token + tenant_id)。

Tier 经济学

  • 每 tier 的成本 = (共享基础设施 / tenant 数) + (per-tenant 变量成本)。Pool 模式下共享基础设施 amortize,所以基础 tier 能定低价。
  • Silo 模式(platinum)每 tenant 有 fixed 成本(独立 Neon project / 独立 connection pool)。pricing 必须覆盖。
  • 临界点 — 算清楚 "Pool tenant 升到 Silo 的成本差" → 决定 platinum 起步价。

Tier 越高 margin 越高 vs. 越低:取决于 silo 模式的成本结构。如果 silo 的 fixed 成本被 platinum 价格 cover 还有富余 → high tier high margin。如果 silo 实际烧钱(独立 DR / 独立 24x7 oncall)→ basic tier 反而 high margin(amortize 充分)。两种都见过。

When to enable

  • Per-tenant cost attribution — 想做 tier pricing 之前。盲定价 = 不知道哪 tier 烧钱。
  • Silo placement cost model — 第一个 platinum 客户之前。
  • AI token attribution — 加 AI feature 之前。LLM 成本 spike 是 SaaS startup 最常见的 cost surprise。

Anti-pattern

  • 成本只看总账单,不按 tenant 归因 — 一个客户烧钱不知道是哪个。pricing 凭感觉。
  • 给 enterprise 客户白嫖 silo("反正 Neon free tier")— 几个客户后 paid plan 上来一算账亏的。
  • AI feature 不限 token — 一个客户的 agent 死循环烧 1000 美金 token,公司付账。

Reference:AWS Well-Architected Cost Optimization pillar、Stripe Connect platform fee model、Snowflake per-customer cost attribution。


四、业界参考 + 概念附录

Pool / Bridge / Silo 一图速查

Pool(共享 DB + tenant_id + RLS)
  ↓ 单位成本最低
  ↓ 新 tenant 0 op
  ↓ blast radius = 整个 DB

Bridge(共享 cluster + schema per tenant)
  ↓ 折中
  ↓ schema 数量 ~1000 后变慢

Silo(独立 DB / 独立 cluster per tenant)
  ↓ 单位成本最高
  ↓ 新 tenant = provision 一套
  ↓ blast radius = 单 tenant

业界对照表

厂商顶层(tenant 等价)二级(location 等价)三级 / 业务字段隔离模型
SlackWorkspaceChannelHybrid(大客户 Enterprise Grid = 独立 Workspace 联邦)
NotionWorkspaceTeamspacePagePool
LinearWorkspaceTeamProjectPool
StripeCustomerSubscription / InvoicePool(账户层)+ Silo(Connect 平台账户)
SalesforceAccountContact / OpportunityHybrid(大客户独立 Org)
WorkOSOrganizationDirectoryUserPool
AWS Serverless SaaS referencetenantIdbasic/standard/premium = Pool, platinum = Silo
TwilioAccountSubaccountPhoneNumberHybrid(Subaccount 隔离 sub-tenant)
RingCentralAccountSiteExtension / PhoneProvider 层级(不是 SaaS 多租户)
GenesysOrgSiteQueueProvider 层级
Five9DomainCampaignSkillProvider 层级
Amazon ConnectInstanceRouting ProfileQueueProvider 层级
Ariel(多租户 SaaS framework)tenantPool with RLS

CVE-2024-10976

PostgreSQL RLS bypass — 影响 PG <17.1 / <16.5 / <15.9 / <14.14 / <13.17 / <12.21。攻击者在 RLS 启用的表上 SET ROLE 后能绕过 policy。Neon 当前已 patched(PG 16.5+)。自己跑 PG 的 SaaS 必须升级。详见 PG CVE-2024-10976

概念 glossary(人话)

tenant_id : 一行数据或一个 request "属于哪家客户"的字段。通常 1 个 tenant = 1 个 billing customer。Pool 模式里它必须出现在业务表和 RLS policy 中;Silo 模式里它至少存在于 Control Plane / request context,业务表可选。

location_id : tenant 内部的运营单位 ID(一家店 / 一个仓库 / 一个 region office)。同 tenant 跨 location 允许互看。为什么重要:业务扩张(开新店)= 加 location 行,不动 schema。

RLS(Row Level Security) : PostgreSQL 内置功能,给表加 policy,让数据库强制每个 query 必须满足某条件。例:CREATE POLICY tenant_isolation ON contacts USING (tenant_id = current_setting('app.tenant_id'))。为什么重要:应用层漏过滤时,DB 层兜底。Defense in depth。

SET LOCAL vs SET : PG 设置 session 变量的两种方式。SET = session 级,跨 query 持续,pgbouncer 复用 connection 时上一次的值漏给下一次。SET LOCAL = transaction 级,COMMIT / ROLLBACK 自动清。为什么重要:连接池 + multi-tenant 必须用 SET LOCAL,否则上 tenant 的设置会泄到下 tenant。

Defense in Depth : 每一层独立强制 tenant scope,任一层失效其他层仍能挡。Auth 层、API 层、Service 层、DB 层(RLS)。为什么重要:单层防御一旦被绕(bug、依赖更新、配置错误),数据全泄。多层防御要求攻击者同时绕过所有层。

CPaaS(Communications Platform as a Service) : Twilio / RingCentral / Vonage 这类提供电话 / 短信 / 视频 API 的平台。为什么重要:multi-tenant SaaS 不绑死单一 CPaaS — 客户带自己的 Twilio / Genesys 合同进来很常见。Provider adapter (Provider adapter 层) 就是为这个。

BPO(Business Process Outsourcing) : 一家公司代另一家公司运营某个业务环节(call center、客服、销售)。SaaS 视角:BPO 是 tenant(付账户主),end-clients 是 tenant_clients(业务字段)。为什么重要:BPO 业务模型决定了 tenant 必须是计费单位,end-client 不是 — 否则 Stripe Customer 数量爆炸而且和真实付款关系不符。

指针

retaintive 自己的隔离层级(tenants / locations / phone_lines)、当前哪些表满足 / 缺什么、迁移路径、未填的 gap → final/migration-plan.md

Store-level isolation 的具体落地(store_id UUID 作为单一隔离键、PhoneStoreAssignments、电话移店场景)→ store-level-isolation.md


五、完整 Hybrid 8 维度图

Overview 里给的是 3 面简版 (数据 / 接入 / 运维)。这里是工程团队需要的完整 8 维度。

这张图是 generic Hybrid reference,不是 retaintive V1 的默认部署图。真实成本要按当时的 Neon / AWS / Cloudflare pricing、compute scale-to-zero、branch 数量、迁移自动化和 oncall 成本单独算;这里不写固定 dollar 数字,避免把示例当成预算。

                            Sarah          Glow Beauty      United Airlines
                          (1 店 SMB)      (3 店 Pro)       (10 site Platinum)
─────────────────────── ┼───────────────┼─────────────────┼──────────────────────
接入面 (Access Plane)
  登录 SSO              │ 共享 Cognito  │ 共享 Cognito    │ ☆ 独立 SAML SSO
  API endpoint          │ 共享          │ 共享            │ ☆ 独立 (api-united.x)
  Rate limit            │ 共享配额      │ 共享配额        │ ★ 独占配额
─────────────────────── ┼───────────────┼─────────────────┼──────────────────────
数据面 (Data Plane)
  Neon DB               │ 共享 main DB  │ 共享 main DB    │ ★ 独立 proj-united
  DynamoDB              │ 共享 table    │ 共享 table      │ ★ 独立 table
  S3 bucket             │ 共享 (前缀隔离)│ 共享 (前缀隔离)│ ★ 独立 bucket
  AI prompt / model     │ 共享 prompt   │ 共享 prompt     │ ☆ 独立 prompt key
─────────────────────── ┼───────────────┼─────────────────┼──────────────────────
运维面 (Control Plane)
  AWS Account           │ 共享 prod acct│ 共享 prod acct  │ ☆ 可独立 sub-account
  CloudWatch            │ 共享 namespace│ 共享 namespace  │ ★ 独立 namespace
  Audit log             │ 共享表        │ 共享表          │ ★ 独立流 (合规)
─────────────────────── ┼───────────────┼─────────────────┼──────────────────────
计费 tier               │ low-price     │ mid-market      │ enterprise
基础设施成本/tenant     │ shared compute│ shared + higher │ dedicated compute
                         │ 摊薄后低      │ quota / support │ + endpoint / secret
                         │               │                 │ + migration / monitor
─────────────────────── ┼───────────────┼─────────────────┼──────────────────────
                         └─── Pool 区 ────┘                └─── Silo 区 ─────┘
                         低价或 trial 才需要                高价值 / 合规 / 大 BPO
                         成熟 Pool                           才需要 Silo

★ = 必须独立 (合规 / 性能硬要求)
☆ = 按需独立 (客户选,默认共享)
共享 = Pool 默认行为,靠 tenant_id 强制隔离

3 个关键认知:

  1. Hybrid 不是开关,是 8 维度逐项决定。每一维可独立选 Pool / Silo。
  2. Platinum tier ≠ 全部独立。AWS Account 通常仍共享 (太贵);Neon / S3 / 监控是必须独立 (合规硬要求)。
  3. placement 这个概念在不同时期用不同 schema 表达,见下方演进表。

Placement 字段演进 (V1 → 中期 → Hybrid)

为什么这一节单独存在:placement 在 4 份文档里出现 3 个不同名字 — overview 说 tenants.placement 单字段,本文 §五 说 8 维度 placement_profile,v1-launch-plan 说独立表 tenant_database_placements3 个名字不是矛盾,是 3 个时期的演进:

时期schema 抽象字段为什么
原 V1 方案(已 DEFERRED,未落地)独立表 tenant_database_placementsplacement_type (shared_pool / neon_branch / neon_project / external)
placement_key (pool_orange_theory / silo_&lt;tenant&gt;)
connection_secret_ref (AWS Secrets Manager path)
Control Plane 路由的最小抽象。1 个 tenant 1 row,routing 是 atomic op。原方案详见已归档的 rollout.md §2 Control Plane Schema
中期 (单字段简化)tenants.placement TEXT 单字段'pool' / 'silo' (overview / migration-plan 用这个)Final 业务模型描述时简化讲法;实际仍走 tenant_database_placements
Hybrid (8 维度组合)tenants.placement_profile 命名组合'smb-pool' / 'pro-pool' / 'platinum-silo' / 'platinum-air-gapped' 等命名组合客户开始要"数据 Silo 但 SSO 共享 Cognito"这种细粒度组合时启用

关键:V1 实际落地是 identity-only,没有建任何 placement 表或字段 — placement 抽象整体 DEFERRED,首个需要分库 routing(Silo/Pool)的客户来时再落,届时优先按 tenant_database_placements 独立表方案。overview / migration-plan 提到"placement"是为了讲业务模型容易讲。

触发升级:

现在触发条件升级到
tenant_database_placementsV1 上线即用 已 DEFERRED — 首个需要 DB routing(Silo/Pool 分库)的客户来时建(默认)
tenants.placement 单字段 alias业务文档想简化讲解时文档约定,不改 schema
tenants.placement_profile 命名组合第一个客户提"数据 Silo 但 SSO 共享" / "数据 Pool 但独立 rate limit" 这种细粒度组合加 profile 名进枚举,8 维度组合落进 tenant_database_placements 多 row

Why not 一开始就 placement_profile 16 组合:V1 客户 1-3 个,没人要细粒度组合。一开始就上 16 组合 = over-engineering,徒增维护成本。

→ 原方案表结构(包括 placement_type 枚举值 / connection_secret_ref 字段)见已归档的 rollout.md §2.3 tenant_database_placements(DEFERRED,未落地)。


六、Provider adapter 完整 DDL

Overview 给的是 2 张新表的职责。这里是完整建表语句 + 索引 + 约束。

-- adapter 表 1: 凭证 + provider 顶层连接
CREATE TABLE provider_connections (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id          UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  provider_type      TEXT NOT NULL CHECK (provider_type IN (
                       'ringcentral', 'twilio', 'genesys', 'five9', 'amazon_connect'
                     )),
  provider_account_id TEXT NOT NULL,    -- 他们的顶层 ID (各家叫法不同)
  credentials_kms    TEXT NOT NULL,     -- OAuth refresh token / API key, KMS 加密
  status             TEXT NOT NULL DEFAULT 'active' CHECK (status IN (
                       'active', 'disconnected', 'revoked', 'expired'
                     )),
  connected_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_sync_at       TIMESTAMPTZ,
  UNIQUE (tenant_id, provider_type, provider_account_id)
);

CREATE INDEX idx_provider_connections_tenant ON provider_connections(tenant_id);
CREATE INDEX idx_provider_connections_status ON provider_connections(status)
  WHERE status = 'active';

-- adapter 表 2: location 跟 provider 的绑定 (多对多)
CREATE TABLE location_provider_mappings (
  id                        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  location_id               UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
  provider_connection_id    UUID NOT NULL REFERENCES provider_connections(id) ON DELETE CASCADE,
  external_site_id          TEXT NOT NULL,  -- 他们的 site/subaccount/queue/campaign ID
  external_site_name        TEXT,           -- 他们那边的显示名 (sync 缓存)
  is_primary                BOOLEAN NOT NULL DEFAULT TRUE,  -- 多 provider 时哪个主
  sync_status               TEXT DEFAULT 'ok',
  created_at                TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (provider_connection_id, external_site_id)
);

CREATE INDEX idx_location_mappings_location ON location_provider_mappings(location_id);
CREATE INDEX idx_location_mappings_primary ON location_provider_mappings(location_id)
  WHERE is_primary = TRUE;

Adapter interface (TypeScript)

interface ProviderAdapter {
  type: 'ringcentral' | 'twilio' | 'genesys' | 'five9' | 'amazon_connect';
  
  // 拉取 provider 的 site/subaccount 列表
  listExternalSites(connection: ProviderConnection): Promise<ExternalSite[]>;
  
  // 拉取某 site 下的 phone numbers
  listPhones(connection: ProviderConnection, externalSiteId: string): Promise<Phone[]>;
  
  // Webhook 标准化
  parseWebhook(rawPayload: unknown): NormalizedCallEvent | NormalizedSmsEvent;
  
  // 健康检查
  testConnection(connection: ProviderConnection): Promise<HealthCheck>;
}

// 5 个 adapter 各自实现这个接口
class RingCentralAdapter implements ProviderAdapter { ... }
class TwilioAdapter implements ProviderAdapter { ... }
class GenesysAdapter implements ProviderAdapter { ... }

多 provider 数据合并策略

同一个 location 挂多 provider 时,业务表里 calls / messages 怎么去重?

3 个策略 (按场景选):

1. last-write-wins (默认)
   - call_id 用 (provider_type, provider_call_id) 复合
   - 简单, 但 2 个 provider 同一通话可能存 2 条记录

2. primary-only
   - 只接受 is_primary=true 的 provider 写入
   - 主备 failover 场景适用 (Genesys 主 + AC 溢出)

3. cross-provider dedupe
   - 用 phone+timestamp 模糊匹配 (±5s 窗口)
   - 复杂, 适合双轨 A/B test 场景

七、MCP server 实现细节

Overview 给的是 MCP 时序流。这里是工程实现细节。

Tenant 注入机制

// MCP server 启动时根据 API key 解析 tenant
async function authMiddleware(apiKey: string): Promise<TenantContext> {
  const row = await db.query(
    'SELECT id, tier, placement FROM tenants WHERE api_key = $1 AND status = $2',
    [apiKey, 'active']
  );
  if (!row) throw new UnauthorizedError();
  return { tenantId: row.id, tier: row.tier, placement: row.placement };
}

// 每个 MCP tool 包装,自动注入 tenant
function withTenant<TArgs, TResult>(
  toolHandler: (ctx: TenantContext, args: TArgs) => Promise<TResult>
) {
  return async (mcpRequest: McpRequest) => {
    const ctx = await authMiddleware(mcpRequest.apiKey);
    
    // AI 调用时根本看不到 tenant 参数
    // 它只能传 args, ctx 由 server 强制注入
    return toolHandler(ctx, mcpRequest.args);
  };
}

// Tool 实现
export const searchContactsTool = withTenant(async (ctx, { query }) => {
  const db = await getDbForTenant(ctx.tenantId);  // 路由到 Pool 或 Silo
  // Pool placement: query must include tenant_id / RLS.
  // Silo placement: DB is already tenant-scoped, but service code still uses ctx for audit/rate limit.
  return searchContacts(db, ctx, query);
});

Tool 列表 (建议初始 6 个)

Tool输入输出用途
search_contacts(query)关键字联系人列表"找一下姓王的客户"
get_call_summary(call_id)call ID通话纪要 + 关键点老板想看具体一通电话
list_pending_tasks(location_id?)可选 location任务列表"今天还有啥要跟进"
create_task(contact_id, due, note)联系人 + 截止 + 备注创建的 taskAI 主动提醒
send_sms(phone, body)号码 + 内容发送状态AI 发短信
get_dashboard(period)时段KPI 指标AI summary "本周怎么样"

Rate limit per tenant + tier

const RATE_LIMITS = {
  smb:      { rpm: 60,    burst: 10  },
  pro:      { rpm: 300,   burst: 50  },
  platinum: { rpm: 6000,  burst: 500 },  // 大客户独占额度
};

→ Rate limit 也是 placement_profile 的一个维度 (见 §5)。