> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# Store-Level 数据隔离

终态设计:`store_id`(UUID) 是唯一隔离键。所有 Neon 表查询统一用 `WHERE store_id = ?`。

::: details 实施溯源 + 调研过程

- 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](https://github.com/retaintive/callytics-infrastructure/issues/599)
- 核心改动: [callytics-infrastructure#610](https://github.com/retaintive/callytics-infrastructure/issues/610) resolvePhoneIdentity 返回 storeId
- Peter 链路设计: [studio-website-monorepo#214](https://github.com/retaintive/studio-website-monorepo/issues/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 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`            | 自增序号，全局唯一                                                                                  |

### 终态查询规则

```sql
-- 所有表统一
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_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 分析。
```

**Phase 4: Historical Backfill**（新店激活后的内部 operator step）

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

```text
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 不重写 pipeline**：`reconciliation-worker` 已支持 `reconciliation_window.startTime/endTime`，并把 missing calls 注入现有 `transcribe-queue`。Onboarding 只需要一个更清晰的 trigger / job wrapper。
- **Messages 现在是 gap**：`studio-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_id`** | ✅ `store_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                                                               |
| 7  | Caller ID 显示错误           | 不影响（我们查 PhoneStoreAssignments，不看 CNAM）                                                        |
| 8  | 临时来电转接（Devon 装修，转到 WP）   | 按拨入号码归属，客户数据还是归 Devon                                                                         |
| 9  | 号码被运营商回收                 | 取消绑定后 resolvePhoneIdentity 返回 null → 跳过                                                       |
| 10 | Lead 通过邮件进来（无电话）         | 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](https://github.com/retaintive/studio-website-monorepo/issues/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](https://github.com/retaintive/studio-website-monorepo/issues/228) |

::: details Migration 历史细节(已 archive)
Store-Level isolation 4-phase rollout 细节 + Peter hybrid 切换 + DDB→Neon 历史数据回填 + 实施计划 P0/P1/P2 等已 archive:[`docs/archive/store-id-migration-rollout.md`](../archive/store-id-migration-rollout.md)。新开发不读这个,现状以本文档 §1 终态设计 + §3 落地状态为准。
:::
