短信验证码—Java实现
作者:mmseoamin日期:2023-12-21

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

一.架构设计

短信验证码—Java实现,image-20230429224839744,第1张

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

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

    二.编码实现

    ① 环境搭建

    • Springboot 2.7.0
          
              
                  org.springframework.boot
                  spring-boot-starter-web
              
              
                  org.springframework.boot
                  spring-boot-starter-data-redis
              
              
                  org.springframework.boot
                  spring-boot-starter-amqp
              
              
              
                  com.google.code.gson
                  gson
                  2.9.0
              
              
              
                  org.apache.commons
                  commons-lang3
                  3.12.0
              
              
                  cn.hutool
                  hutool-all
                  5.8.9
              
              
                  org.springframework.boot
                  spring-boot-configuration-processor
                  true
              
              
                  org.projectlombok
                  lombok
                  true
              
              
                  org.springframework.boot
                  spring-boot-starter-test
                  test
              
              
              
                  junit
                  junit
                  4.13.2
                  test
              
          
      

      ② 令牌桶算法

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

      所以实现思路大致就是:

      • Redis 中记录上次拿取令牌的时间,以及令牌数,每个手机号对应一个桶
      • 每次拿令牌时,校验令牌是否足够。
        /**
         * @author YukeSeko
         */
        @Component
        public class RedisTokenBucket {
            @Resource
            private RedisTemplate 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);
                    // 如果获取到令牌,则返回true
                    return true;
                }
                // 如果没有获取到令牌,则返回false
                return 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 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 {
                  @Resource
                  private RedisTemplate redisTemplate;
                  @Resource
                  private RedisTokenBucket redisTokenBucket;
                  @Resource
                  private 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
                   */
                  @Bean
                  public Queue smsQueue(){
                      Map 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短信消息生产者

              • 通过实现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 {
                    @Resource
                    private RedisTemplate redisTemplate;
                    @Resource
                    private 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 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();
                                //生成全局唯一id
                                messageProperties.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,如果可用,否则为空。
                     */
                    @Override
                    public void confirm(CorrelationData correlationData, boolean b, String s) {
                        // 消息发送成功,将redis中消息的状态(status)修改为1
                        if (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
                     */
                    @Override
                    public void returnedMessage(ReturnedMessage returnedMessage) {
                        log.error("发生异常,返回消息回调:{}", returnedMessage);
                        // 发送失败,放入redis失败集合中,并删除集合数据
                        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消息监听器

                • 根据messageId从Redis中找到对应的消息(为了判断重试次数,规定重试3次为失败,加入死信队列)
                • 调用第三方云服务商提供的短信服务发送短信,通过返回值来判断是否发送成功
                • 手动确认消息
                  /**
                   * @author niuma
                   * @create 2023-04-29 15:35
                   */
                  @Component
                  @Slf4j
                  public class SendSmsListener {
                      @Resource
                      private RedisTemplate redisTemplate;
                      @Resource
                      private 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
                       */
                      @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;
                      /**
                       * 发送短信工具
                       * @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: #你的secretId
                    secretKey: #你的secretKey
                    sdkAppId: #你的sdkAppId
                    signName: #你的signName
                    templateId: #你的templateId
                  

                  三. 心得

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