跳到正文

运行与后台任务 ​

数据库结构 ​

表位于 PostgreSQL 的 callout schema(与 callflow 分离);定义在 src/db/schema.ts。部署或本地初始化时执行 bun run db:push。

表作用
campaigns外呼任务:业务、绑定线路、cellId(绑定的 FS cell,空 = 派发时自动分配)、maxConcurrency、maxRetries、retryIntervalSeconds、callIntervalMs、workdayStart/End、scheduledStartAt/EndAt、status(draft/running/paused/completed/archived)、businessConfig(业务专属 variables / intentKeywords / monitoringMode / monitoringPriority)
scripts流程脚本:可视化节点图、变量目录、话术、NLU 分支、字段抽取、结果归类和运行时 JSON 版本
lines外呼线路:拨号串模板、默认主叫号码、共享 maxConcurrency,以及号码分配模式 allocationMode(single_shared 一号多并发 / number_pool 多号轮换 / bound_numbers 一号一并发)和主叫透传头策略 callerIdHeaderMode;启动时由 src/db/seed.ts 按 code 缺失补齐默认线路(inner / out-001 / inner-gateway)
line_numbers线路号码池:number_pool / bound_numbers 模式下一条线路可挂多个主叫号码,含号码级 maxConcurrency、轮换权重 weight 和启用标志
campaign_lines外呼任务与线路池关联;任务必须至少绑定一条启用线路
contacts任务中的单个被叫:phoneNumber(任务内唯一)、customerName、status(pending/dialing/submitted/failed/completed/paused)、retryCount/nextRetryAt 重试计数、metadata(字符串值会作为 channel variable 下发),以及 lastCallStatus/lastHangupCause/lastBusinessResult 等最近一次结果快照
call_attempts一次 originate 尝试:requestId、实际使用的 lineId/lineNumberId/主叫、cellId(实际使用的 FS cell 快照,空 = cell 化之前的历史记录)、提交 status(accepted/rejected/error)、callStatus、hangupCause、时间与时长、recordingUrl(只存短文件名)、emotionResult,以及 payload.result 业务扩展结果
callout_batches一次派发批次(source 为 manual 或 scheduler),记录 requestedLimit/claimedCount/submittedCount/failedCount 与批次状态(running/completed/failed/canceled)
callout_policies外呼策略中心;支持全局 / 任务级禁呼日期、法定节假日、可呼时段、号码规则、频控、重试和并发限制
users / auth_sessions管理台用户与登录会话(auth_sessions 承载 token 与重复登录踢线)
roles / menus / user_roles / role_menus / role_buttons系统管理 RBAC:角色(roleCode,如 R_SUPER)、菜单路由树及其按钮清单、用户-角色、角色-菜单和角色-按钮授权;启动时由 src/db/seed.ts 幂等初始化,接口见 API 参考 的系统管理 RBAC 一节
audit_logs敏感操作审计:用户、动作、资源、请求路径和变更快照
agents / agent_groups坐席和坐席分组,用于坐席工作台、软电话和转人工路由;agents.cellId 记录分机注册所在 FS cell(注册对账 worker 写入),转人工按「与客户通话同 cell」过滤坐席
agent_call_records坐席呼出、人工回拨和转人工请求的提交与 FreeSWITCH 接听 / 挂机状态
dnc_numbersDNC 免打扰名单
runtime_events调度、生命周期补偿、告警等运行事件
transcript_segments按外呼尝试或坐席通话记录归属的分角色转写片段
conference_sessions转人工会议会话:会议室 ID、cellId(会议所在 FS cell,对账按 cell 分片)、关联呼叫 / 联系人、客户腿 UUID、当前主坐席及其软电话腿、会话状态
ai_llm_configsLLM 推理引擎与模型配置表:支持 OpenAI 兼容协议,承载多场景模型路由(NLU、Chat、Script 生成)与推理超参配置,支持 Web 管理端可视化维护与热更新
ai_usage_recordsAI 模型调用与 Token 用量明细记录:记录模型调用场景、耗时、Prompt Token 与 Completion Token 消耗,支持按场景与时间聚合统计

坐席状态统一为示闲(idle)、忙碌(busy)、离线(offline)三态。只有示闲坐席参与来电与转人工分配;忙碌和离线坐席不参与分配。历史 online 数据会在服务启动时幂等归一为 idle。手动操作仅提供示闲、离线,示闲要求电话已注册;转人工路由预占立即置为忙碌,振铃、接通期间保持忙碌,所有角色均不能手动切换。挂断、拒接、超时或失败后自动示闲;存在其他进行中的转接腿时继续忙碌。状态接口约束见 API 参考。

campaigns.status = completed 表示任务已结束:当前没有待调度联系人,也没有仍在 dialing / submitted 的活跃联系人。它不等于所有联系人都成功接通;任务可能是全部成功、 全部失败且重试耗尽,或成功与失败混合。后台调度器只扫描 running 任务,因此 completed 任务不会再被自动调度;需要再次处理时,可通过手动重试接口把联系人重置为 pending 并重新启动任务。

情绪分析 ​

callout-server 可以把已完成的呼叫尝试加入队列,进行通话后的情绪分析。 通过 emotionAnalysis.enabled=true 启用,并在配置的端点运行 onnx-platform/emotion-analysis-server。

后台工作线程会把 attemptId、尽力提取的转写文本和拼好的完整 recordingUrl 发送到 POST /analyze, 再把结果保存到 call_attempts.emotionResult 字段;情绪音频同样只入库文件名短值,面向前端的 API 原样 返回短值,前端通过 media.emotionAudioBaseUrl 拼播放 URL。

录音基地址按该尝试的 cellId 解析(cells[].media.freeswitchRecordingBaseUrl):cellId 为空是 cell 化之前的历史数据,回落顶层 media.freeswitchRecordingBaseUrl;cellId 非空但已不在配置里时 不发录音,避免拼出指向另一台 FreeSWITCH 的地址,见 src/shared/media-urls.ts。

相关字段定义在 src/db/schema.ts,部署或本地初始化统一通过 bun run db:push 同步。

调度与重试 ​

活动现在包含 maxRetries、retryIntervalSeconds、callIntervalMs、workdayStart 和 workdayEnd,并支持 scheduledStartAt、scheduledEndAt 计划窗口。 任务允许提前启动为 running;调度器先刷新完成度,再按服务端时间检查 [scheduledStartAt, scheduledEndAt),未到开始时间或已到结束时间时正常跳过,不调用派发入口、 不创建批次,也不记录排期错误事件。到开始时间后的下一次轮询按策略、工作时段、间隔和容量派发; 领取事务仍复核时间边界,结束时间只限制新派发,已建立通话继续正常收尾。 启动、等待与完成边界见外呼任务生命周期。 外呼策略中心可在全局或任务级继续收紧这些限制;执行语义为更严格优先: 全局策略和任务策略都必须通过,任务级策略不能绕过全局禁呼。手动派发和后台调度器都会遵守 maxConcurrency。当 retryCount < maxRetries 且 nextRetryAt 已过时,失败联系人可以再次重试。 如果失败联系人尚未到 nextRetryAt,调度器不会领取;如果 retryCount >= maxRetries, 该联系人不再自动重试。任务完成判定仍以“无 pending、无可继续重试的 failed、无 dialing/submitted 活跃联系人”为准。

策略类型包括:

  • blocked_dates:命中 YYYY-MM-DD 日期时阻断派发。
  • holiday_blocklist:按节假日阻断派发,支持指定年份日期、每年重复日期和补班放行,例如 { "dates": ["2026-02-17"], "recurringDates": ["01-01"], "workdayOverrides": ["2026-02-14"], "region": "CN" }。
  • daily_window / weekly_window:仅允许指定每日或星期时段派发。
  • phone_blacklist / phone_prefix_blocklist:按号码或前缀阻断单个联系人。
  • contact_frequency_limit:限制同一号码最近 N 天最大触达次数,例如 { "days": 7, "maxAttempts": 2 }。
  • rate_limit / retry_rule / concurrency_limit:收紧呼叫间隔、重试和并发配置。

全局 DNC 免打扰名单由 dnc_numbers 表维护,派发前会独立于策略表进行阻断。 当 POST /api/call-results 回写的 payload.result.status 为 opt_out、unsubscribe、do_not_call、dnc、rejected, 或 payload.result 中包含 optOut / unsubscribe / doNotCall / dnc / rejectCall: true 时, 平台会自动把该号码沉淀到 DNC,后续派发前直接拦截。

运行事件与告警 ​

runtime_events 记录调度、补偿、告警等后台运行事件。管理端通过 /api/runtime/overview 和 /api/runtime/events SSE 展示这些事件。配置 notifications.webhookUrl 后,达到 notifications.minLevel 的事件会同步发送 webhook。 目前调度器未捕获异常会写入 scheduler_failed 事件;生命周期超时补偿会写入 lifecycle_timeout_reconciled;情绪分析 worker 异常写入 emotion_analysis_worker_failed; 会议对账收尾僵尸会话写入 conference_session_reconciled;转人工会议加 / 减成员写入 conference_participant_added / conference_participant_removed。

服务启动时装配并拉起 1 个会话清理定时器 + 5 个后台轮询 Worker + 多 Cell 分布式租约监听器(定时任务均以 setInterval 驱动并具备执行中防重入互斥保护):

  1. 过期会话清理定时器 (setupSessionCleanup):服务启动即执行一次,之后以 24 小时为周期轮询清理 PostgreSQL callout.auth_sessions 中已过期的登录会话与失效令牌。
  2. 外呼调度派发器 (scheduler):受 scheduler.enabled 控制,按 scheduler.intervalMs(默认 5000ms)轮询处于 running 状态的外呼活动并分批占用联系人提交拨号。
  3. 通话生命周期对账 (lifecycleReconcile):受 lifecycleReconcile.enabled 控制,默认每 60s(intervalMs)通过分布式锁(lifecycle-reconcile)轮询一次,清理停留在 dialing/submitted 超过 30 分钟(stuckAfterMs)的超时卡单联系人并安排重试。
  4. 转人工会议状态对账 (conferenceReconcile):受 conferenceReconcile.enabled 控制,默认每 30s(intervalMs)轮询一次,由持有对应 Cell 租约的实例向 FreeSWITCH 探测实时成员数,自动收尾超过 60s 宽限期(graceMs)的空置僵尸会议并释放被占坐席。
  5. 坐席分机注册对账 (agentRegistrationReconcile):受 agentRegistrationReconcile.enabled 控制,默认每 30s(intervalMs)通过分布式锁(agent-registration-reconcile)轮询一次,利用 FreeSWITCH show registrations 把库中空闲但分机未实际注册的坐席降级为 offline。
  6. 离线通话情绪分析 (emotionAnalysis):受 emotionAnalysis.enabled 控制,按 emotionAnalysis.intervalMs(默认 5000ms)轮询并以 maxConcurrency(默认 2)并发将已完成的呼叫录音/转写发送至情绪分析服务。

FreeSWITCH 事件监听采用单 Cell 独立监听器 + 集中租约协调机制:每个 cell 实例实例化一个独立的 FreeSwitchEventListener(freeswitchEvents.enabled=false 时 start() 自身为 no-op)。由 CellLeaseManager 统一管理:以 redis.lockTtlMs / 3 为心跳周期对每个 cell 抢 / 续 Redis 租约(Key 格式为 <keyPrefix>:esl-listener:<cellId>),抢到租约即拉起监听器、丢失租约即注销监听器,记录 cell_lease_acquired / cell_lease_lost 日志事件。持有实例若异常崩溃,租约到期后其余实例可在下一轮心跳中自动抢占接管;接管缝隙内遗漏的事件由生命周期与会议对账平滑兜底。停机时通过 server.stop() 保证定时器注销、租约释放、ESL 连接切断与持久连接的级联优雅退出。

并发模型 ​

callout-server 派发联系人时逐层收敛四类并发上限:

  • 任务并发:campaigns.maxConcurrency 限制同一个活动内可同时活跃的联系人数量。
  • 线路并发:lines.maxConcurrency 限制共享同一外呼线路的所有活动中可同时活跃的联系人数量。 线路内部还有号码池一层:allocationMode 为 number_pool / bound_numbers 时按 line_numbers.maxConcurrency 单独扣减,取不到可用号码的线路本轮直接置零。
  • cell 并发:cells[].maxConcurrency 限制同一 FS cell 上可同时活跃的联系人数量(0 不限制)。
  • 严格盯屏容量:仅当任务业务是 kb-audio-chat-business 且 businessConfig.monitoringMode 为 strict 时生效,用在线盯屏坐席总容量减去该 cell 上已活跃的严格盯屏通话;elastic(缺省) 与 unattended 不占容量,因此这一层对其它业务恒为无限。

活跃联系人指状态为 dialing 或 submitted 的联系人。任务必须绑定至少一条启用线路;一次批次的实际派发数量为:

text
min(请求批次大小, 任务剩余槽位, sum(已绑定启用线路的剩余槽位), 选中 cell 剩余槽位, 严格盯屏剩余容量)

每个批次先选定一个 FS cell:任务绑定了 cellId 时只用该 cell(满载则本轮跳过,不换 cell;绑定的 cell 不在 config.cells 中时派发直接报错);未绑定时在全部 cell 中选剩余槽位最多的,并列取活跃数少、再并列取配置顺序在前的。本批全部 originate 都提交到选中 cell 的 FreeSWITCH,&socket 回调地址取该 cell 的 callbackHost:callbackPort(即与之配对的 callflow-esl),并把 cell id 写入 call_attempts.cellId。

批次内每个联系人会选择当前剩余槽位最多的线路,剩余槽位相同则按线路 ID 升序兜底。主叫号码、主叫名称和拨号模板完全使用本次实际选中线路的配置;历史任务级覆盖字段不再生效。若线路池为空、绑定线路被停用或删除,批次不会提交 originate。当本轮没有可派发联系人(例如仅剩通话中的活跃联系人)或没有可用槽位时,直接跳过、不再落 claimedCount=0 的空批次;调度器也会预先跳过没有待派发联系人的运行中任务。

手动派发的 limit 是单次请求批次大小,不是并发配置;API 会把它限制在 1..100。 后台调度器对每个运行中的活动使用 min(scheduler.batchSize, campaign.maxConcurrency) 作为请求批次大小,最终实际提交数量仍由同一套活动 / 线路槽位计算决定。

多实例部署中,派发使用两层协调:

  • Redis 锁防止重复进入派发流程。没有全局调度器锁:每个实例都扫描全部运行中的活动,多实例靠任务锁自动分摊;单个活动的互斥由活动级 Redis 锁(campaign:<id>)保证——手动派发与调度器、多个实例之间都不会同时提交同一活动,抢锁失败自动跳过该活动。
  • PostgreSQL 行锁仍是最终数据一致性保护。派发事务会先锁定活动和绑定线路池,再计算可用槽位,然后通过 FOR UPDATE SKIP LOCKED 占用联系人;Redis 负责跨实例互斥、实时状态、Pub/Sub 与租约,任一连接失败都不会降级运行。

其它后台 worker 的多实例语义:生命周期对账用 try-lock 去重(抢不到锁说明别的实例在跑); 情绪分析任务领取在事务内用 FOR UPDATE SKIP LOCKED 原子完成;同步导入用 campaign FOR UPDATE 行锁串行写入,并与任务状态更新互斥;会议对账与 坐席注册对账按 cell 监听租约分片(见运行事件与告警),只由持有该 cell 租约的实例执行,与事件视野保持一致。

数据库锁会刻意保持短生命周期:只覆盖批次创建、并发计算、联系人占用和 lastScheduledAt 更新。FreeSWITCH originate 提交发生在事务提交之后,因此网络调用期间不会持有数据库锁。当 retryCount < maxRetries 且 nextRetryAt 已过时,失败联系人也可派发。

手动重置 / 重试接口:

http
POST /api/campaigns/retry
json
{ "campaignId": 1, "status": "failed" }

启动前可以调用 POST /api/campaigns/preflight(请求体 { "campaignId": 1 })检查任务状态、计划窗口、待呼名单、线路和策略。 运行中可以通过 POST /api/contacts/pause 暂停指定联系人,或通过 POST /api/campaigns/batches-cancel 取消仍处于 running 的调度批次。

回铃音检测 ​

callout-server 可选支持在回铃阶段检测运营商提示音和回铃嘟声:

  • 运营商提示音:把 early-media 音频 fork 到 ASR 风格的 WebSocket(同一个 sherpa-asr-server /audio 端点可用),再把转写文本与已知提示关键词匹配。
  • 回铃嘟声:在 B-leg early-media 上启动 FreeSWITCH tone_detect(依赖 mod_spandsp), 默认检测 450Hz,累计 5 次后判定为无人接听。

这样活动可以在人工介入前区分真正的“无人接听”和无效号码。

该能力由 config.toml 的 ringbackDetection 配置块控制,也可用 CALLOUT_RINGBACK_DETECTION_* 环境变量覆盖。关键字段:enabled(代码默认 false,但仓库内 config.toml 默认开启)、 wsUrl、bugName、mixType(mono/mixed/stereo)、sampleRate、detectionTimeoutMs(默认 20000), 以及嘟声检测的 toneDetectionEnabled(默认随回铃检测启用)、toneFrequency(默认 450)、 toneHits(默认 5)、toneTimeoutMs。启用后,派发使用 submitOutboundCallWithRingbackDetection(),并把检测结果写入呼叫尝试记录; 见 src/freeswitch/ringback-client.ts。

日志 ​

结构化日志写入控制台和 logs/application-YYYY-MM-DD.log,默认级别 info,单文件 20m, 保留 14d。通过 config.toml 的 logging 配置,或用 CALLOUT_LOG_DIR、 CALLOUT_LOG_LEVEL、CALLOUT_LOG_MAX_SIZE、CALLOUT_LOG_MAX_FILES 覆盖。日志包含 HTTP 摘要、worker 生命周期、FreeSWITCH 监听、Redis 锁和未捕获错误;/health 默认跳过。 访问日志不写请求体、认证口令、token、Redis 密码或数据库连接串,数据库运行事件仍按原逻辑保留。

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