Store-Level 数据隔离 — 设计文档¶
终态设计:store_id(UUID) 是唯一隔离键。所有 Neon 表查询统一用 WHERE store_id = ?。
实施溯源 + 调研过程
- 2026-04-14 终态设计定稿(Max + Peter + AI 三方对齐)
- 2026-04-30 v3 API 端点全部切换到纯
WHERE store_id = ?(studio-website-monorepo PR #277) - 调研过程:
.claude/specs/2026-04-12-store-isolation-investigation-journal.md(3 session, 23 insights) - 总 issue: callytics-infrastructure#599
- 核心改动: callytics-infrastructure#610 resolvePhoneIdentity 返回 storeId
- Peter 链路设计: studio-website-monorepo#214
1. 终态设计¶
层级关系¶
用 Peter 的真实 prod 数据(13 店、8 个 RC 账号、78 个电话号码):
retaintive 平台
│
├── Brand: "orangeTheory" ← franchise_id(硬编码,所有店一样,不做隔离)
│ │
│ ├── Account: "193365026" ← account_id(原 site_id,来自 RC OAuth owner_id)
│ │ │ 一个账号可以有多家店
│ │ ├── 🏪 Store: Devon ← store_id = UUID(我们 randomUUID() 生成)
│ │ │ └── 📞 ×6 phones 一个店有 4-9 个电话
│ │ ├── 🏪 Store: White Plains ← store_id = UUID
│ │ │ └── 📞 ×7 phones
│ │ ├── 🏪 Store: Scarsdale ← store_id = UUID
│ │ │ └── 📞 ×5 phones
│ │ ├── 🏪 Store: Marlboro ← store_id = UUID
│ │ │ └── 📞 ×7 phones
│ │ ├── 🏪 Store: Woburn ← store_id = UUID
│ │ │ └── 📞 ×5 phones
│ │ └── 🏪 Store: Ardmore ← store_id = UUID
│ │ └── 📞 ×5 phones
│ │
│ ├── Account: "809646016" ← 另一个 RC 账号,只有 1 家店
│ │ └── 🏪 Store: Auburn
│ │ └── 📞 ×4 phones
│ │
│ └── ... (共 8 个 RC 账号,13 家店,78 个电话号码)
│
└── (未来) Brand: "Rinse & Shine"
└── Account (RC / Zoom / Vonage)
├── 🏪 Store: Downtown
└── 🏪 Store: Uptown
每一层是什么¶
| 层级 | 字段 | 值来自 | 一对几 | 做隔离? |
|---|---|---|---|---|
| 品牌 | franchise_id |
代码硬编码 "orangeTheory" |
1 品牌 = 13 店 | ❌ |
| 账号 | account_id |
RC OAuth tokens.ownerId |
1 账号 = 1~6 店 | ❌ |
| 门店 | store_id |
我们 randomUUID() |
1 店 = 1 UUID | ✅ 唯一隔离键 |
| 电话 | phoneNumber |
RC API 拉回来 | 1 店 = 4~9 个 | ❌ |
终态 Neon Schema¶
所有 7 张表统一规则:
| 字段 | 类型 | 终态角色 |
|---|---|---|
store_id |
text NOT NULL |
隔离键。所有查询用 WHERE store_id = ? |
account_id |
text NOT NULL |
RC 账号标识。安全网 + 性能分区(原 site_id) |
franchise_id |
text NOT NULL |
品牌标识。保留不删,目前无隔离价值 |
store_phone |
text (nullable) |
仅 tasks 表保留,记录"这通电话用了哪个号码",审计字段,不做隔离 |
PK 和隔离是两件不同的事:
所有 7 张表都有 store_id 列用于隔离,但只有 contacts 把 store_id 放进 PK。
每张表的终态:
| 表 | PK | 终态改动 | 为什么 PK 这样设计 |
|---|---|---|---|
| contacts | (phone, store_id) |
PK 从 (phone, franchise_id, site_id) 改为 (phone, store_id) |
同一个人 Allen 在 Devon 和 WP = 两条不同的 contact(不同 AI 画像、不同互动记录)。phone 单独不够唯一,必须加 store_id 区分 |
| calls | telephony_session_id |
加 store_id NOT NULL,site_id 重命名为 account_id |
每通电话有 RC 给的全局唯一 ID。一通电话只发生一次,不管属于哪个店 |
| messages | id (bigint) |
加 store_id NOT NULL,site_id 重命名为 account_id |
RC 消息 ID 全局唯一,同理 |
| leads | id (varchar) |
加 store_id NOT NULL,site_id 重命名为 account_id |
去重 key 全局唯一(由 email + 时间窗口计算) |
| tasks | task_id (UUID) |
store_id 改为 NOT NULL,site_id 重命名为 account_id |
我们自己 randomUUID(),全局唯一 |
| staff | id (UUID) |
加 store_id NOT NULL,site_id 重命名为 account_id |
UUID 全局唯一 |
| contact_timeline | id (bigserial) |
加 store_id NOT NULL,site_id 重命名为 account_id |
自增序号,全局唯一 |
终态查询规则¶
-- 所有表统一
WHERE store_id = :storeId
-- 不再需要:
-- ❌ WHERE franchise_id = ? AND site_id = ?
-- ❌ WHERE store_phone IN (?, ?, ?) OR store_id = ?
-- ❌ WHERE site_id = ? AND (store_phone IN (...) OR store_id = ?)
为什么是 storeid 不是 storephone(Peter 共识 2026-04-14)
Peter 原本提议:电话/短信数据可以用 store_phone IN (...) 动态过滤,绑错改 PhoneStoreAssignments 即可自动修正。
讨论后共识:
- 写入时两个都存:store_phone(事实"用了哪个号码")+ store_id(归属"属于哪个店")
- 查询用 store_id:终态 WHERE store_id = ?,简单稳定
- 为什么不只用 phone-based 查询:电话移店时旧数据跟着电话跑,不符合行业标准(Twilio/Gong/CallRail 数据都留在原店)和 TCPA 合规要求
- 绑错场景:用户在 UI 编辑店铺电话列表(PhoneStoreAssignments 更新),之后每通新电话进来时 resolvePhoneIdentity 查到的就是正确的 storeId,写入自动正确
- 2026-04-28 切换完成:studio-website-monorepo PR #277 ship 后,contacts / calls / messages 三表 store_id 填充率 ~99.7%,LATERAL JOIN/EXISTS 子查询全部下线,Lambda 查询时间从 30s 超时降到 ~60ms
终态写入规则¶
每条数据写入 Neon 时,必须同时带 store_id 和 store_phone(如适用)。不同数据来源的解析方式:
| 数据来源 | 怎么拿到 store_id | 写入的 Lambda / API |
|---|---|---|
| Call 进来 | resolvePhoneIdentity(from, to) 查 PhoneStoreAssignments → 返回 storeId |
transcribe-processor, ai-analysis-processor, contacts-analyzer |
| SMS 进来 | 同上 | message-processor |
| Lead 邮件 | StoresV2.leadEmails[] 匹配 → storeId |
lead-tracking poller |
| 手动操作 | 用户 session 自带 storeId(登录时选店) |
studio-api |
| AI 分析 | 继承 call 的 storeId |
contacts-analyzer |
概念词典¶
| 术语 | 一句话定义 | 值的样子 |
|---|---|---|
store_id |
门店的身份证号。我们自己生成,永不变,和任何外部系统无关 | "a1b2c3d4-..." (UUID) |
account_id |
这家店用的哪个 RingCentral 电话账户。一个账户可以有多家店 | "193365026" |
franchise_id |
品牌名。目前硬编码,单品牌无隔离价值 | "orangeTheory" |
store_phone |
某次通话/短信用了哪个电话号码。审计用,不做隔离 | "+16104403480" |
PhoneStoreAssignments |
DDB 表,存电话号码和门店的映射关系。PK=storeId, SK=phoneNumber | — |
StoresV2 |
DDB 表,存门店基本信息。PK=storeId | — |
2. 场景验证¶
新客户 Onboarding¶
代码分布在 3 个 repo:studio-website-monorepo(主)、ringcentralSubscriptionService、callytics-infrastructure。
Phase 1: 连接 RingCentral(用户点 "Connect RingCentral")
用户浏览器 studio-api RingCentral
────────── ────────── ────────────
点击 Connect → POST /v2/auth/initiate
生成 stateId, 存 OAuthStates DDB
← 返回 RC 授权 URL ──────────────→
用户登录授权
← RC redirect ──── callback?code=xxx
GET /v2/auth/callback
① code 换 tokens
② providerAccountId = tokens.ownerId ← 这就是 account_id
③ 并行写 3 个 DDB 表:
├─ UserConnections(加密 tokens)
├─ PhoneNumbers(从 RC API 拉所有电话,storeId=null)
└─ CallAnalysisConfiguration(callytics 配置)
✅ 结果:RC 账号已连接,电话号码已导入。但还没有"店"。
Phase 2: 创建门店(用户点 "New Store",手动操作)
POST /v2/stores
① 验证电话属于该用户
② storeId = randomUUID() ← ★ store_id 在这里诞生
③ 并行写 3 个 DDB 表:
├─ StoresV2(PK=storeId,存店名/配置/providerAccountId)
├─ PhoneStoreAssignments(每个电话一条,PK=storeId SK=phoneNumber)
└─ UserStore(userId+storeId, role=OWNER)
✅ 结果:门店存在了,电话号码已绑定。
Phase 3: 开启实时分析(用户开 "Realtime Analysis" 开关)
studio-api → invoke ringcentralSubscriptionService Lambda
① 用加密 tokens 调 RC API 拿所有分机
② 每 19 个分机一组,创建 RC webhook 订阅
③ RC 每通电话/短信 → webhook → WebhookReceiver → SQS → pipeline
✅ 结果:电话录音开始自动转录和 AI 分析。
关键点:storeId 是我们自己 randomUUID() 生成的,不来自 RingCentral。换任何电话平台都不影响 store_id。
电话移动(A 店 → B 店)¶
用户在 UI 里编辑门店电话列表,代码算 diff:
操作前:Devon 有 📞A B C D E F,White Plains 有 📞G H I
用户把 📞F 从 Devon 移到 White Plains
PUT /v2/stores/devon-id → DELETE PhoneStoreAssignments (devon-id, 📞F)
PUT /v2/stores/wp-id → PUT PhoneStoreAssignments (wp-id, 📞F)
操作后:Devon 有 📞A B C D E,White Plains 有 📞G H I F
对历史数据的影响:
| 隔离键 | 移动前用 📞F 打的电话 | 移动后用 📞F 打的电话 |
|---|---|---|
store_phone |
⚠️ Devon 的 phone list 没有 📞F 了 → 这条 task 消失 | 归 White Plains |
store_id |
✅ store_id=devon-id 永远不变 → task 永远归 Devon |
归 White Plains |
这就是 storeid 比 storephone 安全的核心原因。
换平台(RC → Zoom / Vonage)¶
变的部分(接入层):
新增一个 Zoom webhook adapter Lambda
PhoneStoreAssignments 加 Zoom 的电话号码
不变的部分(核心):
resolvePhoneIdentity() → 照样查 PhoneStoreAssignments → 返回 storeId
所有 Neon 表的 store_id → 不变
所有 API 的 WHERE store_id = ? → 不变
store_id 和电话平台完全解耦。换平台 = 加一个 webhook adapter + 绑电话号码,核心 pipeline 零改动。
✅ 已覆盖场景¶
| # | 场景 | 为什么 OK |
|---|---|---|
| 1 | 单店单账号(Auburn) | WHERE store_id = ? 直接隔离 |
| 2 | 多店共享 RC 账号(Peter 6 店) | 每店独立 store_id |
| 3 | 一个店 4-9 个电话 | store_id 不关心电话数量 |
| 4 | 电话从 A 店移到 B 店 | 历史数据 store_id 不变 |
| 5 | 客户跨店(Allen 去 Devon 和 WP) | contacts PK (phone, store_id) = 两条独立记录 |
| 6 | 门店关闭,号码给别的店 | 旧数据归旧 storeid,号码重绑新 storeid |
| 7 | Caller ID 显示错误 | 不影响(我们查 PhoneStoreAssignments,不看 CNAM) |
| 8 | 临时来电转接(Devon 装修,转到 WP) | 按拨入号码归属,客户数据还是归 Devon |
| 9 | 号码被运营商回收 | 取消绑定后 resolvePhoneIdentity 返回 null → 跳过 |
| 10 | Lead 通过邮件进来(无电话) | StoresV2.leadEmails → storeId |
| 11 | 换 VoIP 平台(RC → Zoom) | store_id 和平台无关 |
🔴 Critical Bug + 必须堵的 Gap¶
Bug: 电话可以同时属于多家店,代码没处理 (🔴 Still UNFIXED as of 2026-04-26)
代码确认(studio-api/routes/stores/create.ts:67):
resolvePhoneIdentity() 用 Limit: 1 查 GSI — 一个电话在两个店里时随机返回一个。
后果: - Task 写到错的 store - AI analysis 混合两个店的 staff list - 跨店数据泄漏
修复:建店/改店时验证:同一 providerAccountId 下,一个电话只能绑一个 store 类型的店。(group 类型不受此限)
Tracked: studio-website-monorepo#228 (P0 as of 2026-04-26)
| # | Gap | 风险 | 修复 |
|---|---|---|---|
| 1 | 加盟店转让(Devon 卖给新老板) | 新老板看到旧客户数据 → 隐私泄漏 | 转让必须创建新 store_id,旧数据归档 |
| 2 | 共享 IVR / 800 号码(一个号码路由多店) | 所有 call 归同一个店 | OTF 目前不用。未来:用 RC call log extension/site 做二次判断 |
| 3 | Store 删除没清理 UserStore | deleteAllStoreAccess() 存在但从未被调用 |
一行代码:delete handler 加上调用 |
📋 Known Limitations(OTF 目前不涉及)¶
| 场景 | 影响 | 处理 |
|---|---|---|
| 动态追踪号码(CallRail 类) | 临时号码频繁轮换,PhoneStoreAssignments 不适合 | 如未来加营销追踪,需独立映射表 |
| SMS 路由延迟 | 电话移动后 SMS 可能短暂归错店 | PhoneStoreAssignments 查询不缓存即可 |
| Webhook 失效(员工离职 token 过期) | 所有店同时丢数据,静默无告警 | 加健康检查:每个 RC 账号最后 webhook 时间 |
| Group 类型店跨账号绑电话 | group 可绑不同 RC 账号的电话,但 account_id 只存一个 |
group 目前很少用 |
| Sub-account 删除没禁用 Cognito | 用户被移除但仍能登录(看到空界面) | 删除时同步禁用 Cognito user |
| LeadEmails 跨店共享 | 两个店配同一个 leadEmail → lead 互相可见 | 建店时 validate:同 account 下 leadEmail 不重复 |
| RC 账号级录音 | RC admin portal 可跨店听录音(在我们系统外) | RC 访问控制问题,不是 Neon 隔离问题 |
3. 落地状态(2026-04-26 verified)¶
| 维度 | 状态 |
|---|---|
8 张 Neon 表 store_id 列 |
✅ 全部具备(contacts PK 一部分 + staff NOT NULL,其他 6 表 nullable rollout 中,writer 全写) |
| 4 Lambda writer 写 store_id | ✅ transcribe / ai-analysis / message / contacts-analyzer 全部写入 |
resolvePhoneIdentity 返回 storeId |
✅ callytics-infrastructure#611 |
contacts PK = (phone, store_id) |
✅ callytics-common migration 0014 |
account_id 单列(原 site_id rename) |
✅ callytics-common migration 0016 + 0.22.0 published |
API v3 端点 WHERE store_id = ? |
✅ studio-website-monorepo PR #277(2026-04-28 ship) |
| Staff Neon dual-write | ✅ studio-monorepo #249 + infra #666 |
| Critical bug:电话可同时绑多店 | 🔴 未修 — studio-website-monorepo#228 |
Migration 历史细节(已 archive)
Store-Level isolation 4-phase rollout 细节 + Peter hybrid 切换 + DDB→Neon 历史数据回填 + 实施计划 P0/P1/P2 等已 archive:docs/archive/store-id-migration-rollout.md。新开发不读这个,现状以本文档 §1 终态设计 + §3 落地状态为准。