外呼结果与业务结果约定
本文定义 callflow-esl 通话结束后回写 callout-server(POST /api/call-results)的完整接口契约、字段规范、数据库落库语义、多业务载荷范式以及底层级联副作用。
端到端业务调用示例见 outbound-business-example.md;全局接口概览见 api-reference.md。
1. 接口契约规范
1.1 HTTP 请求定义
- 方法与路径:
POST /api/call-results - 内容格式:
Content-Type: application/json - 内部鉴权: 请求头必须携带
X-Callout-Internal-Token: <server.serviceApi.authToken>。 服务端采用 SHA-256 + 定长安全比较(timingSafeEqual)校验凭证,防止计时侧信道攻击;本地联调若配置为空则直接放行。
1.2 响应结构与状态码
该接口虽为内部服务间通道,但与其它 HTTP 路由一样,出口会经 normalizeApiResponse 统一归一为标准 { code, data, msg } 信封(处理器内部返回的 { ok, data } / { ok, error } 会被自动转换;仅 SSE、二进制下载与 204 无内容响应不改写):
- 成功响应 (HTTP 200):json
{ "code": "0000", "data": { "attempt": {/* 序列化后的 call_attempts 行数据 */}, "contact": {/* 序列化后的 contacts 行数据(若关联了联系人) */} }, "msg": "请求成功" } - 失败响应:
code取对应 HTTP 状态码字符串(如"400"/"401"/"404"/"500"),data为null,msg为中文错误原因:json常见 HTTP 状态码:{ "code": "404", "data": null, "msg": "中文错误原因" }400 Bad Request:参数校验失败(如定位信息全空、speaker非法取值、转写缺少必填text等)。401 Unauthorized:X-Callout-Internal-Token凭证缺失或比对失败。404 Not Found:未能定位到对应的外呼尝试记录(attempt)。500 Internal Server Error:数据库更新或级联操作异常。
2. 记录定位规则(必须提供一组)
每次回写必须精准关联到单次外呼尝试记录(call_attempts)。服务按以下优先级严格依次回退定位记录:
| 优先级 | 定位字段 | 匹配逻辑与规则 |
|---|---|---|
| P1 | attemptId | 精确匹配 call_attempts.id = attemptId。唯一且最高优先级。 |
| P2 | requestId | 匹配 call_attempts.requestId = requestId 或 payload->'outboundPayload'->'variables'->>'callout_request_id' = requestId,按 id DESC 取最新一次尝试。 |
| P3 | campaignId + contactId | 两者同时提供,匹配 call_attempts.campaignId = campaignId AND call_attempts.contactId = contactId,按 id DESC 取最新一次尝试。 |
定位字段硬约束
入参校验强制要求 attemptId、requestId 与 (campaignId + contactId) 三者至少提供一组,否则直接返回 400 Bad Request。 尽管入参支持可选的 destinationNumber,但系统不会通过被叫号码兜底定位外呼记录(同号码多次外呼易产生串单)。业务侧只要能拿到 callout_request_id,建议优先传 requestId。
3. 入参字段明细
回写请求体支持的所有顶层字段如下:
| 字段名 | 类型 | 必填 | 规范与落库行为 |
|---|---|---|---|
attemptId | number | 选其一 | 外呼尝试主键 ID。 |
requestId | string | 选其一 | 外呼请求 UUID。 |
campaignId | number | 选其一 | 外呼活动 ID(需与 contactId 搭配)。 |
contactId | number | 选其一 | 联系人 ID(需与 campaignId 搭配)。 |
destinationNumber | string | 否 | 被叫号码快照(入参校验字段,不参与记录定位)。 |
callStatus | string | 否 | 真实通话状态。仅限:"ringing"、"answered"、"no_answer"、"busy"、"failed"、"completed"、"unknown",其他取值报 400。 |
hangupCause | string | 否 | FreeSWITCH 挂机原因码(如 NORMAL_CLEARING、USER_BUSY、NO_ANSWER)。 |
ringStartedAt | string | number | 否 | 振铃时间。支持 ISO 8601 字符串或秒/毫秒/微秒时间戳。 |
answeredAt | string | number | 否 | 客户接听时间。支持 ISO 8601 字符串或秒/毫秒/微秒时间戳。 |
endedAt | string | number | 否 | 通话结束时间。支持 ISO 8601 字符串或秒/毫秒/微秒时间戳。 |
durationSeconds | number | 否 | 通话时长(秒)。显式传入优先;若缺省则由 endedAt - answeredAt 自动推导。负值、非有限值或超过 24 小时(86400 秒)一律存 null 兜底。 |
recordingUrl | string | 否 | 录音文件名或完整 URL。服务统一通过 toStoredMediaName() 提取并归一化为短文件名存入 call_attempts.recordingUrl。 |
transcript | array | 否 | 逐句转写分段数组。详细规范见 4. transcript 逐句转写规范。 |
payload | object | 否 | 业务扩展数据载荷。内部的 payload.result 与数据库已有值执行深度合并。详细规范见 5. 业务结果载荷规范。 |
recordingDurationSeconds | number | 否 | ⚠️ 仅校验不落库:通过 Schema 校验但 call_attempts 无对应列,需存档请放入 payload.result。 |
recordingMimeType | string | 否 | ⚠️ 仅校验不落库:通过 Schema 校验但 call_attempts 无对应列,需存档请放入 payload.result。 |
增量合并与字段缺省语义
- 本次回写未传的通话字段(
callStatus、answeredAt等)会保留记录上的旧值,不会被清空置 null。业务支持在接通时先行上报部分字段、挂断后再完整回写终态。 - 时间字段内置数量级自动探测器(
< 1e11为秒、< 1e14为毫秒、更大者为微秒),FreeSWITCH channel 变量输出的微秒级时间戳无需调用方额外折算。
4. transcript 逐句转写规范
transcript 数组用于保存通话过程中的双向/多向分角色对话片段。只要传入非空数组,就会写入 transcript_segments 表,供前端按时间轴回放并作为情绪分析的文本输入。
4.1 数据结构
json
[
{
"speaker": "bot",
"startMs": 0,
"endMs": 2400,
"text": "您好,这里是智能售后回访,请问是张先生吗?",
"metadata": { "phase": "greeting", "source": "tts" }
},
{
"speaker": "customer",
"startMs": 2800,
"endMs": 4200,
"text": "对,是我,什么事?",
"metadata": { "source": "asr", "confidence": 0.92 }
},
{
"speaker": "agent",
"startMs": 8500,
"endMs": 12000,
"text": "张先生您好,我是人工坐席李工,协助处理您昨天的工单。",
"metadata": { "transferSessionId": "tr-9021" }
}
]4.2 字段约定与落库规则
speaker(角色标识): 必须是以下 5 个枚举值之一,传入其他取值整个请求返回400(缺省或非字符串回落为unknown):"bot":AI 机器人/TTS 播报话术。"customer":客户发言(由 ASR 识别)。"agent":人工坐席发言。"supervisor":主管监听、强插或耳语提示发言。"unknown":未识别或第三方声音。
startMs/endMs(时间戳): 相对通话或录音起点的毫秒数,必须是非负整数。若endMs < startMs,服务端自动抬升为startMs。text(转写文本): 必须为非空字符串且去空字符后不能为空,否则整个请求返回400。metadata(片段元数据): 可选对象,可记录 phase(业务阶段)、confidence(ASR 置信度)、打断标记等。非对象类型自动清洗为空对象{}。- 整段事务替换(Overwrite Semantics): 写入采用整段替换策略:服务端在事务内先删除该
attemptId下所有旧的transcript_segments,再批量写入本次传入的片段。调用方必须传入完整时间线,不能只传增量追加片段。
5. 业务结果载荷规范 (payload.result)
5.1 合并机制(保留 originate 快照)
回写请求中的 payload 与数据库中既有 payload 执行深度合并(保留顶层的 outboundPayload 拨号串、线路快照与主叫号码等审计信息),将业务层自定义结果合并至 payload.result:
typescript
// 服务端合并逻辑(state.ts):
nextPayload = {
...currentPayload,
...patchPayload,
result: {
...currentPayload.result,
...patchPayload.result
}
};5.2 主流业务场景推荐结构
场景 A:流程图编排外呼 (callout_flow - 平台主力业务)
由 apps/callflow-esl 中的可视动态流程脚本引擎驱动,回写推荐包含节点链路与运行时变量:
json
{
"payload": {
"result": {
"type": "callout_flow",
"scriptId": 12,
"finalNodeId": "node_interested_confirm",
"outcome": "completed",
"errorMessage": null,
"recognizedText": "可以,下午三点以后再联系我。",
"variables": {
"user_name": "张先生",
"intent_level": "A",
"callback_time": "15:00",
"quoted_price": 5000
},
"routeTrace": [
"start",
"greeting",
"ask_interest",
"handle_objection",
"node_interested_confirm"
]
}
}
}场景 B:AI 知识库对话与大模型外呼 (kb_audio_chat / kb_llm_chat)
json
{
"payload": {
"result": {
"type": "kb_llm_chat",
"outcome": "completed",
"errorMessage": null,
"recognizedText": "我想咨询一下退换货的具体流程。",
"intervention": {
"needHuman": false,
"sentimentScore": 0.85,
"matchedFaq": "FAQ_RETURN_POLICY_01"
}
}
}
}场景 C:传统意向打标与 NLU 决策结果
json
{
"payload": {
"result": {
"businessCode": "sales-followup",
"recognizedText": "可以,下午三点以后再联系我。",
"outcomeCode": "interested",
"outcomeCategory": "positive",
"metadata": {
"customerLevel": "vip",
"orderId": "ORD-2026-001"
},
"nlu": {
"decision": {
"profile": "sales-intent",
"key": "interested",
"confidence": 0.82,
"reason": "客户明确表达合作意愿并预约时间"
},
"extract": {
"profile": "customer-info",
"fields": { "callbackTime": "下午三点以后" },
"confidence": 0.88
}
}
}
}
}`recognizedText` 的重要性
强烈建议在 payload.result.recognizedText 中存放仅客户侧的 ASR 识别文本。 当后台情绪分析模块处理单声道录音或未拆分多角色时,会优先抓取此字段进行纯客户情绪评分,避免把 AI 自身播报内容送入情感模型。
6. 回写触发的系统级级联副作用
一次通话结果回写不仅仅修改 call_attempts 表,还会触发平台层面的四大级联机制:
6.1 联动联系人状态与自动重试调度
服务端根据 callStatus 执行 deriveContactStatus():
"completed"→ 联系人状态置为"completed";"no_answer"、"busy"、"failed"、"carrier_prompt_detected"→ 联系人状态置为"failed";"ringing"、"answered"、"unknown"→ 保持为"submitted"。
自动重试机制:若联系人转入 "failed",系统会自动读取所属活动的重试间隔(campaigns.retryIntervalSeconds),将联系人的 nextRetryAt 设为 now() + interval '1 second' * retryIntervalSeconds。调度器后台将在到达该时间戳后自动派发下一轮呼叫。同时联系人的 lastBusinessResult、lastCallStatus、lastDurationSeconds 均会同步更新。
6.2 自动沉淀 DNC 全局黑名单
若 payload.result.status 为 opt_out / unsubscribe / do_not_call / dnc / rejected,或者 payload.result 中包含 optOut: true、unsubscribe: true、doNotCall: true、dnc: true、rejectCall: true 任一布尔标记:
- 服务端自动将该号码写入
dnc_numbers(防重复 upsert)。 - 此后该系统内所有任务派发均会自动拦截该号码,除非运营人员在合规名单管理页面手工解除。名单维护见 operations-background-jobs.md 的 DNC 一节。
6.3 排入情绪分析分析队列
当 callStatus 为 "completed",且满足以下条件之一:
- 包含有效录音(
recordingUrl非空); - 包含非空
transcript转写片段; payload.result中包含recognizedText/transcript/text/customerText任一有效非空字符串;
且既有 emotionResult.status 不处于 "running" 时,系统会将 emotionResult.status 置为 "pending",由后台情绪分析 Worker 取走执行多模态(语音分轨 + 文本)情感分析。
- 注:情绪分析 Worker 在拉取文本时,会强制过滤排除
speaker: "bot"的片段,确保模型只对客户与人工坐席的发言评分。
6.4 自动刷新所属任务完成度
回写事务收尾时会自动执行 refreshCampaignCompletion(campaignId):
- 重新统计该活动下已完成联系人、需重试联系人及总名单数量;
- 若全量联系人均已闭环(已成功或已达到最大重试次数上限),自动将活动状态切为完成(
completed)。
7. 最小完整请求与响应示例
7.1 请求示例 (HTTP)
http
POST /api/call-results
Content-Type: application/json
X-Callout-Internal-Token: secret_callout_token_2026
{
"requestId": "callout-req-20260603-001",
"callStatus": "completed",
"hangupCause": "NORMAL_CLEARING",
"answeredAt": "2026-06-03T09:00:00.000Z",
"endedAt": "2026-06-03T09:01:20.000Z",
"durationSeconds": 80,
"recordingUrl": "callout_req_20260603_001.wav",
"transcript": [
{
"speaker": "bot",
"startMs": 0,
"endMs": 2500,
"text": "您好,这里是云语音客服,请问有什么可以帮您?"
},
{
"speaker": "customer",
"startMs": 3000,
"endMs": 5500,
"text": "可以,下午三点以后再联系我。"
}
],
"payload": {
"result": {
"type": "callout_flow",
"scriptId": 105,
"finalNodeId": "node_agree",
"outcome": "completed",
"recognizedText": "可以,下午三点以后再联系我。",
"variables": {
"user_callback_time": "15:00",
"customer_intent": "high"
},
"routeTrace": ["start", "node_ask", "node_agree"]
}
}
}7.2 成功响应示例 (HTTP 200)
json
{
"code": "0000",
"data": {
"attempt": {
"id": 1024,
"campaignId": 18,
"contactId": 890,
"requestId": "callout-req-20260603-001",
"destinationNumber": "13800138000",
"callStatus": "completed",
"hangupCause": "NORMAL_CLEARING",
"answeredAt": "2026-06-03T09:00:00.000Z",
"endedAt": "2026-06-03T09:01:20.000Z",
"durationSeconds": 80,
"recordingUrl": "callout_req_20260603_001.wav",
"emotionResult": {
"status": "pending"
},
"payload": {
"outboundPayload": { "lineId": 3 },
"result": {
"type": "callout_flow",
"scriptId": 105,
"finalNodeId": "node_agree",
"outcome": "completed",
"recognizedText": "可以,下午三点以后再联系我。",
"variables": {
"user_callback_time": "15:00",
"customer_intent": "high"
},
"routeTrace": ["start", "node_ask", "node_agree"]
}
}
},
"contact": {
"id": 890,
"campaignId": 18,
"phoneNumber": "13800138000",
"status": "completed",
"lastAttemptId": 1024,
"lastCallStatus": "completed",
"lastHangupCause": "NORMAL_CLEARING",
"lastDurationSeconds": 80,
"nextRetryAt": null
}
},
"msg": "请求成功"
}