做后端这几年,短信验证码算是接得最多的第三方服务之一,从账号注册、找回密码到登录二次校验,短信通道一旦出问题,整个业务流程都得跟着停摆。这次项目里需要把短信能力接到华为云短信服务上,网上搜了一圈,资料要么停留在老版控制台截图,要么只讲文档里已经废弃的参数,能照着跑通的不多。这篇内容就是我实际接入过程的完整记录,按“三步接入”的思路来组织——准备阶段、工程集成、业务联调,每一步的选型理由、核心代码和踩过的坑都会讲清楚。如果你正打算在 SpringBoot 里接华为云短信服务,或者已经被鉴权那段绕得头晕,这篇应该能帮你省掉不少试错时间。
1. 为什么是“三步”:接入前的整体设计
写代码之前,先把思路捋清楚。短信接入链路看着不长,实际牵扯到账号资质、签名审核、模板审核、鉴权算法、回调处理好几个环节,任何一个出问题都会把整个流程卡住。我把接入过程压缩成三步,不是少了步骤,而是把最容易绕晕的部分归类到三个阶段,每个阶段都有明确产出,验收起来也方便。
1.1 华为云短信服务解决了什么问题
短信发送这件事,表面上是“给用户发一条消息”,实际背后是运营商通道、签名报备、模板审核、状态回执、并发处理这一整套链路。自建通道既不现实也不合规,云厂商把这些底层事都打包好了,我们只需要把业务参数填进去,让平台把短信发出去就行。
我选华为云短信服务,主要看中几点:
- 国内通道覆盖三网,实名认证之后就能申请使用。
- 签名和模板支持在线申请,审核进度透明可控。
- 提供状态回调接口,能精确记录每条短信的送达结果。
- 鉴权方式稳定,适合封装成独立模块在工程里复用。
说句实话,各家短信服务商提供的功能大差不差,真正影响开发体验的是接入文档的清晰度和审核效率。华为云的控制台和文档更新得挺勤快,但正因为更新快,网上很多老教程的截图已经对不上了。我写这篇时采用的接口形态和参数规则,尽量贴近当前版本。如果你照着操作时发现控制台界面和我描述的不完全一致,大概率是功能位置挪了,搜索对应关键词就能找到。
1.2 三步法的拆解逻辑
我的三个步骤划分是这样的:
第一步,准备资源和资质。包括账号实名认证、创建短信应用、申请签名和模板,拿到 App Key 和 App Secret。这一步的产出是“所有一次性资源都就绪,后面写代码时不会因为缺东少西而中断”。
第二步,工程集成。在 SpringBoot 工程里配置连接参数、实现鉴权工具类、封装发送短信的方法。这一步的产出是“代码能成功发出一短信”。
第三步,业务联调。把发送能力接入真实场景,比如登录验证码,配合 Redis 做防刷限流、存储验证码、接收状态回调。这一步的产出是“业务闭环完全跑通”。
这样拆最大的好处是每步的验收标准非常清楚。我见过不少同事一上来就写发送代码,写完之后才发现签名还没申请、模板没过审、密钥找不到了,回头再补齐这些准备工作,等于全部返工。把准备阶段单独拎出来,强制优先级,能绕开这些坑。
1.3 三个容易混淆的核心概念:应用、签名、模板
新手最容易搞混的是应用、签名、模板三者的关系。
用生活化的比喻:应用是你的“业务入口”,相当于寄快递时选的那个网点;签名是包裹上显示的寄件人抬头,用户看到的“【某科技】”就是它;模板是发货清单,规定了短信的正文长什么样、哪些位置可以填变量。
具体到华为云控制台:
- 短信应用:创建后生成 App Key 和 App Secret,这两个值用于后续 API 鉴权。
- 短信签名:申请时需要关联某个应用,审核通过后才能在发送接口中使用。
- 短信模板:同样关联应用,模板里的变量用
${1}、${2}占位。
发送短信时,接口请求里同时携带签名内容和模板 ID,服务端会根据“应用 + 签名 + 模板”的组合关系做校验。这也解释了一个常见现象:签名审核不通过时,模板就算审核过了也发不出短信,因为签名和应用没绑定成功。所以准备阶段的核心任务,就是把这三个资源全部备齐。
2. 第一步:准备资源与资质
这一步主要在控制台操作,不写代码,但它的优先级最高。我按实际操作顺序写一遍,每个环节的注意事项都标出来。
2.1 账号实名认证,别在这里省时间
注册华为云账号后,第一件事就是完成实名认证。个人认证能注册账号,也能进控制台,但短信签名申请对主体要求很严格,企业相关的签名类型基本都要求企业认证。
我的建议是:如果你是替公司业务接入,直接走企业认证流程,把营业执照准备好,按提示上传,一般几分钟到几小时就能通过。如果先做了个人认证再改企业认证,中间要额外提交材料,反而耽误时间。
注意:实名认证的主体要和签名内容匹配。比如签名想申请“某科技”(假设公司名包含“某”字),认证主体就得是这家公司,否则审核人员大概率会驳回。
2.2 创建短信应用,拿到密钥
登录华为云控制台,搜索“消息&短信服务”进入管理页面:
- 左侧菜单选择“短信应用”。
- 点击“创建应用”,填写应用名称,比如“登录验证码”或“通知发送”。
- 创建完成后进入应用详情,能看到 App Key。
重点是 App Secret。它只在创建成功时显示一次,错过之后只能通过“重置密钥”再获取一次。我踩过的坑是创建完随手截了个图,后来清理截图把密钥一起删了,只能重置,好在当时还没上线。正规做法是创建完立刻把 App Key 和 App Secret 记到团队的密钥管理工具里,不要在聊天工具里传来传去,更不要写死在代码里。
2.3 申请短信签名与模板
签名和模板都在控制台提交申请,审核周期通常 1 到 2 个工作日,有的加急当天能过。这一节是准备阶段里最需要细心的部分。
申请签名
需要填写的关键信息:
- 签名内容:用户最终看到的发送方标识,比如“某科技”。
- 签名类型:根据品牌载体选择,比如 APP 应用、网站、公众号。
- 适用范围:写清楚业务场景,比如“用户登录时发送验证码”。
如果是 APP 应用签名,通常需要提供软件著作权证书或应用商店上架截图;网站签名则需要提供网站备案号。材料越齐全,一次性通过的概率越高。
申请模板
模板类型有验证码、通知、营销等。以验证码模板为例:
您的验证码为${1},${2}分钟内有效。如非本人操作,请忽略本短信。变量规则有三个重点:
- 变量用
${数字}表示,数字是变量序号,从 1 开始。 - 变量不能连续出现,比如
${1}${2}这种写法不合法,中间至少要有一个固定字符。 - 提交审核时必须写明每个变量的含义,比如“${1}:6位数字验证码”。
我在模板审核上踩过的坑:某次写了营销类模板,内容里带链接但没有退订文案,被驳回了。后来加上“回复TD退订”才通过。说白了,审核关心的不只是格式正确,还有合规和用户体验。所以申请信息里的“业务描述”别偷懒,写清楚用户会在什么场景收到短信,通过率会明显提高。
3. 第二步:SpringBoot 工程集成
现在进入写代码阶段。工程集成拆成三块:依赖、配置、工具类。这三块做好,短信发送能力就具备了。
3.1 依赖选择:只要 Spring Web
很多人的惯性思维是找官方 SDK,先加一坨依赖再说。我的选择是不加 SDK,直接基于 HTTP 接口实现。原因有两个。
第一,短信服务的调用链路非常轻,本质就是一个 POST 请求加一个鉴权头,RestTemplate 完全可以覆盖。第二,SDK 版本更新频繁,不同版本之间 API 有差异,网上教程经常对不上,反而增加排错成本。
工程里只需要 Spring Web 提供的依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>如果你的项目不是 Web 工程,单独引入 spring-boot-starter-web 即可。短信发送用同步 REST 调用就够,没有必要上消息队列这类重组件。
3.2 配置参数:密钥不要裸奔
把短信相关配置放到application.yml:
sms: huawei: app-key: ${SMS_APP_KEY} app-secret: ${SMS_APP_SECRET} endpoint: https://msgsms.cn-north-4.myhuaweicloud.com:443 sender: "某科技" template-id: "your-template-id" callback-url: https://your-domain.com/sms/callback各参数含义:
app-key、app-secret:创建短信应用后获得的鉴权凭证。endpoint:短信服务接入地址,以控制台“应用接入”页面展示的地址为准,不同区域不一样。sender:签名内容,注意是签名本身,不是签名 ID。template-id:模板 ID,审核通过后可在控制台查到。callback-url:状态回调地址,可选的,不填不影响发送,但建议配置。
密钥用${SMS_APP_KEY}占位符引用环境变量,不要在 git 仓库里提交真实密钥。这个习惯非常重要,我见过不止一次公司后台源码泄露,短信密钥跟着被滥用,一夜之间产生大量扣费短信。
然后定义配置属性类:
@Component @ConfigurationProperties(prefix = "sms.huawei") public class SmsProperties { private String appKey; private String appSecret; private String endpoint; private String sender; private String templateId; private String callbackUrl; // getter / setter 省略 }3.3 核心工具类:几乎所有人都会卡在鉴权上
短信接口的关键在于 WSSE 鉴权。华为云短信 API 要求每个请求都必须携带Authorization和X-WSSE两个请求头。
先看完整代码,再解释算法:
@Component public class SmsUtil { private final SmsProperties properties; private final RestTemplate restTemplate; public SmsUtil(SmsProperties properties, RestTemplateBuilder builder) { this.properties = properties; this.restTemplate = builder .setConnectTimeout(Duration.ofSeconds(5)) .setReadTimeout(Duration.ofSeconds(10)) .build(); } public SendResult send(String to, String templateParas) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("Authorization", "WSSE realm=\"SMS\", profile=\"UsernameToken\", type=\"Appkey\""); headers.set("X-WSSE", buildWsseHeader( properties.getAppKey(), properties.getAppSecret())); Map<String, Object> body = new HashMap<>(); body.put("from", properties.getSender()); body.put("to", to); body.put("templateId", properties.getTemplateId()); body.put("templateParas", templateParas); HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(body, headers); String url = properties.getEndpoint() + "/sms/batch/send"; ResponseEntity<SmsApiResponse> response = restTemplate.postForEntity(url, requestEntity, SmsApiResponse.class); if (response.getStatusCode().is2xxSuccessful() && "000000".equals(response.getBody().getCode())) { return SendResult.success(response.getBody().getSmsId()); } return SendResult.failed(response.getBody().getDescription()); } private String buildWsseHeader(String appKey, String appSecret) { String nonce = UUID.randomUUID().toString().replace("-", ""); String created = DateTimeFormatter .ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") .format(LocalDateTime.now(ZoneOffset.UTC)); byte[] passwordBytes = hmacSha256( appSecret.getBytes(StandardCharsets.UTF_8), (nonce + created).getBytes(StandardCharsets.UTF_8)); String password = Base64.getEncoder().encodeToString(passwordBytes); return String.format( "UsernameToken username=\"%s\", password=\"%s\", nonce=\"%s\", created=\"%s\"", appKey, password, nonce, created); } private byte[] hmacSha256(byte[] key, byte[] data) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key, "HmacSHA256")); return mac.doFinal(data); } catch (Exception e) { throw new SmsException("HMAC-SHA256 计算失败", e); } } }代码里的SendResult、SmsApiResponse、SmsException是我自己封装的基础类。SendResult包含success、smsId、desc三个字段,SmsException继承 RuntimeException,你可以按自己项目的规范定义,这里不占篇幅贴全部代码了。
现在解释 WSSE 鉴权算法,这是很多人第一次接触时最容易懵的地方:
- 生成一个随机字符串
nonce,每次请求都要不同,它是为了防止重放攻击。 - 取当前 UTC 时间,格式化成为
created。 - 用 HmacSHA256 算法,以 App Secret 为密钥,对
nonce + created的拼接串做签名,得到字节数组后再 Base64 编码。 - 把 App Key、编码后的 password、nonce、created 四个值按固定格式拼进
X-WSSE请求头。
两个关键点:签名时 App Secret 是 HMAC 的 key,nonce + created是 data,顺序反了服务端会一直报鉴权失败。另外created必须是 UTC 时间,不是本地时间,如果直接用LocalDateTime.now()不带时区,请求会被当成过期或无效。
3.4 快速验证发送能力
写一个简单的测试方法,先验证工具类能不能通:
@SpringBootTest class SmsUtilTest { @Autowired private SmsUtil smsUtil; @Test void sendCode() { String params = "[\"123456\",\"5\"]"; SendResult result = smsUtil.send("13800138000", params); System.out.println(result); assertTrue(result.isSuccess()); } }第一次发送不成功别慌,先看接口返回的code和description。常见的鉴权错误、模板参数错误都在第 5 章列出来了。如果你改了 HMAC 计算方法,建议先用文档里的示例参数手算一遍 Base64 输出,确认无误再放到工具类里。
4. 第三步:业务联调与验证
工具类能发短信了,接下来把它接进真实业务。这一步不只是“调一个接口”这么简单,要考虑防刷、验证码存储、回调处理、发送记录留痕。
4.1 验证码发送的完整链路
以最常见的“手机号 + 验证码登录”为例,完整流程是这样的:
- 用户提交手机号,点击“获取验证码”。
- 后端检查该手机号在最近 60 秒内是否已经发过(防刷)。
- 生成 6 位随机码,存入 Redis,设置 5 分钟过期。
- 调用 SmsUtil 发送短信。
- 前端收到“发送成功”,开始倒计时。
- 用户提交验证码,后端从 Redis 取出比对。通过后标记该手机号已验证。
代码实现:
@Service public class AuthService { @Resource private SmsUtil smsUtil; @Resource private StringRedisTemplate redisTemplate; private static final String CODE_PREFIX = "sms:code:"; private static final String FLOOD_PREFIX = "sms:flood:"; private static final long CODE_TTL = 5; private static final long FLOOD_TTL = 60; public void sendCode(String phone) { Boolean first = redisTemplate.opsForValue() .setIfAbsent(FLOOD_PREFIX + phone, "1", FLOOD_TTL, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(first)) { throw new BizException("发送太频繁,请稍后再试"); } String code = String.format("%06d", ThreadLocalRandom.current().nextInt(1000000)); String params = "[\"" + code + "\",\"" + CODE_TTL + "\"]"; SendResult result = smsUtil.send(phone, params); if (!result.isSuccess()) { throw new BizException("短信发送失败:" + result.getDesc()); } redisTemplate.opsForValue().set( CODE_PREFIX + phone, code, CODE_TTL, TimeUnit.MINUTES); } public boolean verifyCode(String phone, String inputCode) { String cached = redisTemplate.opsForValue().get(CODE_PREFIX + phone); if (cached != null && cached.equals(inputCode)) { redisTemplate.delete(CODE_PREFIX + phone); return true; } return false; } }两个细节值得说。生成验证码用ThreadLocalRandom,比Math.random()在并发下更友好。防刷标记和验证码分两个 key:防刷标记 60 秒过期,验证码 5 分钟过期,互不影响,用户至少得等 60 秒才能重新发送。
4.2 状态回调:把“发出去”变成“送达了”
短信接口返回000000只代表华为云接受了请求,不代表用户一定收到了短信。要精确掌握送达状态,必须配置状态回调。
在控制台或请求参数里配置回调地址后,华为云会在短信状态变化时回调这个接口。回调报文是 JSON 数组,包含短信 ID、状态码等信息。
后端处理回调:
@RestController public class SmsCallbackController { @Resource private SmsRecordService recordService; @PostMapping("/sms/callback") public void receive(@RequestBody List<SmsCallbackItem> items) { for (SmsCallbackItem item : items) { recordService.updateStatus( item.getSmsId(), item.getStatus(), item.getDescription()); } } }回调接口建议独立成一个 Controller,不要和业务接口混在一起。一是方便排查问题,二是防止业务代码改动时不小心影响回调接收。生产环境里回调地址一定是公网可访问的 HTTPS 地址,测试环境可以用内网穿透临时调试。
4.3 联调时最容易忽略的测试点
- 用真实手机号测试。文档示例号或虚拟号可能被运营商限制,收发都会异常。
- 模板变量个数和内容严格对应。模板里
${1}对应参数数组第一个元素,位置不能错。 - 测试时注意频率限制。频繁给同一号码发验证码会被华为云限流,严重的话需要申诉解封,联调阶段别为了验证接口反复点发送。
- 验证码发送成功但不入库,用户永远都验证不过。先确认 Redis 里能不能读到刚写的 key,再排查其他环节。
5. 常见问题与排查技巧实录
这部分是实践中最有价值的内容。我直接按“现象—原因—解法”的顺序整理,全是自己或同事真实遇到过的。
5.1 鉴权失败:401 或 403
这是最高频的错误,原因几乎都集中在 WSSE 头的构造上。可能性从高到低排列:
- App Secret 用错,而不是 App Key。
- HMAC 的 key 和 data 顺序反了。
created用了本地时间而不是 UTC 时间。nonce不唯一,或者带了非法字符。X-WSSE头拼写有误,比如多了空格或漏了引号。
排查技巧:写一个最简的 main 方法,把buildWsseHeader的输出打印出来,然后用文档里的示例参数手算一次 Base64 输出。如果你手算的结果和接口返回的错误不一致,说明你的加密算法错了;如果手算结果正确但接口还是报鉴权失败,那就是请求头格式或密钥本身的问题,逐个排查。
5.2 发送成功但手机收不到
用排除法:
- 登录华为云控制台,查看“发送记录”,确认该条短信的实际状态。
- 如果状态是“送达”但用户没收到,让用户检查手机安全软件是否拦截,或者是否填错了号码。
- 如果签名是刚审核通过的新签名,部分通道对接可能有延迟。
- 确认发送号码不是运营商黑名单号码。
还有一个容易忽略的点:to字段如果传了带区号的号码,比如+8613800138000,部分接口会解析失败或直接忽略,表现就是“发送成功”但实际没有真实下发。国内号码建议只传 11 位纯数字,如果业务需要支持海外号码,单独处理区号逻辑。
5.3 模板变量解析失败
报错信息里一般带模板关键字,比如Invalid template parameter。逐一核对:
- 模板里变量是
${1},不是{1}也不是${1}(多空格)。 - 传入的
templateParas是 JSON 数组格式的字符串。 - 数组长度不小于模板变量个数。
- 数组每个元素都是字符串,不要传数字类型。
举例:模板是您的验证码为${1},${2}分钟内有效,参数必须是["123456","5"]。如果传了[123456, 5]这种非字符串格式,服务端大概率报错。这个格式问题的根因,往往是对接时直接把前端数值塞进了参数,忘了包一层字符串。
5.4 被限流了
短时间内对同一手机号频繁发送,会触发华为云的频率限制,接口返回类似Limit exceeded的错误码。
解法是:
- 到控制台查看该应用的配额和限流策略。
- 业务层主动降频,把防刷逻辑加上,别全靠平台兜底。
- 确实需要高频触达的场景,考虑申请独立通道或调整模板类型。
我在生产上固定的规则是:同一用户验证码 60 秒一次、同一个用户 24 小时最多 10 条、同一个 IP 每小时 20 次。用 Redis 实现成本很低,却能有效避免账号被平台拉进风控名单,这个规则组合我实测下来够用了。
5.5 回调验签问题
严格来说,华为云回调报文里带有签名信息,需要对回调来源做校验,防止伪造。
如果你的回调接口暴露在公网,至少要做一层认证校验。我在项目里用固定 token 加自定义请求头的简单方案,代码量不多,但能挡住常见的脚本扫描。短信回调一旦被伪造,攻击者可能把验证码状态批量改成“成功”,后果不只是数据不准,还可能绕过整个校验流程。
下面是问题速查表,排查时直接对号入座:
| 现象 | 常见原因 | 解决方向 |
|---|---|---|
| 401/403 鉴权失败 | Secret 错误、HMAC 顺序反、时间非 UTC | 检查密钥与 WSSE 构造 |
| 发送成功但收不到 | 号码黑名单、新签名延迟、区号问题 | 控制台发送记录确认 |
| 模板参数错误 | 变量格式或个数不匹配 | 核对${1}与 JSON 数组 |
| 频率超限 | 同一号码高频发送 | 业务层加 Redis 防刷 |
| 回调未触发 | 回调地址不可达或未配置 | 检查 HTTPS 与编码格式 |
最后分享一个我在这次接入中体会最深的事:短信通道不出问题的时候没人关心,一出问题就是大事。所以代码里该打的日志一定要打,尤其是 requestId、smsId 和回调状态。我习惯把短信发送记录落到一张独立的表里,每次发送都记一条,包括请求参数、接口返回、回调状态三个维度的信息。排查“用户说没收到短信”这类问题时,翻这张表比翻聊天记录靠谱得多。
另外还有个小技巧:验证码模板里加上“如非本人操作,请忽略本短信”这类提示语,审核通过率高,用户体验也会好一些。短信这东西,安全感和合规感比什么都重要。