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

# Identity 字段速查

> 系统里 8 个 identity 字段的身份证 + crosswalk 表。新同事 onboarding 必读。
>
> **历史与教学**: 字段为什么是这样的、`siteId` genesis wiring error、franchise 3-way split 故事 → [`docs/archive/identity-naming-history.md`](../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/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`](../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](../archive/identity-naming-history.md))。

***

### 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_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`](/system-design/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 步自查:

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 代码** — 这个字段当前**谁写**、**谁读**:
   ```bash
   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`](../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

```bash
# 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`](../archive/identity-naming-history.md)。
