跳转至

Call Lifecycle Tracking

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

实施溯源 + Phase 状态
  • Issue: callytics-infrastructure#592
  • Phase 1 ✅ shipped 2026-04-29: PR #681 merged。每个 webhook 写 1 行 event_type='call.status_changed'contact_timeline
  • Phase 2 ⏳ pending: #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',本设计不做。
  • Disconnectedparty-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 - 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

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 (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 / Goneparty-level 终态 — 当前 party leg 离开 session,不等于整通 call/session 结束。Transfer 场景下 Disconnected 只表示原 party 退出,接手 party 仍在通话。是否整通 call 终结要看 status.reason / peerId / Call Log API。来源: RC 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。

{
  // ── 必填(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.tsTIMELINE_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:102logger.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:

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-analyzerstudio-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 示意:

-- 这通电话有没有接通(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-data-pipeline.md 后端管道总图。本设计是其中 transcribe-processor webhook 路径的细化
write-matrix/per-call-analysis-writes.md Disconnected 之后的 AI 分析写入。本设计是 Disconnected 之前的 lifecycle 记录,互补
store-level-isolation.md timeline 写入必须填 store_id,遵循三元组隔离
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-33LeadStatusEnum 12 个值。


调研草稿(不读,只在复盘时参考)
  • 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