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 / accountIdNeon 8 表 NOT NULL 列 / Lambda TS 变量 / DDB config当前 canonical
providerAccountIdDDB (UserConnections, StoresV2, PhoneStoreAssignments)✅ 当前 canonical(DDB 侧历史名,语义同 account_id)
_account_idRC webhook payload✅ RingCentral 官方字段名
site_id / siteIdNeon 列 / Lambda TS 变量已 deprecated — migration 0016 rename 6 表(contacts/leads/tasks/task_events/staff/contact_timeline);calls.legacySiteId + messages.legacySiteId 仍保留(标 legacy,未删除)

::: details 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 / task_events / staff / contact_timeline 的 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 概念,不是终端客户
  • ❌ "client_id 是 franchise_id 的别名" → 。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,语义不同)

字段名存在位置语义
phoneNumberDDB (PhoneNumbers.phoneNumber, PhoneStoreAssignments.SK)通用电话号码
store_phoneNeon (tasks.store_phone)某次通话用的店铺号码(审计)
contact_phoneNeon (calls.contact_phone)客户电话
from_phone_number / to_phone_numberNeon (calls, messages)通话方向

⚠️ 格式约定: 所有 phone 必须 E.164 格式(+1XXXXXXXXXX)。不 normalize 会错 query。详见 phone-normalization-strategy.md


1.8 extensionId / extensionName(RC 分机号)

字段样本
extensionId62733082007(纯数字,和 RC account ID 长得像但不同意思)
extensionName"101"

关键认知: extensionIdaccount_id 都是纯数字字符串,长得像,但语义完全不同。读代码时看上下文区分。


2. 命名 Crosswalk(同一值在不同地方叫什么)

真实概念样本DDB 里叫Neon 里叫Lambda TS 变量叫RC webhook 里叫
Cognito 用户e43874d8-...userId(不存)userId
RC OAuth Account193365026providerAccountIdaccount_idaccountId_account_id
RC OAuth 连接11940300-a8ff-...connectionId(不存)connectionId
品牌"orangeTheory"franchisefranchise_idfranchisefranchiseId_client_id 前缀
派生 client"orangeTheory-193365026"client_id (CallAnalysisConfigurations PK)client_id (冗余)clientId_client_id
门店53f49dbe-...storeIdstore_idstoreId
客户电话+19142654371contact_phone / from_phone_number / to_phone_number同 Neonfrom/to
店铺电话+19142654371phoneNumberstore_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 文件:

    • callytics-common/src/db/schema/*.ts(Neon)
    • callytics-infrastructure/lib/stacks/storage-stack.ts(DDB)
    • CDK 里的 KeySchema + AttributeDefinitions
  2. 看列是 NOT NULL 还是 nullable — NOT NULL 意味着 Lambda 必须写,nullable 意味着 rollout 中或历史残留

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

    grep -rn "storeId\|store_id" lambda/ --include="*.ts" | head -30
  4. 查 issue tracker — 这个字段有无 ongoing rename / migration

  5. 查 memory / archive history — 有无相关 pitfall 或 genesis 故事

::: details 命名 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