ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot短信验证码集成全指南:从控制台到代码的完整闭环

SpringBoot短信验证码集成全指南:从控制台到代码的完整闭环 短信验证码应该是SpringBoot项目里最常见的小功能了但不少人在私信里问我的问题都是代码照着教程写完了短信却发不出去。原因也是五花八门——签名审核没过、模板变量写错、余额不足甚至AccessKey的Secret还没来得及复制就关掉了页面。我最近完整做了一轮集成从阿里云控制台到SpringBoot代码再到验证码的业务闭环把能踩的坑基本都趟了一遍。这篇文章就把整个过程拆成三步来写控制台准备、代码集成、业务联动最后附一份排障清单。不管是刚接触SpringBoot的初学者还是想快速接入短信功能的后端开发照这个流程走大概率能一次跑通。1. 第一步先从控制台开始签名、模板和AccessKey是硬门槛1.1 开通短信服务实名认证和余额这两道坎登录阿里云控制台在搜索框里输入“短信服务”就能找到入口。点进去之后会提示开通服务这一步本身不收费但有两个前置条件容易踩坑。第一个是实名认证。个人开发者用个人实名认证也可以开通但后面申请签名时可选类型会受限。公司名义做项目的话建议直接用企业实名签名审核通过率高很多。我见过有人拿个人账号申请公司简称的签名被驳回之后一脸懵其实类型对不上就是不行。第二个是账户余额。短信服务是预付费模式账户里没钱API调用频率再正常也发不出去。我当时第一次测试时余额是零日志里的错误码给了“isv.AMOUNT_NOT_ENOUGH”这才反应过来短信不是开通就能用的而是要先充值。最低充值金额比较友好测试阶段充个几十块够发几百条了。开通之后先别急着写代码页面右上角往下翻把几个核心入口认清楚签名管理、模板管理、AccessKey管理。后面每一步都要用到。1.2 签名是短信的“名片”审核要点一次说透签名就是用户收到短信时开头那一截【某某科技】。它不只是一张名片也是阿里云审查短信内容合规性的第一道关卡。创建签名时签名来源有网站、APP应用、公众号/小程序、电商平台店铺名等好几种。关键点在于你选择的来源必须能提供对应的证明材料。比如选“APP应用”要上传应用商店的截图选“网站”要填网站域名并上传ICP备案截图个人开发者的签名来源相对少一般只能选“测试或学习”这种签名适合测试环境正式上线容易被限制。签名内容本身也有些讲究。第一长度在2~12个字符之间纯英文字符上限会放宽一些。第二不能包含“测试”这种字样但“学习”类签名在个人认证下可以过。第三签名不能是纯数字或纯字母要能看出主体名称。审核时间一般十几分钟到几个小时不等审核状态会在签名管理页面显示。这里有个实操心得签名审核期间先把模板和AccessKey准备好三者并行走不浪费时间。如果你的签名初次被驳回系统会给出具体原因最常见的是“证明材料不清晰”或“签名内容与备案主体不一致”。处理方式很简单——调整材料重新提交别改签名文字硬凑。1.3 模板变量严格按规则填别在里面放链接模板就是短信正文的骨架验证码类模板长这样您的验证码为${code}您正在登录如非本人操作请勿泄露。中间这个${code}是变量是阿里云模板系统规定的格式不能自己发明别的写法。举个例子如果你写成{$code}或者#{code}审核直接不通过。模板内容的规则值得多说几句不能包含链接、网址、二维码。想放H5页面想都别想审核过不去。不能包含“抽奖”“中奖”“回T退订”等营销敏感词。验证码模板就老老实实做验证码别试图夹带营销内容。变量数量尽量精简。验证码模板一个变量就够用了变量多了阅读体验差也容易被判定为模板不规范。模板内容里要带有产品名称或品牌词这样更容易通过审核。比如“您正在登录某某云平台”比裸写“您正在登录”要稳妥。创建模板时选“验证码”类型内容示例填好之后等待审核即可。审核通过后模板管理页面会生成一个模板CODE格式像SMS_1234567890这个CODE后面写代码要用到建议复制到本地备忘录里。1.4 AccessKey创建方式与权限最小化AccessKey就是你的API钥匙分AccessKey ID和AccessKey Secret两部分。ID相当于用户名Secret相当于密码。很多人在这里犯一个低级错误直接用主账号的AccessKey跑项目。方便是方便但一旦Secret泄漏等于把整个云账号的钥匙交给了别人。正确做法是使用RAM子账号并且只授予短信服务的权限。创建路径控制台搜索“RAM访问控制”→创建用户→勾选“OpenAPI调用访问”→保存AccessKey ID和Secret→给这个用户添加权限策略选中AliyunDysmsFullAccess即可。Secret只在创建时显示一次页面关掉就再也找不回来了。如果没保存成功只能删除重建。这也是我踩过的一个坑所以单独拎出来提醒一下。还有测试阶段可以把权限收紧到AliyunDysmsReadOnlyAccess不行这只读权限不能发送短信。最小够用方案就是AliyunDysmsFullAccess配合RAM用户使用风险已经可控了。2. 第二步在SpringBoot里跑通短信发送链路2.1 引入依赖Core包就够了别把整个SDK全家桶都拉进来阿里云短信的SDK封装有好几种有老牌的aliyun-java-sdk-core加aliyun-java-sdk-dysmsapi组合也有新的POP风格SDKdysmsapi20170525。我推荐用的是aliyun-java-sdk-core单依赖方案。原因很简单——发短信本质上就是一次HTTP请求而CommonRequest已经能把请求组装这件事包圆了没必要为此引入整个dysmsapi模块。依赖越少后续版本冲突的可能性就越小这对SpringBoot项目来说很重要。dependency groupIdcom.aliyun/groupId artifactIdaliyun-java-sdk-core/artifactId version4.6.3/version /dependency注意版本4.6.3是我实测稳定的版本。不要盲目追新因为阿里云这个核心包的高版本有时候会依赖更高版本的Jackson等库容易跟SpringBoot自带的版本起冲突。如果你更喜欢类型明确的SDK也可以加dysmsapi包调用代码写成SendSmsRequest和SendSmsResponse。效果一样只是个人偏好问题。我的示例都用CommonRequest的方式代码量更少。2.2 配置文件与自动装配敏感信息别硬编码在application.yml里补上短信配置aliyun: sms: access-key-id: ${ALIYUN_SMS_ACCESS_KEY_ID} access-key-secret: ${ALIYUN_SMS_ACCESS_KEY_SECRET} sign-name: 某某科技 template-code: SMS_1234567890这里有个我一直坚持的实践AccessKey ID和Secret不要直接写在YAML文件里而是通过环境变量引用。原因不复杂——代码仓库可能会被分享、上传到Git如果把Secret明文提交上去等于把钥匙贴在门上。用${ALIYUN_SMS_ACCESS_KEY_ID}的方式本地启动时在IDE的环境变量里配一下就行上线时在服务器环境变量里设置或者放到配置中心。配置类用ConfigurationProperties更优雅创建一个SmsProperties类Component ConfigurationProperties(prefix aliyun.sms) Data public class SmsProperties { private String accessKeyId; private String accessKeySecret; private String signName; private String templateCode; }ConfigurationProperties是SpringBoot自动装配的经典玩法它会把YAML里aliyun.sms前缀下的所有字段自动绑定到这个POJO上不用一堆Value逐个注入。绑定完成后把对象注入到Service里就可以了。2.3 发送服务封装不只是发出去还要把日志和错误处理写到位核心发送逻辑我封装在SmsService里代码如下Service Slf4j public class SmsService { Resource private SmsProperties smsProperties; public void sendSmsCode(String phone, String code) { try { DefaultProfile profile DefaultProfile.getProfile( cn-hangzhou, smsProperties.getAccessKeyId(), smsProperties.getAccessKeySecret() ); IAcsClient client new DefaultAcsClient(profile); CommonRequest request new CommonRequest(); request.setSysMethod(MethodType.POST); request.setSysDomain(dysmsapi.aliyuncs.com); request.setSysVersion(2017-05-25); request.setSysAction(SendSms); MapString, String params new HashMap(); params.put(PhoneNumbers, phone); params.put(SignName, smsProperties.getSignName()); params.put(TemplateCode, smsProperties.getTemplateCode()); params.put(TemplateParam, {\code\:\ code \}); request.setQueryParameters(params); CommonResponse response client.getCommonResponse(request); String data response.getData(); log.info(短信API返回{}, data); JSONObject json JSONObject.parseObject(data); String respCode json.getString(Code); if (OK.equals(respCode)) { log.info(短信发送成功手机号{}RequestId{}, phone, json.getString(RequestId)); } else { log.error(短信发送失败手机号{}Code{}Message{}, phone, respCode, json.getString(Message)); throw new BizException(短信发送失败 json.getString(Message)); } } catch (ClientException e) { log.error(短信调用异常手机号{}, phone, e); throw new BizException(短信服务暂时不可用请稍后重试); } } }这段代码有几个容易被忽略的细节第一地域节点。发短信接口的Region统一用cn-hangzhou不要想当然地改成自己服务器所在区域。短信服务只有这一个网关入口写cn-beijing反而可能出问题。第二TemplateParam的JSON格式。变量参数必须是一个合法的JSON字符串code的值是字符串类型。我之前见过有人把整个JSON参数写成{code:123456}数字类型会导致模板渲染时报变量类型不匹配这里建议统一用字符串拼接。第三返回值判断。阿里云短信API返回的结构里有一个Code字段只有它的值是OK才代表发送成功其他值都是失败而且Message里会有具体原因。很多人只判断HTTP状态码200就以为成功了其实API层面的业务错误码才是关键。日志一定要记录RequestId后面提交工单排查问题时这是阿里云工程师问你最多的一句话。2.4 异步发送别让短信拖慢用户请求短信发送的网络耗时通常在200到500毫秒之间某些极端情况下接近一秒。如果用户在注册接口里同步等待短信返回整个请求的响应时间会被明显拉长接口QPS也跟着受影响。常规做法是把发送动作丢给线程池异步执行。SpringBoot里最简单的方式就是AsyncService public class SmsCodeService { Async(smsThreadPool) public void sendCodeAsync(String phone, String code) { smsService.sendSmsCode(phone, code); } }注意两个细节。一是Async要配合EnableAsync使用主启动类加一下注解即可。二是异步方法必须从外部调用才生效同类的内部调用因为走的是this引用而不是代理对象注解会静默失效这是Spring AOP经典自调用问题。我习惯做法是拆两个ServiceSmsCodeService负责业务编排SmsService负责发送天然规避这个问题。线程池建议单独定义不要直接用Spring默认的SimpleAsyncTaskExecutor因为那个线程池每来一个任务就新开一个线程高并发下容易打爆内存。自定义一个核心线程数5、最大20、队列100的小线程池就够用了。3. 第三步实现验证码闭环生成、缓存、校验与防刷3.1 生成6位验证码的正确姿势生成验证码最简单的方式是Math.random()拼字符串但我不建议这么做。Math.random()的随机性来源并不是为安全场景设计的在验证码这种防暴力枚举的场景里应该使用密码学安全随机数。实际项目中我直接用java.security.SecureRandomprivate String generateCode() { SecureRandom random new SecureRandom(); int code 100000 random.nextInt(900000); return String.valueOf(code); }这样得到的6位数字均匀分布在100000到999999之间不会出现前导0导致的长度不一致问题也避免用字符串拼接随机数时可能出现的位数不到6位的情况。如果项目中已经引入了Hutool工具包直接调RandomUtil.randomNumbers(6)也行底层用的也是SecureRandom。但自己动手实现也就两行代码没必要额外引依赖。3.2 存储方案Redis优先本地缓存兜底验证码必须有过期时间这是基本要求。存储方案的选择取决于项目架构。如果项目是单机应用本地缓存就能跑起来。用一个ConcurrentHashMap加一条定时清理任务搞定Component public class LocalCodeStore { private static final MapString, CacheItem CACHE new ConcurrentHashMap(); public void save(String phone, String code) { CACHE.put(phone, new CacheItem(code, System.currentTimeMillis() 5 * 60 * 1000)); } public String getAndRemove(String phone) { CacheItem item CACHE.remove(phone); if (item null || item.expireTime System.currentTimeMillis()) { return null; } return item.code; } Scheduled(cron 0 */10 * * * ?) public void cleanExpired() { CACHE.entrySet().removeIf(entry - entry.getValue().expireTime System.currentTimeMillis()); } Data AllArgsConstructor static class CacheItem { private String code; private long expireTime; } }定时任务用的是SpringBoot的Scheduled每隔10分钟清理一次过期数据这台机器上不会留下堆积的内存垃圾。但如果项目是集群部署本地缓存方案就不能用了。原因很容易理解短信验证码是用户请求落到哪台机器就存在哪台机器上下次用户校验验证码时请求被负载均衡转发到另一台机器那边的缓存里根本查不到这条验证码。这种场景必须用Redis。Redis方案简单得多直接利用Key的过期时间stringRedisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES);五分钟过期不需要手动清理。后面的示例我都用Redis来写毕竟集群部署才是主流本地缓存方案了解原理即可。3.3 注册/登录接口的完整接入一个完整的验证码业务流程包含两块发送验证码和校验验证码。发送接口RestController RequestMapping(/api/sms) public class SmsCodeController { Resource private SmsCodeService smsCodeService; PostMapping(/code) public ResultVoid sendCode(RequestBody SendCodeRequest request) { smsCodeService.sendVerifyCode(request.getPhone()); return Result.success(); } }SmsCodeService里面做几件事校验手机号格式、检查60秒重发限制、生成验证码、异步发送短信、把验证码写入Redis。Service public class SmsCodeService { private static final String CODE_KEY_PREFIX sms:code:; private static final String SEND_FLAG_PREFIX sms:send:flag:; private static final long CODE_EXPIRE_MINUTES 5; Resource private StringRedisTemplate stringRedisTemplate; Resource private SmsService smsService; public void sendVerifyCode(String phone) { // 简单的手机号格式校验正则可根据项目调整 if (!Pattern.matches(^1[3-9]\\d{9}$, phone)) { throw new BizException(手机号格式不正确); } // 60秒内不能重复发送 String sendFlag stringRedisTemplate.opsForValue().get(SEND_FLAG_PREFIX phone); if (sendFlag ! null) { throw new BizException(发送太频繁请一分钟后再试); } String code generateCode(); smsService.sendSmsCode(phone, code); stringRedisTemplate.opsForValue().set(CODE_KEY_PREFIX phone, code, CODE_EXPIRE_MINUTES, TimeUnit.MINUTES); stringRedisTemplate.opsForValue().set(SEND_FLAG_PREFIX phone, 1, 60, TimeUnit.SECONDS); } public boolean verifyCode(String phone, String code) { String key CODE_KEY_PREFIX phone; String cachedCode stringRedisTemplate.opsForValue().get(key); if (cachedCode null) { return false; } if (cachedCode.equals(code)) { // 一次性验证码校验成功后立即删除 stringRedisTemplate.delete(key); return true; } return false; } }校验接口通常在注册或登录流程里调用验证成功后直接走业务逻辑。要注意的是验证码是一次性的不管校验成功还是失败建议都在校验结束后删除对应Key。失败的场景删除Key可以防止暴力穷举攻击这个细节容易被忽略。3.4 防刷策略60秒重发、每日上限与定时清理上面代码里的SEND_FLAG_PREFIX就是最简单的防重发机制保证同一手机号60秒内只能发起一次发送请求。但仅仅这个还不够。恶意用户可以用大量不同手机号轰炸接口每个号只发一次照样能把短信费用刷爆。我在项目里实际使用了两层补充策略一层是单手机号每日上限。在Redis里维护一个计数器String dailyKey sms:daily: phone; Long count stringRedisTemplate.opsForValue().increment(dailyKey); if (count ! null count 1) { stringRedisTemplate.expire(dailyKey, 24, TimeUnit.HOURS); } if (count ! null count 10) { throw new BizException(今日验证码发送次数已达上限); }10次是我自己项目里的阈值大家可以根据业务调整。注意increment第一次调用后要顺手设置过期时间否则这个Key永远不会过期。另一层是IP维度限流。可以在网关或过滤器里做比如同一个IP每分钟最多发5次验证码请求。Redis方案同样适用Key换成sms:ip:加IP地址逻辑跟60秒重发限制完全一致。这两层防刷加上60秒重发限制之后短信费用被恶意刷爆的概率就低多了。顺带提一句个人项目也要把单日总发送量监控起来阿里云控制台有短信发送统计报表定期看一眼异常波动早发现早处理。4. 短信发不出去一份完整的排障流程4.1 高频错误码一览真到了线上短信发不出去的原因来来去去就那几种。我整理一个高频错误码表格方便对照查找。错误码含义常见原因isv.BUSINESS_LIMIT_CONTROL触发业务流控同一手机号短时间发送太频繁最常见于测试时反复点击isv.SMS_TEMPLATE_ILLEGAL模板不合法模板未审核通过、模板CODE写错、变量和模板不匹配isv.SMS_SIGNATURE_ILLEGAL签名不合法签名未审核通过、签名名称写错isv.AMOUNT_NOT_ENOUGH账户余额不足短信服务是预付费余额为0当然发不出去SignatureDoesNotMatch签名不匹配AccessKey Secret错误、代码里多传了空格InvalidAccessKeyId.NotFoundAccessKey不存在AccessKey ID写错、RAM子账号被删除Throttling.User用户维度限流账号整体QPS超限单日发送量到了配额上限这七个错误码覆盖了我见过的大多数线上问题。遇到短信发送失败第一反应不是改代码而是把日志里的Code字段捞出来对照表格很多问题一眼就能定位。4.2 一个完整的排查记录我拿“isv.BUSINESS_LIMIT_CONTROL”举例说说我当时是怎么排查的。测试时反复点“获取验证码”按钮点了七八次之后突然短信收不到了代码里也没有任何报错日志但API返回的Code就是isv.BUSINESS_LIMIT_CONTROL。一开始我以为是代码逻辑有问题还重试了好几次结果越试越是这个错误码。后来查了阿里云官方文档才知道同一手机号在天然频控规则下是有发送频率限制的1小时内最多发5条24小时内最多发10条。测试阶段多按几次按钮很容易就触到这个阈值。解决方式也很简单等频控窗口过去或者换一个手机号继续测。如果业务场景确实需要更高频率可以申请调整频控白名单但正常用户场景不会有人一分钟收好几条验证码所以保持默认就好。另一个让我印象深刻的坑是签名或模板还没审核通过就开始测试。那个时候接口返回的也是isv.SMS_SIGNATURE_ILLEGAL或isv.SMS_TEMPLATE_ILLEGAL但页面控制台里签名和模板的状态明明是“待审核”。所以排查时先看签名和模板的审核状态比对着错误码猜更快。还有一类问题是网络上偶发超时。阿里云SDK默认的连接超时和读取超时设置偏保守在弱网环境或服务器出口带宽不稳定时偶尔会出现异常但代码里每次都抛异常用户看到的短信就是时有时无。这种情况下对发送失败加一个简单的重试机制最多重试两次间隔100毫秒成功率会明显提升。前提是发送动作要保持幂等验证码场景天然幂等发了两条都能用用户看到内容也一样所以重试是安全的。4.3 SpringBoot版本与依赖冲突的兼容性近几年SpringBoot版本升级很快网上能找到的教程很多还是基于2.x写的但新项目起步就是3.x甚至4.x的预览版都出来了。版本“太高”带来的兼容问题主要集中在这几个地方。第一是javax包名迁移到jakarta。SpringBoot 3.x把Servlet API从javax.servlet换成了jakarta.servlet如果项目中某些老依赖还在用javax包的Servlet类启动时会出现ClassNotFoundException。阿里云短信SDK是纯HTTP调用不依赖Servlet API所以这一项对短信功能本身没影响。但如果你参考的博客里贴了自定义拦截器、过滤器之类的代码注意看SpringBoot版本差异。第二是Jackson版本冲突。SpringBoot的spring-boot-starter-web自带Jackson阿里云SDK也依赖Jackson。版本不一致时可能出现NoSuchMethodError特征很隐蔽运行到短信发送的那一刻才爆出来。解决办法是统一用SpringBoot管理的Jackson BOM版本阿里云SDK那个依赖传递尽量排除掉。第三是JDK版本的问题。SpringBoot 3.x要求JDK 17以上但阿里云老版本SDK在JDK 17某些版本下有模块访问问题。如果你用的是JDK 17加SpringBoot 3.x升级aliyun-java-sdk-core到4.6.x基本就没事了4.6.x对高版本JDK的兼容性明显更好。如果项目里同时用了Spring Cloud全家桶依赖树非常复杂建议在集成短信模块之后跑一遍mvn dependency:tree重点看Jackson和HttpClient有没有重复且版本不一致的情况。这算是大型项目接第三方SDK的通用经验不只是短信服务独有。最后说一个我坚持至今的习惯短信发送的成功率直接关系到用户体验线上环境一定要把日志打全至少包含手机号、模板CODE、错误码、RequestId这四个字段。手机号可以脱敏但错误码和RequestId必须原样记录。这样出了问题给阿里云提工单的时候数据都是现成的不用再去翻老日志。短信验证码这个功能虽然不大但每一步细节都处理到位了一年下来能省掉很多半夜排查问题的精力。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进