防重复提交

防重复提交

作者:kaoli

防重复提交,幂等性提交,这个有的直接在前端做,不过我觉得这类的放在后端实现更安全。

网上有好多这类的实现,这里也记录一下我的实现方式,在生产上也跑了一段时间,目前没啥问题。。。

随大流,也是通过 redis set key nx ex expire 实现。。。

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @author : kaoli
 * @description : 防重复提交注解
 */
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
    /**
     * 间隔时间(s),小于此时间视为重复提交
     */
    int interval() default 2;

    /**
     * 提示消息
     */
    String message() default "请勿重复提交";

    /**
     * 排除字段,使用的是 redis set key nx ex expire
     * 其中key由(uri | token + : + param -> [SHA1])构成,默写情况下期望排除部属性,可在该列填写排除的属性名。
     * 场景,例如 传的参数中有当前时间,两次请求间时间内相差毫秒级,但是期望这两次请求为重复请求,则排除时间参数。一般用不到。。。
     */
    String[] exclude() default {};
}

import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.serializer.SimplePropertyPreFilter;
import com.pingpongx.common.exception.DataConflictException;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DigestUtils;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;

/**
 * @author : kaoli
 * @description : 防重复提交切面
 */
@Aspect
@Component
public class RepeatSubmitAspect {

    private  RedisTemplate<String, String> redisTemplate;

    /**
     * 防重提交 redis key
     */
    public static final String REPEAT_SUBMIT_KEY = "repeat_submit:";
    /**
     * 令牌
     */
    public static final String TOKEN = "Authorization";

    public RepeatSubmitAspect(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 定义切点
     */
    @Pointcut("@annotation(com.*.*.*.*.RepeatSubmit)")
    public void preventDuplication() {
    }

    /**
     * 如果key不存在,set key and expire key
     * set key value [EX seconds] [PX milliseconds] [NX|XX]
     * EX seconds:设置失效时长,单位秒
     * PX milliseconds:设置失效时长,单位毫秒
     * NX:key不存在时设置value,成功返回OK,失败返回(nil)
     * XX:key存在时设置value,成功返回OK,失败返回(nil)
     *
     * @param key
     * @param value
     * @param expire
     * @return
     */
    public Boolean setIfAbsent(String key, String value, int expire) {
        // NOTE:直接SETNX不支持带过期时间,所以设置+过期不是原子操作,极端情况下可能设置了就不过期了,后面相同请求可能会误以为需要去重,所以这里使用底层API,保证SETNX+过期时间是原子操作。。。测试用的环境比较老,近几年redisTemplate已有setIfAbsent方法,可以直接设置过期时间  redisTemplate.opsFoeValue().setIfAbsent()
        return redisTemplate.execute((RedisCallback<Boolean>) connection ->
                connection.set(key.getBytes(), value.getBytes(), Expiration.seconds(expire),
                RedisStringCommands.SetOption.SET_IF_ABSENT));
    }

    /**
     * 校验重复提交
     *
     * @param joinPoint
     * @return
     */
    @Around("preventDuplication()")
    public Object before(ProceedingJoinPoint joinPoint)throws Throwable {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
                .getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        Assert.notNull(request, "request cannot be null.");

        //获取执行方法
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        //获取防重复提交注解
        RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
        // 获取token以及方法标记,生成redisKey和redisValue
        String submitKey = request.getHeader(TOKEN);
        // 若无登录用户,则获取uri作为key的一部分
        submitKey = submitKey == null ? request.getRequestURI(): submitKey.replaceAll("bearer ", "");
        String redisKey = REPEAT_SUBMIT_KEY
                .concat(submitKey).concat(":")
                .concat(getMethodSign(annotation.exclude(), method, joinPoint.getArgs()));
        String redisValue = annotation.message() + "|" + annotation.interval();
        if (setIfAbsent(redisKey, redisValue, annotation.interval())) {
            try {
                return joinPoint.proceed();
            } catch (Throwable e){
                //确保方法执行异常实时释放限时标记
                redisTemplate.delete(redisKey);
                throw e;
            }
        }
        throw new DataConflictException(annotation.message());
    }

    /**
     * 生成方法标记:采用数字签名算法SHA1对方法签名字符串加签
     *
     * @param exclude
     * @param method
     * @param args
     * @return
     */
    private String getMethodSign(String[] exclude, Method method, Object... args) {
        StringBuilder sb = new StringBuilder(method.toString());
        for (Object arg : args) {
            sb.append(toString(arg, exclude));
        }
        return DigestUtils.sha1DigestAsHex(sb.toString());
    }

    private String toString(Object arg, String[] exclude) {
        if (Objects.isNull(arg)) {
            return "null";
        }
        if (arg instanceof Number) {
            return arg.toString();
        }
        if (exclude != null && exclude.length > 0) {
            SimplePropertyPreFilter excludeFilter = new SimplePropertyPreFilter();
            // 注意:如果类包含子对象,下级同字段名的也会被排除掉
            excludeFilter.getExcludes().addAll(Arrays.asList(exclude));
            return JSONObject.toJSONString(arg, excludeFilter);
        }
        return JSONObject.toJSONString(arg);
    }
}

  // 切面不失效的地方使用都行
 /**
  * 新增
  *
  * @param dto
  * @return
  */
  @PostMapping
  @RepeatSubmit(interval = 1, message = "kaoli 😝", exclude = {"dealTime"})
  public ApiResponse submitApply(List<MultipartFile> files, @Validated ApplyInfo dto) {
	// ......
	return null;
  }

posted @ 2022-04-30 15:57  kaoli-烤梨  阅读(88)  评论(0)    收藏  举报