在业务需求中我们经常会用到短信验证码,比如手机号登录、绑定手机号、忘记密码、敏感操作等,都可以通过短信验证码来保证操作的安全性,于是就记录下了一次开发的过程。
发送短信是一个比较慢的过程,因为需要用到第三方服务(腾讯云短信服务),因此我们使用RabbitMq来做异步处理,前端点击获取验证码后,后端做完校验限流后直接返回发送成功。
发送短信的服务是需要收费的,而且我们也不允许用户恶意刷接口,所以需要有一个接口限流方案,可考虑漏桶算法、令牌桶算法,这里采用令牌桶算法。
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实现令牌桶算法,令牌桶算法具体细节可参考其他博客,这里不赘述,大致就是在 一个时间段 内,存在一定数量的令牌,我们需要拿到令牌才可以继续操作。
所以实现思路大致就是:
/** * @author YukeSeko */ @Component public class RedisTokenBucket { @Resource private RedisTemplateredisTemplate; /** * 过期时间,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; } }
/** * 短信服务传输对象 * @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; }
/** * 发送短信验证码 * @param phoneNum * @return */ @GetMapping("/smsCaptcha") public BaseResponsesmsCaptcha(@RequestParam String phoneNum){ userService.sendSmsCaptcha(phoneNum); // 异步发送验证码,这里直接返回成功即可 return ResultUtils.success("获取短信验证码成功!"); }
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); }
/** * @author niuma * @create 2023-04-28 22:18 */ @Component @Slf4j public class SmsUtils { @Resource private RedisTemplateredisTemplate; @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; } }
创建交换机和消息队列
/** * RabbitMQ配置 * @author niumazlb */ @Slf4j @Configuration public class RabbitMqConfig { /** * 普通队列 * @return */ @Bean public Queue smsQueue(){ Maparguments = 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); } }
/** * 向mq发送消息,并进行保证消息可靠性处理 * * @author niuma * @create 2023-04-29 15:09 */ @Component @Slf4j public class RabbitMqUtils implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnsCallback { @Resource private RedisTemplateredisTemplate; @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); } }
/** * @author niuma * @create 2023-04-29 15:35 */ @Component @Slf4j public class SendSmsListener { @Resource private RedisTemplateredisTemplate; @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); } } }
@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