SpringBoot 接入华为云短信服务三步实操:从鉴权到业务联调
做后端这几年短信验证码算是接得最多的第三方服务之一从账号注册、找回密码到登录二次校验短信通道一旦出问题整个业务流程都得跟着停摆。这次项目里需要把短信能力接到华为云短信服务上网上搜了一圈资料要么停留在老版控制台截图要么只讲文档里已经废弃的参数能照着跑通的不多。这篇内容就是我实际接入过程的完整记录按“三步接入”的思路来组织——准备阶段、工程集成、业务联调每一步的选型理由、核心代码和踩过的坑都会讲清楚。如果你正打算在 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency如果你的项目不是 Web 工程单独引入 spring-boot-starter-web 即可。短信发送用同步 REST 调用就够没有必要上消息队列这类重组件。3.2 配置参数密钥不要裸奔把短信相关配置放到application.ymlsms: 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())); MapString, Object body new HashMap(); body.put(from, properties.getSender()); body.put(to, to); body.put(templateId, properties.getTemplateId()); body.put(templateParas, templateParas); HttpEntityMapString, Object requestEntity new HttpEntity(body, headers); String url properties.getEndpoint() /sms/batch/send; ResponseEntitySmsApiResponse 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-ddTHH:mm:ssZ) .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 的 keynonce 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 ListSmsCallbackItem 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 和回调状态。我习惯把短信发送记录落到一张独立的表里每次发送都记一条包括请求参数、接口返回、回调状态三个维度的信息。排查“用户说没收到短信”这类问题时翻这张表比翻聊天记录靠谱得多。另外还有个小技巧验证码模板里加上“如非本人操作请忽略本短信”这类提示语审核通过率高用户体验也会好一些。短信这东西安全感和合规感比什么都重要。