短信验证码大概是后端项目里最"日常"的功能之一了。小到毕设里的登录注册,大到电商系统的实名校验,几乎每个Java项目都绕不开它。这篇实战文章讲的是SpringBoot集成阿里云短信服务的完整过程,我把标题里的"三步"拆成加依赖、配参数、写服务,三步走完就能把一条真实的验证码发到用户手机上。但真正能扛住生产环境的短信功能,远不止这三次调用。签名模板怎么过审、AccessKey怎么保管、验证码怎么存、接口怎么防刷,这些都是我在多个项目里被现实教育过之后才补齐全的经验,这里一并整理出来。不管你是正在做毕设的学生,还是要在老项目里快速接入短信能力的在职开发,这篇内容都可以直接照做。
1. 方案选型与整体架构:为什么是阿里云短信
1.1 服务商横向对比,我最终选了哪家
短信这个功能,自己对接运营商网关基本是自虐。你得准备SP资质、申请通道号、处理各种协议和状态报告,个人和中小团队根本扛不住。云厂商把运营商通道封装成HTTP接口和SDK,我们只需要申请签名和模板,一条短信就能发出去。所以在2024年前后这个时间点,选择云短信是最务实的技术决策,不用犹豫。
市面上的主力短信服务商我大概都过了一遍,这里做一个横向对比。
| 服务商 | SDK对Java的友好度 | 文档质量 | 审核速度 | 价格(验证码类) | 备注 |
|---|---|---|---|---|---|
| 阿里云 | 很友好,OpenAPI自动生成各语言SDK | 完整,错误码表齐全 | 签名模板通常1天以内 | 按量计费,阶梯价 | Java生态下排查问题最顺手 |
| 腾讯云 | 友好,SDK风格类似 | 不错 | 快 | 按量计费 | 依赖腾讯云生态时可选 |
| 华为云 | 一般 | 一般 | 中等 | 中规中矩 | 项目已经在华为云上时可以考虑 |
| 网易云信 | 友好 | 不错 | 中等 | 偏高 | 国际短信和语音验证码有优势 |
我实际用下来,阿里云短信对Java开发者最友好。第一,SDK是OpenAPI体系自动生成的,和SpringBoot集成几乎没有摩擦。第二,错误码文档非常全,线上出问题了能快速定位。第三,生态和社区成熟,搜一个问题能搜到大量前人踩坑的记录。如果你的项目本来就部署在阿里云上,内网调用短信接口的链路也更短,这个优势在排查问题时会体现得很明显。
1.2 整体数据流,一条验证码要经过几个环节
先明确整体架构,再动手写代码,后面会顺畅很多。一个完整的短信验证码流程大概是这样的。
- 用户在前端输入手机号,点击"发送验证码"按钮。
- 后端收到请求,先做图形验证码校验(防止脚本直接刷接口)。
- 再查Redis里的发送频率记录,判断这个手机号60秒内是否已经发过。
- 通过之后生成6位数字验证码。
- 调用阿里云短信OpenAPI发送短信。
- 发送成功后,把验证码写入Redis,设置5分钟过期时间。
- 用户收到短信,在表单里输入验证码并提交。
- 后端从Redis取出验证码比对,一致则通过,并立即删除这个key。
整个链路里,短信服务商只负责第5步,其他环节都是我们业务系统自己实现的。很多新手以为短信集成就是把SDK调通就完了,其实第3步、第6步、第8步才是决定这个功能能不能上生产的核心。后面我会按这个数据流一步步拆解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前置准备:账号体系、签名模板与SDK选型
2.1 用RAM子账号,别把主账号AccessKey写进代码
这是我在项目里见过的最普遍的安全问题。很多教程里直接让你复制主账号的AccessKey ID和Secret,然后硬编码在application.yml里。这么做本地开发demo当然能跑,但生产环境一旦代码仓库泄露,整个云账号都完了。
正确做法是去阿里云控制台创建RAM子账号。搜索"RAM访问控制",创建一个新用户,访问方式勾选"OpenAPI调用访问",系统会生成一对AccessKey ID和Secret。权限策略选择"AliyunDysmsFullAccess",或者更精准地只赋予短信服务的发送权限。子账号的AK即使泄露,我们也可以在RAM控制台一键禁用或轮换,主账号安全不受影响。
密钥本身不要提交到git仓库。本地开发可以用环境变量覆盖,或者放到配置文件里但通过gitignore排除;生产环境推荐放到配置中心(Nacos、Apollo)或者K8s的Secret里。我自己的习惯是:本地用~/.bashrc里导出环境变量,SpringBoot的配置里用占位符接收,代码仓库里永远不出现真实密钥。
提示:如果发现AccessKey已经泄露,第一时间去RAM控制台把旧AK删除并生成新AK,同时检查这个账号下所有资源有没有异常操作。
2.2 签名和模板申请,审核坑提前避开
短信签名和短信模板是阿里云短信的两个前置资源,没有它们,代码写得再对也发不出去。
签名是短信开头用【】包裹的称呼,比如【XX科技】。企业用户申请公司名或品牌名通常非常快,几个小时到一天就能过。个人开发者在这个环节容易卡住,阿里云对个人认证用户的签名归属校验比较严,通常会要求提供对应的App应用名称、公众号名称等材料。我的建议是:学生做毕设的话,可以用测试签名先跑通代码,等正式项目提交材料申请企业签名;在职开发者直接找公司要营业执照和授权书。
模板是短信正文的规范格式。验证码模板的标准写法类似:
text复制您的验证码为${code},5分钟内有效,请勿泄露给他人。
注意两点。第一,动态内容必须用${}占位,不能直接写死成数字或手机号。第二,模板文字需要通顺、无违规内容,不能含营销词、链接、诱导用户回复等元素。模板审核一般比签名快,快的话几十分钟就过了。
签名和模板审核通过之后,控制台会给你一个TemplateCode,格式类似SMS_123456789,后面代码里要用到。我在实际项目中踩过最大的坑,就是代码全部写完了,才发现签名还在审核中,整个联调往后推了半天。所以签名和模板一定要在开发第一天就去申请,让审核和编码并行。
2.3 SDK版本选型,新老版本怎么取舍
阿里云短信SDK有两代,网上教程经常混着写,很容易把人绕晕。
老一代是基于aliyun-java-sdk-core的体系,代码比较啰嗦,需要构造CommonRequest、手动设置Domain和Version参数。新一代是基于OpenAPI体系的独立SDK,每个产品一个包,类名更直观,代码量少很多。以dysmsapi为例,新包的Maven坐标是:
xml复制<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.24</version>
</dependency>
老包则是:
xml复制<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.3</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
<version>2.2.1</version>
</dependency>
我推荐新项目直接用新版SDK。它内部自动处理签名、序列化和HTTP连接管理,代码风格也更符合现代Java习惯。如果你的老项目已经在用老版SDK且运行稳定,那也没必要重构,下面几节我会给两个版本的代码,按需取用就行。
3. 三步集成:依赖、配置、服务实现
3.1 第一步:把SDK加入项目
这里直接给出pom.xml里需要添加的依赖。我用的是新版SDK,前面已经给过坐标了。如果使用SpringBoot 2.7+或SpringBoot 3.x,都没问题,这个SDK不依赖Spring,只依赖阿里云的Tea框架,兼容性很好。
xml复制<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.24</version>
</dependency>
引入依赖后,用Maven重新刷新一下,确认能下载com.aliyun.dysmsapi20170525.Client这个类,说明SDK已经就位。新版SDK会传递依赖teaopenapi等基础模块,一般不需要手动指定。
3.2 第二步:把参数写进配置文件
在application.yml里增加短信相关配置:
yaml复制aliyun:
sms:
access-key-id: ${SMS_ACCESS_KEY_ID:your-access-key-id}
access-key-secret: ${SMS_ACCESS_KEY_SECRET:your-access-key-secret}
sign-name: ${SMS_SIGN_NAME:【你的签名】}
template-code: ${SMS_TEMPLATE_CODE:SMS_123456789}
endpoint: dysmsapi.aliyuncs.com
我用环境变量占位符的方式给了一份默认值,本地开发可以直接填真实值,但记得让git忽略配置文件或者只提交占位符版本。接下来写一个配置属性类,让SpringBoot自动绑定:
java复制import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.sms")
public class AliyunSmsProperties {
private String accessKeyId;
private String accessKeySecret;
private String signName;
private String templateCode;
private String endpoint;
}
有人会问为什么还要多写一个属性类,直接@Value注入不就行了?属性类的优势是多处使用时不需要重复写一堆@Value,而且IDE自动补全、参数校验都更好做。如果项目里配置项很多,还可以用@ConfigurationProperties开启宽松绑定,规范统一。
3.3 第三步:发送服务实现
先定义一个短信服务接口,方便后面做单元测试和替换实现:
java复制public interface SmsService {
/**
* 发送短信验证码
*
* @param phone 手机号
* @param code 验证码
* @return 是否发送成功
*/
boolean sendVerifyCode(String phone, String code);
}
然后写阿里云实现类。这里有几个需要重点注意的细节。
- 阿里云的Client是线程安全的,不要每次发送都new一个Client,而是通过构造方法注入并复用。
templateParam必须传JSON字符串,格式不能错,比如{"code":"123456"}。- 发送后要判断响应的
code字段,只有等于OK才是发送成功,不能只看客户端有没有异常。
java复制import com.alibaba.fastjson2.JSON;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import com.aliyun.teaopenapi.models.Config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class AliyunSmsServiceImpl implements SmsService {
private final Client client;
private final AliyunSmsProperties properties;
public AliyunSmsServiceImpl(AliyunSmsProperties properties) throws Exception {
Config config = new Config()
.setAccessKeyId(properties.getAccessKeyId())
.setAccessKeySecret(properties.getAccessKeySecret())
.setEndpoint(properties.getEndpoint());
this.client = new Client(config);
this.properties = properties;
}
@Override
public boolean sendVerifyCode(String phone, String code) {
try {
String templateParam = JSON.toJSONString(java.util.Map.of("code", code));
SendSmsRequest request = new SendSmsRequest()
.setPhoneNumbers(phone)
.setSignName(properties.getSignName())
.setTemplateCode(properties.getTemplateCode())
.setTemplateParam(templateParam);
SendSmsResponse response = client.sendSms(request);
String respCode = response.getBody().getCode();
if ("OK".equals(respCode)) {
log.info("短信发送成功, phone={}", maskPhone(phone));
return true;
}
log.error("短信发送失败, code={}, message={}, requestId={}",
respCode,
response.getBody().getMessage(),
response.getBody().getRequestId());
return false;
} catch (Exception e) {
log.error("短信发送异常, phone={}", maskPhone(phone), e);
return false;
}
}
private String maskPhone(String phone) {
if (phone == null || phone.length() != 11) {
return phone;
}
return phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
}
}
关于fastjson2的JSON序列化:模板参数必须是一个合法的JSON对象字符串。用Map.of("code", code)再序列化,比手动拼字符串更安全,不容易出现引号转义错误。如果你的项目里没有fastjson2,也可以用Jackson的ObjectMapper,效果一样。
如果用老版SDK,发送核心逻辑略有不同,需要组装CommonRequest:
java复制DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", accessKeyId, accessKeySecret);
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");
request.putQueryParameter("PhoneNumbers", phone);
request.putQueryParameter("SignName", signName);
request.putQueryParameter("TemplateCode", templateCode);
request.putQueryParameter("TemplateParam", "{\"code\":\"" + code + "\"}");
CommonResponse response = client.getCommonResponse(request);
两种版本都能跑通,但我还是建议新项目拥抱新版SDK,少写很多模板代码。
3.4 完整接口示例,Controller层怎么组织
Service层面写完,还需要一个入口给前端调用。这里给出一个Controller示例,注意几个点:
- 入参用
@Valid做参数校验,手机号格式必须在入口就拦截掉。 - 发送成功后把验证码写入Redis,这一步是后续校验的基础。
- 图形验证码和发送频率控制先留好位置,下一章详细讲。
java复制import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import java.time.Duration;
@Slf4j
@RestController
@RequestMapping("/api/sms")
public class SmsController {
private final SmsService smsService;
private final StringRedisTemplate redisTemplate;
public SmsController(SmsService smsService, StringRedisTemplate redisTemplate) {
this.smsService = smsService;
this.redisTemplate = redisTemplate;
}
@PostMapping("/sendCode")
public String sendCode(@RequestBody @Validated SendCodeRequest request) {
// 1. 图形验证码校验(前置防刷,下一章详细说明)
// 2. 同手机号60秒冷却校验
String key = "sms:code:" + request.getPhone();
String cached = redisTemplate.opsForValue().get("sms:cooldown:" + request.getPhone());
if (cached != null) {
return "操作太频繁,请稍后再试";
}
// 3. 生成6位验证码
String code = VerifyCodeUtils.generate(6);
// 4. 发送短信
boolean success = smsService.sendVerifyCode(request.getPhone(), code);
if (!success) {
return "短信发送失败,请稍后再试";
}
// 5. 保存验证码,5分钟有效
redisTemplate.opsForValue().set(key, code, Duration.ofMinutes(5));
// 6. 设置60秒冷却标记
redisTemplate.opsForValue().set("sms:cooldown:" + request.getPhone(), "1", Duration.ofSeconds(60));
return "发送成功";
}
@Data
public static class SendCodeRequest {
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
}
}
验证码生成工具类很简单,核心是确保每次都是纯数字且位数统一:
java复制import java.util.concurrent.ThreadLocalRandom;
public final class VerifyCodeUtils {
private VerifyCodeUtils() {
}
public static String generate(int length) {
if (length <= 0) {
throw new IllegalArgumentException("length must be positive");
}
StringBuilder sb = new StringBuilder(length);
ThreadLocalRandom random = ThreadLocalRandom.current();
for (int i = 0; i < length; i++) {
sb.append(random.nextInt(10));
}
return sb.toString();
}
}
生成的验证码位数我习惯用6位。位数太短容易被暴力枚举,太长用户记不住。6位配合5分钟有效期和一个5次错误限制,安全性足够日常业务场景。
4. 验证码存储、校验与防刷设计
4.1 Redis存取,验证码状态怎么管理
短信验证码这类临时凭证,最好的归宿就是Redis。它天然支持过期时间,读取速度快,还能保证验证码的天然一次性。
存储方案我推荐直接把验证码字符串作为value,key带上手机号标识:
text复制sms:code:13800138000 -> 483920
设置过期时间为5分钟。这样用户提交验证码时,后端只需要根据手机号拼接key去读取。校验通过后必须立即删除这个key,保证验证码只能用一次。如果不做删除操作,用户反复用同一个验证码都能通过校验,这在账号注册、找回密码这类高危操作里是严重安全漏洞。
校验逻辑代码:
java复制public boolean verifyCode(String phone, String code) {
String key = "sms:code:" + phone;
String cached = redisTemplate.opsForValue().get(key);
if (cached == null) {
return false;
}
if (!cached.equals(code)) {
return false;
}
redisTemplate.delete(key);
return true;
}
这里还可以叠加一个错误次数限制。比如用户连续输错5次,直接删除缓存并要求重新获取验证码。用Redis的INCR命令计数,设置过期时间等于验证码剩余有效时间,这样每次错误都让验证码更快失效,暴力破解的成本大幅提高。我在真实项目里遇到过脚本用遍历方式尝试10000次以内的验证码枚举,不限制错误次数的话,总有撞中的可能。
4.2 发送频率控制和图形验证码前置
短信接口是典型的"高成本高风险"接口,每一次真实发送都是钱和时间,被脚本刷一次可能亏掉一天利润。所以发送频率控制必须做。
我项目里的标准做法是双层校验。第一层是图形验证码或者滑块验证。前端在点击发送验证码之前,先完成图形验证码,后端校验通过后才继续执行短信逻辑。这样脚本在没有AI破解图形码的能力之前,很难批量轰炸接口。
第二层是Redis里的发送冷却和计数。同一手机号60秒内不允许重复发送,key设置为sms:cooldown:手机号,60秒过期。同时用sms:count:手机号:yyyyMMdd记录当天该手机号的发送次数,超过10次直接拒绝。前者是短时保护,后者是长周期配额。这里用日期的粒度做计数,Redis设置key的过期时间为当天24点,比较省事。
需要注意,阿里云平台本身也有流控策略,同一验证码模板对同一手机号每天有发送上限,超过之后会报isv.BUSINESS_LIMIT_CONTROL。我们业务层的限制尽量要比平台侧更严格,这样才能保证用户遇到的是友好的业务提示,而不是直接的报错码。
4.3 防短信轰炸,我在实战里怎么处理
短信轰炸是验证码接口最大的安全威胁。攻击者拿到一个手机号,放到脚本里轮询各种平台的验证码接口,几分钟内用户手机会收到几十条短信。这种问题单个系统很难完全根治,因为攻击者可以调用很多家平台,但我们至少可以从自己系统角度做到最大程度的拦截。
第一道防线是图形验证码,前面已经说了。第二道防线是请求来源的限制,包括IP维度的每分钟次数限制、单个设备ID的频控。如果发现某个IP在短时间内对大量不同手机号发起发送请求,基本可以断定是脚本在扫,直接拉黑该IP一段时间。第三道防线是风控策略,可以用简单的规则判断:比如注册当天、新手机号、短时间内频繁切换IP等特征组合,命中异常模式的请求即使通过了图形验证码,也要二次人工确认。
我还见过一个比较实用的技巧:短信发送接口的安全等级可以做成分级配置。正常业务场景(比如用户主动点击"获取验证码")走全量校验;对风险较高的场景(比如未登录状态下频繁操作)设置更严格的图形验证码或直接拒绝。这样既能保护真实用户体验,又能最大程度降低被刷风险。
注意:不要在日志里打印完整验证码和完整手机号。日志经过收集系统后访问面很广,一旦日志泄露,验证码就形同虚设。我的习惯是验证码不打印,手机号脱敏打印。
5. 高频报错与生产环境优化
5.1 高频报错速查表,遇到别慌
阿里云短信的错误码体系很完善,我整理了一份高频错误码对照表,基本覆盖了最常见的问题。
| 错误码 | 含义 | 解决办法 |
|---|---|---|
| InvalidAccessKeyId.NotFound | AccessKey ID不存在 | 检查配置里的AK是否正确,注意ID与Secret是成对的 |
| Forbidden.RAM | RAM权限不足 | 给RAM子账号授权AliyunDysmsFullAccess |
| SignatureDoesNotMatch | 签名密钥不匹配 | 检查Secret是否填错,或服务器时间是否偏移 |
| isv.SMS_SIGNATURE_ILLEGAL | 签名不合法 | 签名未审核通过或签名名称与申请的不一致 |
| isv.SMS_TEMPLATE_ILLEGAL | 模板不合法 | 模板未审核通过或TemplateCode填错 |
| isv.MOBILE_NUMBER_ILLEGAL | 手机号不合法 | 手机号格式不正确,正则拦截或平台校验 |
| isv.BUSINESS_LIMIT_CONTROL | 业务流控限流 | 触发平台频率限制,冷却一段时间再发 |
| Throttling.System | 系统流控 | 请求过于频繁,降低调用频率 |
其中isv.BUSINESS_LIMIT_CONTROL最容易让人抓狂。它通常在两种情况出现:一是我们自己的冷却逻辑没做,导致高频发送触发平台限制;二是平台的每日配额用完了。遇到这个错误,最直接的排查方式是去阿里云控制台看该签名/模板当天的发送记录,确认是不是到达了配额上限。
另外有个经常被忽略的问题:服务器时间偏差。SignatureDoesNotMatch有个隐蔽的原因是系统时间和标准时间差了超过一定阈值,导致请求里的timestamp参数验证不过。检查方法是在服务器上执行date命令,确认时间准确。这个坑我在一次线上排查时花了大半天才定位,最后发现是服务器NTP同步被禁用导致时间慢了三分钟。
5.2 生产环境四条建议,少走弯路
第一,密钥管理要上配置中心。前面说过RAM子账号的正确用法,生产环境里密钥不要躺在配置文件里,而是放到Nacos或Apollo等配置中心,支持动态刷新。这样即使密钥轮换也不需要重启服务。
第二,短信发送要异步化。阿里云短信接口的响应时间一般几百毫秒,极端情况下可能超过1秒。如果放在请求链路里同步调用,用户点击发送验证码后要等半天,体验很差。我常用的方案是Spring的@Async把它丢到线程池执行,接口立即返回"验证码发送中"。对于订单通知之类的业务短信,甚至可以扔进MQ异步消费,削峰填谷效果更好。
第三,监控和告警不能省。短信是花钱的服务,而且依赖第三方,任何一个环节失败都需要及时知道。我习惯用日志埋点把发送结果上报到监控平台,统计发送成功率、失败原因分布、各模板消耗量。同时设置两个告警阈值:成功率达到99%以下时告警,短信余额低于某个金额时告警。短信余额告警很容易被忽略,直到某天短信突然发不出去用户疯狂投诉才意识到,那时候已经晚了。
第四,测试和生产隔离。测试环境建议用阿里云提供的免费测试签名和测试模板,或者干脆mock掉SmsService接口。真实短信按条计费,测试阶段大批量发真实短信,月底账单会很难看。版本发布到生产之前,再用测试手机号验证一次真实链路,确认签名模板、配置都切换到了生产环境。
5.3 签名审核周期要纳入项目排期
最后分享一个我实际遇到的教训。之前有一个上线计划排得很紧的项目,我第二周才去提交签名申请,结果因为材料里营业执照上的主体名称和提交的签名名称不完全一致,被驳回了两次,审核耗时远超预期。最后为了不延误上线,临时改用仅覆盖基础功能的其他方式过渡,紧急程度远超预期。
所以签名和模板的申请一定要放在项目启动的第一天。注册完阿里云账号、创建好RAM子账号之后,立刻去提交签名和模板审核。审核期间把代码开发、自测全部做完,等审核通过后直接替换配置,整体时间能压缩到非常短。此外,提交前检查一下认证主体和签名名称的关联性,营业执照或认证名称必须要能证明该主体确实有权使用这个签名,这是最常见的驳回理由。
短信这个领域,看似只有一次API调用,实际涉及的安全和工程细节非常多。尤其验证码这种直接面对用户的高频场景,在防轰炸、防枚举、频率控制上花的心思越多,后面线上省的事就越多。把依赖、配置、服务实现这三步跑通,再把存储、校验、防刷的闭环补上,短信功能才算真正做完。我自己的项目后来都是这套思路在迭代,发给新人也照着这套模板做,基本没有出过大问题。
