在业务需求中我们经常会用到短信验证码,比如手机号登录、绑定手机号、忘记密码、敏感操作等,都可以通过短信验证码来保证操作的安全性,于是就记录下了一次开发的过程。
一.架构设计
- 发送短信是一个比较慢的过程,因为需要用到第三方服务(腾讯云短信服务),因此我们使用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
*/@ComponentpublicclassRedisTokenBucket{@ResourceprivateRedisTemplate<String,String> redisTemplate;/**
* 过期时间,400秒后过期
*/privatefinallong EXPIRE_TIME =400;/**
* 令牌桶算法,一分钟以内,每个手机号只能发送一次
* @param phoneNum
* @return
*/publicbooleantryAcquire(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);// 如果获取到令牌,则返回truereturntrue;}// 如果没有获取到令牌,则返回falsereturnfalse;}}
③ 业务代码
0.Pojo
/**
* 短信服务传输对象
* @author niuma
* @create 2023-04-28 21:16
*/@Data@AllArgsConstructorpublicclassSmsDTOimplementsSerializable{privatestaticfinallong serialVersionUID =8504215015474691352L;String phoneNum;String code;}
1.Controller
/**
* 发送短信验证码
* @param phoneNum
* @return
*/@GetMapping("/smsCaptcha")publicBaseResponse<String>smsCaptcha(@RequestParamString phoneNum){
userService.sendSmsCaptcha(phoneNum);// 异步发送验证码,这里直接返回成功即可returnResultUtils.success("获取短信验证码成功!");}
2.Service
- 手机号格式校验可参考其他人代码。
publicBooleansendSmsCaptcha(String phoneNum){if(StringUtils.isEmpty(phoneNum)){thrownewBusinessException(ErrorCode.PARAMS_ERROR,"手机号不能为空");}AuthPhoneNumberUtil authPhoneNumberUtil =newAuthPhoneNumberUtil();// 手机号码格式校验boolean checkPhoneNum = authPhoneNumberUtil.isPhoneNum(phoneNum);if(!checkPhoneNum){thrownewBusinessException(ErrorCode.PARAMS_ERROR,"手机号格式错误");}//生成随机验证码int code =(int)((Math.random()*9+1)*10000);SmsDTO smsDTO =newSmsDTO(phoneNum,String.valueOf(code));return smsUtils.sendSms(smsDTO);}
3.发送短信工具类
- 提供两个方法 - sendSms:先从令牌桶中获取令牌,获取失败不允许发短信,获取成功后,将验证码信息存入Redis,使用RabbitMq异步发送短信- verifyCode:根据手机号校验验证码,使用Redis
/**
* @author niuma
* @create 2023-04-28 22:18
*/@Component@Slf4jpublicclassSmsUtils{@ResourceprivateRedisTemplate<String,String> redisTemplate;@ResourceprivateRedisTokenBucket redisTokenBucket;@ResourceprivateRabbitMqUtils rabbitMqUtils;publicbooleansendSms(SmsDTO smsDTO){// 从令牌桶中取得令牌,未取得不允许发送短信boolean acquire = redisTokenBucket.tryAcquire(smsDTO.getPhoneNum());if(!acquire){
log.info("phoneNum:{},send SMS frequent", smsDTO.getPhoneNum());returnfalse;}
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);returntrue;}publicbooleanverifyCode(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);returntrue;}returnfalse;}}
4.RabbitMq初始化
创建交换机和消息队列
/**
* RabbitMQ配置
* @author niumazlb
*/@Slf4j@ConfigurationpublicclassRabbitMqConfig{/**
* 普通队列
* @return
*/@BeanpublicQueuesmsQueue(){Map<String,Object> arguments =newHashMap<>();//声明死信队列和交换机消息,过期时间: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);returnnewQueue(SMS_QUEUE_NAME,true,false,false,arguments);}/**
* 死信队列:消息重试三次后放入死信队列
* @return
*/@BeanpublicQueuedeadLetter(){returnnewQueue(SMS_DELAY_QUEUE_NAME,true,false,false);}/**
* 主题交换机
* @return
*/@BeanpublicExchangesmsExchange(){returnnewTopicExchange(SMS_EXCHANGE_NAME,true,false);}/**
* 交换机和普通队列绑定
* @return
*/@BeanpublicBindingsmsBinding(){returnnewBinding(SMS_QUEUE_NAME,Binding.DestinationType.QUEUE,SMS_EXCHANGE_NAME,SMS_EXCHANGE_ROUTING_KEY,null);}/**
* 交换机和死信队列绑定
* @return
*/@BeanpublicBindingsmsDelayBinding(){returnnewBinding(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@Slf4jpublicclassRabbitMqUtilsimplementsRabbitTemplate.ConfirmCallback,RabbitTemplate.ReturnsCallback{@ResourceprivateRedisTemplate<String,String> redisTemplate;@ResourceprivateRabbitTemplate rabbitTemplate;privateString finalId =null;privateSmsDTO smsDTO =null;/**
* 向mq中投递发送短信消息
*
* @param smsDTO
* @throws Exception
*/publicvoidsendSmsAsync(SmsDTO smsDTO){String messageId =null;try{// 将 headers 添加到 MessageProperties 中,并发送消息
messageId = UUID.randomUUID().toString();HashMap<String,Object> messageArgs =newHashMap<>();
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);thrownewRuntimeException(e);}}/**
* 发布者确认的回调
*
* @param correlationData 回调的相关数据。
* @param b ack为真,nack为假
* @param s 一个可选的原因,用于nack,如果可用,否则为空。
*/@Overridepublicvoidconfirm(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
*/@OverridepublicvoidreturnedMessage(ReturnedMessage returnedMessage){
log.error("发生异常,返回消息回调:{}", returnedMessage);// 发送失败,放入redis失败集合中,并删除集合数据
redisTemplate.delete(RedisConstant.SMS_MESSAGE_PREFIX + finalId);
redisTemplate.opsForHash().put(RedisConstant.MQ_PRODUCER, finalId,this.smsDTO);}@PostConstructpublicvoidinit(){
rabbitTemplate.setConfirmCallback(this);
rabbitTemplate.setReturnsCallback(this);}}
6.Mq消息监听器
- 根据messageId从Redis中找到对应的消息(为了判断重试次数,规定重试3次为失败,加入死信队列)
- 调用第三方云服务商提供的短信服务发送短信,通过返回值来判断是否发送成功
- 手动确认消息
/**
* @author niuma
* @create 2023-04-29 15:35
*/@Component@Slf4jpublicclassSendSmsListener{@ResourceprivateRedisTemplate<String,String> redisTemplate;@ResourceprivateSendSmsUtils sendSmsUtils;/**
* 监听发送短信普通队列
* @param smsDTO
* @param message
* @param channel
* @throws IOException
*/@RabbitListener(queues = SMS_QUEUE_NAME)publicvoidsendSmsListener(SmsDTO smsDTO,Message message,Channel channel)throwsIOException{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)){thrownewRuntimeException("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())){thrownewRuntimeException("发送验证码失败");}//手动确认消息
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)publicvoidsmsDelayQueueListener(SmsDTO sms,Message message,Channel channel)throwsIOException{try{
log.error("监听到死信队列消息==>{}",sms);
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);}catch(Exception e){
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);}}}
7.腾讯云短信服务
@ComponentpublicclassTencentClient{@Value("${tencent.secretId}")privateString secretId;@Value("${tencent.secretKey}")privateString secretKey;/**
* Tencent应用客户端
* @return
*/@BeanpublicSmsClientclient(){Credential cred =newCredential(secretId, secretKey);SmsClient smsClient =newSmsClient(cred,"ap-guangzhou");return smsClient;}}
@ComponentpublicclassSendSmsUtils{@ResourceprivateTencentClient tencentClient;@Value("${tencent.sdkAppId}")privateString sdkAppId;@Value("${tencent.signName}")privateString signName;@Value("${tencent.templateId}")privateString templateId;/**
* 发送短信工具
* @param phone
* @return
* @throws TencentCloudSDKException
*/publicSendSmsResponse sendSmsResponse (String phone,String code)throwsTencentCloudSDKException{SendSmsRequest req =newSendSmsRequest();/* 短信应用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 =newString[]{"+86"+ phone};
req.setPhoneNumberSet(phoneNumberSet);/* 用户的 session 内容(无需要可忽略): 可以携带用户侧 ID 等上下文信息,server 会原样返回
String sessionContext = "";
req.setSessionContext(sessionContext);
*//* 通过 client 对象调用 SendSms 方法发起请求。注意请求方法名与请求对象是对应的
* 返回的 res 是一个 SendSmsResponse 类的实例,与请求对象对应 */SmsClient client = tencentClient.client();returnclient.SendSms(req);}}
配置文件
tencent:secretId:#你的secretIdsecretKey:#你的secretKeysdkAppId:#你的sdkAppIdsignName:#你的signNametemplateId:#你的templateId
三. 心得
- 消息队列的一个用法
- ConfirmCallback、ReturnsCallback接口的使用
- 腾讯云短信服务的使用
- 令牌桶算法的实践
版权归原作者 dogbin丶 所有, 如有侵权,请联系我们删除。