跳到正文

运行与后台任务

数据库结构

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

作用
campaigns外呼任务:业务、绑定线路、cellId(绑定的 FS cell,空 = 派发时自动分配)、maxConcurrencymaxRetriesretryIntervalSecondscallIntervalMsworkdayStart/EndscheduledStartAt/EndAtstatusdraft/running/paused/completed/archived)、businessConfig(业务专属 variables / intentKeywords / monitoringMode / monitoringPriority)
scripts流程脚本:可视化节点图、变量目录、话术、NLU 分支、字段抽取、结果归类和运行时 JSON 版本
lines外呼线路:拨号串模板、默认主叫号码、共享 maxConcurrency,以及号码分配模式 allocationModesingle_shared 一号多并发 / number_pool 多号轮换 / bound_numbers 一号一并发)和主叫透传头策略 callerIdHeaderMode
line_numbers线路号码池:number_pool / bound_numbers 模式下一条线路可挂多个主叫号码,含号码级 maxConcurrency、轮换权重 weight 和启用标志
campaign_lines外呼任务与线路池关联;任务必须至少绑定一条启用线路 …391 tokens truncated…
users / auth_sessions管理台用户、角色和会话
audit_logs敏感操作审计:用户、动作、资源、请求路径和变更快照
agents / agent_groups坐席和坐席分组,用于坐席工作台、软电话和转人工路由;agents.cellId 记录分机注册所在 FS cell(注册对账 worker 写入),转人工按「与客户通话同 cell」过滤坐席

坐席状态统一为示闲(idle)、忙碌(busy)、离线(offline)三态。只有示闲坐席参与来电与转人工分配;忙碌和离线坐席不参与分配。历史 online 数据会在服务启动时幂等归一为 idle。 | agent_call_records | 坐席呼出、人工回拨和转人工请求的提交与 FreeSWITCH 接听 / 挂机状态 | | dnc_numbers | DNC 免打扰名单 | | runtime_events | 调度、生命周期补偿、告警等运行事件 | | transcript_segments | 按外呼尝试或坐席通话记录归属的分角色转写片段 | | conference_sessions | 转人工会议会话:会议室 ID、cellId(会议所在 FS cell,对账按 cell 分片)、关联呼叫 / 联系人、客户腿 UUID、当前主坐席及其软电话腿、会话状态 |

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

情绪分析

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

后台工作线程会把 attemptId、尽力提取的转写文本和由 media.freeswitchRecordingBaseUrl 拼出的完整 recordingUrl 发送到 POST /analyze,再把结果保存到 call_attempts.emotionResult 字段;情绪音频同样只入库文件名短值,面向前端的 API 原样返回短值,前端通过 media.emotionAudioBaseUrl 拼播放 URL。 相关字段定义在 src/db/schema.ts,部署或本地初始化统一通过 bun run db:push 同步。

调度与重试

活动现在包含 maxRetriesretryIntervalSecondscallIntervalMsworkdayStartworkdayEnd,并支持 scheduledStartAtscheduledEndAt 计划窗口。 外呼策略中心可在全局或任务级继续收紧这些限制;执行语义为更严格优先: 全局策略和任务策略都必须通过,任务级策略不能绕过全局禁呼。手动派发和后台调度器都会遵守 maxConcurrency。当 retryCount < maxRetriesnextRetryAt 已过时,失败联系人可以再次重试。 如果失败联系人尚未到 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.statusopt_outunsubscribedo_not_calldncrejected, 或 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

服务启动时按配置拉起 5 个后台 worker(均以 setInterval 驱动并带重入保护):调度器 scheduler、生命周期对账 lifecycleReconcile、会议对账 conferenceReconcile、坐席注册对账 agentRegistrationReconcile、情绪分析 emotionAnalysis,外加一个 FreeSWITCH inbound ESL 事件监听器(freeswitchEvents)。server.stop() 会逐一清理这些 定时器与连接。

并发模型

callout-server 派发联系人时使用三层并发限制:

  • 任务并发campaigns.maxConcurrency 限制同一个活动内可同时活跃的联系人数量。
  • 线路并发lines.maxConcurrency 限制共享同一外呼线路的所有活动中可同时活跃的联系人数量。
  • cell 并发cells[].maxConcurrency 限制同一 FS cell 上可同时活跃的联系人数量(0 不限制)。

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

text
min(requested batch size, remaining campaign slots, sum(remaining slots of bound lines), remaining cell slots)

每个批次先选定一个 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 锁保证——手动派发与调度器、多个实例之间都不会同时提交同一活动,抢锁失败自动跳过该活动。
  • PostgreSQL 行锁仍是最终数据一致性保护。派发事务会先锁定活动和绑定线路池,再计算可用槽位,然后通过 FOR UPDATE SKIP LOCKED 占用联系人;Redis 负责跨实例互斥、实时状态、Pub/Sub 与租约,任一连接失败都不会降级运行。

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

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

手动重置 / 重试接口:

http
POST /api/campaigns/:id/retry
json
{ "status": "failed" }

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

回铃音检测

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

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

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

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

日志

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

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