Java實現(xiàn)短信驗證碼詳細過程
前言
在業(yè)務(wù)需求中我們經(jīng)常會用到短信驗證碼,比如手機號登錄、綁定手機號、忘記密碼、敏感操作等,都可以通過短信驗證碼來保證操作的安全性,于是就記錄下了一次開發(fā)的過程。
一.架構(gòu)設(shè)計

發(fā)送短信是一個比較慢的過程,因為需要用到第三方服務(wù)(騰訊云短信服務(wù)),因此我們使用RabbitMq來做異步處理,前端點擊獲取驗證碼后,后端做完校驗限流后直接返回發(fā)送成功。
發(fā)送短信的服務(wù)是需要收費的,而且我們也不允許用戶惡意刷接口,所以需要有一個接口限流方案,可考慮漏桶算法、令牌桶算法,這里采用令牌桶算法。
二.編碼實現(xiàn)
① 環(huán)境搭建
- Springboot 2.7.0
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.9.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.9</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/junit/junit -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>② 令牌桶算法
這里使用Redis實現(xiàn)令牌桶算法,令牌桶算法具體細節(jié)可參考其他博客,這里不贅述,大致就是在 一個時間段 內(nèi),存在一定數(shù)量的令牌,我們需要拿到令牌才可以繼續(xù)操作。
所以實現(xiàn)思路大致就是:
- Redis 中記錄上次拿取令牌的時間,以及令牌數(shù),每個手機號對應(yīng)一個桶
- 每次拿令牌時,校驗令牌是否足夠。
/**
* @author YukeSeko
*/
@Component
public class RedisTokenBucket {
@Resource
private RedisTemplate<String,String> redisTemplate;
/**
* 過期時間,400秒后過期
*/
private final long EXPIRE_TIME = 400;
/**
* 令牌桶算法,一分鐘以內(nèi),每個手機號只能發(fā)送一次
* @param phoneNum
* @return
*/
public boolean tryAcquire(String phoneNum) {
// 每個手機號碼一分鐘內(nèi)只能發(fā)送一條短信
int permitsPerMinute = 1;
// 令牌桶容量
int maxPermits = 1;
// 獲取當前時間戳
long now = System.currentTimeMillis();
String key = RedisConstant.SMS_BUCKET_PREFIX + phoneNum;
// 計算令牌桶內(nèi)令牌數(shù)
int tokens = Integer.parseInt(redisTemplate.opsForValue().get(key + "_tokens") == null ? "0" : redisTemplate.opsForValue().get(key + "_tokens"));
// 計算令牌桶上次填充的時間戳
long lastRefillTime = Long.parseLong(redisTemplate.opsForValue().get(key + "_last_refill_time") == null ? "0" : redisTemplate.opsForValue().get(key + "_last_refill_time"));
// 計算當前時間與上次填充時間的時間差
long timeSinceLast = now - lastRefillTime;
// 計算需要填充的令牌數(shù)
int refill = (int) (timeSinceLast / 1000 * permitsPerMinute / 60);
// 更新令牌桶內(nèi)令牌數(shù)
tokens = Math.min(refill + tokens, maxPermits);
// 更新上次填充時間戳
redisTemplate.opsForValue().set(key + "_last_refill_time", String.valueOf(now),EXPIRE_TIME, TimeUnit.SECONDS);
// 如果令牌數(shù)大于等于1,則獲取令牌
if (tokens >= 1) {
tokens--;
redisTemplate.opsForValue().set(key + "_tokens", String.valueOf(tokens),EXPIRE_TIME, TimeUnit.SECONDS);
// 如果獲取到令牌,則返回true
return true;
}
// 如果沒有獲取到令牌,則返回false
return false;
}
}③ 業(yè)務(wù)代碼
0.Pojo
/**
* 短信服務(wù)傳輸對象
* @author niuma
* @create 2023-04-28 21:16
*/
@Data
@AllArgsConstructor
public class SmsDTO implements Serializable {
private static final long serialVersionUID = 8504215015474691352L;
String phoneNum;
String code;
}1.Controller
/**
* 發(fā)送短信驗證碼
* @param phoneNum
* @return
*/
@GetMapping("/smsCaptcha")
public BaseResponse<String> smsCaptcha(@RequestParam String phoneNum){
userService.sendSmsCaptcha(phoneNum);
// 異步發(fā)送驗證碼,這里直接返回成功即可
return ResultUtils.success("獲取短信驗證碼成功!");
}2.Service
- 手機號格式校驗可參考其他人代碼。
public Boolean sendSmsCaptcha(String phoneNum) {
if (StringUtils.isEmpty(phoneNum)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "手機號不能為空");
}
AuthPhoneNumberUtil authPhoneNumberUtil = new AuthPhoneNumberUtil();
// 手機號碼格式校驗
boolean checkPhoneNum = authPhoneNumberUtil.isPhoneNum(phoneNum);
if (!checkPhoneNum) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "手機號格式錯誤");
}
//生成隨機驗證碼
int code = (int) ((Math.random() * 9 + 1) * 10000);
SmsDTO smsDTO = new SmsDTO(phoneNum,String.valueOf(code));
return smsUtils.sendSms(smsDTO);
}3.發(fā)送短信工具類
- 提供兩個方法
- sendSms:先從令牌桶中獲取令牌,獲取失敗不允許發(fā)短信,獲取成功后,將驗證碼信息存入Redis,使用RabbitMq異步發(fā)送短信
- verifyCode:根據(jù)手機號校驗驗證碼,使用Redis
/**
* @author niuma
* @create 2023-04-28 22:18
*/
@Component
@Slf4j
public class SmsUtils {
@Resource
private RedisTemplate<String, String> redisTemplate;
@Resource
private RedisTokenBucket redisTokenBucket;
@Resource
private RabbitMqUtils rabbitMqUtils;
public boolean sendSms(SmsDTO smsDTO) {
// 從令牌桶中取得令牌,未取得不允許發(fā)送短信
boolean acquire = redisTokenBucket.tryAcquire(smsDTO.getPhoneNum());
if (!acquire) {
log.info("phoneNum:{},send SMS frequent", smsDTO.getPhoneNum());
return false;
}
log.info("發(fā)送短信:{}",smsDTO);
String phoneNum = smsDTO.getPhoneNum();
String code = smsDTO.getCode();
// 將手機號對應(yīng)的驗證碼存入Redis,方便后續(xù)檢驗
redisTemplate.opsForValue().set(RedisConstant.SMS_CODE_PREFIX + phoneNum, String.valueOf(code), 5, TimeUnit.MINUTES);
// 利用消息隊列,異步發(fā)送短信
rabbitMqUtils.sendSmsAsync(smsDTO);
return true;
}
public boolean verifyCode(String phoneNum, String code) {
String key = RedisConstant.SMS_CODE_PREFIX + phoneNum;
String checkCode = redisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(code) && code.equals(checkCode)) {
redisTemplate.delete(key);
return true;
}
return false;
}
}4.RabbitMq初始化
創(chuàng)建交換機和消息隊列
/**
* RabbitMQ配置
* @author niumazlb
*/
@Slf4j
@Configuration
public class RabbitMqConfig {
/**
* 普通隊列
* @return
*/
@Bean
public Queue smsQueue(){
Map<String, Object> arguments = new HashMap<>();
//聲明死信隊列和交換機消息,過期時間:1分鐘
arguments.put("x-dead-letter-exchange", SMS_EXCHANGE_NAME);
arguments.put("x-dead-letter-routing-key", SMS_DELAY_EXCHANGE_ROUTING_KEY);
arguments.put("x-message-ttl", 60000);
return new Queue(SMS_QUEUE_NAME,true,false,false ,arguments);
}
/**
* 死信隊列:消息重試三次后放入死信隊列
* @return
*/
@Bean
public Queue deadLetter(){
return new Queue(SMS_DELAY_QUEUE_NAME, true, false, false);
}
/**
* 主題交換機
* @return
*/
@Bean
public Exchange smsExchange() {
return new TopicExchange(SMS_EXCHANGE_NAME, true, false);
}
/**
* 交換機和普通隊列綁定
* @return
*/
@Bean
public Binding smsBinding(){
return new Binding(SMS_QUEUE_NAME, Binding.DestinationType.QUEUE,SMS_EXCHANGE_NAME,SMS_EXCHANGE_ROUTING_KEY,null);
}
/**
* 交換機和死信隊列綁定
* @return
*/
@Bean
public Binding smsDelayBinding(){
return new Binding(SMS_DELAY_QUEUE_NAME, Binding.DestinationType.QUEUE,SMS_EXCHANGE_NAME,SMS_DELAY_EXCHANGE_ROUTING_KEY,null);
}
}5.Mq短信消息生產(chǎn)者
- 通過實現(xiàn)ConfirmCallback、ReturnsCallback接口,提高消息的可靠性
- sendSmsAsync:將消息的各種信息設(shè)置進Redis(重試次數(shù)、狀態(tài)、數(shù)據(jù)),將消息投遞進Mq,這里傳入自己設(shè)置的messageId,方便監(jiān)聽器中能夠在Redis中找到這條消息。
/**
* 向mq發(fā)送消息,并進行保證消息可靠性處理
*
* @author niuma
* @create 2023-04-29 15:09
*/
@Component
@Slf4j
public class RabbitMqUtils implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnsCallback {
@Resource
private RedisTemplate<String, String> redisTemplate;
@Resource
private RabbitTemplate rabbitTemplate;
private String finalId = null;
private SmsDTO smsDTO = null;
/**
* 向mq中投遞發(fā)送短信消息
*
* @param smsDTO
* @throws Exception
*/
public void sendSmsAsync(SmsDTO smsDTO) {
String messageId = null;
try {
// 將 headers 添加到 MessageProperties 中,并發(fā)送消息
messageId = UUID.randomUUID().toString();
HashMap<String, Object> messageArgs = new HashMap<>();
messageArgs.put("retryCount", 0);
//消息狀態(tài):0-未投遞、1-已投遞
messageArgs.put("status", 0);
messageArgs.put("smsTo", smsDTO);
//將重試次數(shù)和短信發(fā)送狀態(tài)存入redis中去,并設(shè)置過期時間
redisTemplate.opsForHash().putAll(RedisConstant.SMS_MESSAGE_PREFIX + messageId, messageArgs);
redisTemplate.expire(RedisConstant.SMS_MESSAGE_PREFIX + messageId, 10, TimeUnit.MINUTES);
String finalMessageId = messageId;
finalId = messageId;
this.smsDTO = smsDTO;
// 將消息投遞到MQ,并設(shè)置消息的一些參數(shù)
rabbitTemplate.convertAndSend(RabbitMqConstant.SMS_EXCHANGE_NAME, RabbitMqConstant.SMS_EXCHANGE_ROUTING_KEY, smsDTO, message -> {
MessageProperties messageProperties = message.getMessageProperties();
//生成全局唯一id
messageProperties.setMessageId(finalMessageId);
messageProperties.setContentEncoding("utf-8");
return message;
});
} catch (Exception e) {
//出現(xiàn)異常,刪除該短信id對應(yīng)的redis,并將該失敗消息存入到“死信”redis中去,然后使用定時任務(wù)去掃描該key,并重新發(fā)送到mq中去
redisTemplate.delete(RedisConstant.SMS_MESSAGE_PREFIX + messageId);
redisTemplate.opsForHash().put(RedisConstant.MQ_PRODUCER, messageId, smsDTO);
throw new RuntimeException(e);
}
}
/**
* 發(fā)布者確認的回調(diào)
*
* @param correlationData 回調(diào)的相關(guān)數(shù)據(jù)。
* @param b ack為真,nack為假
* @param s 一個可選的原因,用于nack,如果可用,否則為空。
*/
@Override
public void confirm(CorrelationData correlationData, boolean b, String s) {
// 消息發(fā)送成功,將redis中消息的狀態(tài)(status)修改為1
if (b) {
redisTemplate.opsForHash().put(RedisConstant.SMS_MESSAGE_PREFIX + finalId, "status", 1);
} else {
// 發(fā)送失敗,放入redis失敗集合中,并刪除集合數(shù)據(jù)
log.error("短信消息投送失?。簕}-->{}", correlationData, s);
redisTemplate.delete(RedisConstant.SMS_MESSAGE_PREFIX + finalId);
redisTemplate.opsForHash().put(RedisConstant.MQ_PRODUCER, finalId, this.smsDTO);
}
}
/**
* 發(fā)生異常時的消息返回提醒
*
* @param returnedMessage
*/
@Override
public void returnedMessage(ReturnedMessage returnedMessage) {
log.error("發(fā)生異常,返回消息回調(diào):{}", returnedMessage);
// 發(fā)送失敗,放入redis失敗集合中,并刪除集合數(shù)據(jù)
redisTemplate.delete(RedisConstant.SMS_MESSAGE_PREFIX + finalId);
redisTemplate.opsForHash().put(RedisConstant.MQ_PRODUCER, finalId, this.smsDTO);
}
@PostConstruct
public void init() {
rabbitTemplate.setConfirmCallback(this);
rabbitTemplate.setReturnsCallback(this);
}
}6.Mq消息監(jiān)聽器
- 根據(jù)messageId從Redis中找到對應(yīng)的消息(為了判斷重試次數(shù),規(guī)定重試3次為失敗,加入死信隊列)
- 調(diào)用第三方云服務(wù)商提供的短信服務(wù)發(fā)送短信,通過返回值來判斷是否發(fā)送成功
- 手動確認消息
/**
* @author niuma
* @create 2023-04-29 15:35
*/
@Component
@Slf4j
public class SendSmsListener {
@Resource
private RedisTemplate<String, String> redisTemplate;
@Resource
private SendSmsUtils sendSmsUtils;
/**
* 監(jiān)聽發(fā)送短信普通隊列
* @param smsDTO
* @param message
* @param channel
* @throws IOException
*/
@RabbitListener(queues = SMS_QUEUE_NAME)
public void sendSmsListener(SmsDTO smsDTO, Message message, Channel channel) throws IOException {
String messageId = message.getMessageProperties().getMessageId();
int retryCount = (int) redisTemplate.opsForHash().get(RedisConstant.SMS_MESSAGE_PREFIX + messageId, "retryCount");
if (retryCount > 3) {
//重試次數(shù)大于3,直接放到死信隊列
log.error("短信消息重試超過3次:{}", messageId);
//basicReject方法拒絕deliveryTag對應(yīng)的消息,第二個參數(shù)是否requeue,true則重新入隊列,否則丟棄或者進入死信隊列。
//該方法reject后,該消費者還是會消費到該條被reject的消息。
channel.basicReject(message.getMessageProperties().getDeliveryTag(),false);
redisTemplate.delete(RedisConstant.SMS_MESSAGE_PREFIX + messageId);
return;
}
try {
String phoneNum = smsDTO.getPhoneNum();
String code = smsDTO.getCode();
if(StringUtils.isAnyBlank(phoneNum,code)){
throw new RuntimeException("sendSmsListener參數(shù)為空");
}
// 發(fā)送消息
SendSmsResponse sendSmsResponse = sendSmsUtils.sendSmsResponse(phoneNum, code);
SendStatus[] sendStatusSet = sendSmsResponse.getSendStatusSet();
SendStatus sendStatus = sendStatusSet[0];
if(!"Ok".equals(sendStatus.getCode()) ||!"send success".equals(sendStatus.getMessage())){
throw new RuntimeException("發(fā)送驗證碼失敗");
}
//手動確認消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
log.info("短信發(fā)送成功:{}",smsDTO);
redisTemplate.delete(RedisConstant.SMS_MESSAGE_PREFIX + messageId);
} catch (Exception e) {
redisTemplate.opsForHash().put(RedisConstant.SMS_MESSAGE_PREFIX+messageId,"retryCount",retryCount+1);
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
/**
* 監(jiān)聽到發(fā)送短信死信隊列
* @param sms
* @param message
* @param channel
* @throws IOException
*/
@RabbitListener(queues = SMS_DELAY_QUEUE_NAME)
public void smsDelayQueueListener(SmsDTO sms, Message message, Channel channel) throws IOException {
try{
log.error("監(jiān)聽到死信隊列消息==>{}",sms);
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
}catch (Exception e){
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}7.騰訊云短信服務(wù)
@Component
public class TencentClient {
@Value("${tencent.secretId}")
private String secretId;
@Value("${tencent.secretKey}")
private String secretKey;
/**
* Tencent應(yīng)用客戶端
* @return
*/
@Bean
public SmsClient client(){
Credential cred = new Credential(secretId, secretKey);
SmsClient smsClient = new SmsClient(cred, "ap-guangzhou");
return smsClient;
}
}@Component
public class SendSmsUtils {
@Resource
private TencentClient tencentClient;
@Value("${tencent.sdkAppId}")
private String sdkAppId;
@Value("${tencent.signName}")
private String signName;
@Value("${tencent.templateId}")
private String templateId;
/**
* 發(fā)送短信工具
* @param phone
* @return
* @throws TencentCloudSDKException
*/
public SendSmsResponse sendSmsResponse (String phone,String code) throws TencentCloudSDKException {
SendSmsRequest req = new SendSmsRequest();
/* 短信應(yīng)用ID */
// 應(yīng)用 ID 可前往 [短信控制臺](https://console.cloud.tencent.com/smsv2/app-manage) 查看
req.setSmsSdkAppId(sdkAppId);
/* 短信簽名內(nèi)容: 使用 UTF-8 編碼,必須填寫已審核通過的簽名 */
// 簽名信息可前往 [國內(nèi)短信](https://console.cloud.tencent.com/smsv2/csms-sign) 或 [國際/港澳臺短信](https://console.cloud.tencent.com/smsv2/isms-sign) 的簽名管理查看
req.setSignName(signName);
/* 模板 ID: 必須填寫已審核通過的模板 ID */
// 模板 ID 可前往 [國內(nèi)短信](https://console.cloud.tencent.com/smsv2/csms-template) 或 [國際/港澳臺短信](https://console.cloud.tencent.com/smsv2/isms-template) 的正文模板管理查看
req.setTemplateId(templateId);
/* 模板參數(shù): 模板參數(shù)的個數(shù)需要與 TemplateId 對應(yīng)模板的變量個數(shù)保持一致,若無模板參數(shù),則設(shè)置為空 */
String[] templateParamSet = [code];
req.setTemplateParamSet(templateParamSet);
/* 下發(fā)手機號碼,采用 E.164 標準,+[國家或地區(qū)碼][手機號]
* 示例如:+8613711112222, 其中前面有一個+號 ,86為國家碼,13711112222為手機號,最多不要超過200個手機號 */
String[] phoneNumberSet = new String[]{"+86" + phone};
req.setPhoneNumberSet(phoneNumberSet);
/* 用戶的 session 內(nèi)容(無需要可忽略): 可以攜帶用戶側(cè) ID 等上下文信息,server 會原樣返回
String sessionContext = "";
req.setSessionContext(sessionContext);
*/
/* 通過 client 對象調(diào)用 SendSms 方法發(fā)起請求。注意請求方法名與請求對象是對應(yīng)的
* 返回的 res 是一個 SendSmsResponse 類的實例,與請求對象對應(yīng) */
SmsClient client = tencentClient.client();
return client.SendSms(req);
}
}配置文件
tencent: secretId: #你的secretId secretKey: #你的secretKey sdkAppId: #你的sdkAppId signName: #你的signName templateId: #你的templateId
三. 心得
- 消息隊列的一個用法
- ConfirmCallback、ReturnsCallback接口的使用
- 騰訊云短信服務(wù)的使用
- 令牌桶算法的實踐
總結(jié)
到此這篇關(guān)于Java實現(xiàn)短信驗證碼的文章就介紹到這了,更多相關(guān)Java短信驗證碼內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
idea?maven依賴引入失效無法正常導(dǎo)入依賴問題的解決方法
有時候idea導(dǎo)入一個新項目,或者pom文件修改(新增)了依賴,pom文件和代碼會報紅,提示依賴包不存在,下面這篇文章主要給大家介紹了關(guān)于idea?maven依賴引入失效無法正常導(dǎo)入依賴問題的解決方法,需要的朋友可以參考下2023-04-04
Java Builder Pattern建造者模式詳解及實例
這篇文章主要介紹了Java Builder Pattern建造者模式詳解及實例的相關(guān)資料,需要的朋友可以參考下2017-01-01
Java怎樣創(chuàng)建集合才能避免造成內(nèi)存泄漏你了解嗎
內(nèi)存泄漏是指無用對象持續(xù)占有內(nèi)存或無用對象的內(nèi)存得不到及時釋放,從而造成內(nèi)存空間的浪費稱為內(nèi)存泄漏。長生命周期的對象持有短生命周期對象的引用就很可能發(fā)生內(nèi)存泄漏,盡管短生命周期對象已經(jīng)不再需要,但是因為長生命周期持有它的引用而導(dǎo)致不能被回收2021-09-09
Java獲取resources下文件路徑的幾種方法及遇到的問題
這篇文章主要給大家介紹了關(guān)于Java獲取resources下文件路徑的幾種方法及遇到的問題,在Java開發(fā)中經(jīng)常需要讀取項目中resources目錄下的文件或獲取資源路徑,需要的朋友可以參考下2023-12-12
Java并發(fā)編程必備之Synchronized關(guān)鍵字深入解析
本文我們深入探索了Java中的Synchronized關(guān)鍵字,包括其互斥性和可重入性的特性,文章詳細介紹了Synchronized的三種使用方式:修飾代碼塊、修飾普通方法和修飾靜態(tài)方法,感興趣的朋友一起看看吧2025-04-04

