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

# Call Lifecycle Tracking

把 RingCentral webhook 推过来的所有电话事件信号原样存进 Neon `contact_timeline`,让 backend 数据完整。本设计 scope **只覆盖 backend ingest**(`transcribe-processor` 写入路径),不涉及 leadStatus 推进、前端 query、UI 渲染。

> 文中频繁引用 `lambda/*/src/infrastructure/neon-repository.ts` 路径, 这是 hexagonal 架构的 driven adapter。不熟悉看 [Backend 架构 Pattern](/system-design/backend-patterns.md)。

::: details 实施溯源 + Phase 状态

- **Issue**: [callytics-infrastructure#592](https://github.com/retaintive/callytics-infrastructure/issues/592)
- **Phase 1 ✅ shipped 2026-04-29**: PR [#681](https://github.com/retaintive/callytics-infrastructure/pull/681) merged。每个 webhook 写 1 行 `event_type='call.status_changed'` 到 `contact_timeline`。
- **Phase 2 ⏳ pending**: [#683](https://github.com/retaintive/callytics-infrastructure/issues/683) — Phase 1 ≥4 周稳定后删 transcribe-processor 的 `call.created` writer
- **范围**: `callytics-common` schema 1 行 + `callytics-infrastructure/transcribe-processor` \~160 行(含 storeId resolution prepull + 测试)
- **背景**: 改完前只在 `Disconnected` 时写一行 timeline,中间 webhook(Setup/Answered/Hold/Voicemail/...) 全 drop。改完后每个 webhook 写 1 行,完整 lifecycle 可查询。

## :::

## 1. 一通电话发生了什么

RingCentral 在通话生命周期中按 `party.status.code` 变化推 webhook,通过 webhook handler → SQS 路由到 `transcribe-processor` Lambda。

### 1.1 真实路径

```
   RingCentral ──HTTP──► RingCentralWebhookHandler ──► ringcentral-call-log-queue
   (推 status            (Lambda,加 _client_id /              (SQS)
    每次变化)             _account_id metadata)                    │
                                                                   ▼
                                                          ┌─────────────────┐
                                                          │ transcribe-     │
                                                          │ processor       │
                                                          │ Lambda          │
                                                          └─────────────────┘
```

证据: `lambda-stack.ts:712-719`(SQS event source 绑定)+ `webhook-parser.ts:69`(`_client_id` 是 handler 加的)。

### 1.2 典型场景示例 — Outbound 接通通话

下图是 1 个**典型** outbound 接通通话的 webhook 序列。**实际数量随场景变化**,见 1.3 表。

```
   时间      00:00     00:02     00:08      ...      05:30
            Setup    Proceed   Answered            Disconnected
   ──────────────────────────────────────────────────────────►
   含义:    RC 收到   SIP 协商   RC 路由层               party leg
            外呼请求   成功      目标可达                 退出
                                (≠ human conv,
                                 真说话靠 AI 转录判定)
```

> **重要 caveat**:
>
> - `Answered` 是 RC 路由层"目标可达,Call Handling Rules 开始执行",**不等于** human conversation。"真有人在说话"判定由 AI 听完转录后写 `calls.call_state='human_conversation'`,本设计不做。
> - `Disconnected` 是 **party-level** 终态(当前 party leg 退出),不一定是整通 session 终结(转接场景下原 party Disconnected,接手 party 还在通话)。详见 § 5。

### 1.3 实际 webhook 数量随场景变化

下面是几个**示例场景**,真实情况比这复杂得多。

| 场景              | webhook 序列                                                           | 数量     |
| --------------- | -------------------------------------------------------------------- | ------ |
| Outbound 接通     | Setup → Proceeding → Answered → Disconnected                         | 4      |
| Outbound 短路由    | Setup → Answered → Disconnected                                      | 3      |
| Outbound 响铃无人接  | Setup → Proceeding → Disconnected                                    | 3      |
| Inbound PSTN 接通 | Proceeding → Answered → Disconnected                                 | 3      |
| Inbound 转语音信箱   | Proceeding → Voicemail → Disconnected                                | 3      |
| 接通后 Hold N 次    | ... → Answered → Hold → Answered(unhold) → Hold → ... → Disconnected | 4 + 2N |
| 转接              | 多个 partyId 各自一组,出现 `Gone` event                                      | 多组     |

### 1.4 关键 invariant: 任何 status 都可能 1 / 2 / N 条

**RC 可能对同一通通话的同一个 status 推送任意数量的 webhook**。这不限于 Disconnected,Setup / Answered / Voicemail / Hold / Gone 都可能。

例子:

- **Disconnected 重发**(已知场景): recording encoding 还没好时先推一次,encoding 完成后再推一次。详见 [`docs/archive/cloudaudioai-design/.../FIX_NO_RECORDING_RACE_CONDITION.md`](../archive/cloudaudioai-design/s3-dynamodb-backend-design-code-2025-08-02/FIX_NO_RECORDING_RACE_CONDITION.md)
- **Hold/Unhold 多次**: unhold 在 RC 协议里表现为新的 Answered webhook,客户被 hold 3 次就有 3 次额外 Answered
- **未来 RC 协议变更**: RC 可能在任何 status 上加新的握手,我们不能假设当前观察到的频率是 invariant

**本设计的 invariant**: 每个 webhook(由 `webhookUuid` 唯一标识)进 Lambda → 写 1 行 timeline。**不假设**任何 status 推几次。下游 query 必须用 `EXISTS(...status='X')` 判断"是否发生过",**不能**用 `COUNT(...status='X') = 1` 假设唯一性。

完整 status 含义见 § 5。idempotency 行为见 § 6。

### 1.5 已知 edge cases 与 limitations

| 类别               | 场景                                          | 我们的处理                                                                                                                                   |
| ---------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| ✅ 已 handle       | **乱序 webhook**(RC 官方 confirmed)             | `occurredAt = webhookTimestamp` 是 RC 给的事件时刻,query 用 `ORDER BY occurred_at`。同秒事件 fallback `metadata.sequence`(见 § 6)                     |
| ✅ 已 handle       | RC 主动重发同 status webhook                     | 不同 webhookUuid,各写各的(§ 6.2)                                                                                                              |
| ✅ 已 handle       | SQS Lambda retry                            | 同 webhookUuid,unique index 拦截(§ 6.2)                                                                                                    |
| ✅ 已 handle       | 转接(Gone + 新 partyId)                        | partyId 必填(§ 6),entityId 不变                                                                                                             |
| ✅ 已 handle       | Hold/Unhold 多次                              | 每个 Answered/Hold webhook 各一行(§ 8 测试覆盖)                                                                                                  |
| ⚠️ 边界            | `party.status.code` 缺失                      | 现状 fallback 到 `'Unknown'`(`webhook-parser.ts:199`),timeline 仍写一行,下游 query 要 graceful 处理                                                 |
| ⚠️ 边界            | 同秒多个 webhook(`occurredAt` 相同)               | `metadata.sequence` 字段保留 RC sequence number 用作 tie-breaker                                                                              |
| ⚠️ 边界            | webhookUuid 缺失或重复                           | logger.error + fallback key `${sessionId}:${statusCode}:${occurredAt}`(§ 6.2)                                                           |
| 🚫 RC limitation | `parties.length > 1`(multi-party 单 webhook) | 仅取 `parties[0]`,多余 logger.warn。证据:RC 所有 sample length=1,RC 一般每 party 各推一个 webhook。multi-party 真正数据来源是 Call Log API `legs[]`,不在本设计 scope |
| 🚫 RC limitation | Conference call                             | RC 不发 webhook("if a party belongs to another session ...")。timeline 完全缺这类通话                                                             |
| 🚫 RC limitation | Transferred party 跨 session                 | RC 不发 webhook,只能从 Gone status 推断                                                                                                        |

**RC 官方原文 references**(2026-04-28 fetch):

- 乱序: "later sequence numbers are emitted first ... can be received before sequence 2 or 3"
- Conference / transfer 不发 webhook: "if a party belongs to another session (transferred call, conference, etc)"
- 来源: [Telephony Session Notifications](https://developers.ringcentral.com/guide/voice/telephony-session-notifications)

***

## 2. 现状(改之前)

下图用典型 4-webhook outbound 通话作示意(实际数量随场景变,见 § 1.3):

```
   webhook 1 ──►┐
   (Setup)       │                     ┌─ if (NOT Disconnected) return ─►  ❌ 丢
                 │                     │
   webhook 2 ──►┤                      │   只有 Disconnected
   (Proceeding)  │   ┌──────────────┐  │   才走完整流程
                 ├──►│ transcribe-  │──┤
   webhook 3 ──►┤   │ processor    │  │
   (Answered)    │   │  Lambda      │  │
                 │   └──────────────┘  │
   webhook 4 ──►┘                      └─ Disconnected ──►  ✅ 写 DB
   (Disconnected)                                              │
                                                                ▼
                                          ┌────────────────────────────────────┐
                                          │  Neon Postgres                     │
                                          │  ┌──────────┐  ┌─────────────────┐ │
                                          │  │ calls    │  │ contact_timeline│ │
                                          │  │ (1 行)   │  │ call.created    │ │
                                          │  │          │  │ (1 行)          │ │
                                          │  └──────────┘  └─────────────────┘ │
                                          └────────────────────────────────────┘
```

**问题**: webhook 1, 2, 3 全丢,中间发生过什么 Neon 完全没记录。等通话挂断才一次性写 — 长通话期间数据库无数据。

***

## 3. 改完之后

同样用典型 4-webhook 通话作示意:

```
   webhook 1 ──►┐                       每个 webhook 都写 1 行
   (Setup)       │                            到 contact_timeline
                 │                                  │
   webhook 2 ──►┤   ┌──────────────┐                ▼
   (Proceeding)  │   │ transcribe-  │   ┌────────────────────────────┐
                 ├──►│ processor    │──►│ contact_timeline           │
   webhook 3 ──►┤   │  Lambda      │   │ event_type='call.status_   │
   (Answered)    │   │              │   │            changed'        │
                 │   │ ★ 改这里 ★   │   │                            │
   webhook 4 ──►┘   └──────────────┘   │ 1 行: Setup        14:30:00│
   (Disconnected)            │          │ 1 行: Proceeding   14:30:02│
                             │          │ 1 行: Answered     14:30:05│
                             │ Disconn  │ 1 行: Disconnected 14:35:30│
                             ▼ 时也写   └────────────────────────────┘
                      ┌──────────────┐
                      │ calls 表     │  (这部分不变,Disconn 时一次性写)
                      └──────────────┘
```

**结果**: backend 拿到完整 lifecycle 原料,后续任何 derive 需求都有数据支撑。

***

## 4. 三个独立维度

讨论本设计时容易混以下 3 个维度。它们**不是 1:1 mapping**。

| 维度                                   | 是什么              | 谁定义        | 几个值                   |
| ------------------------------------ | ---------------- | ---------- | --------------------- |
| (1) RC webhook `party.status.code`   | 一通电话**进行中**的协议状态 | RC 官方      | 10 个                  |
| (2) RC Call Log API `result`         | 一通电话**结束后**的最终结果 | RC 官方      | 30+                   |
| (3) retaintive `contacts.leadStatus` | **客户**在销售漏斗哪一步   | retaintive | code SoT 12 / UI 显示 9 |

(1) 和 (2) 是 RC 给的事实,本设计**只 ingest 维度 1**。维度 2 已由 `calls` 表存。维度 3 不在本设计范围,由 `contacts-analyzer` (AI Lambda) 写入。

***

## 5. RC webhook 10 个 status

来源: [RingCentral Telephony Session Notifications](https://developers.ringcentral.com/guide/voice/telephony-session-notifications) (2026-04-28 fetch)。

> **10 个 status 共用 1 个 event\_type**: 全部通过新增的 `event_type='call.status_changed'` 写入 timeline,具体 status 字符串放 `newValue.status` JSONB 字段(见 § 6),不为每个 status 建独立 event\_type。

| status code    | 含义                                                   | Outbound | Inbound        | Party-level terminal? | PROD 7 天 |
| -------------- | ---------------------------------------------------- | -------- | -------------- | --------------------- | -------: |
| `Setup`        | 呼叫请求收到 / 进入路由                                        | 起点       | RC↔RC 起点       | No                    |    17.0% |
| `Proceeding`   | SIP 协商成功 / 路由中                                       | 协商成功     | PSTN/RingMe 起点 | No                    |    19.0% |
| `Answered`     | RC↔RC 目标接通,开始执行 Call Handling Rules / Inbound 媒体连接建立 | RC 路由层   | 媒体建立           | No                    |    30.4% |
| `Hold`         | 有人按了挂起                                               | 双向       | 双向             | No                    |     0.2% |
| `Voicemail`    | 转入语音信箱(RC 路由层判定)                                     | —        | 无应答 → VM       | No                    |     1.2% |
| `VMScreening`  | 被叫监听语音录入                                             | —        | VM 监听中         | No                    |       0% |
| `FaxReceive`   | 传真接收                                                 | —        | 传真启动           | No                    |       0% |
| `Parked`       | 呼叫驻留                                                 | 双向       | 双向             | No                    |       0% |
| `Disconnected` | **当前 party leg** 退出或结束                               | 双向       | 双向             | **Yes (party-level)** |    25.4% |
| `Gone`         | 转接 / 跨 session 退出                                    | 双向       | 双向             | **Yes (party-level)** |    0.08% |

> **重要 caveat**: `Disconnected` / `Gone` 是 **party-level** 终态 — 当前 party leg 离开 session,不等于整通 call/session 结束。Transfer 场景下 `Disconnected` 只表示原 party 退出,接手 party 仍在通话。是否整通 call 终结要看 `status.reason` / `peerId` / Call Log API。来源: [RC Call Log States](https://developers.ringcentral.com/guide/voice/call-log/states)。
>
> **`Answered` 含义保守说**: Outbound `Answered` 是 RC 路由层"目标可达,开始执行 Call Handling Rules",**不等于** "human conversation"。真"有人在说话"判定由 AI 听完转录后写 `calls.call_state='human_conversation'`,本设计不做。

PROD 频率来自 issue #592 body 2026-04-06 截 7 天 CloudWatch。Disconnected + Answered + Setup + Proceeding 占 91.8%。FaxReceive / VMScreening / Parked 在生产从未出现。

***

## 6. 要写入 `contact_timeline` 的字段

每个 webhook 写一行,event\_type 统一是 `'call.status_changed'`,具体 status 放 JSONB。

```typescript
{
  // ── 必填(schema notNull 或租户隔离要求)──
  contactPhone: '+1xxx',                 // schema notNull (contact-timeline.ts:64)
  franchiseId: '...',                    // 租户隔离三元组
  accountId: '...',
  storeId: '...',                        // schema 上 nullable, 但产品要求填(见 store-level-isolation.md)

  // ── 事件分类 ──
  eventType: 'call.status_changed',      // 新增的 14th event_type (加在 13 个原有之后)
  eventCategory: 'call',
  entityType: 'call',
  entityId: telephonySessionId,          // 同 telephonySessionId 按 occurred_at 排 = 这通电话完整 timeline

  // ── 变更载荷(JSONB) ──
  newValue: {
    status: 'Answered',                  // RC 10 个 status 之一
    direction: 'Outbound',               // 'Inbound' | 'Outbound'
    partyId: 'p-123',                    // ★ 必填 — 转接场景 entity_id 不变但 partyId 变
    missedCall: false,                   // RC webhook 已带,Disconnected 时区分"无人接 vs 正常挂断"
    statusReason: 'Pickup',              // RC `status.reason` (Disconnected/Gone 时携带)
    detectedAnswerType: 'human',         // optional — RC `Answered` 时携带,human/voicemail/fax
  },

  metadata: {
    webhookUuid: 'abc-...',              // 去重 key 来源
    sequence: 5,                         // RC webhook sequence number,同秒事件 tie-breaker(见 § 1.5 乱序)
    extensionId: 'staff-id',             // 哪个店员
    fromPhone: '+1xxx',
    toPhone: '+1xxx',
  },

  // ── 操作者 + 幂等 + 时间 ──
  actorType: 'system',
  idempotencyKey: `call.status_changed:${webhookUuid}`,  // 见 § 6.2 — 只防 Lambda retry,不去 RC 主动重发
  occurredAt: webhookTimestamp,
}
```

### 6.1 字段决策依据

| 字段                            | 为什么必须存                                                       | 来源                                      |
| ----------------------------- | ------------------------------------------------------------ | --------------------------------------- |
| `newValue.status`             | 10 个状态全保留,下游 derive 必需                                       | RC webhook                              |
| `newValue.direction`          | 区分店员主动 vs 客户主动                                               | RC webhook                              |
| `newValue.partyId`            | 转接场景 entity\_id 不变靠 partyId 区分                               | RC webhook,转接 silent failure 防护点        |
| `newValue.missedCall`         | "响铃无人接" vs "正常挂断"区分,Codex finding 1                          | `metadata-extractor.ts:73` 已用,parser 漏读 |
| `newValue.statusReason`       | Pickup / BlindTransfer / CallScreening 等转接钩子,Codex finding 2 | RC `party.status.reason`                |
| `newValue.detectedAnswerType` | Answered 时区分 human / VM / fax(optional)                      | RC `party.detectedAnswerType`           |
| `metadata.extensionId`        | per-staff query 必需                                           | RC `party.extensionId`                  |
| `metadata.webhookUuid`        | idempotencyKey 来源                                            | RC webhook header                       |
| `metadata.sequence`           | 乱序 webhook 时同秒事件 tie-breaker(见 § 1.5)                        | RC `body.sequence`                      |

### 6.2 Idempotency: 只防 Lambda retry,不去 RC 主动重发

`idempotencyKey = 'call.status_changed:${webhookUuid}'` + 现有 unique partial index `idx_timeline_idempotency`(`contact-timeline.ts:136`)。

**会拦截的(SQS retry)**:

Lambda 跑同一个 webhook 失败重跑 → 第 2 次 INSERT 用同一个 `webhookUuid` → unique index 命中 → 拦截。timeline 仍只 1 行。

**不拦截的(RC 主动重发,见 § 1.4)**:

RC 自己推 2 次 / N 次同 status webhook → 每次 RC 给的 `webhookUuid` 是新的 → 各写各的。timeline 写 N 行,各自有自己的 `occurredAt` 和 metadata。

**webhookUuid 缺失 fallback**:

若 RC 推送的 webhook payload 没带 `uuid` 字段(罕见,但分布式系统可能),logger.error + fallback `idempotencyKey = 'call.status_changed:${telephonySessionId}:${statusCode}:${occurredAt}'`。这能 dedup 同 session+status+timestamp 的重复,但**不**能区分 RC 主动重发(那时 occurredAt 也可能相同)— 接受 over-dedup 优于 silent drop。

| 场景                                 | webhookUuid       | timeline 行数 |
| ---------------------------------- | ----------------- | ----------- |
| 第 1 次 Disconnected webhook         | `u-001`           | 1           |
| RC 重发(2 分钟后,recording encoding 完成) | `u-002`(RC 给新 ID) | +1,共 2      |
| Lambda 自己网络挂了重跑 `u-001`            | `u-001`(同 ID)     | 不变,仍 2      |

**为什么这样设计**: timeline 是**事件流水账**,RC 真推几次我们记几次。下游 query 用 `EXISTS(...status='X')` 判断"是否发生过",**不假设**每 status 唯一性。

### 6.3 跟现有 `call.created` event\_type 的关系 + Phase out 计划

`contact_timeline.event_type='call.created'` 现状由 **2 个 Lambda 写入**,一通通话最多 2 行:

| Writer                | 文件                                                                           | actorType       | idempotencyKey 后缀         | newValue 字段                          | 触发               |
| --------------------- | ---------------------------------------------------------------------------- | --------------- | ------------------------- | ------------------------------------ | ---------------- |
| transcribe-processor  | `lambda/transcribe-processor/src/infrastructure/neon-repository.ts:241-262`  | `system`        | `${telephonySessionId}`   | `callDirection, duration`            | Disconnected 时回填 |
| ai-analysis-processor | `lambda/ai-analysis-processor/src/infrastructure/neon-repository.ts:239-253` | `call_analysis` | `${callId}:call_analysis` | `callDirection, staffName, duration` | AI 分析完成时         |

跟新增的 `call.status_changed` 关系:

| event\_type                     | 粒度                                   | 一通通话行数 | 谁写                                           |
| ------------------------------- | ------------------------------------ | ------ | -------------------------------------------- |
| `call.created` (现有)             | per-session summary,Disconnected 后回填 | 1-2 行  | transcribe-processor + ai-analysis-processor |
| `call.status_changed` (本 PR 新增) | per-event lifecycle,每 webhook 1 行    | N 行    | transcribe-processor only                    |

**重叠**: `callDirection`(两个 event\_type 都有)。
**`call.created` 独有**: `duration` / `staffName`(只在通话结束 / AI 分析完成才有)。

#### Phase 计划

```
Phase 1 (本 PR — 现在)
  transcribe-processor: 加 call.status_changed,保留 call.created
  ai-analysis-processor: 不动
  一通通话 timeline: 2 行 call.created + N 行 call.status_changed

Phase 2 (call.status_changed 在生产稳定 ≥ 4 周后,单独 PR)
  - grep 全 codebase 确认 0 consumer 读 transcribe-processor 写的 call.created
  - 删 transcribe-processor 的 call.created writer
  ai-analysis-processor: 仍不动
  一通通话 timeline: 1 行 call.created(来自 ai-analysis)+ N 行 call.status_changed

Phase 3 (评估 ai-analysis-processor 的 timeline 写入,单独 PR)
  评估 Option A vs B:
    A. ai-analysis-processor 改用 'call_analysis.completed' event_type
       (该 event_type 已在 TIMELINE_EVENT_TYPE,见 contact-timeline.ts:34)
    B. ai-analysis-processor 完全不写 timeline,只写 calls 表
       (downstream 直接读 calls.callState / callDuration)
  - 删 ai-analysis-processor 的 call.created writer
  - 从 TIMELINE_EVENT_TYPE 移除 'call.created'
  - DB cleanup: DELETE FROM contact_timeline WHERE event_type = 'call.created'
  - 更新 fixture: test-data/fixtures/.../dynamodb-call-events-before.json:39 引用
```

#### 下游消费 query 推荐

| 时期                                                 | 推荐 query                                               |
| -------------------------------------------------- | ------------------------------------------------------ |
| Phase 1 期间(call.created 和 call.status\_changed 并存) | 新代码用 `call.status_changed`(更细粒度);老代码继续读 `call.created` |
| Phase 2 之后                                         | 全部用 `call.status_changed`                              |
| Phase 3 之后                                         | 同上 + 必要时读 `call_analysis.completed`(如果 Option A)       |

***

## 7. 改动范围

### 7.1 callytics-common(1 行)

`src/db/schema/contact-timeline.ts` 的 `TIMELINE_EVENT_TYPE` 数组加 `'call.status_changed'`。Drizzle `text` enum 在 TS 层校验,不需要 DB migration。

### 7.2 callytics-infrastructure transcribe-processor(\~40-50 行)

| 文件                                                                  | 改动                                                                                                                                                                              |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `lambda/transcribe-processor/src/core/webhook-parser.ts:25-49`      | `TelephonyEventDetails` interface 加 `missedCall?: boolean` / `statusReason?: string` / `detectedAnswerType?: string`                                                            |
| `lambda/transcribe-processor/src/core/webhook-parser.ts:172-215`    | `extractTelephonyEventDetails` 多读 4 个字段,从 `party.missedCall` / `party.status.reason` / `party.detectedAnswerType` / `body.sequence`;`parties.length > 1` 时 logger.warn(见 § 1.5) |
| `lambda/transcribe-processor/src/infrastructure/neon-repository.ts` | 新方法 `persistCallStatusEvent()` 写 1 行 timeline                                                                                                                                   |
| `lambda/transcribe-processor/src/record-processor.ts:266 附近`        | 在 `if (!isDisconnectedEvent) return` **之前**调用 `persistCallStatusEvent()`;Disconnected 路径**也调用**(避免漏掉终态)                                                                         |

> **storeId resolution 风险**: 当前 `storeId` 解析在 Disconnected + Call Log API 分支(`record-processor.ts:455-482`),non-Disconnected webhook 走不到。本 PR 必须在 early return 前补一段 storeId resolution,否则非 Disconnected 写入的 timeline 行 storeId 全 null,租户隔离失效。这是 \~40-50 行估算的主要来源(不是 30)。

### 7.3 不在本 PR 做

- 不 UPDATE `contacts.leadStatus`(那是 `contacts-analyzer` 的写入路径)
- 不动 `calls` 表(Disconnected 写入路径完全不变)
- 不写前端 query / API 端点
- 不定义 UI 渲染规则

### 7.4 实施 sub-task 拆解

| Sub-task                                                                                                         | 行数估算                                            |
| ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `webhook-parser.ts` interface 加 4 字段 + extractor 多读字段 + `parties.length>1` warn                                  | \~10                                            |
| `neon-repository.ts` 新方法 `persistCallStatusEvent()`                                                              | \~30                                            |
| `record-processor.ts` 在 early return 前补 `storeId` resolution + 调 `persistCallStatusEvent()` + Disconnected 路径也调用 | \~40(主要是 storeId resolution prepull,见 § 7.2 警示) |
| Unit tests(`neon-repository.test.ts` 加新方法测试 + `webhook-parser.test.ts` 加 4 字段测试)                                 | \~30                                            |
| Integration tests(覆盖 § 8 acceptance criteria 全部 6 条)                                                             | \~50                                            |
| **总计**                                                                                                           | **\~160 行**                                     |

> 比 § 7.2 的 `~40-50 行` 更准确 — 之前的估算只算了 production 代码,没算 storeId resolution prepull 和测试代码。Sprint 排期按 \~160 行 / 1.5-2 天估。

### 7.5 测试 fixture 来源(真实 PROD webhook 取得方式)

**核心发现(2026-04-28 verified by AWS CLI)**: PROD `transcribe-processor` Lambda LOG\_LEVEL=INFO,`webhook-parser.ts:102` 的 `logger.debug('Raw SQS payload', ...)` **在 PROD 不输出**。CloudWatch logs 只有 INFO 级别 metadata(telephonySessionId / clientId / callLogFetched 等),拿不到完整 webhook payload。

**推荐取数顺序**:

| 优先级 | 来源                                                     | 能拿到啥                                                                              | 限制                                                                                       |
| --- | ------------------------------------------------------ | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| 1️⃣ | **`RingCentralWebhookHandler` Lambda CloudWatch logs** | 上游 handler 加 `_client_id` / `_account_id` 之前的 raw payload — 取决于该 Lambda log level | 本 verify 没查到该 Lambda 24h 内 流量,可能要扩大时间窗或看 `-Multi-Tenant` 变体。Retention 7 天。               |
| 2️⃣ | **`ringcentral-call-log-dlq` SQS DLQ**                 | 处理失败的 webhook 完整 SQS body(含 raw RC payload)                                       | 取决于 DLQ 是否有积压 message。本 verify 时 main queue ApproximateNumberOfMessages=0(message 处理完即删) |
| 3️⃣ | **临时改 LOG\_LEVEL=DEBUG 跑 1 小时**                        | 取一批完整 raw payload                                                                 | 需要 ops approval + 准备好 redact PII 流程,会让 CloudWatch 体积涨                                    |
| ❌   | `transcribe-processor` log 直接搜 "Webhook payload"       | 0 hit (verified)                                                                  | LOG\_LEVEL=INFO debug 不输出                                                                |

**实际 log group 名(verified)**:

```
/aws/lambda/call-analytics-prod-transcribe-processor-ts-us-east-1
  retention: None (RETAIN — 永久)
  LOG_LEVEL: INFO

/aws/lambda/RingCentralWebhookHandler              retention: 7 天
/aws/lambda/RingCentralWebhookHandler-Multi-Tenant retention: 7 天
```

**SQS queues(verified)**:

```
ringcentral-call-log-queue              (主 queue,4 天 retention,处理完即删)
ringcentral-call-log-dlq                (DLQ — 失败 webhook 留在这)
ringcentral-call-log-queue-multi-tenant
ringcentral-call-log-dlq-multi-tenant
```

**至少覆盖 4 种场景**(对应 § 1.5 edge cases):

1. Outbound Setup(普通拨号)
2. Disconnected with recording(典型挂断)
3. Disconnected without recording(recording race condition)
4. Voicemail / Hold(低频但语义独特)

**Fallback**: 若以上都拿不到,继续用 `tests/helpers/mock-factory.ts` 合成 fixture,但**必须**显式覆盖 § 8 列的所有 acceptance criteria 场景(转接 / RC 重发 / Hold 多次)。

**放在哪里**: `callytics-infrastructure/lambda/transcribe-processor/tests/fixtures/real-webhooks/` 下,文件名脱敏(去客户名字 / 真实电话号 / accountId)。

**PR review acceptance**: reviewer 人肉抽样 ≥ 3 个 fixture 确认无 PII 泄漏。

***

## 8. 测试 acceptance criteria

不写测试就 ship 的 silent failure 风险点,必须覆盖。

| 风险                                                        | 测试                                                                                                                                                 |
| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `partyId` 漏写进 `newValue` 不会 type error,但转接 derive 永远 0 命中 | Fixture: 一通转接通话(同 telephonySessionId 不同 partyId)→ `SELECT DISTINCT new_value->>'partyId' FROM contact_timeline WHERE entity_id = :sessionId` 应 ≥ 2 |
| storeId resolution 在 non-Disconnected 路径漏掉                | Fixture: Setup webhook → query timeline 行 `storeId IS NOT NULL`                                                                                    |
| Lambda SQS retry 同 webhookUuid                            | 第 2 次 INSERT 应被 idempotencyKey unique index 拦截,timeline 仅 1 行                                                                                      |
| **RC 主动重发同 status webhook(不同 webhookUuid)**               | Fixture: 同 sessionId + 同 status='Disconnected' + 不同 webhookUuid 各 1 次 → timeline 应 2 行,`EXISTS(...status='Disconnected')` 仍返回 true                 |
| Hold/Unhold 多次(同 partyId 多次 Answered)                     | Fixture: Setup → Answered → Hold → Answered → Hold → Answered → Disconnected → timeline 7 行,3 行 `Answered`,2 行 `Hold`                              |
| 10 个 status 都能写                                           | 每个 status 一个 fixture,验证写入成功且 newValue.status 准确                                                                                                    |

***

## 9. 性能与索引

| 指标            | 估算                                                               |
| ------------- | ---------------------------------------------------------------- |
| 写入量           | \~22K events/week → \~1.2M/年(issue #592 body)                    |
| Neon storage  | 完全无压力                                                            |
| 现有 index 是否够用 | `idx_timeline_contact_time` / `idx_timeline_idempotency` 已支撑主读路径 |

如果下游 query 频繁按 status 过滤(例如"今日所有 Voicemail"),建议加 partial expression index:

```sql
CREATE INDEX idx_timeline_call_status ON contact_timeline ((new_value->>'status'))
WHERE event_type = 'call.status_changed';
```

本 PR 暂不加 — 等下游 query 真的慢了再加。

***

## 10. Downstream consumer 示意(非本设计 scope)

backend 抓全后,下游(`contacts-analyzer` / `studio-api` / 前端)**未来**能 derive 出的能力示意 — 这部分**不是本 PR 实施内容**,只用来回答"backend 抓这些数据有什么用"。

| 下游能力                         | 数据来源                                    | 由谁实现                                     |
| ---------------------------- | --------------------------------------- | ---------------------------------------- |
| Lead Funnel `Attempted` 卡片实时 | timeline + Setup outbound + extensionId | `contacts-analyzer` 或 `studio-api` 后续 PR |
| 店员今日外呼数                      | timeline + Setup outbound + extensionId | `studio-api` 后续 PR                       |
| 转接链路追踪                       | timeline + partyId / Gone status        | `studio-api` 后续 PR                       |
| ring time / hold 分析          | timeline timestamp 差                    | `studio-api` 后续 PR                       |
| voicemail 实时提示               | timeline + Voicemail status             | `studio-api` 后续 PR                       |

参考 query 示意:

```sql
-- 这通电话有没有接通(downstream 写法示意)
SELECT EXISTS(
  SELECT 1 FROM contact_timeline
  WHERE entity_id = :sessionId
    AND event_type = 'call.status_changed'
    AND new_value->>'status' = 'Answered'
);
```

***

## 11. 邻居文档关系

| 现有文档                                                                                                | 关系                                                                 |
| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| [Backend 数据管道](/architecture/2-backend.md)                                                          | 后端管道总图。本设计是其中 transcribe-processor webhook 路径的细化                   |
| [write-matrix/per-call-analysis-writes.md](/system-design/write-matrix/per-call-analysis-writes.md) | Disconnected 之后的 AI 分析写入。本设计是 Disconnected 之前的 lifecycle 记录,**互补** |
| [store-level-isolation.md](/system-design/store-level-isolation.md)                                 | timeline 写入必须填 `store_id`,遵循三元组隔离                                  |
| [lead-funnel-status.md](/product-design/v1/lead-tracker-feature/lead-funnel-status.md)              | 9 个 leadStatus 产品口径定义。本设计 ingest 出的数据**未来**支撑这些状态推进,但本 PR 不实施      |

***

## 12. 关联 schema drift fix(单独 PR,非本设计)

Codex verify 顺手发现 2 处 stale 注释,**不在本 PR scope**,但建议同 sprint 单独 PR 修(callytics-common only):

| 文件                                                   | 行       | 当前(stale)                           | 应改为                                                                                          |
| ---------------------------------------------------- | ------- | ----------------------------------- | -------------------------------------------------------------------------------------------- |
| `callytics-common/src/db/schema/contacts.ts`         | 107-108 | 注释写 `lostcontact / badtiming`(无下划线) | `lost_contact / bad_timing`(snake\_case,跟实际 enum 一致)                                         |
| `callytics-common/src/db/schema/contact-timeline.ts` | 51      | 注释写 "12 种事件"                        | "14 种事件"(本设计 ship 后实际 14 个: 原 13 + `'call.status_changed'`,callytics-common PR #83 已 merged) |

实际 enum SoT: `callytics-infrastructure/lambda/contacts-analyzer/src/core/models.ts:20-33` 的 `LeadStatusEnum` 12 个值。

***

::: details 调研草稿(不读,只在复盘时参考)

- Codex 独立设计 brief: `.claude/specs/2026-04-28-call-lifecycle-tracking-codex-brief.md`
- Codex verify 报告: `.claude/specs/2026-04-28-call-lifecycle-tracking-codex-verify.md`
  :::
