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。
::: details 实施溯源 + 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.createdwriter - 范围:
callytics-commonschema 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 真实路径
证据: lambda-stack.ts:712-719(SQS event source 绑定)+ webhook-parser.ts:69(_client_id 是 handler 加的)。
1.2 典型场景示例 — Outbound 接通通话
下图是 1 个典型 outbound 接通通话的 webhook 序列。实际数量随场景变化,见 1.3 表。
重要 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 数量随场景变化
下面是几个示例场景,真实情况比这复杂得多。
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
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, 2, 3 全丢,中间发生过什么 Neon 完全没记录。等通话挂断才一次性写 — 长通话期间数据库无数据。
3. 改完之后
同样用典型 4-webhook 通话作示意:
结果: backend 拿到完整 lifecycle 原料,后续任何 derive 需求都有数据支撑。
4. 三个独立维度
讨论本设计时容易混以下 3 个维度。它们不是 1:1 mapping。
(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.statusJSONB 字段(见 § 6),不为每个 status 建独立 event_type。
重要 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。
Answered含义保守说: OutboundAnswered是 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。
6.1 字段决策依据
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。
为什么这样设计: timeline 是事件流水账,RC 真推几次我们记几次。下游 query 用 EXISTS(...status='X') 判断"是否发生过",不假设每 status 唯一性。
6.3 跟现有 call.created event_type 的关系 + Phase out 计划
contact_timeline.event_type='call.created' 现状由 2 个 Lambda 写入,一通通话最多 2 行:
跟新增的 call.status_changed 关系:
重叠: callDirection(两个 event_type 都有)。
call.created 独有: duration / staffName(只在通话结束 / AI 分析完成才有)。
Phase 计划
下游消费 query 推荐
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 行)
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 拆解
比 § 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。
推荐取数顺序:
实际 log group 名(verified):
SQS queues(verified):
至少覆盖 4 种场景(对应 § 1.5 edge cases):
- Outbound Setup(普通拨号)
- Disconnected with recording(典型挂断)
- Disconnected without recording(recording race condition)
- 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 风险点,必须覆盖。
9. 性能与索引
如果下游 query 频繁按 status 过滤(例如"今日所有 Voicemail"),建议加 partial expression index:
本 PR 暂不加 — 等下游 query 真的慢了再加。
10. Downstream consumer 示意(非本设计 scope)
backend 抓全后,下游(contacts-analyzer / studio-api / 前端)未来能 derive 出的能力示意 — 这部分不是本 PR 实施内容,只用来回答"backend 抓这些数据有什么用"。
参考 query 示意:
11. 邻居文档关系
12. 关联 schema drift fix(单独 PR,非本设计)
Codex verify 顺手发现 2 处 stale 注释,不在本 PR scope,但建议同 sprint 单独 PR 修(callytics-common only):
实际 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:::