在业务需求中我们经常会用到短信验证码,比如手机号登录、绑定手机号、忘记密码、敏感操作等,都可以通过短信验证码来保证操作的安全性,于是就记录下了一次开发的过程。

一.架构设计

  • 发送短信是一个比较慢的过程,因为需要用到第三方服务(腾讯云短信服务),因此我们使用RabbitMq来做异步处理,前端点击获取验证码后,后端做完校验限流后直接返回发送成功。

  • 发送短信的服务是需要收费的,而且我们也不允许用户恶意刷接口,所以需要有一个接口限流方案,可考虑漏桶算法、令牌桶算法,这里采用令牌桶算法

二.编码实现

① 环境搭建

  • 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实现令牌桶算法,令牌桶算法具体细节可参考其他博客,这里不赘述,大致就是在 一个时间段 内,存在一定数量的令牌,我们需要拿到令牌才可以继续操作。

所以实现思路大致就是:

  • Redis 中记录上次拿取令牌的时间,以及令牌数,每个手机号对应一个桶
  • 每次拿令牌时,校验令牌是否足够。
/*** @author YukeSeko*/
@Component
public class RedisTokenBucket {@Resourceprivate RedisTemplate<String,String> redisTemplate;/***  过期时间,400秒后过期*/private final long EXPIRE_TIME = 400;/*** 令牌桶算法,一分钟以内,每个手机号只能发送一次* @param phoneNum* @return*/public boolean tryAcquire(String phoneNum) {// 每个手机号码一分钟内只能发送一条短信int permitsPerMinute = 1;// 令牌桶容量int maxPermits = 1;// 获取当前时间戳long now = System.currentTimeMillis();String key = RedisConstant.SMS_BUCKET_PREFIX + phoneNum;// 计算令牌桶内令牌数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;// 计算需要填充的令牌数int refill = (int) (timeSinceLast / 1000 * permitsPerMinute / 60);// 更新令牌桶内令牌数tokens = Math.min(refill + tokens, maxPermits);// 更新上次填充时间戳redisTemplate.opsForValue().set(key + "_last_refill_time", String.valueOf(now),EXPIRE_TIME, TimeUnit.SECONDS);// 如果令牌数大于等于1,则获取令牌if (tokens >= 1) {tokens--;redisTemplate.opsForValue().set(key + "_tokens", String.valueOf(tokens),EXPIRE_TIME, TimeUnit.SECONDS);// 如果获取到令牌,则返回truereturn true;}// 如果没有获取到令牌,则返回falsereturn false;}
}

③ 业务代码

0.Pojo

/*** 短信服务传输对象* @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

    /*** 发送短信验证码* @param phoneNum* @return*/@GetMapping("/smsCaptcha")public BaseResponse<String> smsCaptcha(@RequestParam String phoneNum){userService.sendSmsCaptcha(phoneNum);// 异步发送验证码,这里直接返回成功即可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.发送短信工具类

  • 提供两个方法

    • sendSms:先从令牌桶中获取令牌,获取失败不允许发短信,获取成功后,将验证码信息存入Redis,使用RabbitMq异步发送短信
    • verifyCode:根据手机号校验验证码,使用Redis
/*** @author niuma* @create 2023-04-28 22:18*/
@Component
@Slf4j
public class SmsUtils {@Resourceprivate RedisTemplate<String, String> redisTemplate;@Resourceprivate RedisTokenBucket redisTokenBucket;@Resourceprivate RabbitMqUtils rabbitMqUtils;public boolean sendSms(SmsDTO smsDTO) {// 从令牌桶中取得令牌,未取得不允许发送短信boolean acquire = redisTokenBucket.tryAcquire(smsDTO.getPhoneNum());if (!acquire) {log.info("phoneNum:{},send SMS frequent", smsDTO.getPhoneNum());return false;}log.info("发送短信:{}",smsDTO);String phoneNum = smsDTO.getPhoneNum();String code = smsDTO.getCode();// 将手机号对应的验证码存入Redis,方便后续检验redisTemplate.opsForValue().set(RedisConstant.SMS_CODE_PREFIX + phoneNum, String.valueOf(code), 5, TimeUnit.MINUTES);// 利用消息队列,异步发送短信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初始化

创建交换机和消息队列

/*** RabbitMQ配置* @author niumazlb*/
@Slf4j
@Configuration
public class RabbitMqConfig {/*** 普通队列* @return*/@Beanpublic 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*/@Beanpublic Queue deadLetter(){return new Queue(SMS_DELAY_QUEUE_NAME, true, false, false);}/*** 主题交换机* @return*/@Beanpublic Exchange smsExchange() {return new TopicExchange(SMS_EXCHANGE_NAME, true, false);}/*** 交换机和普通队列绑定* @return*/@Beanpublic Binding smsBinding(){return new Binding(SMS_QUEUE_NAME, Binding.DestinationType.QUEUE,SMS_EXCHANGE_NAME,SMS_EXCHANGE_ROUTING_KEY,null);}/*** 交换机和死信队列绑定* @return*/@Beanpublic Binding smsDelayBinding(){return new Binding(SMS_DELAY_QUEUE_NAME, Binding.DestinationType.QUEUE,SMS_EXCHANGE_NAME,SMS_DELAY_EXCHANGE_ROUTING_KEY,null);}}

5.Mq短信消息生产者

  • 通过实现ConfirmCallback、ReturnsCallback接口,提高消息的可靠性
  • sendSmsAsync:将消息的各种信息设置进Redis(重试次数、状态、数据),将消息投递进Mq,这里传入自己设置的messageId,方便监听器中能够在Redis中找到这条消息。
/*** 向mq发送消息,并进行保证消息可靠性处理** @author niuma* @create 2023-04-29 15:09*/
@Component
@Slf4j
public class RabbitMqUtils implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnsCallback {@Resourceprivate RedisTemplate<String, String> redisTemplate;@Resourceprivate RabbitTemplate rabbitTemplate;private String finalId = null;private SmsDTO smsDTO = null;/*** 向mq中投递发送短信消息** @param smsDTO* @throws Exception*/public void sendSmsAsync(SmsDTO smsDTO) {String messageId = null;try {// 将 headers 添加到 MessageProperties 中,并发送消息messageId = UUID.randomUUID().toString();HashMap<String, Object> messageArgs = new HashMap<>();messageArgs.put("retryCount", 0);//消息状态:0-未投递、1-已投递messageArgs.put("status", 0);messageArgs.put("smsTo", smsDTO);//将重试次数和短信发送状态存入redis中去,并设置过期时间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,并设置消息的一些参数rabbitTemplate.convertAndSend(RabbitMqConstant.SMS_EXCHANGE_NAME, RabbitMqConstant.SMS_EXCHANGE_ROUTING_KEY, smsDTO, message -> {MessageProperties messageProperties = message.getMessageProperties();//生成全局唯一idmessageProperties.setMessageId(finalMessageId);messageProperties.setContentEncoding("utf-8");return message;});} catch (Exception e) {//出现异常,删除该短信id对应的redis,并将该失败消息存入到“死信”redis中去,然后使用定时任务去扫描该key,并重新发送到mq中去redisTemplate.delete(RedisConstant.SMS_MESSAGE_PREFIX + messageId);redisTemplate.opsForHash().put(RedisConstant.MQ_PRODUCER, messageId, smsDTO);throw new RuntimeException(e);}}/*** 发布者确认的回调** @param correlationData 回调的相关数据。* @param b               ack为真,nack为假* @param s               一个可选的原因,用于nack,如果可用,否则为空。*/@Overridepublic void confirm(CorrelationData correlationData, boolean b, String s) {// 消息发送成功,将redis中消息的状态(status)修改为1if (b) {redisTemplate.opsForHash().put(RedisConstant.SMS_MESSAGE_PREFIX + finalId, "status", 1);} else {// 发送失败,放入redis失败集合中,并删除集合数据log.error("短信消息投送失败:{}-->{}", correlationData, s);redisTemplate.delete(RedisConstant.SMS_MESSAGE_PREFIX + finalId);redisTemplate.opsForHash().put(RedisConstant.MQ_PRODUCER, finalId, this.smsDTO);}}/*** 发生异常时的消息返回提醒** @param returnedMessage*/@Overridepublic void returnedMessage(ReturnedMessage returnedMessage) {log.error("发生异常,返回消息回调:{}", returnedMessage);// 发送失败,放入redis失败集合中,并删除集合数据redisTemplate.delete(RedisConstant.SMS_MESSAGE_PREFIX + finalId);redisTemplate.opsForHash().put(RedisConstant.MQ_PRODUCER, finalId, this.smsDTO);}@PostConstructpublic void init() {rabbitTemplate.setConfirmCallback(this);rabbitTemplate.setReturnsCallback(this);}
}

6.Mq消息监听器

  • 根据messageId从Redis中找到对应的消息(为了判断重试次数,规定重试3次为失败,加入死信队列)
  • 调用第三方云服务商提供的短信服务发送短信,通过返回值来判断是否发送成功
  • 手动确认消息
/*** @author niuma* @create 2023-04-29 15:35*/
@Component
@Slf4j
public class SendSmsListener {@Resourceprivate RedisTemplate<String, String> redisTemplate;@Resourceprivate SendSmsUtils sendSmsUtils;/*** 监听发送短信普通队列* @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) {//重试次数大于3,直接放到死信队列log.error("短信消息重试超过3次:{}",  messageId);//basicReject方法拒绝deliveryTag对应的消息,第二个参数是否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参数为空");}// 发送消息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("发送验证码失败");}//手动确认消息channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);log.info("短信发送成功:{}",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);}}/*** 监听到发送短信死信队列* @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("监听到死信队列消息==>{}",sms);channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);}catch (Exception e){channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);}}
}

7.腾讯云短信服务

@Component
public class TencentClient {@Value("${tencent.secretId}")private String secretId;@Value("${tencent.secretKey}")private String secretKey;/*** Tencent应用客户端* @return*/@Beanpublic SmsClient client(){Credential cred = new Credential(secretId, secretKey);SmsClient smsClient = new SmsClient(cred, "ap-guangzhou");return smsClient;}
}
@Component
public class SendSmsUtils {@Resourceprivate TencentClient tencentClient;@Value("${tencent.sdkAppId}")private String sdkAppId;@Value("${tencent.signName}")private String signName;@Value("${tencent.templateId}")private String templateId;/*** 发送短信工具* @param phone* @return* @throws TencentCloudSDKException*/public SendSmsResponse sendSmsResponse (String phone,String code) throws TencentCloudSDKException {SendSmsRequest req = new SendSmsRequest();/* 短信应用ID */// 应用 ID 可前往 [短信控制台](https://console.cloud.tencent.com/smsv2/app-manage) 查看req.setSmsSdkAppId(sdkAppId);/* 短信签名内容: 使用 UTF-8 编码,必须填写已审核通过的签名 */// 签名信息可前往 [国内短信](https://console.cloud.tencent.com/smsv2/csms-sign) 或 [国际/港澳台短信](https://console.cloud.tencent.com/smsv2/isms-sign) 的签名管理查看req.setSignName(signName);/* 模板 ID: 必须填写已审核通过的模板 ID */// 模板 ID 可前往 [国内短信](https://console.cloud.tencent.com/smsv2/csms-template) 或 [国际/港澳台短信](https://console.cloud.tencent.com/smsv2/isms-template) 的正文模板管理查看req.setTemplateId(templateId);/* 模板参数: 模板参数的个数需要与 TemplateId 对应模板的变量个数保持一致,若无模板参数,则设置为空 */String[] templateParamSet = {code};req.setTemplateParamSet(templateParamSet);/* 下发手机号码,采用 E.164 标准,+[国家或地区码][手机号]* 示例如:+8613711112222, 其中前面有一个+号 ,86为国家码,13711112222为手机号,最多不要超过200个手机号 */String[] phoneNumberSet = new String[]{"+86" + phone};req.setPhoneNumberSet(phoneNumberSet);/* 用户的 session 内容(无需要可忽略): 可以携带用户侧 ID 等上下文信息,server 会原样返回String sessionContext = "";req.setSessionContext(sessionContext);*//* 通过 client 对象调用 SendSms 方法发起请求。注意请求方法名与请求对象是对应的* 返回的 res 是一个 SendSmsResponse 类的实例,与请求对象对应 */SmsClient client = tencentClient.client();return client.SendSms(req);}
}

配置文件

tencent:secretId: #你的secretIdsecretKey: #你的secretKeysdkAppId: #你的sdkAppIdsignName: #你的signNametemplateId: #你的templateId

三. 心得

  1. 消息队列的一个用法
  2. ConfirmCallback、ReturnsCallback接口的使用
  3. 腾讯云短信服务的使用
  4. 令牌桶算法的实践

短信验证码—Java实现相关推荐

  1. 网易云发送短信验证码java实现

    首先,登陆网易云信注册账号然后获取自己的App Key与App Secret,这里就不多说了,可以自行百度. 然后进入如下界面https://www.163yun.com/help/documents ...

  2. java发送短信验证码,java发送短信验证码

    业务: 手机端点击发送验证码,请求发送到java服务器端,由java调用第三方平台(我们使用的是榛子云短信http://smsow.zhenzikj.com)的短信接口,生成验证码并发送. 下载后的S ...

  3. Java调用WebService接口实现发送手机短信验证码功能,java 手机验证码,WebService接口调用...

    近来由于项目需要,需要用到手机短信验证码的功能,其中最主要的是用到了第三方提供的短信平台接口WebService客户端接口,下面我把我在项目中用到的记录一下,以便给大家提供个思路,由于本人的文采有限, ...

  4. Java调用WebService接口实现发送手机短信验证码功能

    为什么80%的码农都做不了架构师?>>>    一.样式示例: 二.前台的注册页面的代码:reg.jsp <%@ page language="java" ...

  5. java + maven 实现发送短信验证码功能

    如何使用java + maven的项目环境发送短信验证码,本文使用的是榛子云短信 的接口. 1. 安装sdk 下载地址: http://smsow.zhenzikj.com/doc/sdk.html ...

  6. php榛子云短信验证,java + maven +榛子云短信 实现发送短信验证码功能

    如何使用java + maven的项目环境发送短信验证码,本文使用的是榛子云短信的接口. 下载下来是jar文件,需要将jar发布到本地的maven仓库中, 在cmd环境下输入: mvn install ...

  7. java实现短信验证码发送(架子是springboot 服务平台选择腾讯云短信服务)

    业务需求:公司扩展新业务,新增短信验证码提醒服务,负责功能模块完善 暂时只研究了腾讯短信服务的发送(看api谁都能copy出来),短信状态回执(也挺简单,只是自己想复杂了),短信回复回执(暂时没弄明白 ...

  8. java 随机手机验证码_基于Java随机生成手机短信验证码的实例代码|chu

    简单版 /** * 产生4位随机数(0000-9999) * * @return 4位随机数 */ public static String getFourRandom() { return Stri ...

  9. java短信验证码实现_社交APP开发短信验证是通过什么技术实现

    我们已经习惯通过手机APP来解决我们生活中的一些问题,社交APP用来聊天交友,购物APP用来购买生活所需用品,游戏APP用来消遣娱乐,移动互联网行业正在飞速发展. 不难发现我们手机里面的各种APP都有 ...

最新文章

  1. 爱奇艺的数据库选型大法,实用不纠结!
  2. 【转】关于 SELECT /*!40001 SQL_NO_CACHE */ * FROM 的解惑
  3. python3 如何读中文路径_Python 3.8.2安装教程
  4. LD(Levenshtein distance)莱文斯坦距离----编辑距离
  5. html所有页面根的对象,在django中显示来自所有用户的对象,无需登录到html页面...
  6. 《自顶向下网络设计(第3版)》——导读
  7. react字符串转html函数,react 字符串强转为html标签
  8. java10 WeakHashMap
  9. java使用poi读取word(简单,简约,直观)
  10. unity常用的引用赋值一个GameObject的三种方法
  11. [2018.07.10 T1]叠盒子
  12. windows/Linux网络工具
  13. liunx下载安装JDK1.8教程
  14. 前端CSS射门动画-为梅西最后一届世界杯加油
  15. 这些微信头像,你敢换吗?
  16. 图解AUTOSAR(五)——微控制器抽象层(MCAL)
  17. APK签名机制原理详解
  18. 普通话测试第四题评分标准_普通话测试第四题评分细则
  19. [3]_人人都是产品经理
  20. 人工智能应用和隐私保护应该如何兼顾?

热门文章

  1. 什么是网络安全?为什么要学网络安全?如何学习网络安全?
  2. 职场关系:甲乙、甲甲、乙乙
  3. nvdiffrec在Windows上的配置及使用
  4. git配置多项目账号密码
  5. ubuntu分区设置
  6. python生成随机列表、每行输出5个数据_python-day5列表
  7. 仿微信群组头像组合边框实现
  8. 在word文档中插入外部对象(例如插入另一个外部word文档或excel文档)
  9. 【com.getui.push.v2.sdk.common.ApiException: 获取token失败: timestamp is invalid】
  10. 周报1_20230707