跳到正文

外呼结果与业务结果约定 ​

本文定义 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
    {
      "code": "404",
      "data": null,
      "msg": "中文错误原因"
    }
    常见 HTTP 状态码:
    • 400 Bad Request:参数校验失败(如定位信息全空、speaker 非法取值、转写缺少必填 text 等)。
    • 401 Unauthorized:X-Callout-Internal-Token 凭证缺失或比对失败。
    • 404 Not Found:未能定位到对应的外呼尝试记录(attempt)。
    • 500 Internal Server Error:数据库更新或级联操作异常。

2. 记录定位规则(必须提供一组) ​

每次回写必须精准关联到单次外呼尝试记录(call_attempts)。服务按以下优先级严格依次回退定位记录:

优先级定位字段匹配逻辑与规则
P1attemptId精确匹配 call_attempts.id = attemptId。唯一且最高优先级。
P2requestId匹配 call_attempts.requestId = requestId 或 payload->'outboundPayload'->'variables'->>'callout_request_id' = requestId,按 id DESC 取最新一次尝试。
P3campaignId + 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. 入参字段明细 ​

回写请求体支持的所有顶层字段如下:

字段名类型必填规范与落库行为
attemptIdnumber选其一外呼尝试主键 ID。
requestIdstring选其一外呼请求 UUID。
campaignIdnumber选其一外呼活动 ID(需与 contactId 搭配)。
contactIdnumber选其一联系人 ID(需与 campaignId 搭配)。
destinationNumberstring否被叫号码快照(入参校验字段,不参与记录定位)。
callStatusstring否真实通话状态。仅限:"ringing"、"answered"、"no_answer"、"busy"、"failed"、"completed"、"unknown",其他取值报 400。
hangupCausestring否FreeSWITCH 挂机原因码(如 NORMAL_CLEARING、USER_BUSY、NO_ANSWER)。
ringStartedAtstring | number否振铃时间。支持 ISO 8601 字符串或秒/毫秒/微秒时间戳。
answeredAtstring | number否客户接听时间。支持 ISO 8601 字符串或秒/毫秒/微秒时间戳。
endedAtstring | number否通话结束时间。支持 ISO 8601 字符串或秒/毫秒/微秒时间戳。
durationSecondsnumber否通话时长(秒)。显式传入优先;若缺省则由 endedAt - answeredAt 自动推导。负值、非有限值或超过 24 小时(86400 秒)一律存 null 兜底。
recordingUrlstring否录音文件名或完整 URL。服务统一通过 toStoredMediaName() 提取并归一化为短文件名存入 call_attempts.recordingUrl。
transcriptarray否逐句转写分段数组。详细规范见 4. transcript 逐句转写规范。
payloadobject否业务扩展数据载荷。内部的 payload.result 与数据库已有值执行深度合并。详细规范见 5. 业务结果载荷规范。
recordingDurationSecondsnumber否⚠️ 仅校验不落库:通过 Schema 校验但 call_attempts 无对应列,需存档请放入 payload.result。
recordingMimeTypestring否⚠️ 仅校验不落库:通过 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",且满足以下条件之一:

  1. 包含有效录音(recordingUrl 非空);
  2. 包含非空 transcript 转写片段;
  3. 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": "请求成功"
}

文档与代码在同一仓库维护,现有 Markdown 是唯一内容源。