最近在做一个运维告警相关的项目,里面有个硬需求:白天手机通知响个不停,但凌晨服务器出问题的时候,微信、App推送根本没几个人看,得直接打电话,用语音把故障内容和验证码念出来。调研了一圈,最终选了阿里云语音通知,用Java接了一套完整的实现。这篇就把整个落地过程、踩过的坑、线上跑稳的经验一起写出来,给同样需要“让手机响起来”的开发者做个参考。
1. 场景定位与方案选型
1.1 为什么需要“语音通知”而不是短信
很多人第一反应是,通知用短信不就完了?但实际业务里,有些场景短信的时效性完全不够。比如线上告警、服务器宕机、订单支付结果确认,或者登录验证码,这类场景需要用户或值班人员立刻注意到。短信容易被手机系统拦截、被用户忽略,甚至验证码短信还会被第三方App折叠。而语音通知是直接打一通电话过来,接起来之后系统自动播放一段语音,或者播完就挂,这种强提醒属性是短信替代不了的。
具体到我的项目,核心场景是两类:
- 告警类通知:凌晨两三点,监控系统发现CPU跑满或者接口大面积超时,需要立刻通知值班工程师。值班电话不能静音,语音通知配合电话响铃,比任何推送都直接。
- 验证码类通知:有些业务是给中老年人用的,他们不太会看短信验证码,语音直接念出来反而体验更好。实测下来不少用户觉得“电话播验证码”更可靠。
所以,语音通知不是一个泛泛的“通知渠道”,它是一个高优先级、强触达的通信手段。如果你的业务里也有“这条消息必须被人看到”的场景,那语音通知就值得接。
1.2 为什么选用阿里云而不是自建或其它方案
自建语音通知,意味着你要有语音网关、线路资源、运营商对接能力,还要处理号码落地、并发调度、语音合成等一系列问题。这显然不是业务团队该干的事,而且起步成本极高,一般中小团队根本接不动。市面上现成的云厂商语音服务,我重点对比了阿里云、腾讯云、华为云三家,最终选了阿里云。
原因有三个:
- 接口简单、SDK完整:Java SDK封装得很完善,核心接口就几个,直接调用就能发语音通知,不用自己拼HTTP签名。
- 文档和工单响应比较成熟:语音服务这东西,报错信息少,出了问题排查起来很依赖文档。阿里云的文档体系相对全,错误码都有解释,工单响应速度也还行。
- 和短信服务一套账号体系:项目中同时要用短信验证码和语音通知,阿里云的短信服务和语音服务共用AccessKey、账号体系、资质审核流程,省去重复认证的麻烦。
当然,如果用腾讯云,接口风格也差不多,选哪家更多是看你的业务生态。比如你们公司已经在用腾讯云的其他服务,那直接用腾讯云语音服务即可,实现逻辑大同小异。我的建议是:选你存量云厂商,统一账号、统一账单、统一工单。
1.3 两个核心接口的选择
阿里云语音通知服务有两大类接口:
- SingleCallByTone 发送文本转语音通知:你传入模板ID(TtsCode),系统用文本转语音(TTS)播放指定文案,适合动态内容(验证码、工单号、金额)。
- SingleCallByVoice 发送语音文件通知:你上传一个音频文件,系统播放这个音频,适合固定话术(欢迎词、公告、预约提醒)。
绝大多数业务场景用SingleCallByTone就够了。动态参数通过模板变量传进去,比如“您的验证码是${code}”,系统自动合成语音播放。只有你希望完全控制录音质量、有专业录制的语音文件时,才需要走SingleCallByVoice。
另外还有一个细节:如果你只是想在接通后播放一段提示音然后挂断,不需要复杂的回调,SingleCallByTone就能满足。如果你的业务还需要用户按键交互(比如按1转人工),那就得用更复杂的呼叫中心接口,普通语音通知接口不支持交互。
2. 前置准备:账号、资质与模板审核
我第一次接这个服务的时候,以为拿到AccessKey就能直接调,后来发现门槛主要在资质和模板审核上。这块如果没提前搞,开发节奏会被卡住。
2.1 开通服务与实名认证
进入阿里云控制台,搜索“语音服务”,进入产品页后按流程开通。这里有几个前置条件:
- 阿里云账号必须完成企业实名认证:语音服务不支持个人实名用户使用,因为语音外呼涉及电话线路资源,运营商侧要求使用企业资质。如果你只是个人开发者,这条会卡住,没有绕过的办法,只能想办法挂靠在公司主体下。
- 开通服务后需要开通“语音通知”权限:在语音服务控制台里,有“语音通知”和“语音验证码”两个子产品,要确认都开通。语音验证码通常用于验证码场景,语音通知用于告警、提醒类场景。
- 申请资质和话术模板:这一步容易忽略。你以为买完服务就能发语音,实际不是的。语音服务需要先申请“资质”(也就是你作为业务方的企业信息),再申请“话术模板”,审核通过之后才能调用接口发送语音通知。
这里我用一句话概括整个链路:企业实名 → 开通语音服务 → 申请资质 → 创建话术模板 → 审核通过 → 写代码调用。
2.2 创建语音通知模板的要点
我踩过的最大的坑就是模板审核。第一次提交模板,写了“您的验证码是${code},请在10分钟内输入”,结果被驳回,理由是模板内容需要更明确地说明业务用途。
后面总结出几个经验:
- 模板变量一定要加说明:比如“${code}”是数字型验证码,请在变量说明里写清楚,审核人员才能知道你用在哪里。
- 话术要完整:不要只写半句话,要模拟一次完整的电话播报。比如“您好,您的订单已发货,快递单号是${expressNo},请保持手机畅通,谢谢使用。”这样审核更容易通过。
- 避免诱导性话术:不要说“回复立即退订”“点击链接领奖”之类的话,语音平台对营销类话术卡得非常严,系统通知类、验证码类最容易被放行。
- 审核时效:正常情况下几小时到一天内审核完成,如果超过24小时没动静,直接提工单催一下。催了之后一般当天就能有结果。
模板ID(TtsCode)生成后,在后面的API调用里直接引用,建议把模板ID放进配置中心或者数据库,不要硬编码在代码里。
3. Java实现:依赖、配置与核心代码
前置准备都搞定之后,真正写代码其实很快。阿里云提供了Java SDK,封装程度很高,核心就三步:初始化客户端、构建请求、发送请求。
3.1 Maven依赖引入
我用的Spring Boot项目,直接在pom.xml里加依赖:
<dependency> <groupId>com.aliyun</groupId> <artifactId>aliyun-java-sdk-dyvmsapi</artifactId> <version>2.2.0</version> </dependency>这个版本我用下来稳定,没有跟其它阿里云SDK冲突。如果你用更高版本,建议先在测试环境跑一下,确认API签名和类路径没有变化。官方文档里有时推荐的版本比较新,但新版本如果改动较大,老项目的兼容性反而要注意。
另外,SDK依赖的“aliyun-java-sdk-core”版本也需要关注。如果项目里已经引入了其它阿里云SDK(比如短信SDK),有可能版本冲突。我遇到过类名冲突的情况,排查了一下午才发现是core版本不一致导致的。建议统一指定core版本:
<dependency> <groupId>com.aliyun</groupId> <artifactId>aliyun-java-sdk-core</artifactId> <version>4.5.3</version> </dependency>在同模块里同时引入短信和语音SDK时,显式声明core版本,能规避大部分兼容性问题。
3.2 初始化客户端与配置文件
在写代码之前,先把AccessKey管理好。阿里云控制台里创建子用户,赋予“阿里云语音服务”相关的权限,然后用子用户的AccessKey调用。生产环境不要用主账号的AccessKey,这是一个底线。权限配到最小范围,即使Key泄露,影响面也有限。
Spring Boot的配置文件application.yml里,我这样组织:
aliyun: voice: access-key-id: your-access-key-id access-key-secret: your-access-key-secret # 语音服务默认接入地址 endpoint: dysmsapi.aliyuncs.com # 模板ID,建议从配置中心读取 tts-code: TTS_1234567 # 主叫号码,一般是95号码或固定号码 called-show-number: 057188888888这里注意几个字段:
- access-key-id / access-key-secret:从环境变量或配置中心读取,不要写死在代码里,更不要把AccessKey提交到Git仓库。
- endpoint:语音服务(Dyvms)的接入地址,我用的这个地址在杭州、上海、北京等地域都能通。据我观察,这个服务没有地域概念,本质上就是一个API入口,不像ECS有地域隔离,所以endpoint一般固定就行。
- called-show-number:是用户手机上来电显示的号码,企业申请下来的号码或95号码。不是所有号码都能作为主叫,你得在控制台申请“主叫号码”,审核通过后才能用。
然后定义配置类和客户端初始化方法:
@Component @ConfigurationProperties(prefix = "aliyun.voice") public class VoiceProperties { private String accessKeyId; private String accessKeySecret; private String endpoint; private String ttsCode; private String calledShowNumber; // getter/setter 省略 }@Configuration public class VoiceClientConfig { @Bean public IAcsClient voiceAcsClient(VoiceProperties properties) { DefaultProfile profile = DefaultProfile.getProfile( "cn-hangzhou", properties.getAccessKeyId(), properties.getAccessKeySecret() ); return new DefaultAcsClient(profile); } }这里有个小知识点:DefaultProfile.getProfile的三个参数,第一个是地域ID,我传的“cn-hangzhou”,实际上语音服务SDK里地域参数影响不大,但别传一个阿里云不支持的地域,否则也有概率报错。最稳妥的做法就是按官方默认的cn-hangzhou来。
3.3 SingleCallByTone核心代码
这是发送文本转语音通知的接口,完整代码如下:
@Service public class VoiceNotifyService { private final IAcsClient acsClient; private final VoiceProperties voiceProperties; public VoiceNotifyService(IAcsClient acsClient, VoiceProperties voiceProperties) { this.acsClient = acsClient; this.voiceProperties = voiceProperties; } /** * 发送语音通知 * * @param phoneNumber 被叫号码 * @param params 模板参数,比如 {"code":"123456"} * @return 阿里云返回的呼叫ID */ public String sendVoiceNotify(String phoneNumber, Map<String, String> params) { SingleCallByToneRequest request = new SingleCallByToneRequest(); // 被叫号码,必须是11位手机号或带区号的固话 request.setCalledNumber(phoneNumber); // 主叫号码,控制台申请 request.setCalledShowNumber(voiceProperties.getCalledShowNumber()); // 语音模板ID request.setTtsCode(voiceProperties.getTtsCode()); // 模板变量,JSON格式字符串 if (params != null && !params.isEmpty()) { String ttsParam = JSON.toJSONString(params); request.setTtsParam(ttsParam); } // 播放次数,默认3次,因为有些人第一次接起来可能没听清 request.setPlayTimes(3); // 外呼音量,范围0-100,默认100 request.setVolume(100); // 接通后延时播报,单位秒,默认1秒 request.setSpeed(1); try { SingleCallByToneResponse response = acsClient.getAcsResponse(request); if ("OK".equals(response.getCode())) { return response.getCallId(); } else { throw new RuntimeException("语音通知发送失败,code:" + response.getCode() + ",message:" + response.getMessage()); } } catch (ClientException e) { throw new RuntimeException("语音通知调用异常", e); } } }这段代码里的几个参数,我需要单独解释一下,因为很多人不知道它们到底有什么影响:
CalledNumber(被叫号码):手机号直接传11位就行,固话要加区号,比如“05711234567”。阿里云会自动处理号码前缀。有一个细节是,如果你是国际号码,需要传“0086”开头之类的格式,具体看文档。我在国内业务上没踩过这个坑,但接口文档里有说明。
TtsParam(模板参数):以JSON字符串传入。比如模板是“您的验证码是${code}”,那么params就是{"code":"123456"}。这里有个反直觉的点:参数的value,阿里云官方建议传字符串,不要传数字。如果传数字,某些场景下TTS合成时可能读得不对。
PlayTimes(播放次数):默认值是3,我实际测试下来也是这样。如果你做的是告警通知,建议保持3次,因为有些人接起电话的第一秒可能在找手机、没听清。如果是验证码场景,播报次数太多次反而让用户烦躁,但也没必要特意改,3次是平衡值。
Volume(音量):范围0-100,默认100。我建议不要调低,因为语音通知通常是在紧急场景下使用的,响一点总比听不清好。除非你有特殊需求,比如夜间不想扰民,那可以调到80-90。
3.4 SingleCallByVoice音频文件通知
如果你的语音是固定文案,比如“您好,欢迎致电某某热线”,那就用SingleCallByVoice。需要先上传音频文件到阿里云语音服务控制台,拿到VoiceId,然后调用:
public void sendVoiceFile(String phoneNumber, String voiceId) { SingleCallByVoiceRequest request = new SingleCallByVoiceRequest(); request.setCalledNumber(phoneNumber); request.setCalledShowNumber(voiceProperties.getCalledShowNumber()); request.setVoiceId(voiceId); request.setPlayTimes(3); try { SingleCallByVoiceResponse response = acsClient.getAcsResponse(request); if ("OK".equals(response.getCode())) { log.info("语音文件通知发送成功,callId={}", response.getCallId()); } else { log.error("语音文件通知发送失败,code={}, message={}", response.getCode(), response.getMessage()); } } catch (ClientException e) { log.error("语音文件通知调用异常", e); } }音频文件上传要求不多,但有一个容易犯的错:文件格式。官方支持WAV、MP3等,我建议优先用MP3,体积小、兼容性好。另外,音频时长不要太长,一般控制在10-30秒内,太长了播放体验差,而且外呼费用也高(按分钟计费)。上传前自己先听一遍,确认没有爆音、没有杂音,否则用户接起来第一感觉就很差。
3.5 查询呼叫状态:发送成功≠接通成功
很多新手踩过这个坑:调用接口返回OK,就以为用户已经接到电话了,实际上这个OK只是“阿里云已经接受请求”,不代表电话已经接通。用户可能没接、挂断、关机、停机,这些都是需要你自己关心的。
用QueryCallDetailByCallId来查询具体的呼叫状态:
public CallDetail queryCallDetail(String callId) { QueryCallDetailByCallIdRequest request = new QueryCallDetailByCallIdRequest(); request.setCallId(callId); request.setProdId(120000); // 110000表示语音验证码,120000表示语音通知 try { QueryCallDetailByCallIdResponse response = acsClient.getAcsResponse(request); if ("OK".equals(response.getCode())) { String data = response.getData(); // data是一个JSON字符串,需要解析 return JSON.parseObject(data, CallDetail.class); } return null; } catch (ClientException e) { log.error("查询呼叫详情异常", e); return null; } }这里返回的data字段是一个JSON字符串,关键字段包括:
- state:呼叫状态,数字类型。常见的有0(未接通)、1(接通)、2(未知)、3(未接听挂断)等。
- callDuration:通话时长,单位秒。
- recordFile:录音文件URL,需要权限才能下载。
这个接口在排查问题时特别有用。有一次线上反馈说某个用户收不到语音通知,接口返回OK,但查了呼叫详情发现state是3,说明用户确实没接听。这种问题就不是你代码的问题,而是用户侧行为导致的。
4. 生产环境:异常排查与性能调优
接口能跑通只是第一步。在线上跑稳、调好,才是真正体现工程能力的地方。下面这几个问题都是我实际遇到的,逐一来说。
4.1 高频错误码与解决方案
阿里云语音服务返回的错误码文档里都有,但文档分散在多个页面,查起来费劲。我把高频遇到的整理成一个表:
| 错误码 | 错误信息 | 原因 | 解决方案 |
|---|---|---|---|
| isv.ACCOUNT_NOT_EXISTS | 账号不存在 | AccessKey错误或未开通服务 | 检查AccessKey、确认服务已开通 |
| isv.TTS_TEMPLATE_ILLEGAL | 模板不合法或被禁用 | 模板未审核通过或审核失败 | 去控制台检查模板审核状态 |
| isv.TTS_TEMPLATE_PARAMS_ILLEGAL | 模板参数不合法 | 传入的TtsParam和模板变量不匹配 | 检查JSON参数名和模板变量名是否一致 |
| isv.BUSINESS_LIMIT_CONTROL | 触发业务流控 | 调用频率超出限制 | 控制并发,必要时提工单提额 |
| isv.INVALID_PARAMETERS | 参数不完整或非法 | 必填参数为空或格式错误 | 检查号码格式、模板ID是否存在 |
| isv.INVALID_JSON_PARAMS | JSON参数格式错误 | 模板参数不是合法JSON字符串 | 确认转义正确、用JSON工具解析 |
| isv.MOBILE_NUMBER_ILLEGAL | 手机号非法 | 被叫号码格式不对 | 确认号码是11位手机号 |
isv.TTS_TEMPLATE_PARAMS_ILLEGAL这个错误我个人碰到过好几次,大部分原因是代码里拼接JSON参数时少了一个双引号,或者变量名和模板里不一致。比如模板写的是${code},你传的时候params的key写成了"code"没问题,但要是一不小心写成了"Code",马上报这个错。所以,最好在封装层写一个校验:把模板ID对应的合法参数名配在配置中心,发送之前先比对一下参数长度和键名。
4.2 并发控制与流控
有一个容易被忽略的现实问题:语音外呼不等同于普通API调用,它的并发是受到严格限制的。阿里云的默认并发限制通常不高,具体数值控制台能看到。如果你一次性大批量发语音通知,比如同时给500个人打电话,就会发现大批请求返回“触发业务流控”。
这个限制其实是有道理的,因为语音外呼涉及真实的电信线路资源,多个并发呼叫会占用大量中继线路。但业务侧往往不理解,认为“发短信都能批量发,发语音也应该可以”,实际上两者的资源模型完全不同。
我的做法是:
- 应用层加限流:自定义一个简单的信号量,控制同时进行的语音外呼请求数量,比如限制在10个并发左右。用Semaphore即可,不需要引入分布式锁,因为流控是针对单API Key的,本地限流就够。
- 批量任务削峰:如果业务是批量通知,比如要给几千个用户发语音,不能一次性全丢给接口,那样必然触发流控。我会用一个队列,把待发送的用户先放到MQ里,消费端按固定的QPS发送,发送间隔控制在100到200毫秒左右,这样既能保证不触发流控,也能合理利用资源。
这里有个需要提醒的点:发送间隔不能设置的太慢。语音外呼是按分钟计费的,如果QPS太低、发送队列积压太久,用户体验会很差。我实践下来,200毫秒间隔、单个账号环境下基本不会触发流控,也不会把队列压死。
4.3 重试策略与幂等设计
语音通知是强实时场景,第一次呼叫失败之后,是否重试、何时重试,直接影响用户体验。我的重试策略是这样:
- 第一次未接听:等待3分钟后重试一次。这是给用户一个缓冲时间,可能在忙没注意到。
- 第二次未接听:不再自动重试,而是在告警平台上标记“未接通”,由值班人员决定是否需要人工介入。
为什么不对所有失败场景都无脑重试?因为有些号码是空号、停机,重试3次也都是徒劳,反而白白消耗外呼费用。还不如把失败详情记录下来,后续做数据分析。
幂等设计同样重要。语音通知的发送接口不是天然幂等的,如果调用端超时重发,用户可能会收到两通电话。我的做法是:
- 发送前生成本次通知的唯一请求ID(requestId),存入数据库。
- 调用阿里云接口前,先查库里是否已经存在相同requestId的记录,存在则直接返回原记录中的callId。
- 阿里云返回callId后,再把callId和requestId绑定,写入数据库。
这样即使下游重试,也只会触发一次真正的外呼。
4.4 录音与回调配置
语音通知场景里,用户是否真的听到语音内容,有时候需要证据(比如纠纷、审计)。阿里云语音服务默认录制通话内容,前提是你在控制台开通了录音功能。通话结束后,可以从QueryCallDetailByCallId返回的recordFile字段获取录音文件地址。
录音功能要提前确认:有些账号默认不开启,需要手动在控制台打开,而且录音文件只保留7天或30天,过期就取不到了。如果你有合规需求,建议把录音文件定期拉到本地或对象存储,长期保存。
另外还有一个回调机制。阿里云语音服务支持配置“呼叫状态回调”,当呼叫状态变化时,阿里云会通过HTTP POST请求通知你的服务器。我用这个来做实时通知,比如一旦呼叫失败,立刻在告警大屏上显示。回调配置在控制台设置,需要提供一个你自己的接口地址,阿里云会在呼叫结束后推送状态。
回调接口要做好验签和幂等处理。回调消息里会带一个callId,你以callId为唯一键做去重,避免重复回调导致数据重复。
5. 工程化落地:从Demo到可运维的完整方案
一个能跑的Demo和一套生产可用的系统,差距在于工程化。下面分享一些落地的细节经验。
5.1 业务封装:只暴露一个send方法
为了不让业务方直接面对阿里云SDK,我封装了一个上层服务,对外只暴露一个语义清晰的方法:
public VoiceNotifyResult sendNotify(NotifyRequest request) { // 1. 校验号码 PhoneNumberValidator.validate(request.getPhoneNumber()); // 2. 幂等校验 String requestId = buildRequestId(request); if (idempotentService.exists(requestId)) { return VoiceNotifyResult.success(idempotentService.getCallId(requestId)); } // 3. 选择模板(按业务类型区分) String ttsCode = templateService.getTtsCode(request.getBizType()); // 4. 构建参数 Map<String, String> params = templateService.buildParams(request); // 5. 调用语音服务 String callId = voiceGatewayClient.send(phoneNumber, ttsCode, params); // 6. 持久化记录 notifyRecordService.save(requestId, phoneNumber, ttsCode, callId); return VoiceNotifyResult.success(callId); }这样设计之后,上层业务完全不需要关心“怎么调阿里云”,只管传业务参数。以后如果想换厂商,只要改voiceGatewayClient这个实现类即可,业务方无感知。
5.2 本地联调与Mock
联调阶段要特别注意:语音服务是会真实拨打电话的,不可能在测试环境随随便便调。如果你的测试环境没有真实手机号,可以用阿里云提供的“测试号码”能力,在控制台绑定测试号码,用测试号码接收语音通知。
本地开发不要直接访问阿里云生产环境,我做了两层隔离:
- 本地环境下使用MockClient:模拟发送成功、失败、超时等场景。关键是把整个发送流程跑通,尤其是幂等、日志、异常处理这些逻辑,不需要真实外呼。
- 测试环境使用测试模板和测试号码:模板ID用测试专用的,不占用正式模板配额,也方便排查问题。
另外,联调时有个经验:如果要验证真实效果,用你自己的手机号接听一遍,确认语音内容、音量、播报速度都符合预期。这个“人工验收”环节不能省,因为TTS合成的效果和文字完全不同,有些数字发音会很生硬,甚至听错。比如“1200”可能被读成“一千二”,但业务方期望的是“一二零零”,这时候就需要在模板里改写数字格式,或者拆分成更清晰的播报文本。
5.3 异常兜底:语音通知挂了怎么办
很多业务把语音通知当成唯一触发手段,这是很危险的。无论阿里云服务多稳定,你无法控制用户手机欠费、关机、信号差等现实因素。所以我在项目里做了一个降级策略:
- 语音通知失败时,自动降级为短信通知:如果调用语音接口抛异常,或者查询状态发现未接通,就自动触发短信通道,保证消息至少以某种形式送达。
- 短信也失败时,进入人工处理队列:由值班人员通过其他方式(比如企业微信)跟进。
这个降级链路就是整个通知系统的“最后一道防线”,能大幅降低“通知漏发”的概率。注意,降级不是越级,要走完整链路,比比如先语音后短信,如果先短信那用户先看到短信,语音电话再打过来就产生了重复打扰。
5.4 成本控制与配额监控
语音外呼是按分钟计费,而且是双向计费还是单向计费,取决于你使用的线路类型。我的经验是:告警类语音通知的呼叫接通率大约只有60%到70%,也就是10通电话里,有3到4通是用户没接的。但没接通也有成本(阿里云按通话时长计费,未接通一般不扣费,但实际账单规则需要仔细看控制台)。
不管计费规则如何,都要做成本控制:
- 设置每日外呼总量上限:比如单日最多发500通语音,超过就熔断。
- 对未接通重试次数做限制:最多重试一次,避免无限制重试导致费用飙升。
- 定期拉取账单分析:语音单价、通话时长分布,找出成本异常点(比如某个号码打了10分钟都没挂断,可能用户在忙线中)。
这些控制逻辑不要写死在代码里,放进配置中心动态调整,因为业务高峰、活动期间可能需要临时提额。
6. 常见问题速查与典型踩坑复盘
最后把大家在接入过程中最容易遇到的几个问题集中复盘一下。
Q1:模板审核到底要多久?
我遇到过半小时就过的,也遇到过卡了两天的。卡住的原因大多是“变量说明不清晰”或“话术涉及金融类敏感内容”。建议提交前仔细阅读模板规范,把变量用途写清楚。比如“${code}”是6位纯数字验证码,用于用户登录验证。实测这样写通过率高很多。
Q2:为什么调用返回OK,但手机没响?
这个问题的原因可能是:被叫号码在黑名单、被叫手机拦截了陌生号码、主叫号码没有申请下来、模板播报的内容被运营商拦截。先查呼叫详情,看state是什么。如果state是0,说明阿里云还没有实际发起呼叫,基本可以确认是号码或模板的问题;如果state是3,说明呼叫已发起但被用户或者运营商拦截了,要检查主叫号码的“信誉度”。
Q3:主叫号码怎么申请?
语音服务控制台里申请主叫号码,通常可以申请固定号码或者95号码。审核条件比较严格,需要企业资质。我建议早期先用阿里云提供的测试主叫号码做开发联调,正式环境再申请正式号码。
Q4:语音通知有延迟吗?
第一次外呼会有一点延迟,通常1到3秒。如果并发量很大,延迟会更明显。如果你做的是验证码类实时业务,用户点击“获取验证码”之后等太久体验就很差,所以要关注P95延迟。我实测在正常并发下,P95延迟在2秒以内,可以接受。
Q5:模板参数里的数字会不会读错?
会。TTS引擎对数字的读法有自己的规则。比如“2010”可能读“二零一零”,也可能读“两千零一十”,这取决于引擎。如果你对数字读法有要求,建议在模板里就写成你希望播报的文字,比如“您的验证码是数字二三四五六”。虽然文本丑了点,但至少保证读出来是对的。
Q6:如何实现一个完整的“外呼-状态回执-人工处理”闭环?
我的建议是做一张notify_record表,字段包括:request_id、phone、call_id、status(SENT/SUCCESS/FAIL)、fail_reason、retry_count、create_time。发送成功后写一条SENT记录,收到回执或主动查询到状态后更新为SUCCESS或FAIL。然后写一个定时任务扫描FAIL的记录,按重试策略重新发送。这样整个流程就有据可查、可追踪。
7. 写在最后的一点个人体会
语音通知这个需求,技术上并不复杂,但要真正落地到生产环境,涉及的细节远比想象中多。模板审核、主叫号码申请、流控、重试、降级、录音留存,每一个环节都有坑。我做完这个项目最大的感受是:不要只盯着“调用接口”这一层,要把整个通知链路当做一个系统来设计,呼叫状态管理、异常兜底、成本控制都要纳入考虑。
如果你只是开发阶段,照着上面第3章的代码,配合测试号码就能把整个流程跑通。如果是要上线,那务必把第4章的错误码排查、第5章的降级策略都安排好。语音通知的本质是一个强触达的通信能力,它不会替你解决所有通知问题,但能在关键时刻把话真正送到用户耳边,这就值得了。