Identity 字段速查¶
系统里 8 个 identity 字段的身份证 + crosswalk 表。新同事 onboarding 必读。
历史与教学: 字段为什么是这样的、
siteIdgenesis wiring error、franchise 3-way split 故事 →docs/archive/identity-naming-history.md
TL;DR¶
1 句话: 我们的 identity 字段混乱不是因为改名太多,是因为 genesis commit 就 wire 错了 — siteId 当初 docstring 写的是 "Store ID",但代码实际绑到 RC Account ID,后面所有"rename"讨论都是在清理这个原始错误(2026-04-25 完成清理,canonical 名 = account_id)。
5 个真相:
account_id+providerAccountId+_account_id= 同一个值(RC OAuth Account ID, e.g.,"193365026"),在不同 repo / layer 叫不同名字client_id="{franchise}-{account_id}"派生组合(not independent identity),但是 DDBCallAnalysisConfigurationsPK,仍然活跃使用store_id= 我们自己randomUUID()生成的 UUID,唯一真隔离键franchise_id当前硬编码"orangeTheory"— 不是隔离键,是审计字段userId= Cognito JWTsub,唯一真身份键,串起 DDB 所有身份表
1. 8 个字段身份证¶
每个字段问 3 个问题: 它是什么 / 它现在叫什么 / 它以前叫过什么。
1.1 userId(Cognito sub)¶
| 维度 | 值 |
|---|---|
| 真实样本 | e43874d8-a0e1-70a0-3ace-27e65270c07d |
| 含义 | AWS Cognito 用户的唯一 ID,JWT payload.sub |
| 层级角色 | Level 1 — Auth 顶层。"老板账号" 指的就是这个 |
| 数据源 | 登录时 Cognito 发 JWT,studio-api middleware 提取 |
| 存在位置(DDB) | UserConnections.PK, UserStore.PK, StoresV2.userId (GSI), SubAccounts.parentUserId/childUserId |
| 存在位置(Neon) | ❌ 零列(except task_events.actor_user_id 做审计) |
| 历史别名 | 无 — 从出生到现在都叫 userId |
| 未来变化 | 稳定。除非换 auth provider |
关键认知: Neon 完全不存 userId — Neon 只存业务数据(按 store_id 隔离),身份解析在 DDB 做。
1.2 store_id / storeId(唯一真隔离键)⭐¶
| 维度 | 值 |
|---|---|
| 真实样本 | 53f49dbe-3864-433b-9fa0-8e45a4101204(UUID) |
| 含义 | 门店的唯一标识,我们自己 randomUUID() 生成 |
| 层级角色 | Level 3 — Store 层。唯一真隔离键 |
| 数据源 | 第一次遇到新店时 crypto.randomUUID() 生成,存 StoresV2.storeId 作 PK |
| 存在位置(DDB) | StoresV2.PK, UserStore.SK, PhoneStoreAssignments.PK |
| 存在位置(Neon) | 8 表全部具备(contacts PK 一部分 + staff NOT NULL,其他 6 表 nullable rollout 中) |
| 历史别名 | 无 — 2026-04 新引入的干净字段 |
| 未来变化 | 终态唯一隔离键,不会变 |
关键认知: store_id 是我们控制的 UUID,不依赖任何 RC / Cognito 外部系统。这是为什么它能做终态隔离键。
1.3 account_id / accountId / providerAccountId / _account_id(canonical 名,历史曾叫 site_id / siteId)¶
| 维度 | 值 |
|---|---|
| 真实样本 | 193365026(纯数字字符串) |
| 真实含义 | RC OAuth Account ID — 一个 RingCentral 账号的唯一 ID,即 tokens.ownerId |
| 层级角色 | Level 2 — RC Account 层 |
| Canonical 名 | Neon 列 account_id / TS 变量 accountId |
| 历史别名 | site_id / siteId(2026-04-25 起 deprecated) |
别名对照表:
| 名字 | 出现位置 | 状态 |
|---|---|---|
account_id / accountId |
Neon 8 表 NOT NULL 列 / Lambda TS 变量 / DDB config | ✅ 当前 canonical |
providerAccountId |
DDB (UserConnections, StoresV2, PhoneStoreAssignments) |
✅ 当前 canonical(DDB 侧历史名,语义同 account_id) |
_account_id |
RC webhook payload | ✅ RingCentral 官方字段名 |
site_id / siteId |
Neon 列 / Lambda TS 变量 | ❌ 已 deprecated — migration 0016 rename 6 表(contacts/leads/tasks/taskevents/staff/contacttimeline);calls.legacySiteId + messages.legacySiteId 仍保留(标 legacy,未删除) |
rename 历史
2026-03-14 ~ 2026-04-25 期间存在 site_id + account_id 双列共存的中间态。infra#499 这个 P2 TODO 在 2026-04-23~04-25 通过 common PR #77/#78/#79 + infra PR #668/#669/#671/#676 完成 sweep,公布为 callytics-common 0.22.0。
6 表 rename(migration 0016): contacts / leads / tasks / taskevents / staff / contacttimeline 的 site_id 列改名为 account_id。
calls + messages 例外: 这两表保留 legacySiteId: text('site_id') 列(标 legacy 但 schema 仍存在)。新代码不应再写这两个 legacy 列,只写 accountId / account_id。
详见 docs/archive/identity-naming-history.md §2 Genesis story。
1.4 franchise_id / franchiseId / franchise(品牌名)¶
| 维度 | 值 |
|---|---|
| 真实样本 | "orangeTheory"(字符串,非 UUID 非数字) |
| 含义 | 品牌名,当前硬编码单值(未来多品牌扩展时升级) |
| 层级角色 | Level 2 平级分类,非隔离键 |
| 数据源 | Lambda 从 RC webhook _client_id 前缀 parse,或从 S3 path 前缀 parse |
| 存在位置(DDB) | StoresV2.franchise, CallAnalysis.franchise, CallAnalysisConfigurations.franchise |
| 存在位置(Neon) | 全部 7 表均 NOT NULL(审计字段) |
| 历史别名 | franchise (DDB) / franchise_id(Neon snake_case)/ franchiseId(TS camelCase)— 同一字段不同 style |
写入规则: 永远写 parseFranchiseFromClientId(clientId) 拆出来,不要直接写 _client_id(2026-04 踩过坑,见 archive history §3.2)。
1.5 client_id / clientId / _client_id(派生组合,不是独立层)¶
| 维度 | 值 |
|---|---|
| 真实样本 | "orangeTheory-193365026"(字符串) |
| 含义 | {franchise}-{account_id} 拼串 |
| 层级角色 | Level 2 派生(不是独立层) |
| 为什么存在 | CallAnalysisConfigurations DDB 表用它作 PK — 一行 config per {franchise}-{account_id} 组合 |
| 存在位置(DDB) | CallAnalysisConfigurations.PK=client_id, RC webhook _client_id |
| 存在位置(Neon) | calls.client_id nullable(冗余存储,方便 join config) |
| 历史别名 | 基本稳定,就这 3 个 style |
⚠️ 常见误解: - ❌ "client = 一个客户" → 错。这里 "client" 是 RC OAuth client 概念,不是终端客户 - ❌ "clientid 是 franchiseid 的别名" → 错。client_id 包含 franchise+site 两部分 - ✅ 正确理解: client_id 是派生的便利字段,用来做 config lookup
1.6 connection_id / connectionId¶
| 维度 | 值 |
|---|---|
| 真实样本 | 11940300-a8ff-462e-a923-40f01427e072(UUID) |
| 含义 | 一次 RC OAuth 连接的内部 ID |
| 层级角色 | Level 2 子标识(同一个 providerAccountId 可能有多次 OAuth 连接) |
| 存在位置(DDB) | UserConnections.SK, PhoneNumbers.connectionId, StoresV2.connectionId |
| 存在位置(Neon) | ❌ 无列 |
| 历史别名 | 稳定,无重命名 |
关键认知: connection_id 是 DDB-only 概念。Neon 根本不存,因为 Neon 只关心"业务数据"(按 store_id 隔离),不关心"哪次 OAuth 连的"。
1.7 Phone 相关字段(4 个 variants,语义不同)¶
| 字段名 | 存在位置 | 语义 |
|---|---|---|
phoneNumber |
DDB (PhoneNumbers.phoneNumber, PhoneStoreAssignments.SK) |
通用电话号码 |
store_phone |
Neon (tasks.store_phone) |
某次通话用的店铺号码(审计) |
contact_phone |
Neon (calls.contact_phone) |
客户电话 |
from_phone_number / to_phone_number |
Neon (calls, messages) |
通话方向 |
⚠️ 格式约定: 所有 phone 必须 E.164 格式(+1XXXXXXXXXX)。不 normalize 会错 query。详见 phone-normalization-strategy.md。
1.8 extensionId / extensionName(RC 分机号)¶
| 字段 | 样本 |
|---|---|
extensionId |
62733082007(纯数字,和 RC account ID 长得像但不同意思) |
extensionName |
"101" |
关键认知: extensionId 和 account_id 都是纯数字字符串,长得像,但语义完全不同。读代码时看上下文区分。
2. 命名 Crosswalk(同一值在不同地方叫什么)¶
| 真实概念 | 样本 | DDB 里叫 | Neon 里叫 | Lambda TS 变量叫 | RC webhook 里叫 |
|---|---|---|---|---|---|
| Cognito 用户 | e43874d8-... |
userId |
(不存) | userId |
— |
| RC OAuth Account | 193365026 |
providerAccountId |
account_id |
accountId |
_account_id |
| RC OAuth 连接 | 11940300-a8ff-... |
connectionId |
(不存) | connectionId |
— |
| 品牌 | "orangeTheory" |
franchise |
franchise_id |
franchise 或 franchiseId |
_client_id 前缀 |
| 派生 client | "orangeTheory-193365026" |
client_id (CallAnalysisConfigurations PK) |
client_id (冗余) |
clientId |
_client_id |
| 门店 | 53f49dbe-... |
storeId |
store_id |
storeId |
— |
| 客户电话 | +19142654371 |
— | contact_phone / from_phone_number / to_phone_number |
同 Neon | from/to |
| 店铺电话 | +19142654371 |
phoneNumber |
store_phone(tasks 表) |
storePhone |
— |
3. 一次通话穿过所有 identity 字段¶
┌────────────────────────────────────────────────────────────────────┐
│ 1. RC Webhook 到达 │
│ payload = { │
│ _client_id: "orangeTheory-193365026", │
│ _account_id: "193365026", │
│ from: "+14145885381", │
│ to: "+19142654371", │
│ telephonySessionId: "s-a786..." │
│ } │
└──────────────────────────┬─────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ 2. message-processor Lambda │
│ franchise = parseFranchiseFromClientId(_client_id) │
│ = "orangeTheory" │
│ accountId = _account_id = "193365026" │
│ clientId = _client_id = "orangeTheory-193365026" │
│ storeId = resolvePhoneIdentity(to).storeId │
│ = "53f49dbe-..." (查 PhoneStoreAssignments DDB) │
└──────────────────────────┬─────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ 3. Neon INSERT (atomic batch) │
│ INSERT INTO contacts │
│ (phone, store_id, franchise_id, account_id, ...) │
│ VALUES ('+14145885381', '53f49dbe-...', │
│ 'orangeTheory', '193365026', ...) │
│ ON CONFLICT (phone, store_id) DO UPDATE ... │
└──────────────────────────┬─────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ 4. 用户登录 dashboard → studio-api │
│ JWT verify → userId = "e43874d8-..." │
│ getAccessibleAccountIds(userId) → [{providerAccountId, storeIds}] │
│ getAuthorizedStore(userId, storeId) → 3-check: │
│ (a) StoresV2.query(storeId).userId === userId? (OWNER) │
│ (b) UserStore.get(userId, storeId)? (shared VIEWER/EDITOR) │
│ (c) SubAccounts.query(parentUserId)? (员工委托) │
└──────────────────────────┬─────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ 5. Neon SELECT │
│ SELECT * FROM calls │
│ WHERE store_id = '53f49dbe-...' ← 终态隔离键 │
│ AND call_start_time BETWEEN ... │
│ → 返回该店所有通话 │
└─────────────────────────────────────────────────────────────────────┘
4. 遇到 identity 字段的自检清单¶
不要猜,按这 5 步自查:
- 先 Read schema 文件:
callytics-common/src/db/schema/*.ts(Neon)callytics-infrastructure/lib/stacks/storage-stack.ts(DDB)-
CDK 里的
KeySchema+AttributeDefinitions -
看列是 NOT NULL 还是 nullable — NOT NULL 意味着 Lambda 必须写,nullable 意味着 rollout 中或历史残留
-
grep Lambda 代码 — 这个字段当前谁写、谁读:
-
查 issue tracker — 这个字段有无 ongoing rename / migration
-
查 memory / archive history — 有无相关 pitfall 或 genesis 故事
命名 DO/DON'T
详见 docs/archive/identity-naming-history.md §8。要点:
- ✅ UUID 优先于数字字符串(避免 account_id / extensionId 长得像)
- ✅ Provider-agnostic naming(store_id 不叫 rcStoreId)
- ✅ Docstring 就是合同 — genesis commit 写错就埋雷
- ❌ 不要直接存 composite 值作 identity key(必须 parse)
- ❌ 不要在 2 个 repo 用 2 个不同 name 代表同一字段
5. 快速查询 cheatsheet¶
# Neon schema
grep -rn "account_id\|accountId" /Users/maxwsy/workspace/callytics-common/src/db/schema/ --include="*.ts"
# DDB CDK
grep -rn "providerAccountId" /Users/maxwsy/workspace/callytics-infrastructure/lib/ --include="*.ts"
# Lambda 使用
grep -rn "accountId\|account_id" /Users/maxwsy/workspace/callytics-infrastructure/lambda/ --include="*.ts"
# git history(字段何时首次出现)
cd /Users/maxwsy/workspace/callytics-common && git log --all --oneline -S "accountId" -- src/db/schema/ | tail -10
Maintenance: 字段改名 / rename 完成时更新 §1 字段身份证 + §2 crosswalk。历史叙事(genesis 故事 / rename timeline / rationale)写入 docs/archive/identity-naming-history.md。