数据模型关系总图(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)
写入时 stamp虚线写入那一刻按当时映射固化的列(store_id / tenant_id / user_id owner)映射后来变了,历史行不跟着变——Peter P2/P3 的根源
运行时推导标注 derive每次查询/处理时现算(phone→store、user→tenant 解析链)跟着最新映射走,但会"重写历史视角"

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

三个框的边界:

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

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

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

历史审计列(不再是隔离键,只读不写新逻辑):franchise_idaccount_idsite_idstore_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 中仍被描述为 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 审计)优先级
1phone → storerc_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 前落地
2store 的存在性(删除/重建/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必须现在修
3lead_email → storestore_config.leadEmails(绑死在 store 行)店删了绑定就没了(P3 的一部分);email 没绑对 → leads 落不到店(会上 Vivian 报的 funnel total 错)leadEmail 也做 temporal assignment,不绑死 store row;唯一性约束(一个 email 同时只 active 归一店)上 prod 前修
4store → tenanttenant_store_mappings(跨库软引用)orphan 已实测(种下 3 周漂 6 条);billing count 会多收写入闭环已在(studio-api ensureTenantMappingsForStores),补每日对账 job + 漂移告警(v1-gaps 缺口 4)上 prod 前修
5user → store(owner)rc_stores.user_id(stamp)owner 变更要改数据行;derived fallback 还活着,transfer 语义不干净(v1-gaps 缺口 2)tenant 层接管 ownership 语义,fallback 计数→归零→退役上 prod 前修
6user → tenanttenant_members + one-active-tenant-per-user 索引tenant transfer 撞死(Peter P5:接手方已有 tenant 就 transfer 不了);当场 workaround 是"再建一个账号"OPEN 决策 D1(见下节)决策后定
7contact 的身份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 stampcalls/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 场景是第一次真实撞击
D2store 级 role 废不废?废掉,全走 tenant 级(Max 会上表态)保留两层:tenant role 管客户级,store grants 管"某人只看某几家店"(Alan 7 店分权场景没它做不了);统一词汇与存储消除 Vivian 报的混乱codex 与本文档作者都倾向 B;Vivian 的原始抱怨是"两套词汇两处存储",不是"两层语义"
D3Peter 的"门牌号"锚点(Google/social 主号)采纳到什么程度?作为 location 的 primary identity只作为 reconciliation 的 match signal / hint,不做 primary identity(codex:号码会 porting、回收、共享)
D4person 层什么时候引入?上 prod 前若 SMS/自动触达已对真人发送,DNC/consent 的 person 层立刻做(合规)
D5评估型访问(收购尽调)要不要进 tenant 模型?不支持访客(Peter 2026-07-02 会上口径)增加"评估中"的 tenant/store 状态:数据照跑分析但不属正式客户,评估结束归档或转正卡收购尽调这条新产品线(redesign-requirements R6);Will 拿目标店 RC login 做尽调的场景已真实出现

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

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

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

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

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

状态

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

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

状态图例:✅ 已有(基本不动)/ 🔨 改造现有表 / 🆕 新建 / 🕐 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_CONNECTIONSRingCentral 的 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 → 店;rolestore_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_ASSIGNMENTSlead 邮箱归属的同款账本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_SUGGESTIONSAI 给任务的行动建议(只追加不覆盖)task_id → 任务(硬 FK)
AI_TASK_DECISION_ASSESSMENTSAI 的答题卡(它当时为什么这么判)assessed_task_id → 任务;contact_analysis_run_id → 那次分析

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

新东西补哪个洞是什么(人话)依赖决策
phone_line_assignments电话归属的历史账本:哪个号从几号到几号归哪家店、是搬家还是纠错;查历史按"当时"算无,可先做
lead_email_assignmentslead 邮箱归属的同款账本(替代 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 标记进修复队列,不堵管道✅ 通
2tenant_id 怎么 stamp:写入时按"当时的 active mapping"取。store→tenant 不需要 temporal——因为跨客户转让语义 = future-only(历史归旧客户,codex 确认是行业标准),写入时 stamp 即正确;phone→store 需要 temporal 是因为"纠错"要改写历史,两者不同✅ 通(理由要写清,否则会被问"为什么这条不做时间轴")
3纠错挪号:把错误 assignment 的区间改短 + 插入正确区间 → restamp job 按 assignment_id 精确找到受影响事件行 → 改 stamp。事件行可机械修复✅ 通
4lead 进来: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,已补)
6tenant 转让:改 tenant_members(D1 放开后无冲突);业务数据全挂 tenant_id/location_id,不动;OAuth 凭据是个人的,新 owner 重新授权即可,店的身份不受影响✅ 通
7权限读路径:user → tenant role → tenant 名下 locations ∩ 店级 grants → location 集合 → WHERE location_id IN。与今天的链路同构,只是每环有了正式的家✅ 通
8quota/计费: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 — DB redesign 需求分析(23 场景清单 + 查询清单 + 口径冲突)
  • why-tenant.md — 为什么需要 tenant 层(决策说明)
  • v1-gaps.md — V1 已知缺口与补齐路线
  • 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