短信验证码应该是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> <groupId>com.aliyun</groupId> <artifactId>aliyun-java-sdk-core</artifactId> <version>4.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"); Map<String, 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里最简单的方式就是@Async:
@Service public class SmsCodeService { @Async("smsThreadPool") public void sendCodeAsync(String phone, String code) { smsService.sendSmsCode(phone, code); } }注意两个细节。一是@Async要配合@EnableAsync使用,主启动类加一下注解即可。二是异步方法必须从外部调用才生效,同类的内部调用因为走的是this引用而不是代理对象,注解会静默失效,这是Spring AOP经典自调用问题。我习惯做法是拆两个Service,SmsCodeService负责业务编排,SmsService负责发送,天然规避这个问题。
线程池建议单独定义,不要直接用Spring默认的SimpleAsyncTaskExecutor,因为那个线程池每来一个任务就新开一个线程,高并发下容易打爆内存。自定义一个核心线程数5、最大20、队列100的小线程池就够用了。
3. 第三步实现验证码闭环:生成、缓存、校验与防刷
3.1 生成6位验证码的正确姿势
生成验证码最简单的方式是Math.random()拼字符串,但我不建议这么做。Math.random()的随机性来源并不是为安全场景设计的,在验证码这种防暴力枚举的场景里,应该使用密码学安全随机数。
实际项目中我直接用java.security.SecureRandom:
private 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 Map<String, 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 Result<Void> 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.NotFound | AccessKey不存在 | 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必须原样记录。这样出了问题,给阿里云提工单的时候,数据都是现成的,不用再去翻老日志。短信验证码这个功能虽然不大,但每一步细节都处理到位了,一年下来能省掉很多半夜排查问题的精力。