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_idRC OAuth tokens.ownerId1 账号 = 1~6 店
门店store_id我们 randomUUID()1 店 = 1 UUID✅ 唯一隔离键
电话phoneNumberRC API 拉回来1 店 = 4~9 个

终态 Neon Schema

所有 7 张表统一规则

字段类型终态角色
store_idtext NOT NULL隔离键。所有查询用 WHERE store_id = ?
account_idtext NOT NULLRC 账号标识。安全网 + 性能分区(原 site_id
franchise_idtext NOT NULL品牌标识。保留不删,目前无隔离价值
store_phonetext (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 区分
callstelephony_session_idstore_id NOT NULLsite_id 重命名为 account_id每通电话有 RC 给的全局唯一 ID。一通电话只发生一次,不管属于哪个店
messagesid (bigint)store_id NOT NULLsite_id 重命名为 account_idRC 消息 ID 全局唯一,同理
leadsid (varchar)store_id NOT NULLsite_id 重命名为 account_id去重 key 全局唯一(由 email + 时间窗口计算)
taskstask_id (UUID)store_id 改为 NOT NULLsite_id 重命名为 account_id我们自己 randomUUID(),全局唯一
staffid (UUID)store_id NOT NULLsite_id 重命名为 account_idUUID 全局唯一
contact_timelineid (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 = ?)

::: details 为什么是 store_id 不是 store_phone(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 → 返回 storeIdtranscribe-processor, ai-analysis-processor, contacts-analyzer
SMS 进来同上message-processor
Lead 邮件StoresV2.leadEmails[] 匹配 → storeIdlead-tracking poller
手动操作用户 session 自带 storeId(登录时选店)studio-api
AI 分析继承 call 的 storeIdcontacts-analyzer

概念词典

术语一句话定义值的样子
store_id门店的身份证号。我们自己生成,永不变,和任何外部系统无关"a1b2c3d4-..." (UUID)
account_id这家店用的哪个 RingCentral 电话账户。一个账户可以有多家店"193365026"
franchise_id品牌名。目前硬编码,单品牌无隔离价值"orangeTheory"
store_phone某次通话/短信用了哪个电话号码。审计用,不做隔离"+16104403480"
PhoneStoreAssignmentsDDB 表,存电话号码和门店的映射关系。PK=storeId, SK=phoneNumber
StoresV2DDB 表,存门店基本信息。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 分析。

Phase 4: Historical Backfill(新店激活后的内部 operator step)

新店刚接入时,用户真正想看的通常不是“从现在开始才有数据”,而是“先把过去两周的电话和消息补进系统”。这个步骤应该由内部运营/工程在 control-plane 或 operator tool 里触发,不是客户自助随便点的按钮。

control-plane / operator
  -> enqueue onboarding_historical_backfill job
       storeId
       providerAccountId / connectionId
       startTime = now - 14 days
       endTime = now
       mode = execute

Calls path:
  reconciliation-worker
    -> RingCentral call-log
    -> compare with Neon calls
    -> missing call becomes synthetic webhook
    -> existing transcribe-queue
    -> transcribe-processor
    -> ai-analysis-processor
    -> contacts-analyzer
    -> calls / contacts / contact_timeline / tasks

Messages path:
  message historical backfill job(待补)
    -> RingCentral message-store
    -> existing message-processor write path
    -> messages / contacts / contact_timeline

设计原则:

  • Calls 不重写 pipelinereconciliation-worker 已支持 reconciliation_window.startTime/endTime,并把 missing calls 注入现有 transcribe-queue。Onboarding 只需要一个更清晰的 trigger / job wrapper。
  • Messages 现在是 gapstudio-api 可以按 storeId/dateFrom/dateTo 从 RingCentral message-store 读取,message-processor 也能把 message-store webhook 写进 Neon;但还缺一个和 call reconciliation 同级的 historical message backfill job(跟踪:retaintive/callytics-infrastructure#1156)。
  • Backfill 仍必须走 store-level isolation:任何导入都要先 resolve / verify store_id,不能只按 franchise_id 或 provider account 粗粒度写库。
  • ⚠️ Backfill window 必须尊重 phone→store 分配的生效时间(DEFERRED):本节上文《电话移动》明确要求 phone move / 号码 recycle 时旧数据留在旧店(TCPA + 行业标准)。但这个保证靠的是 event 在发生当时写库定格 store_id;backfill 是事后回放,会用执行时的 phone→store 映射解析归属。而当前 resolvePhoneIdentity 查的 rc_store_phones 是 current-state 表(只有 store_id + phone_number,无 assignedAt / 生效时间),无法做 point-in-time 解析。后果:若某电话在不足 14 天前才分配 / 移入本店,startTime = now - 14d 会把分配之前的 call/message 按今天的归属误写到新 store/tenant,违反上面的隔离承诺。正确做法是把 window 上限 cap 在该电话对本店的分配生效时间,但当前 schema 不支持,故标 DEFERRED。恢复条件rc_store_phones(或新映射表)加 time-aware 字段(assigned_at / valid_from / valid_to)后,backfill 改为按 event 时间做 point-in-time 解析。
  • Idempotent by default:重复触发同一个 14 天 window 不应该重复写 task / message / timeline;writer 入口需要用 provider message/call id 做 UPSERT 或 duplicate guard。

关键点: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_idstore_id=devon-id 永远不变 → task 永远归 Devon归 White Plains

这就是 store_id 比 store_phone 安全的核心原因。

换平台(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门店关闭,号码给别的店旧数据归旧 store_id,号码重绑新 store_id
7Caller ID 显示错误不影响(我们查 PhoneStoreAssignments,不看 CNAM)
8临时来电转接(Devon 装修,转到 WP)按拨入号码归属,客户数据还是归 Devon
9号码被运营商回收取消绑定后 resolvePhoneIdentity 返回 null → 跳过
10Lead 通过邮件进来(无电话)StoresV2.leadEmails → storeId
11换 VoIP 平台(RC → Zoom)store_id 和平台无关
12新店导入过去两周 calls复用 reconciliation-worker → synthetic webhook → existing transcribe pipeline,不重写 call pipeline
13新店导入过去两周 messages设计上应复用 message-processor 写入路径;当前缺 historical message backfill job(#1156)

🔴 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 做二次判断
3Store 删除没清理 UserStoredeleteAllStoreAccess() 存在但从未被调用一行代码: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 落地状态为准。