Java实现短信验证码详细过程

 更新时间:2023年09月14日 10:41:53   作者:dogbin丶  
这篇文章主要给大家介绍了关于Java实现短信验证码的相关资料, 在业务需求中我们经常会用到短信验证码,比如手机号登录、绑定手机号、忘记密码、敏感操作等,需要的朋友可以参考下

前言

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

一.架构设计

  • 发送短信是一个比较慢的过程,因为需要用到第三方服务(腾讯云短信服务),因此我们使用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 {
    @Resource
    private 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);
            // 如果获取到令牌,则返回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<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 {
    @Resource
    private RedisTemplate<String, String> 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<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短信消息生产者

  • 通过实现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<String, String> 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<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();
                //生成全局唯一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<String, String> 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

三. 心得

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

 总结

到此这篇关于Java实现短信验证码的文章就介绍到这了,更多相关Java短信验证码内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

最新评论