短信验证码这东西,做Java后端的基本都绕不开。注册、登录、改密码、换绑手机,哪个场景都得用。我前前后后在好几个项目里集成过短信服务,从最早的阿里云旧版SDK到现在的版本,坑踩了不少,但凡是理清了那几步路,其实真没那么复杂。今天就把这套“三步走”的完整落地方案摊开来讲,从一个空SpringBoot项目开始,到能实打实收到验证码,再到后面防刷、容错这些生产环境才关心的细节,全部过一遍。
这篇东西适合谁?刚接触SpringBoot想找个完整实战项目的同学能直接跟着敲,在做一个中小型项目、需要快速接入短信功能的开发者也能直接抄作业,就算你已经在用别的短信服务商,里面关于验证码存储、过期策略、防刷限流的思路也完全能平移过去用。
先说结论,整个短信验证码接入链路拆开就三部分:开通阿里云短信服务并准备签名模板,在SpringBoot工程里集成官方SDK并进行配置,最后编写发送验证码、校验验证码的完整业务逻辑。思路理顺了,每一步都是填参数的事。但参数怎么填、逻辑怎么写严谨、遇到限流报错怎么排查,这里面的门道才是今天真正要聊的重点。
1. 方案选型与整体链路梳理
1.1 为什么选阿里云短信,以及比自建好在哪
很多开发者有一个误区,觉得短信验证码不就是发个HTTP请求的事吗,自己对接运营商网关不就行了。真不是这么回事。个人或小团队直接对接运营商,首先资质就很难过,需要企业营业执照、短信业务资质备案,还要协商流量采购价格;其次运营商接口协议各家都不太一样,状态报告、下行短信、签名审核这些环节每一项都是实打实的工作量。阿里云这类云厂商做的事情,就是把运营商资源统一整合好,你只需要管好自己的业务代码,剩下的通道稳定性、到达率、状态回调、高并发处理,都是他们兜底。
更关键的是成本。自建通道需要预充值、谈套餐,小体量业务根本谈不到好价格。阿里云短信按条计费,验证码场景通常几厘钱一条,还有免费额度可以领,对个人项目和初创产品非常友好。而且它提供了完整的OpenAPI和官方SDK,几行代码就能完成调用,不需要你去理解底层协议细节。所以选阿里云短信不是因为它最好,而是它在国内生态最成熟、文档最全、出问题能找到的参考案例最多,对绝大多数SpringBoot项目来说是最稳妥的选项。
1.2 一条验证码从请求到手机的完整链路
在敲代码之前,脑子里必须有一张完整的时序图。我这里用文字把链路画一遍:
用户在前端页面输入手机号,点击“获取验证码”,请求打到我们自己的后端接口。后端收到请求后,先做前置校验,比如这个手机号60秒内是否已经发过、当天发送次数是否超限。校验通过后,后端生成一个6位随机数字验证码,把手机号、验证码、过期时间存到Redis里,同时调用阿里云短信服务的SendSms接口,把手机号、签名、模板Code、模板变量传给阿里云。阿里云内部校验签名和模板是否通过审核,然后通过运营商通道把短信下发到用户手机。用户收到短信后,在前端页面输入验证码提交,后端收到后从Redis取出之前存的验证码进行比对,同时校验过期时间,匹配成功就放行,并且立刻删除这条记录,防止验证码被重复使用。
这个链路里有几个关键点。第一,验证码必须服务端生成、服务端存储,绝对不能在客户端生成,否则就失去了验证码的意义。第二,短信下发和验证码校验是两步独立的逻辑,发送成功不代表校验一定成功,中间可能涉及短信延迟、用户输错等场景。第三,验证码是一次性的,校验通过后必须立即失效。这些点后面会在代码里一一体现。
1.3 技术选型:SDK版本与关键依赖
阿里云短信服务目前主推的是dysmsapi20170525这个SDK包,对应的产品名叫短信服务,API版本号是2017-05-25。新老SDK差异比较大,老版是aliyun-java-sdk-core配合aliyun-java-sdk-dysmsapi,配置方式繁琐,现在已经不推荐了。新版SDK采用com.aliyun:dysmsapi20170525,基于Tea框架,配置方式更简洁,只需要设置AccessKey ID、AccessKey Secret和Endpoint就行。
另外,验证码存储环节,生产环境推荐用Redis。如果项目还没引入Redis,本地先用一个带过期时间的ConcurrentHashMap也能应付小规模测试,但并发高或者多实例部署的情况下会出问题。这个选择背后的逻辑我后面专门小节细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开通阿里云短信服务:签名与模板是重头戏
2.1 账号准备与AccessKey的创建规范
阿里云账号的注册和实名认证我就不展开了,这是基础操作。重点说一下AccessKey。登录阿里云控制台,在右上角头像菜单里进入AccessKey管理,创建一个子用户AccessKey或者直接用主账号AccessKey。我需要强调一点,生产环境务必使用RAM子账号,并且只授予短信服务的权限,而不是直接拿主账号AccessKey到处用。因为AccessKey泄露的后果是非常严重的,主账号Key一旦泄露,别人可以操作你账号下的所有云资源。我个人见过不止一次因为Key硬编码在代码仓库里导致被刷短信、产生巨额账单的事故。正确做法是创建一个只有AliyunDysmsFullAccess权限的RAM用户,把风险降到最低。
创建完RAM用户后,会生成AccessKey ID和AccessKey Secret,这两个值需要妥善保存,Secret只在创建时显示一次,后面再想看只能重置。然后把这两个值配置到SpringBoot的application.yml里,或者更推荐用环境变量、配置中心等方式注入,避免明文写死在代码里。
2.2 申请短信签名:类型选择和审核要点
短信签名是放在短信内容前面的标识,比如【某某科技】,用于告知用户短信发送方身份。这个签名需要单独申请,并且要等审核通过后才能使用。在阿里云短信控制台左侧菜单找到“国内消息 -> 签名管理”,点击新增签名。
签名类型根据你的场景选,个人开发通常选“应用”或“测试”,需要上传对应的证明材料。企业用户选“企业”类型,需要营业执照。审核时间通常几分钟到几小时不等。这里有一个实际经验:签名名称最好和你应用的品牌强相关,比如你做的是一个叫“星辰”的App,签名就申请【星辰】。如果申请一些通用词比如【验证】、【通知】,审核往往会被驳回,原因是不符合签名规范。签名申请通过后,会得到一个纯文本的签名名称,这个名称后面会作为SignName参数传给阿里云。
2.3 申请模板:变量定义与内容规范
模板是短信正文的格式,比如“您的验证码为${code},5分钟内有效。”。同样在短信控制台的“模板管理”里新增。模板类型选择“验证码”,这个类型的模板有几点限制需要特别注意:验证码模板只能包含一个变量,而且变量名只能是${code},不能出现其他变量如${username}或者${time}等;验证码模板不能包含营销内容,不能有链接、电话等;模板内容必须直白明确,说明验证码用途和有效期。
模板审核通过后会分配一个模板Code,格式类似SMS_123456789,后面调用时要用这个Code告诉阿里云用哪个模板来渲染短信内容。我遇到过很多人卡在这一步,模板提交了好几次都被驳回,原因往往就是变量名不规范、内容里混入了“优惠券”、“点击链接”等敏感词。记住一点:验证码模板就做验证码,规规矩矩写“验证码+用途+有效期”,一次过审的概率很高。
2.4 套餐包购买与费用说明
阿里云短信是预付费模式,可以在“套餐包”页面购买短信条数包,有100条、1000条、10000条等不同规格,价格随量递减。新用户一般可以免费领取一定数量的测试条数。我建议个人项目先不要急着买大套餐包,用免费额度把流程跑通,确认线上短信到达率没问题后再按需购买。注意,签名审核和模板审核都是免费的,只有实际发送短信才扣费。如果发送成功但计费异常,费用账单在阿里云费用中心都能查明细。
需要提醒一句:阿里云短信有每日发送上限,个人认证和企业认证的配额不同,一般个人认证的每日上限较低,如果业务量比较大,你可能需要升级企业认证或者申请提升配额。这也是为什么我把这步单独拎出来说,不是代码写好了就完事了,账号层面的配额限制是很多人忽略的隐性坑。
3. 三步实战:从工程搭建到第一条短信发出
3.1 第一步:引入依赖与配置文件
用一个全新的SpringBoot工程来演示。我用的是SpringBoot 2.7.x版本,Java 8以上都兼容。在pom.xml里引入短信SDK依赖:
xml复制<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.24</version>
</dependency>
同时,验证码存储用Redis的话,加上Spring Data Redis相关依赖:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
如果只是本机快速测试,也可以先不引Redis,后面我会给一个本地缓存的替代写法。
接着在application.yml里配置阿里云短信相关参数:
yaml复制aliyun:
sms:
access-key-id: your-access-key-id
access-key-secret: your-access-key-secret
sign-name: 星辰
template-code: SMS_123456789
endpoint: dysmsapi.aliyuncs.com
这里endpoint默认就是这个地址,如果不是特殊情况不需要改动。把这些信息抽到配置文件里,后续修改签名或模板不需要改代码。用@ConfigurationProperties绑定一个配置类,代码里通过注入这个配置类来读取参数。
3.2 第二步:构建短信发送客户端
新版SDK的核心是构造一个com.aliyun.dysmsapi20170525.Client对象。看一下这个步骤的代码:
java复制@Component
@ConfigurationProperties(prefix = "aliyun.sms")
@Data
public class SmsConfig {
private String accessKeyId;
private String accessKeySecret;
private String signName;
private String templateCode;
private String endpoint;
}
然后是客户端配置类和发送服务类。阿里云新版SDK的客户端构造方式是这样的:
java复制@Configuration
public class SmsClientConfig {
@Resource
private SmsConfig smsConfig;
@Bean
public com.aliyun.dysmsapi20170525.Client smsClient() throws Exception {
com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config()
.setAccessKeyId(smsConfig.getAccessKeyId())
.setAccessKeySecret(smsConfig.getAccessKeySecret());
config.endpoint = smsConfig.getEndpoint();
return new com.aliyun.dysmsapi20170525.Client(config);
}
}
这个Client是线程安全的,整个应用生命周期只需要一个实例,不需要反复创建。用@Bean注册成Spring容器里的单例,后续注入到Service里直接使用。有人会问,为什么非要搞个配置类单独建Client,直接在你需要的地方new不行吗?当然可以,但每次new都会重新初始化连接,并发高的时候会白白浪费资源,而且配置文件没法统一管理。用Spring管理,既保证了单例,又能和其他组件统一生命周期。
3.3 第三步:编写发送与校验的完整业务代码
这是整个实战最核心的一步。短信验证码的发送逻辑分为三段:前置检查、生成验证码并存储、调用阿里云发送短信。我直接给一个完整的SmsService:
java复制@Service
@Slf4j
public class SmsService {
@Resource
private com.aliyun.dysmsapi20170525.Client smsClient;
@Resource
private SmsConfig smsConfig;
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final String SMS_CODE_PREFIX = "sms:code:";
private static final String SMS_LIMIT_PREFIX = "sms:limit:";
private static final long SMS_CODE_EXPIRE_SECONDS = 300; // 5分钟有效
private static final long SMS_SEND_INTERVAL_SECONDS = 60; // 同一号码60秒内不可重复发送
public void sendCode(String phone) {
// 1. 前置检查:60秒内是否重复发送
String limitKey = SMS_LIMIT_PREFIX + phone;
Boolean canSend = stringRedisTemplate.hasKey(limitKey);
if (Boolean.TRUE.equals(canSend)) {
throw new RuntimeException("发送过于频繁,请稍后再试");
}
// 2. 生成6位随机验证码
String code = generateCode();
// 3. 存入Redis,有效期5分钟
String codeKey = SMS_CODE_PREFIX + phone;
stringRedisTemplate.opsForValue().set(codeKey, code, SMS_CODE_EXPIRE_SECONDS, TimeUnit.SECONDS);
// 4. 设置发送间隔限制,60秒后自动过期
stringRedisTemplate.opsForValue().set(limitKey, "1", SMS_SEND_INTERVAL_SECONDS, TimeUnit.SECONDS);
// 5. 调用阿里云发送短信
try {
sendSms(phone, code);
log.info("验证码发送成功,手机号:{}", phone);
} catch (Exception e) {
// 发送失败时删除Redis中的验证码,避免留下无效数据
stringRedisTemplate.delete(codeKey);
throw new RuntimeException("短信发送失败,请稍后再试", e);
}
}
private String generateCode() {
Random random = new Random();
return String.format("%06d", random.nextInt(1000000));
}
private void sendSms(String phone, String code) throws Exception {
com.aliyun.dysmsapi20170525.models.SendSmsRequest request = new com.aliyun.dysmsapi20170525.models.SendSmsRequest()
.setPhoneNumbers(phone)
.setSignName(smsConfig.getSignName())
.setTemplateCode(smsConfig.getTemplateCode())
.setTemplateParam("{\"code\":\"" + code + "\"}");
com.aliyun.dysmsapi20170525.models.SendSmsResponse response = smsClient.sendSms(request);
// 根据响应体判断是否发送成功
if (!"OK".equals(response.getBody().getCode())) {
throw new RuntimeException("阿里云返回错误:" + response.getBody().getCode() + " - " + response.getBody().getMessage());
}
}
}
这里有几个细节必须讲透。
第一,验证码生成用的Random其实不是最优选择,更严谨应该用SecureRandom,防止随机数被预测。在安全要求更高的场景,用SecureRandom是标准做法。
第二,templateParam的格式是一个JSON字符串,key必须和模板里定义的变量名一致。模板里用的是${code},那JSON里就是{"code":"123456"}。很多人用的时候把变量名写错,比如模板是${code},程序里传的是{"codeValue":"123456"},阿里云会返回模板变量不匹配的错误。
第三,发送失败时删除Redis里的验证码,这个细节容易被忽略。阿里云返回错误,说明用户收不到短信,如果Redis里还留着“验证码”,用户虽然没收到短信,但拿着一个不存在的验证码去校验,逻辑上不严谨。更合理的做法是发送失败就不写入,或者写入后立刻删除,让校验端无码可验。
第四,StringRedisTemplate和RedisTemplate的选择。用StringRedisTemplate是因为存的就是字符串,不需要额外的序列化器,省去不少乱码问题的排查时间。
3.4 校验验证码接口的实现细节
发送逻辑写完,校验逻辑相对简单,但也有几个容易犯错的地方。校验接口代码:
java复制public boolean verifyCode(String phone, String code) {
String codeKey = SMS_CODE_PREFIX + phone;
String savedCode = stringRedisTemplate.opsForValue().get(codeKey);
if (savedCode == null) {
return false; // 验证码不存在或已过期
}
boolean matched = savedCode.equals(code);
if (matched) {
stringRedisTemplate.delete(codeKey); // 一次性使用,校验成功后立即删除
}
return matched;
}
注意校验成功的分支里,删除操作必须在返回之前完成。这样可以防止同一个验证码被调用两次校验都通过。如果业务场景需要多次校验,比如先校验再改密码再确认,可以在前端或者业务设计上做调整,但标准的验证码场景一定是“一次有效”。
还有一个常见设计问题:校验失败要不要记录次数?我建议要做。比如连续输错5次,直接删除验证码,要求用户重新获取。这样能防止有人拿一个验证码暴力穷举。校验次数的实现可以沿用Redis的increment操作,给同一个key加一个计数器。
4. 验证码的存储方案、过期策略与防刷设计
4.1 本地缓存和Redis,怎么选
我先把两种方案的取舍说清楚。
本地缓存方案,用ConcurrentHashMap加ScheduledExecutorService定时清理过期key,实现成本极低,不需要额外部署中间件,适合单体应用、并发量极小的场景。但它的致命弱点是:一旦应用多实例部署,用户请求落到A实例发的验证码,下次校验落到B实例就读不到;如果应用重启,所有验证码直接清空。所以多实例、高可用场景下必须用Redis。
Redis方案的好处显而易见:验证码存到一个独立中间件里,所有实例共享,天然支持过期时间,底层数据结构简单高效。缺点就是多一个组件要运维,但SpringBoot整合Redis实在太容易了,这几乎不算什么负担。我实际项目的做法是:生产环境一律Redis,本地开发如果不想启动Redis可以用内存版替代,但代码结构上要预留好切换空间。
4.2 过期时间的设置与自动失效机制
验证码有效期,我习惯设为5分钟。这个值不是拍脑袋定的。太短了,用户还没看完短信就过期了,体验很差;太长了,安全风险高,一条验证码长时间有效意味着被暴力破解的概率变大,短信轰炸攻击者可以利用长有效期频繁尝试。5分钟在便利性和安全之间取了个平衡,也是行业内比较常见的默认值。
在使用Redis的set(key, value, timeout, TimeUnit.SECONDS)时,过期时间是Redis服务端强制管理的,到点自动删key,不需要自己在业务代码里判断时间。这一点比本地缓存方案省心得多,不用维护定时任务,也不用每次读取时手动比对System.currentTimeMillis()。
4.3 防刷与限流:不仅仅是一个验证码的事
短信验证码接口天生容易被恶意刷。攻击者拿同一个手机号反复请求、或者批量换手机号请求,会造成短信费用飙升,甚至把接口当短信轰炸平台。我在实际项目里遇到的轰炸场景有两种:一是有人拿你的接口去轰炸别人手机号,二是有人拿别人接口轰炸你自己的用户。防刷必须做在业务代码层。
我常用的防刷策略组合如下:
| 策略 | 具体实现 | 说明 |
|---|---|---|
| 同一手机号发送间隔 | Redis key,60秒过期 | 接口直接拦截 |
| 同一手机号每日上限 | Redis key,次日0点过期 | 每日最多10条,超限拉黑到次日 |
| 同一IP发送上限 | Redis key,统计IP维度 | 每个IP每10分钟最多5条 |
| 前端图形/滑块验证 | 发送前先校验人机 | 针对接口被脚本自动化调用的场景 |
| 全局限流 | 网关或Redis计数器 | 对整体接口QPS做限制 |
这些策略不需要一开始全部做完,至少做前两个,就能拦截掉大部分恶意刷量。在实现每日上限时,key过期时间的设置要注意:不能用固定的24小时过期,而是要在每天零点重置。简单做法是计算到次日零点的剩余秒数作为过期时间,或者用Redis的expireAt指定具体时间点。
4.4 验证码相关接口的幂等与异常处理
发送验证码这个操作天然不是幂等的,点了两次就可能发两条。所以发送间隔限制实际上就是在做幂等控制:同一个手机号在60秒内的请求都返回同样的结果“已发送,请稍后再试”,而不是真的再发一条。这种“软幂等“对用户体验和成本控制都很有价值。
异常处理方面,我建议封装一个统一的Result返回对象。比如Result.success()、Result.error("发送过于频繁"),Controller层不要直接抛RuntimeException,而是捕获后转成友好提示。因为前端拿到异常信息才能直接展示给用户看,否则会显示成网络错误之类的模糊信息,用户根本不知道是被限流了还是手机号格式不对。
5. 踩坑实录与常见问题排查手册
5.1 高频报错代码的逐一拆解
我在集成过程中遇到的报错,基本都能在错误码里找到方向。下面是几个高频错误以及排查思路。
isv.BUSINESS_LIMIT_CONTROL:触发业务限流。原因可能是同一手机号发送过于频繁,或者短信服务当天的发送量达到上限。排查时先看是不是自己代码里的间隔限制没起作用,再看阿里云控制台里该账号的日发送量是不是用完了。
isv.SMS_SIGNATURE_ILLEGAL:签名不合法。要么签名还没审核通过,要么签名名称拼错,要么签名和账号主体身份不一致。去签名管理页面核对签名名称,看看审核状态。
isv.SMS_TEMPLATE_ILLEGAL:模板不合法。模板Code写错、或者模板还没通过审核、又或者模板内容已经修改但代码里还在用旧的。去模板管理页面确认。
isv.MOBILE_NUMBER_ILLEGAL:手机号格式不合法。阿里云要求号码格式为国际区号+号码,比如中国大陆的号码必须是8613812345678这样,你可以传13812345678,阿里云会自动补区号,但加上更保险。
SignatureDoesNotMatch:签名串不匹配。常见原因有两个,一是AccessKey ID或Secret配置错了,二是系统时间不准确导致签名校验失败。检查服务器时间是否有偏差。
InvalidAccessKeyId.NotFound:AccessKey ID不存在或已禁用。去RAM控制台看这个用户是否还在、是否被禁用。
表格整理一下:
| 错误码 | 可能原因 | 排查方向 |
|---|---|---|
| isv.BUSINESS_LIMIT_CONTROL | 触发限流或日发送量超限 | 检查间隔限制、控制台配额 |
| isv.SMS_SIGNATURE_ILLEGAL | 签名不存在/未过审/名称错误 | 核对签名名称及审核状态 |
| isv.SMS_TEMPLATE_ILLEGAL | 模板Code错误/未过审 | 核对模板Code及审核状态 |
| isv.MOBILE_NUMBER_ILLEGAL | 手机号格式错误 | 去掉空格、加国际区号 |
| SignatureDoesNotMatch | Key配置错误/服务器时间不准确 | 检查Key和系统时间 |
| InvalidAccessKeyId.NotFound | AccessKey不存在 | 检查RAM账号状态 |
5.2 上线前必须检查的几个细节
代码能跑通只是第一步,上线前我强烈建议做一轮自查。
第一,AccessKey是否已经从代码仓库里移除了。如果你把application.yml提交到GitHub公开仓库,别人可以扫描到你的Key然后疯狂刷你的短信,你会收到天价账单。Git历史里即使后来删掉,也能被翻出来。一旦发现泄露,立刻在控制台禁用并重新生成。
第二,消息推送的实际耗时。阿里云短信接口的响应时间一般在200~500ms,这个时间对同步调用来说可以接受,但如果在高并发场景下同步发送几十条,会把业务线程拖死。更优雅的方案是用消息队列解耦,把发送请求丢给MQ,异步消费后再把结果写回。
第三,生产环境日志脱敏。短信验证码、手机号都属于敏感信息,日志里打印完整手机号和不脱敏的验证码,一旦日志平台泄露,相当于用户信息裸奔。建议日志只打印前三位和后四位手机号,验证码本身不要打印。
5.3 从开发到上线的完整检查清单
最后分享一个我每次都会过一遍的清单,可以直接抄:
- 签名在控制台已通过审核
- 模板在控制台已通过审核
- 配置中心或环境变量里的AccessKey不含明文
- 同一手机号60秒重复发送已拦截
- 验证码有效期设定为5分钟
- 校验成功的验证码立即删除
- 连续输错5次自动失效
- Redis在生产环境已配置持久化(AOF或RDB)
- 阿里云账号已开启消费阈值预警
- 短信发送失败时Redis数据已回滚
这个清单看起来琐碎,但每一条背后都对应着一个真实的事故教训。短信通道是业务的最后一道确认关卡,验证码能正常收到、正确校验,很多核心流程才能跑通;反之,验证码收不到或者被滥用,流失的不只是用户,还有产品口碑。把这些细节做到位,短信这块才算真正稳了。
