🎉场景介绍:在实际应用场景中,我们通常需要对短信推送消息做发送限制的处理,避免一定时间间隔内发送过多相同内容的推送短信对用户造成骚扰,也要防止攻击者恶意调用短信推送接口造成短信资源的极大浪费。尽管部分三方接口提供方的原生接口就已经做了限制,但是为了能够更好地满足定制化需求以及在编码过程中的疏忽造成接口重复调用导致资源浪费,因此我们需要在项目中做短信推送限制的处理。
💡方案设计:
首先,将需要推送的短信内容以及接收短信的手机号进行拼接成新的字符串,并对该字符串进行加密处理转换成16进制字符串,作为存入到 redis 中的 key.
其次,执行 Lua 脚本,该脚本用于在 redis 中存储具有过期时间的 key,并返回重复校验的结果。
最后,获取校验结果,根据校验结果进行相应的处理操作,如果是重复发送的短信就将该接口和手机号加入数据埋点,用于监控推送限制情况,并抛出重复发送的异常;否则,正常发送短信消息。
在对项目中的业务代码做了抽离后,只保留了和方案设计相关的代码,如下所示:
public class SmsService { private static Logger logger = LoggerFactory.getLogger(SmsService.class); @Autowired private JedisTemplate jedisTemplate; @Autowired private MeterRegistry meterRegistry; private static final String LIMIT_LUA = "local num = redis.call('incr', KEYS[1])\n" + "if num == 1 then\n" + " redis.call('expire', KEYS[1], ARGV[1])\n" + "end\n" + "if num > tonumber(ARGV[2]) then\n" + " return 0\n" + "end\n" + "return 1"; /** * 发送短信 * @param sendSmsRequest 发送短信请求信息 * @return */ @Transactional public String sendSms(SendSmsRequest sendSmsRequest) { String key = sendSmsRequest.getPhoneNumber() + sendSmsRequest.getTemplateCode() + sendSmsRequest.getTemplateParam(); key = DigestUtils.md5Hex(key); // 60秒内同一手机号相同内容只允许发送两次 boolean limitFlag = checkIsLimited(key, 60L, 2L); if (limitFlag) { logger.warn("短信推送被限制,sendSmsRequest:{}", JsonTool.serialize(sendSmsRequest)); // 加入数据埋点,用于监控超出推送限制的情况 meterRegistry.counter("sms.repeat.limit.count", "/test/sms", uri, sendSmsRequest.getPhoneNumber(), user).increment(); throw new ServiceException("短信推送超出限制"); } // 推送短信,次数省略100w行代码... } /** * 推送频率限制,seconds内只允许limitNum次 * @param key key * @param seconds 时间 秒 * @param limitNum 限制次数 * @return true:限制 false:不限制 */ public boolean checkIsLimited(String key, long seconds, long limitNum) { try { Long result = (Long) jedisTemplate.eval(LIMIT_LUA, CoreTool.array2List(key), CoreTool.array2List(String.valueOf(seconds), String.valueOf(limitNum))); return result == 0; } catch (Exception e) { logger.warn("校验是否需要推送限制异常", e); } return false; } } }
其中,对上述 Lua 脚本功能具体的注释如下:
local num = redis.call('incr', KEYS[1]) -- 计数加1(第一次调用时,key不存在,默认会创建并赋值1) if num == 1 then -- 第一次调用 redis.call('expire', KEYS[1], ARGV[1]) -- 对key设置过期时间 end if num > tonumber(ARGV[2]) then -- 非第一次访问,判断是否在过期时间内大于限定的访问次数 return 0 end return 1