为什么 retaintive 需要 Tenant:从 user-store 到 customer boundary 的决策说明

核心结论

当前 rc_stores.user_id + store_members 是一个好的 store access model,它回答"某个 user 能不能访问某家 store"。但它不是 customer ownership model,回答不了"这些 stores 共同属于哪个客户/公司/付费主体"。当系统开始支持一个客户多家店、多个用户、一个账单、多 RingCentral accounts、套餐等级、quota、owner transfer 和跨店 dashboard 时,tenant 就是分水岭。

当前落地口径(2026-07-01 验证)

V1 是 IDENTITY ONLY,已落地的是 control plane(独立 Neon project retaintive-identity)的三张表:tenants / tenant_members / tenant_store_mappings(外加过渡期兼容表 tenant_store_overrides)。provider connections、DB placement、provisioning job ledger 全部 DEFERRED。

旧文档描述的"6 张表 + getDbForTenant + SQS webhook"方案没有按原样落地,已归档。schema 细节以 callytics-common/src/control-plane/schema/tenants.ts 为准,本文不复制 DDL。

当前铺开进度和已知缺口见 v1-gaps.md — 目前还没正式上 prod(pre-launch 阶段),control plane 只对 test 数据面生效;设计承诺和机制现状之间的差距(fallback 退役、对账、status 语义等)都在那份文档里,读本文时请配对读。

一、这次讨论最终在回答什么

问题答案
当前 user-store 设计是不是错的?不是。它适合做 store-level access:owner / editor / viewer。
tenant 是不是替代 store_members?不是。tenant 在上层定义客户边界;store_members 在下层定义某个 user 能看哪些 stores。
tenant 真正解决什么?客户级 ownership、billing、quota、plan tier、跨店 dashboard、owner transfer、audit;provider ownership 和 Pool/Silo routing 属于 future extension,不能混进当前 V1 schema。
什么情况下 tenant 是多余的?如果永远是一人一店、没有公司级 billing、没有 team、没有跨店、没有 quota,user-store 就够。

行业共识:B2B SaaS 的标准分层

结论

tenant 这一层不是 retaintive 自己发明的复杂化,而是 B2B SaaS 的主流建模方式。成熟系统通常把 User(登录身份)、Tenant/Organization(客户主体)、Membership(某个 user 在某个客户里的角色)、Resource/Location(客户拥有的业务资源)分开。这样才能把 billing、quota、SSO、team role、audit 和数据隔离放在稳定的客户边界上。

行业对象它解决的问题retaintive 对应
User谁登录系统。它是人,不是公司;可能离职、换邮箱、同时服务多个组织。Cognito user_id
Tenant / Organization哪个客户/公司拥有资源、签合同、付费、配置 SSO/品牌。tenants
Membership某个 user 在某个 tenant 里的角色;role 必须有 scope,不能只挂在 user 全局。tenant_members
Resource / Location客户名下的具体业务对象,可以是一家店、一个 site、一个 group。rc_stores.id today;future locations
Resource Ownership资源属于哪个 tenant;billing count、dashboard boundary、guard 都从这里算。tenant_store_mappings

外部产品命名不同,但模式相似:Auth0 叫 Organizations,WorkOS 叫 Organizations,AWS SaaS Lens 讨论 tenant isolation,Stripe 的 Customer 是付款侧客户对象。我们的 tenant 是产品/数据模型里的客户主体,不等于 RingCentral account,也不等于 Stripe Customer,但会和它们建立映射。

参考官方资料:AWS SaaS Lens: Tenant isolationAuth0 OrganizationsWorkOS OrganizationsStripe Customer object

二、当前模型:user-store access model

Note

当前 schema 的强项是:从登录 user 出发,算出这个 user 能访问哪些 stores。它不是差的设计,只是抽象层级停在 store access。

表/字段当前职责它不能表达什么
rc_stores.user_idstore owner,隐式 OWNER不能表达"公司主体";owner 换人不等于 store 换公司。
store_members某个 user 对某家 store 的 EDITOR/VIEWER 权限不能表达 tenant-level billing/admin/SSO 权限。
user_connections某个 user 连接的 RingCentral OAuth account不能表达 provider account 属于哪个客户主体。
store_configstore-level alias、activation、lead emails、pricing不适合放 Stripe Customer、plan tier、tenant quota。

三、真正的分水岭

分水岭

系统里是否存在一个业务对象,它不是某个 user,也不是某家 store,但它拥有多家 stores、多个 users、一个账单、一个套餐、一组 quota 和一套客户级配置。如果存在,这个对象就是 tenant

能力/场景只有 user-store有 tenant
单店 owner 看数据支持支持
manager 看指定几家店支持,靠 store_members支持,仍靠 store_members 或未来 location_members
一个公司 8 家店一张账单需要把 payer/tier 复制到每家 store 或绑到某个 user挂在 tenant 一份即可
owner transfer批量改 rc_stores.user_id,语义像 data transfer只改 tenant_members,公司和 stores 不变
套餐限制最多 5 users / 10 stores容易按 user 分散计数,被多个 admin 绕过按 tenant 统一计数
多个 RingCentral accounts 属于一个客户只能通过 user connections 间接推断V1 先用 tenant_store_mappings 表达 stores 归 tenant;future 再加显式 tenant_provider_connections
未来 Pool/RLS/Silo routing缺客户级 routing keytenant_id 是 routing/isolation/audit key

四、没有 tenant 时,复杂度会散落到 user/store/provider

Note

这个模型的关键问题不是"查不出数据",而是公司主体没有一行稳定记录。payer、tier、quota、provider ownership、owner transfer、company audit 都只能靠 user/store/provider 的组合推断。

五、目标模型:tenant 是客户主体,store 是业务地点

各层类型的用法 — 每一层只回答一个问题:

层级回答的问题典型字段/表
Tenant谁付钱、谁签合同、谁拥有客户级配置tenants, tier, status;billing fields / Stripe mapping 是 future billing integration
Tenant member这个 user 在客户层是什么角色tenant_members(user_id, tenant_id, role)
Store / Location具体业务地点或 RingCentral group/siterc_stores.id today, future locations
Store member这个 user 能看/编辑哪些具体 storesstore_members(user_id, store_id, role)
Phone line具体号码、extension、provider native idrc_store_phones, future phone_lines

六、Billing、Plan Tier 和 Quota 为什么必须按 tenant 算

Quota/收费项没有 tenant 的问题tenant 下的正确计数
最多 5 个 usersAlan 创建 5 个,Mary 还能再创建 5 个;按 user 计数会被拆散count(active tenant_members)
最多 10 家 stores不同 user 或多个 RC accounts sync 出来的 stores 难以合并计数count(active tenant_store_mappings)
按 stores 收费payer_id 复制到 stores,升级/降级要批量改Stripe Customer 挂 tenant,quantity 来自 active store count
Plus / Premium / Diamond挂 user 会跟人走;挂 store 会复制 N 份;挂 provider 会被 RC 拓扑绑死tenant.tier 是客户级套餐

七、Provider/RingCentral 关系:不要画成两个 source of truth

Warning

正确语义:RingCentral 是 provider topology 和 provisioning input;tenant_store_mappings 是我们内部的 customer ownership/index。它们不是两套并列真相。

对象source of truth用途
RingCentral group/site/unassignedRingCentral API生成/同步 stores 和 phones 的输入。
rc_storesData Plane我们内部稳定 store identity,业务数据用 store_id 归属。
tenant_store_mappingsControl Plane某个 store 属于哪个 tenant;做 guard、billing count、dashboard boundary。
Stripe quantity由 tenant 下 active stores/metering 计算收钱数量,不定义客户主体。

八、权限模型:老板看全部,manager 看部分

Note

Owner/admin 的默认边界是 tenant 下所有 stores;viewer/manager 类用户再用 store_members 缩小到指定 stores。未来如果需要"区域"这个业务对象,再加 store_groups 或 capability,不要把区域经理升成 tenant owner。

注意两个容易混的命名:tenant 层的 viewer(客户层角色,默认不带任何业务数据可见范围)和 store 层的 VIEWER(store_members 里对某家店的只读授权)是两个不同的东西,详见 v1-gaps.md 的命名对照。

九、如何从当前状态 establish tenant

阶段现在可做的最小落地以后再做
Identitytenants + tenant_membersmulti-tenant user switcher / platform super admin
Store ownershiptenant_store_mappings,active store 全局唯一归属locations rename、store groups
Provider当前通过 user_connections + store sync 间接归属tenant_provider_connections 显式挂 tenant
BillingV1 可先 manual billing;Stripe Customer mapping 属于 future billing integration,仍应挂 tenantStripe webhook、metered billing、plan limit enforcement
IsolationV1 继续用 store_id 查询,入口加 tenant guardPool/RLS 时业务表强制 tenant_id

各 step 的实际铺开进度(哪些做完了、哪些卡在半路)见 v1-gaps.md

十、最终给团队的一句话

Info

不要把 user 当公司。user_id 是登录身份,会离职、换邮箱、被禁用、成为多个角色;store_id 是业务地点,会新增、关闭、重组;provider_account_id 是外部连接,会重连、换供应商。tenant_id 是稳定客户主体,承载 billing、quota、tier、SSO、cross-store dashboard、audit 和 future isolation;provider ownership 可以在 future 通过显式 mapping 接进来。

十一、未来演进:只加,不重做

按本文设计演进,control plane 基本只加不重做;data plane 不需要重新设计,但 Final 阶段有一次"改名 + 收紧约束"的工程。分三层:

第一层:纯加(deferred 的 3 张表 + 客户级配置)

原 6 表方案里没落地的 3 张表,终态仍然需要,各有明确触发条件——触发前不建(空转纯开销),触发后加表挂在 tenant_id 上即可,不动现有表:

解决什么触发条件
tenant_database_placements这个客户的数据在哪个 DB(routing)第一个 Silo(独享库)客户 / 第 2 个 pool / 客户在库间搬家
tenant_provider_connectionsprovider 账号显式归属客户(不再通过 user 的 OAuth 推断)一客户多 RC 账号合并计费 / webhook 按 provider account 反查 tenant / 接第二家电话商 / owner 离职导致 user_connections 悬空
tenant_provisioning_jobsonboarding 多步流程账本(幂等、断点续跑)onboarding 自动化 / self-serve;Silo 时代"给客户建库"是多步异步操作

加回来时不要照抄归档 DDL(docs/archive/multi-tenant-v1-plan/rollout.md 只作起点参考),按当时现实重推。

第二层:要改,但是改名/收紧,不是重新设计(即 final/migration-plan.md 的 Final 阶段)

动作性质
rc_storeslocationsrc_store_phonesphone_linesstore_memberslocation_members改名,形状/主键/数据不动,把"店"中性化(4 repo lockstep)
业务表 tenant_id NULLABLE → NOT NULL + 开 RLS收紧约束——列已提前加好,就是为了避免将来重做
rc_stores.user_id(隐式店主)退役删一列,前提是 derived fallback 先退役(见 v1-gaps.md 缺口 2)

第三层:唯一一个设计时标注的 revisit 点

tenant_members 上"一个 user 只属一个 active tenant"的 partial unique index。将来出现 agency/BPO 代管多客户时放开——tenants.ts 注释原话:"THIS index is the one decision to revisit"。也只是删一个索引,不是重做表。

这个"只加不重做"的保证来自三个设计选择:① 归属放 tenant_store_mappings 独立行而不是塞列进 rc_stores(改归属模型 = 改 mapping,业务数据不动);② tenant_id 列提前加、启用推迟,两个动作解耦;③ 改名和拆职责是两件独立小事,不是一次 SPLIT。

前提

"只加不重做"成立的前提是 v1-gaps.md 缺口 2-4 的机制先补上。fallback 不退役、mapping 不对账,rc_stores.user_id 的旧语义就一直赖着,Final 阶段就要同时处理"改名 + 退役 + 补账"三件事,工程量不再是改名那么简单。

Source of Truth