业务流程编写文档
本文档讲清楚一段业务(CallBusiness)从“一个空函数”到“跑通通话”的所有 约定:函数签名、生命周期、可用能力、异常处理与注册流程。所有示例都基于 仓库中已经跑通的业务,可以直接复制改造。
如果你想看“目前有哪些业务、各自做什么”,请看 existing-businesses.md。
如果你想看“这套代码能实现哪些通话场景”,请看 use-cases.md。
如果你想看“speak / hear / originate / conference 这些组件本身的参数与回调 契约”,请看 components.md。
1. 业务在整个调用链中的位置
text
FreeSWITCH 拨号计划
│ <action application="socket" data="host:port async full"/>
▼
callflow-esl: Bun.listen() ──► SessionRunner.run()
│
├── 连接 / 捕获通道
├── 解析业务路由(DB)
├── 订阅事件 / linger
├── activeBusiness.handler(ctx) ◀── 你的业务在这里执行
├── waitForHangup()
└── session_completedSessionRunner 负责所有“与业务无关的协议琐事”(握手、订阅事件、兜底 清理);业务函数只关心“这通电话要做什么”。
2. 业务函数签名
业务实现位于 src/business/:简单业务用单文件 xxx-business.ts,复杂业务用 xxx/index.ts 文件夹形态(可再拆 prompts / 子流程等同级文件)。每个业务的 handler 都遵循统一签名:
ts
// src/types/business.ts
export type CallBusiness = (ctx: BusinessContext) => Promise<void>;业务入口不直接导出裸 handler,而是用 defineBusiness({ code, category, desc, handler }) 把「稳定业务编码 / 主场景分类 / 中文描述 / handler」四者就地共置,并具名导出。defineBusiness 是恒等函数,只为对象字面量提供精确的上下文类型——handler 的 ctx 会被自动 推导为 BusinessContext,无需在箭头函数上再标注 CallBusiness。
category 是业务注册表同步至数据库的稳定主场景分类,只允许四个值:
| 值 | 主场景 |
|---|---|
outbound | 外呼主导业务(通用外呼流程解释器、电销对话、知识库外呼等) |
inbound | 呼入主导业务(客服接待、会议主持/嘉宾、验证码接听识别等) |
assist | 坐席辅助与协同(软电话辅助、AI 转人工坐席入会、回铃检测等) |
demo | 测试、诊断、模拟与功能验证演示业务 |
分类按业务的主要场景选择,不等同于物理目录。分类只用于 API/UI 展示、分组和筛选,不参与 handler 路由、号码映射、campaign,也不会写入 FreeSWITCH 通道变量。现有 26 项权威映射见 existing-businesses.md。
BusinessContext 由 runtime-context.ts 的 createCallContext() 返回值推导, 方法清单见 组件 API。
最小业务模板:
ts
import { defineBusiness } from "../types/business.ts";
export const myDemoBusiness = defineBusiness({
code: "my-demo-business",
category: "demo",
desc: "示例业务",
handler: async ctx => {
// 1. 决定是否接通:answer / pre_answer / 直接挂机
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config.eslServer.settleDelayMs));
// 2. 做你的事情:speak / hear / originate / conference / DTMF ...
await ctx.speak({ kind: "tts", text: "你好,这里是测试业务。" });
// 3. 函数返回(或主动 hangup)后,SessionRunner 会接管 waitForHangup
}
});也可以先写一个
const handler: CallBusiness = async (ctx) => {...},再export const myDemoBusiness = defineBusiness({ code, category, desc, handler })—— 当 handler 较长、或需要拆出多个辅助函数时这种写法更清晰。
3. 业务必须知道的约束
- 不会自动 answer。
SessionRunner不自动摘机,普通交互业务必须显式 调用ctx.execute("answer"),模拟运营商早媒体业务则用ctx.execute("pre_answer"),需要直接“回绝”的业务可以连pre_answer都 不做、直接ctx.hangup("CALL_REJECTED")。 - 业务函数返回 ≠ 通话结束。函数
return后,SessionRunner 会waitForHangup()等对端挂机;想立刻结束需要await ctx.hangup(...),再return。 - 只能依赖 ctx 暴露的能力。runtime 内部状态(
activeRecognition、audioForkActive等)由 controllers 管理,业务不应直接修改。 - callHear / hear / conference.hear 互斥。这三者共用
audio_fork, 同一时间只能存在一个。runtime 会在第二次启动时抛错。 - 回调内禁止抛错把会话拖死。组件回调(
onResult/onFinal/onDtmf…)抛错只会被 runtime 吞掉、落component_callback_error日志,不会 触发业务级清理;如果你需要中止业务,自己持一个标志位+ctx.cancelHear()。
4. ctx 上常用能力速查
下表只列“写业务时最常用”的方法,完整契约与回调字段见 components.md。
| 类别 | 方法 | 用途 |
|---|---|---|
| 通道控制 | ctx.execute(app, arg?) | 阻塞式执行拨号计划 application |
ctx.api(command) | 任意 FreeSWITCH API | |
ctx.hangup(cause?) / ctx.waitForHangup() | 主动挂 / 等待挂 | |
ctx.setVariable(name, value) / ctx.setVariables({...}) | 写通道变量 | |
ctx.getVariable(name) / ctx.getVariables([names]) | 读通道变量 | |
ctx.channel | 当前通道快照(uuid、主被叫等) | |
ctx.config | 应用配置 | |
ctx.log(event, details) | 结构化日志 | |
| 播报 | ctx.speak(input, handlers?) | 独立播报,可设 repeat |
| 识别 | ctx.hear(input, handlers?) | 一轮识别(可携带提示音 + barge-in) |
ctx.cancelHear(reason?) | 取消当前 hear(如 DTMF 抢占) | |
ctx.callHear(input, handlers?) | 整通通话持续识别(手动 stop) | |
ctx.stopCallHear(reason?) | 停 callHear | |
| 全双工 | ctx.duplex.start(options?, handlers?) | 建立全双工流式会话与 media bug |
ctx.duplex.speak(text, options?) | 下行推流播报(支持开嗓瞬时打断与定稿收尾) | |
ctx.duplex.interrupt(reason?) | 业务主动瞬时打断当前下行播报 | |
ctx.duplex.stop(reason?) | 停止全双工会话并注销 media bug | |
| DTMF | ctx.onDtmf(handler) → unsub | 订阅 DTMF |
ctx.sendDtmf(digit) | 主动发送 DTMF | |
| 外呼/桥接 | ctx.originate(request) | 通过 inbound ESL 发起新通 |
ctx.bridgeToNumber(numberOrInput, options?) | 当前通道直接 bridge 到号码 | |
ctx.originateParkedCall(numberOrInput, options?) | 外呼接通后停在 park 状态 | |
ctx.originateAndBridge(numberOrInput, options?) | 外呼后再 uuid_bridge 回当前通道 | |
ctx.originateWithRingbackDetection(numberOrInput, options?) | 外呼并 ASR 检测回铃运营商提示音 | |
ctx.bridgeCall(bLegUuid, options?) | 当前通道与已有 B-leg uuid_bridge | |
ctx.watchCallHangup(callUuid, timeoutMs?) | 监听指定通道挂机 | |
ctx.hangupCall(callUuid, cause?) | 挂断指定通道 | |
| 会议 | ctx.conference.join({...}) | 当前通道加入会议 |
ctx.conference.play({...}) / injectSpeak | 向会议或单成员播报 | |
ctx.conference.hear({...}, handlers?) | 会议级 ASR(按成员聚合) | |
ctx.conference.publishMessage / onMessage | 业务层消息广播 / 订阅 | |
ctx.conference.getMembers(conferenceId) | 列出会议成员 | |
ctx.conference.recording.start/stop/pause/resume/status | 会议级录音调度与状态查询 | |
| 录音 | ctx.startRecording(input?) | 当前通道录音(文件名可省略) |
| 转人工协同 | requestHumanAgentTransfer(ctx, options) | 请求 ACD 路由、Cell 注册预检与坐席预占 |
| (helper) | joinConferenceWithAgentWatchdog(ctx, options) | 客户入会并执行两阶段在会看护(防抖+优雅自退) |
requestHumanAgentTransferEnd(ctx, options) | 客户离会时幂等收尾会议人工腿 |
5. 业务流程模式
5.1 一次性流程(线性)
适合一次性提示、外呼确认、单次回显这类业务。示例: prompt-then-recognize-echo-business。
ts
export const linearDemoBusiness: CallBusiness = async ctx => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config.eslServer.settleDelayMs));
await ctx.speak({ kind: "tts", text: "您好,请说出您的需求。" });
const result = await ctx.hear({ timeoutMs: 10000 });
if (result.status !== "recognized") {
ctx.log("business_no_recognition", { status: result.status });
return;
}
await ctx.speak({ kind: "tts", text: `您说的是:${result.text}` });
await ctx.hangup("NORMAL_CLEARING");
};5.2 循环识别(while + hear)
适合连续问答 / 闲聊。示例:default-call-business、dtmf-business。
ts
export const loopDemoBusiness: CallBusiness = async ctx => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config.eslServer.settleDelayMs));
let prompt: SpeakInput | undefined = {
kind: "tts",
text: "您好,请说话。"
};
while (true) {
const result = await ctx.hear({
prompt,
interruptible: true,
bargeInTrigger: "partial",
timeoutMs: 10000
});
prompt = undefined;
if (result.status === "recognized") {
if (result.text === "挂机") {
await ctx.speak({ kind: "tts", text: "好的,再见" });
await ctx.hangup();
return;
}
prompt = { kind: "tts", text: `你刚刚说了,${result.text}` };
continue;
}
ctx.log("business_turn_unrecognized", { status: result.status });
}
};5.3 持续 ASR + 业务驱动
callHear 启动后整通电话一直识别,runtime 在 final 文本到达时回调;业务 拿到文本后再驱动播报、挂机等动作。示例:call-hear-business。
ts
export const continuousAsrBusiness: CallBusiness = async ctx => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config.eslServer.settleDelayMs));
let shuttingDown = false;
let actionQueue: Promise<void> = Promise.resolve();
const session = await ctx.callHear(
{
partialResults: true,
interruptPlayback: true,
bargeInTrigger: "partial"
},
{
onFinal: event => {
actionQueue = actionQueue.then(async () => {
if (shuttingDown) return;
if (event.text.trim() === "挂机") {
shuttingDown = true;
await ctx.speak({ kind: "tts", text: "好的,再见" });
await ctx.hangup();
return;
}
await ctx.speak({
kind: "tts",
text: `你刚刚说了,${event.text}`,
interruptible: true
});
});
}
}
);
try {
await ctx.speak({ kind: "tts", text: "欢迎致电", interruptible: true });
await ctx.waitForHangup();
} finally {
shuttingDown = true;
await session.stop("business-complete");
await actionQueue;
}
};5.4 原生全双工流式交互(ctx.duplex)
全双工模式适合支持流式播报和用户打断的实时对话。bargeInTrigger 决定打断时机;下例选择 partial,收到首个非空增量文本后打断。完整实现见 duplex-ai-chat-demo-business。
ts
export const duplexChatDemoBusiness: CallBusiness = async ctx => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config.eslServer.settleDelayMs));
let actionQueue: Promise<void> = Promise.resolve();
let exiting = false;
const session = await ctx.duplex.start(
{ bargeInTrigger: "partial" },
{
onFinal: e => {
if (exiting || !e.text.trim()) return;
actionQueue = actionQueue.then(async () => {
if (e.text.includes("再见")) {
exiting = true;
await ctx.duplex.speak("好的,再见!", { interruptible: false });
await ctx.hangup();
return;
}
await ctx.duplex.speak(`您刚刚说:${e.text}。`, {
interruptible: true
});
});
},
onBargeIn: e => {
ctx.log("user_barge_in", { reason: e.reason });
}
}
);
ctx.log("duplex_started", { uuid: session.uuid });
try {
await ctx.duplex.speak(
"您好!我是原生全双工语音助手,请问有什么可以帮您?",
{
interruptible: true
}
);
await ctx.waitForHangup();
} finally {
exiting = true;
await actionQueue.catch(() => {});
await ctx.duplex.stop("call-completed");
}
};onFinal 是同步事件回调,不会等待其中启动的异步任务。用 actionQueue 串行处理 连续到达的 final,并在清理前等待队列结束,避免播报与挂机重入。
挂机后尾部识别(postHangupDrainMs):默认对端一挂机就停 callHear,可能丢掉 “挂机瞬间还在说”的最后一段 final。给 callHear 入参加 postHangupDrainMs: 1500 后,runtime 会在挂机后多保留 audio_fork 一小段时间收尾部识别——这些事件走 onPostHangupResult / onPostHangupFinal(不再触发 onResult / onFinal), 事件 phase 为 "post-hangup",只应用于补收文本,不要再驱动播放 / 按键 / 挂机 等通话内动作。session.stop() 会等 drain 结束后才返回。完整示例见 verification-code-business/(持续 ASR + 自动按键 + 尾部补收 + 结果上报)。
5.5 并发:语音 + DTMF 抢占
ctx.onDtmf(...) 与 ctx.hear(...) 并存时,自己持一个 turnState 标志, 两边在抢占时互相 cancelHear 或忽略对方结果即可。完整实现参考 dtmf-business。
ts
type TurnState = { handled: boolean; cancelledByDtmf: boolean };
let currentTurn: TurnState | null = null;
const unsubscribeDtmf = ctx.onDtmf(async event => {
if (!currentTurn || currentTurn.handled) return;
currentTurn.handled = true;
currentTurn.cancelledByDtmf = true;
await ctx.cancelHear(`dtmf:${event.digit}`);
await ctx.speak({ kind: "tts", text: `您按了${event.digit}` });
});
try {
while (true) {
currentTurn = { handled: false, cancelledByDtmf: false };
const result = await ctx.hear({
prompt,
interruptible: true,
timeoutMs: 10000
});
if (currentTurn.cancelledByDtmf) {
currentTurn = null;
continue;
}
if (result.status === "recognized" && result.text === "挂断") {
await ctx.hangup();
return;
}
currentTurn = null;
}
} finally {
unsubscribeDtmf();
}5.6 早媒体 / 直接拒接
不 answer,只 pre_answer 发 183 early media,再用 TTS 播报运营商提示音 (早媒体阶段即可出声,无需 200 OK):
ts
export const earlyMediaDemoBusiness: CallBusiness = async ctx => {
await ctx.execute("pre_answer");
await ctx.execute("sleep", String(ctx.config.eslServer.settleDelayMs));
// 早媒体阶段播报;真实运营商提示通常会循环,直到主叫取消或上游清理 B-leg。
await ctx.speak({
kind: "tts",
text: "您好,您所拨打的号码是空号,请核对后再拨。"
});
};需要“直接回绝”的业务可以连 pre_answer 都不做,直接 await ctx.hangup("CALL_REJECTED")。完整实现参考 carrier-simulator-business/ (按通道变量选三大运营商 19 条样本、支持回铃 + 提示音两种时序、循环播报且 不主动挂机),它专门给 carrier-ringback-detection-demo-business 的 originateWithRingbackDetection 链路当被叫陪练。
5.7 外呼 / 桥接
四种外呼能力的语义见 components.md 的 “Originate” 小节。call-transfer-demo-business 是最典型的 “当前通道直接 bridge 到 人工号码”;parked-dial-demo-business 演示 “外呼停泊+确认接通+桥接” 的完整流程,包括被叫提前挂机后的重试。运营商回铃 ASR 检测则参考 carrier-ringback-detection-demo-business。
ts
const result = await ctx.bridgeToNumber("1001");
// 桥接成功后当前 A-leg 已经与 B-leg 拼接,bridge application 返回,
// 业务通常 hangup 即可让 SessionRunner 走 waitForHangup
if (result.status === "bridged") {
await ctx.hangup();
}5.8 会议
会议至少有“主持人”和“访客”两段业务。主持人 originate 邀请访客,访客 拿到 conference_id 后 ctx.conference.join({conferenceId})。会议级 ASR 通过 ctx.conference.hear() 启动后,会按成员维度回调 partial / final。
参考 conference-host-business 和 conference-guest-business。
5.9 转人工与两阶段会议看护
转人工采用会议室桥接模型。业务侧停单呼 ASR、调用 requestHumanAgentTransfer 预占坐席并派发 originate,随后进入 joinConferenceWithAgentWatchdog(两阶段自治看门狗):
- Phase 1(waiting_for_agent):等待坐席加入会议,振铃超时(默认 +5s 缓冲)无坐席入会则自动通过自退打断阻塞,执行超时兜底;
- Phase 2(in_call_guard):坐席入会后平滑切换在会看护。职员离会使
count <= 1时启动防抖倒计时(默认 3 秒);期满复核仍无职员,客户腿优雅自退。 - 在
finally块中调用requestHumanAgentTransferEnd幂等收尾所有人工腿。
ts
import {
requestHumanAgentTransfer,
joinConferenceWithAgentWatchdog,
requestHumanAgentTransferEnd
} from "./shared/callout/index.ts";
const conferenceId = `transfer-${randomUUID()}`;
await ctx.speak({ kind: "tts", text: "正在为您转接人工坐席,请稍候。" });
await asrSession?.stop("transfer-to-agent");
const route = await requestHumanAgentTransfer(ctx, {
conferenceId,
customerCallUuid: ctx.channel.uuid,
ringTimeoutSeconds: 30
});
if (!route.routed) {
await ctx.speak({ kind: "tts", text: "人工坐席忙,请稍后再拨。" });
await ctx.hangup("NORMAL_CLEARING");
return;
}
try {
const watch = await joinConferenceWithAgentWatchdog(ctx, {
conferenceId,
ringTimeoutSeconds: 30,
emptyGraceSeconds: 3
});
if (!watch.agentJoined && !watch.hungUp) {
await ctx.speak({
kind: "tts",
text: "坐席振铃超时未接听,电话即将挂断。"
});
await ctx.hangup("NORMAL_CLEARING");
}
} finally {
await requestHumanAgentTransferEnd(ctx, {
conferenceId,
customerCallUuid: ctx.channel.uuid,
reason: "customer_hangup"
});
}5.10 声明式 Voice Agent SDK(defineVoiceAgent + runVoiceAgent)
对于大模型驱动的语音智能体对话业务,无需手动编写复杂的持续 ASR 状态机、多轮转写队列与分片播报逻辑,推荐使用 src/llm/ 提供的 Voice Agent SDK:
- 声明式智能体角色(
defineVoiceAgent):结构化声明角色职责(persona.role)、行为约束(persona.constraints)、对话样例(persona.examples)与大模型采样参数(sampling),SDK 会自动组合编译为符合语音对话最佳实践的系统提示词; - 一行式编排运行(
runVoiceAgent):在handler内直接调用,全自动托管接通问候、整通持续 ASR、开嗓即打断(bargeInTrigger: "partial")、SSE 流式分句、TTS 即产即播、静默催问与挂机收尾; - Provider 抽象:支持通过
channel: "openai"直连 OpenAI 兼容后端,或通过channel: "callout"接入外呼平台 AI 策略中台。
ts
import { defineBusiness } from "../types/business.ts";
import { defineVoiceAgent, runVoiceAgent } from "../llm/index.ts";
const demoAgent = defineVoiceAgent({
persona: {
role: "你是亲切、有耐心的智能语音客服助手,先直接回答用户当前的问题,不寒暄绕弯。",
constraints: ["涉及具体金额、账号等敏感信息时,引导用户走人工核实。"],
examples: [
{
user: "你们几点下班?",
assistant: "我们晚上九点下班,现在还在营业,有需要您现在说就行。"
}
]
},
// openingPrompt 支持纯文本字符串(默认可打断),或 { text, interruptible: false } 对象(在模拟线/电信号转 SIP 设备回音消除收敛慢时可设为 false,确保开场白播完后再建 ASR)
openingPrompt: "您好,我是智能语音助手,请直接说出您的问题。",
goodbyePrompt: "好的,本次通话就到这里,祝您生活愉快,再见。"
});
export const voiceAgentDemoBusiness = defineBusiness({
code: "voice-agent-demo-business",
category: "demo",
desc: "Voice Agent SDK 演示业务",
handler: async ctx => {
await runVoiceAgent(ctx, demoAgent, {
channel: "openai",
record: true,
bargeInTrigger: "partial",
minBargeInTextLength: 2
});
}
});完整端到端样板见 src/business/demo/voice-agent-demo-business.ts。
6. 异常与挂机约定
业务层应当只关心“逻辑异常”(DB 查询失败、外部 HTTP 失败、参数非法等)。 所有“对端挂机”相关的错误已经被 runtime 标记成 normal termination:
- 业务直接 throw → SessionRunner 判定是否
isNormalTerminationError:- 是 → 落
session_completed,按对端挂机收尾 - 否 → 落
session_error,并尝试停播放 / 停识别 / 主动hangup NORMAL_TEMPORARY_FAILURE
- 是 → 落
- 业务
await ctx.hangup(...)后 return → 等待 hangup 事件触达 → 正常退出
推荐写法:
ts
try {
await doSomethingThatMayFail(ctx);
} catch (error) {
ctx.log("business_step_failed", {
error: error instanceof Error ? error.message : String(error)
});
await ctx.speak({ kind: "tts", text: "处理出错,电话即将挂断。" });
await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
return;
}对端挂机后还触发的命令(typically playback / uuid_audio_fork)会被 FreeSWITCH 直接拒绝;runtime 已经在 executeApp 里加了 tolerateError 分支用于挂机原因 ,不需要业务自己捕获。
7. 业务级常量 vs 配置项
业务参数不通过环境变量配置,归属如下:
| 参数 | 应放在哪里 |
|---|---|
| 业务文本、关键词、循环次数 | 业务文件顶部 const |
| 模型 / 后端地址 / 超时 / TCP 监听 | config.toml + AppConfig |
| barge-in 触发模式、停播命令 | 业务文件常量;可在 hear() 入参覆盖 |
| 业务编码 / 主场景分类 / 中文描述 / handler | 业务文件内 defineBusiness({...}) |
| 业务汇总(清单与导出) | src/business/index.ts |
| 业务反查与索引 | src/runtime/business-registry.ts |
| 业务 ↔ 号码映射 | 数据库 call_business_number_mappings |
7.1 业务路由解析
SessionRunner 完成 outbound 握手后按以下顺序解析业务:
business_code非空时,查询call_businesses获取稳定businessCode。- 否则按
destinationNumber查询call_business_number_mappings并关联call_businesses。 - 使用
businessCode在business-registry中定位 handler。
路由失败时会接通并播报“系统错误”后挂机。路由结果携带 call_businesses.businessConfig;只有号码映射命中时才携带 call_business_number_mappings.mappingConfig。最终只读 ctx.businessConfig 按 “配置文件 < 业务 < 映射”覆盖,显式 business_code 路由不应用映射层。
8. 注册一段新业务(操作清单)
在
src/business/新建业务。简单业务用单文件voice-vote-business.ts, 入口用defineBusiness把编码 / 主场景分类 / 描述 / handler 就地写在一起并具名导出:tsimport { defineBusiness } from "../types/business.ts"; export const voiceVoteBusiness = defineBusiness({ code: "voice-vote-business", category: "demo", desc: "语音投票业务", handler: async ctx => { await ctx.execute("answer"); // ... 业务逻辑 ... } });复杂业务改用文件夹形态
voice-vote/index.ts,并把文案常量、子流程拆到prompts.ts/xxx.ts等同级文件(参考ai-voice-platform-demo/)。引入业务并追加到业务清单数组:
- 核心生产业务:在
src/business/index.ts中引入并追加到coreBusinesses(或allBusinesses); - 测试/演示/模拟业务:放在
src/business/demo/下,并在src/business/demo/index.ts的demoBusinesses数组中追加。
ts// 核心业务 (src/business/index.ts) import { voiceVoteBusiness } from "./voice-vote-business.ts"; export const coreBusinesses: RegisteredCallBusiness[] = [ // ... voiceVoteBusiness ]; // 测试演示业务 (src/business/demo/index.ts) import { voiceVoteDemoBusiness } from "./voice-vote-demo-business.ts"; export const demoBusinesses: RegisteredCallBusiness[] = [ // ... voiceVoteDemoBusiness ];src/runtime/business-registry.ts会直接消费全量allBusinesses自动构建索引与唯一性校验,无需再手动修改 registry。- 核心生产业务:在
运行一次
bun run typecheck确保类型通过。选择一种路由方式:
- 按
businessCode命中:服务启动时syncRegisteredCallBusinesses会把 registry 中每条业务的code/category/desc同步到call_businesses;businessCategory仅供查询与展示,不参与本步骤的命中 - 按号码命中:先启动一次服务完成业务同步,再在
call_business_number_mappings插入("destinationNumber"="9919", "businessCode"="<业务编码>", "isEnabled"=1)
开发期可以在
apps/callflow-server下通过bun run db:studio在浏览器里改表,或在数据库迁移脚本中加入。业务如需声明业务配置校验器,可在
defineBusiness中增加config: { validate }(如需强制要求静态配置文件必填,可增加requiredInFile: true)。 文件默认值由稳定code自动从config.toml.businessConfig[code]定位,handler 只通过 只读ctx.businessConfig读取最终值。覆盖优先级为配置文件、call_businesses.businessConfig、call_business_number_mappings.mappingConfig;数据库两层 直接填写业务字段,不增加业务编码包裹,显式business_code路由跳过最后一层。- 按
启动外呼(通过 HTTP)或在 FreeSWITCH 拨号计划中拨那个号码即可触达。
9. 调试技巧
ctx.log(event, details)写出来的日志带callUuid/businessCode/businessName/sessionStage,落盘到config.logging.dir下的结构化日志文件中verbose-prompt-then-recognize-business把speak/hear的全部回调 都接上了,初次接入时很适合复制一份当作“打印一切”的脚手架- 单元测试与模块同目录(
src/**/*.test.ts,如src/runtime/、src/runtime/conference/与各业务目录下的用例),可用bun test跑(部分用例需要 mock socket) - 数据库结构与迁移统一由
apps/callflow-server维护,变更后在apps/callflow-server执行bun run db:generate+bun run db:push - 与远端 FreeSWITCH 联调时,
config.freeswitch.callbackHost必须是 FreeSWITCH 主机能访问到的本机地址;否则 originate 接通后 outbound 永远 连不到 callflow-esl