使用Redis+自定义注解实现接口防刷


  最近开发了一个功能,需要发送短信验证码鉴权,考虑到短信服务需要收费,因此对此接口做了防刷处理,实现方式主要是Redis+自定义注解(需要导入Redis的相关依赖,完成Redis的相关配置,gs代码,这里不做展示)。

  首先定义注解AccessFrequencyLimiter,注解包含四个参数,限制一段时间内同一IP地址最多访问接口次数,以及报错信息和报错之后再次可以访问接口的时间间隔。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AccessFrequencyLimiter {

    /**
     * 从第一次访问接口的时间到cycle周期时间内,无法超过frequency次
     */
    int frequency() default 5;

    /**
     * 周期时间,单位ms:
     * 默认周期时间为一分钟
     */
    long cycle() default 60 * 1000;

    /**
     * 返回的错误信息
     */
    String message() default "操作过于频繁,请稍后再试";

    /**
     * key到期时间,单位s:
     * 如果在cycle周期时间内超过frequency次,则默认5分钟内无法继续访问
     */
    long expireTime() default 5 * 60;
}

  利用AOP,实现防刷逻辑。具体代码如下,通过Redis保存某个IP首次访问接口的时间,和访问次数,然后在限制时间内对访问次数进行累加,超过最大次数则抛出操作太频繁的异常,需要等待Redis的key过期之后才能再次访问该接口,达到接口防刷的效果。

@Aspect
@Component
public class AccessFrequencyLimitingAspect {
    private static final String LIMITING_KEY = "limiting:%s:%s";
    private static final String LIMITING_BEGINTIME = "beginTime";
    private static final String LIMITING_EXFREQUENCY = "exFrequency";

    @Autowired
    private RedisTemplate redisTemplate;

    @Pointcut("@annotation(accessFrequencyLimiter)")
    public void pointcut(AccessFrequencyLimiter accessFrequencyLimiter) {
    }

    @Around("pointcut(accessFrequencyLimiter)")
    public Object around(ProceedingJoinPoint pjp, AccessFrequencyLimiter accessFrequencyLimiter) throws Throwable {
        //获取请求的ip和方法
        String ipAddress = WebUtil.getIpAddress();
        String methodName = pjp.getSignature().toLongString();
        
        //获取redis中周期内第一次访问方法的时间和已访问过接口的次数
        Long beginTimeLong = (Long) redisTemplate.opsForHash().get(String.format(LIMITING_KEY, ipAddress, methodName), LIMITING_BEGINTIME);
        Integer exFrequencyLong = (Integer) redisTemplate.opsForHash().get(String.format(LIMITING_KEY, ipAddress, methodName), LIMITING_EXFREQUENCY);
        long beginTime = beginTimeLong == null ? 0L : beginTimeLong;
        int exFrequency = exFrequencyLong == null ? 0 : exFrequencyLong;

        //当两次访问时间差超过限制时间时,记录最新访问时间作为一个访问周期内的首次访问时间,并设置访问次数为1
        if (System.currentTimeMillis() - beginTime > accessFrequencyLimiter.cycle()) {
            redisTemplate.opsForHash().put(String.format(LIMITING_KEY, ipAddress, methodName), LIMITING_BEGINTIME, System.currentTimeMillis());
            redisTemplate.opsForHash().put(String.format(LIMITING_KEY, ipAddress, methodName), LIMITING_EXFREQUENCY, 1);
            //设置key的过期时间
            redisTemplate.expire(String.format(LIMITING_KEY, ipAddress, methodName), accessFrequencyLimiter.expireTime(), TimeUnit.SECONDS);
            return pjp.proceed();
        } else {
            //如果该次访问与首次访问时间差在限制时间段内,则访问次数+1,并刷新key的过期时间
            if (exFrequency < accessFrequencyLimiter.frequency()) {
                redisTemplate.opsForHash().put(String.format(LIMITING_KEY, ipAddress, methodName), LIMITING_EXFREQUENCY, exFrequency + 1);
                redisTemplate.expire(String.format(LIMITING_KEY, ipAddress, methodName), accessFrequencyLimiter.expireTime(), TimeUnit.SECONDS);
                return pjp.proceed();
            } else {
                //限制时间内访问次数超过最大可访问次数,抛出异常
                throw new ServiceException(accessFrequencyLimiter.message());
            }
        }
    }
}

  获取访问接口的IP地址,工具类WebUtil代码如下:

public class WebUtil {

    private static final String UNKNOWN = "unknown";

    //获取request
    public static HttpServletRequest getRequest() {
        return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
    }

    //获取response
    public static HttpServletResponse getResponse() {
        return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
    }

    public static String getIpAddress() {
        HttpServletRequest request = getRequest();
        String ip = request.getHeader("x-forwarded-for");
        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }

        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }

        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }

        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_CLIENT_IP");
        }

        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader("X-Real-IP");
        }

        if (ip == null || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_X_FORWARDED_FOR");
        }

        String regex = ",";
        if (ip != null && ip.indexOf(regex) > 0) {
            ip = ip.split(regex)[0];
        }

        return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
    }

}

  在接口上添加该注解进行测试,自定义接口限制时间内访问次数,以及报错信息等参数,代码如下:

    @AccessFrequencyLimiter(frequency = 3, cycle = 5 * 60 * 1000, message = "操作过于频繁,请五分钟之后再试")
    @ApiOperation("发送验证码,同一个ip五分钟内最多只能发送三次验证码,超过次数即提示“操作过于频繁,请五分钟之后再试")
    @GetMapping("insurance_policy/unbind_verification_code")
    Response sendUnbindVerificationCode(@RequestParam(name = "mcard_no") String mcardNo) {return Response.success;}

  测试结果如下: