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

# 数据模型关系总图(Entity Relationship Map)

**为什么有这份文档**:2026-07-02 会议上 Peter 指出的系列问题(电话挪店历史全错、删店重建 lead 绑定全丢、contact 归属层级、tenant transfer 撞约束)有一个共同病根——**所有归属关系(phone→store→tenant,以及事件行上的 stamp)都被当成"不会变的事实"写死,但它们全都会变,而系统没有"归属变更后历史数据怎么办"的统一答案**。本文把全部表、全部关系、每条关系的变更策略摆在一张桌上,multi-tenant 相关的设计讨论以这里为共同语言。

数据口径:表清单来自 2026-07-02 `callytics-test` 库 information\_schema 实测(22 张)+ control plane `retaintive-identity`(4 张)+ DDB(7 张)。schema 字段细节以 `callytics-common/src/db/schema/` 与 `control-plane/schema/tenants.ts` 为准,本文不复制 DDL。

## 一、边的四种类型(先看图例)

| 类型            | 图中画法      | 含义                                                      | 变更时的行为                                                                         |
| ------------- | --------- | ------------------------------------------------------- | ------------------------------------------------------------------------------ |
| **硬 FK**      | 实线        | DB 内 `.references()`,有级联                                | DB 保证一致,删除会级联                                                                  |
| **跨库软引用**     | 虚线(跨框)    | 引用另一个 Neon project 的 id,无 FK                            | **没人保证**——orphan 已实测出现([v1-gaps 缺口 4](/system-design/multi-tenant/v1-gaps.md)) |
| **写入时 stamp** | 虚线        | 写入那一刻按当时映射固化的列(store\_id / tenant\_id / user\_id owner) | **映射后来变了,历史行不跟着变**——Peter P2/P3 的根源                                            |
| **运行时推导**     | 标注 derive | 每次查询/处理时现算(phone→store、user→tenant 解析链)                 | 跟着最新映射走,但会"重写历史视角"                                                             |

## 二、身份与归属层(identity chain)

```mermaid
erDiagram
  COGNITO_USER ||--o{ USER_CONNECTIONS : "拥有 OAuth 连接"
  USER_CONNECTIONS ||--o{ RC_STORES : "sync 生成 connection_id"
  COGNITO_USER ||..o{ RC_STORES : "stamp user_id 隐式 owner"
  RC_STORES ||--o{ RC_STORE_PHONES : "硬 FK cascade"
  USER_CONNECTIONS ||..o{ PHONE_NUMBERS : "provider metadata 软关联"
  PHONE_NUMBERS }o..|| RC_STORES : "nullable store_id 软映射"
  RC_STORES ||--|| STORE_CONFIG : "硬 FK cascade + leadEmails 绑死在此"
  RC_STORES ||--o{ STORE_MEMBERS : "硬 FK cascade"
  COGNITO_USER ||..o{ STORE_MEMBERS : "user_id EDITOR或VIEWER"

  TENANTS ||--o{ TENANT_MEMBERS : "硬 FK"
  COGNITO_USER ||..o{ TENANT_MEMBERS : "user_id 一人一 active tenant"
  TENANTS ||--o{ TENANT_STORE_MAPPINGS : "硬 FK"
  TENANT_STORE_MAPPINGS }o..|| RC_STORES : "跨库软引用 store_id"

  DDB_PHONE_STORE_ASSIGNMENTS }o..|| RC_STORES : "legacy 仅 v2 API 读(pipeline 已切 Neon)"
```

三个框的边界:

| 层                  | 表                                                                                                                                           | 库                                                                                                                                       |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| **Control plane**  | `tenants` / `tenant_members` / `tenant_store_mappings` / `tenant_store_overrides`(legacy,0 行)/ `config_change_log`                          | Neon `retaintive-identity`                                                                                                              |
| **Data plane 身份区** | `rc_stores` / `rc_store_phones` / `phone_numbers` / `store_config` / `store_members` / `user_connections` / `oauth_states` / `sub_accounts` | Neon per-env                                                                                                                            |
| **DDB 过渡层**        | `PhoneStoreAssignments` / `StoresV2` / `UserConnections` / `UserStore` / `SubAccounts` / `PhoneNumbers` / `OAuthStates`                     | DynamoDB(Neon-primary 策略下逐步退役;**pipeline 的 phone 反查已于 phone-identity 0.36.0 切至 Neon `rc_store_phones`**,残余读方主要是 v2 API 的 call-analysis) |

## 三、业务事实表:全部 stamp 归属

```mermaid
erDiagram
  RC_STORES ||..o{ CALLS : "stamp store_id"
  RC_STORES ||..o{ MESSAGES : "stamp store_id"
  RC_STORES ||..o{ LEADS : "stamp store_id 经 leadEmail 路由"
  RC_STORES ||..o{ CONTACTS : "stamp store_id 且是 PK 一半"
  RC_STORES ||..o{ TASKS : "stamp store_id"
  RC_STORES ||..o{ CONTACT_TIMELINE : "stamp store_id"
  RC_STORES ||..o{ TASK_SUGGESTIONS : "stamp store_id"
  RC_STORES ||..o{ AI_FEEDBACK : "stamp store_id"
  RC_STORES ||..o{ AI_TASK_DECISION_ASSESSMENTS : "stamp store_id"
  RC_STORES ||..o{ COACHING_REVIEWS : "stamp store_id"
  TENANTS ||..o{ CALLS : "stamp tenant_id NULLABLE"
  TENANTS ||..o{ MESSAGES : "stamp tenant_id NULLABLE"
  TENANTS ||..o{ LEADS : "stamp tenant_id NULLABLE"
  TENANTS ||..o{ CONTACTS : "stamp tenant_id NULLABLE"
  TENANTS ||..o{ TASKS : "stamp tenant_id NULLABLE"
  TENANTS ||..o{ CONTACT_TIMELINE : "stamp tenant_id NULLABLE"
  TENANTS ||..o{ TASK_SUGGESTIONS : "stamp tenant_id NULLABLE"
  TENANTS ||..o{ AI_FEEDBACK : "stamp tenant_id NULLABLE"
  TENANTS ||..o{ AI_TASK_DECISION_ASSESSMENTS : "stamp tenant_id NULLABLE"
  TENANTS ||..o{ COACHING_REVIEWS : "stamp tenant_id NULLABLE"

  LEADS ||..o{ CALLS : "lead_id 软关联"
  LEADS ||..o| TASKS : "source_lead_id partial unique"
  CONTACTS ||..o{ TASKS : "contact_phone+store_id 软关联"
  CONTACTS ||..o{ CONTACT_TIMELINE : "contact_phone+store_id 软关联"
  TASKS ||--o{ TASK_SUGGESTIONS : "硬 FK task_id"
  TASKS ||--o{ AI_TASK_DECISION_ASSESSMENTS : "硬 FK assessed_task_id"
```

图中 `tenant_id` 边列的是 2026-07-01 test 实测已有 `tenant_id` 的 10 张表。其余 store-scoped 表(同样 stamp `store_id`,但 V1 不重复存 `tenant_id` 或暂不在主图展开):`staff`、`blackout_periods`、`operating_schedules`、`operating_overrides`。

历史审计列(不再是隔离键,只读不写新逻辑):`franchise_id`、`account_id`、`site_id`、`store_phone`。

> ⚠️ 代码与库的漂移(2026-07-03 codex review + 代码验证收口):
>
> - `task_progress_events` **不是缺 migration**——设计上已并入 `contact_timeline` 事件(`task-progress.ts`:"progress is no longer its own table")
> - `task_playbooks` **已从 final schema 移除**(`ai-output-tables.test.ts` 明确断言不导出)
> - 两者在 [tasks-schema.md](/product-design/v2/tasks-feature/tasks-schema.md) 中仍被描述为 live 表——该文档过时,待修
> - **`calendar_events`**:studio-api(`calendar-events-neon.ts`)把它当 Neon SoT 直接 INSERT/SELECT,但 test 库 information\_schema 里**没有这张表**——待建或待查(与 backfill 脚本引用已删除表同族:代码期望的表和库不一致)

## 三.5 全量 schema 精读补充发现(2026-07-02,26 个 schema 文件逐一读)

**设计基线:final Neon-only。** DDB 的 7 张表在 Neon 已全部有对应(`rc_stores`/`rc_store_phones`/`user_connections`/`store_members`/`phone_numbers`/`oauth_states`/`sub_accounts`),schema 层退役已完成;pipeline 的 phone 反查也已切 Neon(phone-identity 0.36.0+,DDB `PhoneStoreAssignments` 不再被该模块读取),运行时残留主要是 v2 API 读 DDB call-analysis——迁移工作,不影响设计。

精读发现(文件级证据):

1. **store 身份的 unique key 含 user\_id**:`rc_stores` 唯一键 = `(user_id, connection_id, rc_site_id)` — owner 换人或 OAuth 重连,同一物理店 sync 即新 UUID。删店重建只是断链的一种触发方式,这是更深的根
2. **同号可挂两店是 schema 允许的**:`rc_store_phones` 唯一键 = `(store_id, phone_number)`;同号可同时有 A 店 `rc_sync` 行 + B 店 `user_manual` 行,靠查询侧 "user\_manual 优先" 约定兜底(schema 注释自认)
3. **挪号 = DELETE + INSERT**(`assigned_at` 刷新)— 归属历史物理归零
4. **lead email 配置双轨**:`user_connections.leadEmails`(用户级)与 `store_config.leadEmails`(店级)并存
5. **成员关系三轨并存**:`sub_accounts`(父子账号)+ `store_members`(店级 EDITOR/VIEWER)+ `tenant_members`(客户级 4 角色)— D2 的统一设计必须三轨一起收
6. **`task_progress_events` 表不存在**:`task-progress.ts` 明确 "progress is no longer its own table",进 `contact_timeline` 事件;tasks-schema.md 对此描述已过时(待修)
7. **好骨头(目标策略的本土先例)**:`operating_schedules` 已是 `effective_date` 时间轴模式;`staff.is_active` 软删除;`task_suggestions`/`ai_feedback` append-only + supersede;`contact_timeline` 审计列完备(多态 actor + AI 取证 + 幂等);`tasks` 有 evidence 链(source\_entity\_\*)与完整 CHECK 纪律 — **"归属历史表"是把 repo 已有模式推广到 phone→store,不是引入新范式**

## 四、核心:每条"会变的归属关系"的变更策略

这张表是本文档的重点。**每一行都是一个已经发生过或必然发生的变更场景**:

| # | 关系                              | 存在哪                                                                                         | 它变了之后,现状会发生什么                                                                                                                                                                                                                                                             | 目标策略(2026-07-02 codex 审计)                                                                                                                                                                                                                                                                                                                                                                                                               | 优先级                                         |
| - | ------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| 1 | phone → store                   | `rc_store_phones`(**Setup UI `user_manual` 写入;sync 已不写 phones**,RC 发现仅作建议源 — store-sync.ts) | 挪号 = DELETE + INSERT,归属历史物理归零;历史 calls/messages 的 store\_id **不跟着变,全部变错**(Peter P2 实锤);且系统不区分"真实搬家"和"纠错"两种语义                                                                                                                                                              | 改成 **temporal assignment**(`valid_from`/`valid_to`/`reason`),resolver 按 `event_time` 查当时映射;**business\_move**(历史留旧店)与 **correction**(显式 restamp 历史)分成两种操作;Postgres range exclusion constraint 防止同一 phone 有效期重叠;若 phone 是 `text`/`varchar`,migration 先启用 `btree_gist`                                                                                                                                                                    | 必须现在修(设计),上 prod 前落地                        |
| 2 | store 的存在性(删除/重建/owner 变更)      | `rc_stores` 行;unique key 含 `user_id`+`connection_id`(owner 换人或 OAuth 重连 = 新 UUID)           | lead email 绑定全丢、历史断链(Peter P3 实锤 = leads 面板归零的根因之一);`tenant_store_mappings` 会有 orphan 风险,但 2026-07-01 实测 6 条 orphan 是 pre-#516 seed/import leftover,不是已证实的删店重建根因。**部分缓解已在代码**:store-sync.ts 已是非破坏式(ADOPT-IN-PLACE 复用 UUID、cleanup 保留带配置/成员/号码的行)——残余风险在手动删除路径和 owner/连接变更 | **禁 hard delete**,只允许 `archived_at`;重建时按 external\_ref(rc\_site\_id)+ 电话组 + leadEmail 匹配已有 location 后 **reattach**;merge/split 用 `location_lineage`                                                                                                                                                                                                                                                                                     | 必须现在修                                       |
| 3 | lead\_email → store             | `store_config.leadEmails`(绑死在 store 行)                                                      | 店删了绑定就没了(P3 的一部分);email 没绑对 → leads 落不到店(会上 Vivian 报的 funnel total 错)                                                                                                                                                                                                     | leadEmail 也做 temporal assignment,不绑死 store row;唯一性约束(一个 email 同时只 active 归一店)                                                                                                                                                                                                                                                                                                                                                           | 上 prod 前修                                   |
| 4 | store → tenant                  | `tenant_store_mappings`(跨库软引用)                                                              | orphan 已实测(种下 3 周漂 6 条);billing count 会多收                                                                                                                                                                                                                                 | 写入闭环已在(studio-api `ensureTenantMappingsForStores`),补每日对账 job + 漂移告警([v1-gaps 缺口 4](/system-design/multi-tenant/v1-gaps.md))                                                                                                                                                                                                                                                                                                             | 上 prod 前修                                   |
| 5 | user → store(owner)             | `rc_stores.user_id`(stamp)                                                                  | owner 变更要改数据行;derived fallback 还活着,transfer 语义不干净([v1-gaps 缺口 2](/system-design/multi-tenant/v1-gaps.md))                                                                                                                                                                 | tenant 层接管 ownership 语义,fallback 计数→归零→退役                                                                                                                                                                                                                                                                                                                                                                                               | 上 prod 前修                                   |
| 6 | user → tenant                   | `tenant_members` + one-active-tenant-per-user 索引                                            | tenant transfer 撞死(Peter P5:接手方已有 tenant 就 transfer 不了);当场 workaround 是"再建一个账号"                                                                                                                                                                                           | **OPEN 决策 D1**(见下节)                                                                                                                                                                                                                                                                                                                                                                                                                     | 决策后定                                        |
| 7 | contact 的身份                     | `contacts` PK = (store\_id, phone)                                                          | phone 挪店 → contact 身份分裂(Peter P4);同一人跨店两份档案;DNC/consent 挂店级有触达合规风险                                                                                                                                                                                                        | 拆两层:**person/contact\_identity**(tenant 级:phone/email/consent/DNC)+ **contact\_location\_profile**(store 级:leadStatus/tasks/本店摘要);门店级 profile 用 `person_id`/`identity_id` FK 关联人头,不再把 `(store_id, phone)` 当身份锚点                                                                                                                                                                                                                         | 上 prod 前修;若 SMS 自动触达已开,DNC person 层 = 必须现在修 |
| 8 | 事件行的 store\_id/tenant\_id stamp | calls/messages/leads/tasks/timeline 等全部事实表                                                  | NULL 有 monitor(storeid-coverage-monitor),**错值没人管**;late event/backfill 会按"当前"映射误 stamp 老事件                                                                                                                                                                                | stamp 保留(事实表 stamp 是行业正确做法),但 ① 加 assignment\_id 引用——**仅事件事实表**(calls/messages/leads/timeline);tasks/task\_suggestions 等 derived/current-state 表走已有 evidence 链(source\_entity\_\*),不重复绑 assignment ② backfill/late event 按 `event_time` point-in-time resolve ③ correction 操作触发受控 restamp job;若找不到有效 assignment,允许 `assignment_id` 为 NULL 并标 `resolution_status='unresolved_assignment'`,告警 + repair queue,不能阻塞 pipeline 或静默按当前映射 stamp | 必须现在修(backfill 误 stamp),其余上 prod 前          |

## 五、Open decisions(需要 Max 拍板,ER 图定型的前置)

| #  | 决策                                       | 选项 A(会议共识)                                                | 选项 B(codex/行业)                                                                              | 备注                                                                                                                           |
| -- | ---------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| D1 | 一个 user 几个 active tenant?                | 保持一人一 tenant(Max/Vivian 2026-07-02 会上表态),transfer 走原子交换流程 | 放开为多 membership,"当前 tenant"是 session/UI 选择(WorkOS/Auth0 模式),transfer 自然消解                   | schema 注释本来就标了这个索引是 "the one decision to revisit";Peter 的 transfer 场景是第一次真实撞击                                                |
| D2 | store 级 role 废不废?                        | 废掉,全走 tenant 级(Max 会上表态)                                  | **保留两层**:tenant role 管客户级,store grants 管"某人只看某几家店"(Alan 7 店分权场景没它做不了);统一词汇与存储消除 Vivian 报的混乱 | codex 与本文档作者都倾向 B;Vivian 的原始抱怨是"两套词汇两处存储",不是"两层语义"                                                                           |
| D3 | Peter 的"门牌号"锚点(Google/social 主号)采纳到什么程度? | 作为 location 的 primary identity                            | 只作为 reconciliation 的 match signal / hint,不做 primary identity(codex:号码会 porting、回收、共享)       | —                                                                                                                            |
| D4 | person 层什么时候引入?                          | 上 prod 前                                                  | 若 SMS/自动触达已对真人发送,DNC/consent 的 person 层立刻做(合规)                                              | —                                                                                                                            |
| D5 | 评估型访问(收购尽调)要不要进 tenant 模型?               | 不支持访客(Peter 2026-07-02 会上口径)                              | 增加"评估中"的 tenant/store 状态:数据照跑分析但不属正式客户,评估结束归档或转正                                            | 卡收购尽调这条新产品线([redesign-requirements](/system-design/multi-tenant/redesign-requirements.md) R6);Will 拿目标店 RC login 做尽调的场景已真实出现 |

## 六、冻结令(会议共识,已生效)

Peter(01:53):**"我们先把这个事情想明白,再做 analytics。先不操作,越弄越麻烦。"**

在第四节 #1/#2/#3 的目标策略定稿前:

- 不做店的删除/重建/电话挪动(有纠错需求先记录,攒批处理)
- 不基于 store\_id 口径新增 analytics 功能
- Dashboard 各数字的取数表(contacts vs tasks vs leads)不再逐个打补丁——等 lead↔task 桥接和本图 #1 定稿后统一改

## 七、目标模型(Final 草案 — 把所有数据连上的那张图)

:::warning 状态
DRAFT。基于 §五 决策的**推荐答案**画出(D1 放开多 membership / D2 保留两层 / D3 门牌号只作匹配信号 / D4 person 层上 prod 前 / D5 开评估型口子)——决策拍板后只改标 ⟨D?⟩ 的地方,不重画。表名用 Final 词汇(`locations` / `phone_lines`);**概念可以先落地在现有表名上**(如 `phone_line_assignments` 直接引用 `rc_stores.id`),改名是 Final 阶段的机械活。
:::

```mermaid
erDiagram
  %% ===== 管理面(control plane)=====
  TENANTS ||--o{ TENANT_MEMBERS : "客户级角色 ⟨D1:一人可多客户⟩"
  COGNITO_USER ||..o{ TENANT_MEMBERS : "登录身份"
  TENANTS ||--o{ TENANT_LOCATION_MAPPINGS : "归属(one active per location)"
  TENANT_LOCATION_MAPPINGS }o..|| LOCATIONS : "跨库软引用 + 每日对账"
  TENANTS ||--o{ TENANT_PROVIDER_CONNECTIONS : "future:电话商账号归客户 ⟨触发:多RC账号⟩"
  TENANTS ||--o{ TENANT_DB_PLACEMENTS : "future:Silo 路由 ⟨触发:首个大客户⟩"
  TENANTS ||--o{ PERSONS : "顾客=人,挂客户级 ⟨D4⟩"

  %% ===== 身份区(data plane)=====
  COGNITO_USER ||--o{ PROVIDER_CONNECTIONS : "OAuth 凭据(可移交,不再定义店的身份)"
  LOCATIONS ||--o{ LOCATION_LINEAGE : "合并拆分传承谱系(洞⑤)"
  LOCATIONS ||--o{ LOCATION_EXTERNAL_REFS : "认亲输入:rc_site_id 门牌号 等 ⟨D3⟩"
  LOCATIONS ||--|| LOCATION_CONFIG : "alias 定价 archived_at"
  LOCATIONS ||--o{ LOCATION_MEMBERS : "店级授权 ⟨D2:保留⟩"
  LOCATIONS ||--o{ STAFF : "员工(软删除)"
  PHONE_LINES ||--o{ PHONE_LINE_ASSIGNMENTS : "归属历史(洞②)"
  LOCATIONS ||--o{ PHONE_LINE_ASSIGNMENTS : "valid_from valid_to reason=搬家或纠错"
  LOCATIONS ||--o{ LEAD_EMAIL_ASSIGNMENTS : "lead 邮箱归属历史(同款 temporal)"

  %% ===== 顾客层(person,洞③)=====
  PERSONS ||--o{ PERSON_CHANNELS : "多渠道身份 phone email IG"
  PERSONS ||--o{ CONTACT_PROFILES : "每店一份档案(leadStatus 本店摘要)"
  LOCATIONS ||--o{ CONTACT_PROFILES : "location_id"

  %% ===== 事件事实(不可变历史)=====
  LOCATIONS ||..o{ CALLS : "stamp location_id + assignment_id"
  LOCATIONS ||..o{ MESSAGES : "stamp 同上"
  LOCATIONS ||..o{ LEADS : "stamp 经 email assignment 路由"
  LOCATIONS ||..o{ CONTACT_TIMELINE : "stamp(审计骨干)"

  %% ===== 派生工作对象 =====
  CONTACT_PROFILES ||..o{ TASKS : "person x location 的待办"
  TASKS ||--o{ TASK_SUGGESTIONS : "硬 FK append-only"
  TASKS ||--o{ AI_TASK_DECISION_ASSESSMENTS : "AI 答题卡"
```

### 逐表说明书(图里每个框:干什么、靠什么连、现在有没有)

状态图例:✅ 已有(基本不动)/ 🔨 改造现有表 / 🆕 新建 / 🕐 future(有触发条件才建)。

**管理面(谁是客户、谁能进)**

| 表                             | 干什么(人话)                | 关键连接字段 → 连到哪                                                         | 现在有吗                            |
| ----------------------------- | ---------------------- | -------------------------------------------------------------------- | ------------------------------- |
| `TENANTS`                     | 客户主体:谁签合同、谁付钱          | `id` 被下面所有 tenant\_\* 表引用;`tier`/`status` 挂套餐和生命周期                   | ✅ `tenants`                     |
| `TENANT_MEMBERS`              | 某人在某客户里是什么角色           | `tenant_id` → 客户;`user_id` → 登录账号;`role`(owner/admin/billing/viewer) | ✅(D1 拍板后改"一人一客户"那个约束)           |
| `TENANT_LOCATION_MAPPINGS`    | 哪家店归哪个客户(计费、可见范围都从这数)  | `tenant_id` → 客户;`location_id` → 店(跨库软引用,靠对账 job 保真)                 | ✅ `tenant_store_mappings`(改名而已) |
| `TENANT_PROVIDER_CONNECTIONS` | 哪个 RingCentral 账号归哪个客户 | `tenant_id` → 客户;`provider_account_id` → 电话商账号                       | 🕐 多 RC 账号客户出现时建                |
| `TENANT_DB_PLACEMENTS`        | 这个客户的数据放哪个数据库          | `tenant_id` → 客户;`placement_key` → 库                                 | 🕐 第一个 Silo 大客户时建               |

**身份区(店、电话、邮箱的归属)**

| 表                            | 干什么(人话)                              | 关键连接字段 → 连到哪                                                                                                    | 现在有吗                                            |
| ---------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `PROVIDER_CONNECTIONS`       | RingCentral 的 OAuth 凭据(纯钥匙,不再定义店的身份) | `provider_account_id` → RC 账号;`user_id` → 当前持钥人(可换人重授权)                                                         | 🔨 `user_connections`(表在,语义要降级成"凭据")            |
| `LOCATIONS`                  | 店的**稳定身份证**——不再由"owner+连接+RC 分组"拼出   | `id` 是全系统的店锚点,被 mappings/assignments/事实表引用;`archived_at` 代替删除                                                   | 🔨 `rc_stores`(去掉 user\_id 参与身份、加 archived\_at) |
| `LOCATION_EXTERNAL_REFS`     | 认亲线索:这家店在外部世界的各种编号                   | `location_id` → 店;`ref_type`+`ref_value`(rc\_site\_id/门牌号/Google 主号)                                            | 🆕(字段现散落在 rc\_stores 里)                         |
| `LOCATION_LINEAGE`           | 店的传承谱系:谁合并成谁、谁拆成谁                    | `location_id` / `parent_location_id` → 店                                                                        | 🆕                                              |
| `LOCATION_CONFIG`            | 店的用户配置(别名、定价、激活)                     | `location_id` → 店(1:1)                                                                                          | ✅ `store_config`                                |
| `LOCATION_MEMBERS`           | 某人能看/编辑**哪几家店**(Alan 7 店分权靠它)        | `user_id` → 登录账号;`location_id` → 店;`role`                                                                       | ✅ `store_members`(D2 拍板保留)                      |
| `STAFF`                      | 店里的员工名册(通话归因、关单人)                    | `store_id` → 店;`is_active` 软删除                                                                                  | ✅ `staff`                                       |
| `PHONE_LINES`                | 电话号码本体(号码的元数据)                       | `phone_number` 是全系统电话锚点                                                                                         | 🔨 从 `rc_store_phones`/`phone_numbers` 抽出号码本体   |
| **`PHONE_LINE_ASSIGNMENTS`** | **核心新表:电话归属的历史账本**——哪个号从几号到几号归哪家店    | `phone_number` → 号码;`location_id` → 店;`valid_from`/`valid_to` 时间轴;`reason`(搬家/纠错);`id` 被事实表的 `assignment_id` 引用 | 🆕(替代 rc\_store\_phones 的"当前值覆盖")               |
| `LEAD_EMAIL_ASSIGNMENTS`     | lead 邮箱归属的同款账本                       | `email` → 邮箱;`location_id` → 店;`valid_from`/`valid_to`                                                          | 🆕(替代 store\_config/user\_connections 里的两处数组)   |

**顾客层(人,不是号码)**

| 表                  | 干什么(人话)                             | 关键连接字段 → 连到哪                                                    | 现在有吗                            |
| ------------------ | ----------------------------------- | --------------------------------------------------------------- | ------------------------------- |
| `PERSONS`          | 顾客本人(跨店唯一);**拒联/consent 挂这里**才能跨店生效 | `id` 是人的锚点;`tenant_id` → 客户(人属于客户,不属于店)                         | 🆕(D4 定时机)                      |
| `PERSON_CHANNELS`  | 这个人的各个联系方式(电话/邮箱/IG)                | `person_id` → 人;`channel_type`+`channel_value`(事实表靠 phone 反查到人) | 🆕                              |
| `CONTACT_PROFILES` | 这个人**在某家店**的档案(leadStatus、本店摘要)     | `person_id` → 人;`location_id` → 店                               | 🔨 `contacts`(主键从"店+号码"改成"人+店") |

**事实层(发生过的事,永不改写)**

| 表                    | 干什么(人话)               | 关键连接字段 → 连到哪                                                                                                           | 现在有吗                    |
| -------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| `CALLS` / `MESSAGES` | 每通电话/每条短信             | `location_id` → 店(写入时定格);`tenant_id` → 客户;**`assignment_id` → 归属账本(纠错修复的钥匙)**;`from/to_phone` → 经 PERSON\_CHANNELS 连到人 | ✅(各加一列 `assignment_id`) |
| `LEADS`              | 每条进来的 lead            | 同上 + `lead_email` 经 email 账本归店                                                                                         | ✅(加 `assignment_id`)    |
| `CONTACT_TIMELINE`   | 所有事件的审计流水(谁在何时对谁做了什么) | `entity_type`+`entity_id` → 任意对象;`actor_*` → 操作者                                                                       | ✅ 不动(骨干已很强)             |

**工作层(派生的待办和 AI 产出)**

| 表                              | 干什么(人话)             | 关键连接字段 → 连到哪                                                                                   | 现在有吗   |
| ------------------------------ | ------------------- | ---------------------------------------------------------------------------------------------- | ------ |
| `TASKS`                        | 待办:某人在某店该被跟进的事      | `contact_phone`+`store_id` → 档案(终态换 `person_id`+`location_id`);`source_entity_*` → 证据(哪通电话生的它) | ✅ 不动结构 |
| `TASK_SUGGESTIONS`             | AI 给任务的行动建议(只追加不覆盖) | `task_id` → 任务(硬 FK)                                                                           | ✅      |
| `AI_TASK_DECISION_ASSESSMENTS` | AI 的答题卡(它当时为什么这么判)  | `assessed_task_id` → 任务;`contact_analysis_run_id` → 那次分析                                       | ✅      |

与现状的差异,就是五个洞的补法(全部有本土先例,见 §三.5 第 7 条):

| 新东西                                                   | 补哪个洞 | 是什么(人话)                                                         | 依赖决策          |
| ----------------------------------------------------- | ---- | --------------------------------------------------------------- | ------------- |
| `phone_line_assignments`                              | ②    | 电话归属的**历史账本**:哪个号从几号到几号归哪家店、是搬家还是纠错;查历史按"当时"算                   | 无,可先做         |
| `lead_email_assignments`                              | ②    | lead 邮箱归属的同款账本(替代 store\_config 里绑死的数组 + user 级双轨)              | 无             |
| `locations` 稳定身份 + `archived_at` + `location_lineage` | ⑤    | 店的身份不再由"owner+连接+RC 分组"拼出;禁真删,只归档;重建走认亲(sync 已有雏形);合并拆分记谱系      | D3(门牌号只作认亲信号) |
| `persons` + `person_channels` + `contact_profiles`    | ③    | 顾客 = 人(挂客户级,DNC/consent 在这)+ 每店一份档案;多渠道身份挂人不挂店                  | D4(时机)        |
| `tenant_members` 放开 / `tenants` 评估型状态                 | ④    | 一人可在多个客户下有角色(转让/BPO);"评估中"客户跑分析不算正式(尽调产品)                       | **D1 / D5**   |
| 指标语义层                                                 | ①    | 不是表——一份"每个指标怎么算"的定义文档 + 全站查询统一改写                                | 无,杠杆最高        |
| 事件表加 `assignment_id`                                  | ②    | 每条 call/message 记下"当时按哪条归属记录算的",错了可精确修复(仅事件表,派生表走已有 evidence 链) | 无             |

### 逻辑走查(2026-07-03,9 条主干流程在目标模型上从头走到尾)

| # | 流程                                                                                                                                                                                     | 走查结果                                                                                                                           |
| - | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| 1 | **电话进来**:webhook → 按 `event_time` 查 phone assignment → 拿 location\_id + assignment\_id → stamp 进 calls;查不到 → NULL + `unresolved` 标记进修复队列,不堵管道                                          | ✅ 通                                                                                                                            |
| 2 | **tenant\_id 怎么 stamp**:写入时按"当时的 active mapping"取。**store→tenant 不需要 temporal**——因为跨客户转让语义 = future-only(历史归旧客户,codex 确认是行业标准),写入时 stamp 即正确;phone→store 需要 temporal 是因为"纠错"要改写历史,两者不同 | ✅ 通(理由要写清,否则会被问"为什么这条不做时间轴")                                                                                                   |
| 3 | **纠错挪号**:把错误 assignment 的区间改短 + 插入正确区间 → restamp job 按 assignment\_id 精确找到受影响事件行 → 改 stamp。事件行可机械修复                                                                                    | ✅ 通                                                                                                                            |
| 4 | **lead 进来**:email → `lead_email_assignments`(按时间)→ location → stamp;phone/email → `person_channels` 找人(没有则建)→ 建 contact\_profile → 开 lead\_outreach task                               | ✅ 通                                                                                                                            |
| 5 | **删店/重建/换 owner**:location 身份 = 内部 UUID,owner 和 OAuth 连接不再参与身份;archived 代替删除;重建走认亲(`location_external_refs`:rc\_site\_id/电话组/lead 邮箱/门牌号做匹配信号)→ reattach 同一 UUID,历史连续                  | ✅ 通(前提是图里补上 `location_external_refs`,已补)                                                                                       |
| 6 | **tenant 转让**:改 `tenant_members`(D1 放开后无冲突);业务数据全挂 tenant\_id/location\_id,不动;OAuth 凭据是个人的,新 owner 重新授权即可,店的身份不受影响                                                                     | ✅ 通                                                                                                                            |
| 7 | **权限读路径**:user → tenant role → tenant 名下 locations ∩ 店级 grants → location 集合 → `WHERE location_id IN`。与今天的链路同构,只是每环有了正式的家                                                              | ✅ 通                                                                                                                            |
| 8 | **quota/计费**:count active mappings / members,挂 tenant                                                                                                                                  | ✅ 通                                                                                                                            |
| 9 | **纠错之后,派生对象怎么办**:事件行(calls/messages)可按 assignment\_id 机械 restamp,但**在错误的店上已经生成的 task / contact\_profile 怎么处置**——task 可能已被员工跟进过,不能简单搬走                                                  | ⚠️ **半通,唯一真堵点**。候选:(a) correction 报告列出受影响的 open tasks 供人工处置,closed 不动;(b) open task 自动搬 + timeline 记录。倾向 (a)——工作对象带人的劳动,不该静默改写 |

另两个走查时明确"先不做、记下来"的:**号码易主/回收**(同一号码换了主人)→ V1.5 用现有 `needs_review` 冲突机制兜,person\_channels 不上时间轴;**派生表不绑 assignment\_id**(codex F8,已收窄)。

## 相关文档

- [`redesign-requirements.md`](/system-design/multi-tenant/redesign-requirements.md) — DB redesign 需求分析(23 场景清单 + 查询清单 + 口径冲突)
- [`why-tenant.md`](/system-design/multi-tenant/why-tenant.md) — 为什么需要 tenant 层(决策说明)
- [`v1-gaps.md`](/system-design/multi-tenant/v1-gaps.md) — V1 已知缺口与补齐路线
- [`prod-rollout-checklist.md`](/system-design/multi-tenant/prod-rollout-checklist.md) — 上 prod 铺开 checklist
- 会议原始记录:2026-07-02 sync(1h55m,Lark Minutes `objpkpo636fwztfyrh4v8z4v`)
- codex 审计 brief:`.claude/specs/2026-07-02-codex-table-relationship-audit.md`
