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.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 真实路径¶
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
- 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.statusJSONB 字段(见 § 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。
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。
{
// ── 必填(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):
- 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 风险点,必须覆盖。
| 风险 | 测试 |
|---|---|
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-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 示意:
-- 这通电话有没有接通(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-33 的 LeadStatusEnum 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