跳转至

Identity 字段速查

系统里 8 个 identity 字段的身份证 + crosswalk 表。新同事 onboarding 必读。

历史与教学: 字段为什么是这样的、siteId genesis 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 个真相:

  1. account_id + providerAccountId + _account_id = 同一个值(RC OAuth Account ID, e.g., "193365026"),在不同 repo / layer 叫不同名字
  2. client_id = "{franchise}-{account_id}" 派生组合(not independent identity),但是 DDB CallAnalysisConfigurations PK,仍然活跃使用
  3. store_id = 我们自己 randomUUID() 生成的 UUID,唯一真隔离键
  4. franchise_id 当前硬编码 "orangeTheory"不是隔离键,是审计字段
  5. userId = Cognito JWT sub,唯一真身份键,串起 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_idDDB-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"

关键认知: extensionIdaccount_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 franchisefranchiseId _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 步自查:

  1. 先 Read schema 文件:
  2. callytics-common/src/db/schema/*.ts(Neon)
  3. callytics-infrastructure/lib/stacks/storage-stack.ts(DDB)
  4. CDK 里的 KeySchema + AttributeDefinitions

  5. 看列是 NOT NULL 还是 nullable — NOT NULL 意味着 Lambda 必须写,nullable 意味着 rollout 中或历史残留

  6. grep Lambda 代码 — 这个字段当前谁写谁读:

    grep -rn "storeId\|store_id" lambda/ --include="*.ts" | head -30
    

  7. 查 issue tracker — 这个字段有无 ongoing rename / migration

  8. 查 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