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 个电话号码):
每一层是什么
终态 Neon Schema
所有 7 张表统一规则:
PK 和隔离是两件不同的事:
所有 7 张表都有 store_id 列用于隔离,但只有 contacts 把 store_id 放进 PK。
每张表的终态:
终态查询规则
::: 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_id 和 store_phone(如适用)。不同数据来源的解析方式:
概念词典
2. 场景验证
新客户 Onboarding
代码分布在 3 个 repo:studio-website-monorepo(主)、ringcentralSubscriptionService、callytics-infrastructure。
Phase 1: 连接 RingCentral(用户点 "Connect RingCentral")
Phase 2: 创建门店(用户点 "New Store",手动操作)
Phase 3: 开启实时分析(用户开 "Realtime Analysis" 开关)
Phase 4: Historical Backfill(新店激活后的内部 operator step)
新店刚接入时,用户真正想看的通常不是“从现在开始才有数据”,而是“先把过去两周的电话和消息补进系统”。这个步骤应该由内部运营/工程在 control-plane 或 operator tool 里触发,不是客户自助随便点的按钮。
设计原则:
- Calls 不重写 pipeline:
reconciliation-worker已支持reconciliation_window.startTime/endTime,并把 missing calls 注入现有transcribe-queue。Onboarding 只需要一个更清晰的 trigger / job wrapper。 - Messages 现在是 gap:
studio-api可以按storeId/dateFrom/dateTo从 RingCentralmessage-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:
对历史数据的影响:
这就是 store_id 比 store_phone 安全的核心原因。
换平台(RC → Zoom / Vonage)
store_id 和电话平台完全解耦。换平台 = 加一个 webhook adapter + 绑电话号码,核心 pipeline 零改动。
✅ 已覆盖场景
🔴 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)
📋 Known Limitations(OTF 目前不涉及)
3. 落地状态(2026-04-26 verified)
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 落地状态为准。