OutboundCall 域统一了 7 家外呼供应商。和其他域最大的不同在于:它的抽象包除了接口,还内含一套 provider-agnostic 的拨号编排引擎(CampaignDialer / CampaignRuntime)。原因是国内厂商走”预测式外呼”的服务端活动模型,国际厂商只提供 per-call 单次呼叫 API,需要客户端自己编排节流、暂停/恢复/停止。
本页是完整手册,从两种实现模型到编排引擎内部机制,从境内/境外号码分流到活动状态机。
域结构
Bitzsoft.Integrations.OutboundCall ← 抽象层(接口 + 编排引擎)├── Bitzsoft.Integrations.OutboundCall.Tencent ← 腾讯云(campaign 模型)├── Bitzsoft.Integrations.OutboundCall.Aliyun ← 阿里云(campaign 模型)├── Bitzsoft.Integrations.OutboundCall.Volcengine ← 火山引擎(campaign 模型)├── Bitzsoft.Integrations.OutboundCall.Yuntongxun ← 容联云通讯(campaign 模型)├── Bitzsoft.Integrations.OutboundCall.Huawei ← 华为云(campaign 模型)├── Bitzsoft.Integrations.OutboundCall.Twilio ← Twilio(per-call 模型)├── Bitzsoft.Integrations.OutboundCall.Vonage ← Vonage(per-call 模型)└── Bitzsoft.Integrations.OutboundCall.All ← 聚合包两种实现模型
7 家厂商分两阵营,但都实现同一个 IOutboundCallService,消费者无感知:
- campaign 模型(腾讯云 / 阿里云 / 火山引擎 / 容联云 / 华为云):供应商服务端管理活动生命周期、号码队列和并发节流。
CreateCampaignAsync/StartCampaignAsync等方法直接调用供应商活动 API。 - per-call 模型(Twilio / Vonage):供应商只提供单次呼叫 API,没有服务端活动概念。抽象层的
CampaignDialer在客户端编排队列、节流、暂停/恢复/停止,把 per-call 厂商”伪装”成 campaign 模型。
统一接口(9 方法)
IOutboundCallService 围绕”活动(Campaign)“这一核心概念组织,共 9 个方法:
public interface IOutboundCallService{ string ProviderName { get; }
// 活动生命周期 Task<CreateCampaignResponse> CreateCampaignAsync(CreateCampaignRequest request, CancellationToken ct = default); Task<OutboundCallResult> ImportContactsAsync(ImportContactsRequest request, CancellationToken ct = default); Task<OutboundCallResult> StartCampaignAsync(StartCampaignRequest request, CancellationToken ct = default); Task<OutboundCallResult> PauseCampaignAsync(string campaignId, CancellationToken ct = default); Task<OutboundCallResult> ResumeCampaignAsync(string campaignId, CancellationToken ct = default); Task<OutboundCallResult> StopCampaignAsync(string campaignId, CancellationToken ct = default);
// 查询 Task<List<CampaignInfo>> ListCampaignsAsync(int pageIndex = 1, int pageSize = 20, CancellationToken ct = default); Task<CampaignInfo> GetCampaignAsync(string campaignId, CancellationToken ct = default); Task<List<CallRecord>> QueryCallRecordsAsync(QueryCallRecordsRequest request, CancellationToken ct = default);}生命周期操作的统一返回是 OutboundCallResult { Success, ErrorMessage?, RequestId? },RequestId 便于在厂商控制台排查。
境内/境外号码分流
CreateCampaignRequest.Region 指定外呼区域,供应商实现据此选择不同的主叫号码池和应用配置:
public class CreateCampaignRequest{ public PhoneRegion Region { get; set; } = PhoneRegion.Mainland; // 默认中国大陆 // ...}| Region 值 | 含义 | 供应商使用的配置 |
|---|---|---|
Mainland | 中国大陆(区号 86) | Options.Callers + SdkAppId + CnIvrId |
Overseas | 海外(区号非 86) | Options.OverseaCallers + OverseaSdkAppId + EnIvrId |
Unknown | 无效号码 | 触发号码清洗告警 |
号码在导入前会经过 PhoneNumberClassifier 清洗和校验,PhoneRegion.Unknown 的号码会被过滤。
编排引擎:CampaignDialer
CampaignDialer 是 per-call 厂商共用的编排引擎,provider-agnostic:实际呼叫由注入的 CallCreator 委托执行,引擎只管队列推进和状态机。
// 呼叫创建委托——由具体厂商提供(含 429 退避)public delegate Task<CallAttempt> CallCreator(string callee, CancellationToken cancellationToken);
public sealed class CampaignDialer{ public Task StartAsync(CampaignRuntime runtime, CallCreator creator, int cps, CancellationTokenSource cts); public void Pause(CampaignRuntime runtime); // 保留断点 public void Resume(CampaignRuntime runtime, CallCreator creator, int cps); // 从断点继续 public void Stop(CampaignRuntime runtime); // 终态 public async Task WaitAsync(CampaignRuntime runtime); // 测试/同步语义用}节流(throttling)
拨号按 cps(calls per second) 串行节流,相邻两次拨号间隔 1000 / cps 毫秒,由 Task.Delay 控制。cps 由供应商配置或调用方传入。
暂停/恢复断点
CampaignRuntime.NextIndex 记录下一个待呼叫联系人的索引。Pause 取消当前拨号循环(通过 CancellationTokenSource.Cancel)但不重置索引;Resume 从同一位置继续,不会重复拨打已处理的号码。
代次守卫(Generation)
每次 Start / Resume 自增 CampaignRuntime.Generation,拨号循环发现代次与启动时不一致立即退出。这防止暂停后旧循环和新循环重叠或重复记账——是纵深防御,即使取消信号没及时传播也不会重复拨号。
Resume 时会回收旧 CTS 句柄再建新 CTS,避免多次暂停/恢复累积句柄泄漏。
异常处理
循环内 catch (OperationCanceledException) 单独处理(暂停/停止的正常退出路径),其余异常把活动状态置为 Failed 并记录。CallCreator 委托应观察其 CancellationToken 以便在 Pause/Stop 时及时退出,避免对在途联系人重复拨号。
CampaignRuntime 内存运行态
per-call 厂商的活动元数据和联系人队列在客户端内存里维护(因为 per-call 厂商没有服务端 campaign)。CampaignRuntime 是这个运行态:
| 字段 | 类型 | 说明 |
|---|---|---|
Id | string | 活动 ID(GUID) |
Contacts | List<string> | 被叫号码列表 |
NextIndex | int | 下一个待呼叫联系人索引(暂停/恢复断点) |
Status | CampaignStatus | 活动状态 |
Generation | int | 拨号循环代次(防止重叠) |
Cts | CancellationTokenSource? | 控制拨号循环的取消令牌 |
CalledCount | int | 已呼叫数 |
InitiatedCount | int | 已发起数(获 sid) |
CallSids | List<string> | 已记录的通话 sid 集合 |
活动状态机
| 状态 | 含义 | 可流转到 |
|---|---|---|
Pending | 已创建未启动 | Running |
Running | 运行中 | Paused / Completed / Stopped / Failed |
Paused | 已暂停(可恢复) | Running |
Completed | 全部联系人拨完(终态) | — |
Stopped | 已终止(终态) | — |
Failed | 拨号循环异常(终态) | — |
Unknown | 未知(厂商返回未识别状态) | — |
CampaignDialer.StartAsync 在状态为 Running 或 Paused 时直接返回(幂等),避免重复启动。
厂商清单
| 厂商 | Provider ID | 实现模型 | 区域专长 | DI 方法 |
|---|---|---|---|---|
| 腾讯云 | Tencent | campaign | 境内 + 境外 | AddTencentOutboundCall() |
| 阿里云 | Aliyun | campaign | 境内 + 境外 | AddAliyunOutboundCall() |
| 火山引擎 | Volcengine | campaign | 境内 | AddVolcengineOutboundCall() |
| 容联云通讯 | Yuntongxun | campaign | 境内 | AddYuntongxunOutboundCall() |
| 华为云 | Huawei | campaign | 境内 + 境外 | AddHuaweiOutboundCall() |
| Twilio | Twilio | per-call | 海外(全球) | AddTwilioOutboundCall() |
| Vonage | Vonage | per-call | 海外(全球) | AddVonageOutboundCall() |
国内业务优先选 campaign 模型厂商(服务端节流更稳),跨境业务选 Twilio / Vonage(per-call 模型靠 CampaignDialer 编排)。
注册
单厂商
// ① 国内厂商——服务端 campaign 模型builder.Services.AddTencentOutboundCall(builder.Configuration.GetSection("OutboundCall:Tencent"));
// ② 国际厂商——客户端 per-call 模型(内部用 CampaignDialer 编排)builder.Services.AddTwilioOutboundCall(builder.Configuration.GetSection("OutboundCall:Twilio"));全量聚合
builder.Services.AddBitzsoftOutboundCallAll(builder.Configuration, "OutboundCall");按配置节存在性自动注册:
{ "OutboundCall": { "Tencent": { "SecretId": "...", "SecretKey": "...", "SdkAppId": "...", "Callers": ["..."] }, "Twilio": { "AccountSid": "...", "AuthToken": "...", "FromNumber": "+1..." } }}消费
完整活动生命周期
public class CampaignManager(IOutboundCallService outbound){ public async Task RunCampaignAsync(List<string> contacts) { // ① 创建活动(国内厂商在服务端创建,国际厂商在内存创建) var campaign = await outbound.CreateCampaignAsync(new CreateCampaignRequest { Name = "回访活动", Region = PhoneRegion.Mainland, Callees = contacts, MaxAttempts = 2, });
// ② 导入号码(部分供应商在 CreateCampaign 时已导入) await outbound.ImportContactsAsync(new ImportContactsRequest { CampaignId = campaign.CampaignId, Contacts = contacts, });
// ③ 启动 await outbound.StartCampaignAsync(new StartCampaignRequest { CampaignId = campaign.CampaignId });
// ④ 暂停 / 恢复 / 停止操作一致 // await outbound.PauseCampaignAsync(campaign.CampaignId); // await outbound.ResumeCampaignAsync(campaign.CampaignId); }}查询通话记录
public class CallRecordService(IOutboundCallService outbound){ public async Task<List<CallRecord>> GetRecordsAsync(string campaignId) { return await outbound.QueryCallRecordsAsync(new QueryCallRecordsRequest { CampaignId = campaignId, StartTime = DateTimeOffset.UtcNow.AddDays(-7), }); }}