跳转至

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 和隔离是两件不同的事

PK       = "这条记录的唯一身份是什么"  → 决定数据怎么存
store_id = "谁能看到这条记录"          → 决定数据怎么查(WHERE store_id = ?)

所有 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 NULLsite_id 重命名为 account_id 每通电话有 RC 给的全局唯一 ID。一通电话只发生一次,不管属于哪个店
messages id (bigint) store_id NOT NULLsite_id 重命名为 account_id RC 消息 ID 全局唯一,同理
leads id (varchar) store_id NOT NULLsite_id 重命名为 account_id 去重 key 全局唯一(由 email + 时间窗口计算)
tasks task_id (UUID) store_id 改为 NOT NULLsite_id 重命名为 account_id 我们自己 randomUUID(),全局唯一
staff id (UUID) store_id NOT NULLsite_id 重命名为 account_id UUID 全局唯一
contact_timeline id (bigserial) store_id NOT NULLsite_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_idstore_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(主)、ringcentralSubscriptionServicecallytics-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):

// Note: phones can be assigned to multiple stores (no restriction on already-assigned)

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 落地状态为准。