SpringBoot:基于 Redis 自定义注解实现后端接口防重复提交校验(幂等操作)

发布于:2025-08-03 ⋅ 阅读:(9) ⋅ 点赞:(0)

SpringBoot:基于 Redis 自定义注解实现后端接口防重复提交校验(幂等操作)


可基于 时间间隔用于幂等判断的参数名称 实现防重复提交校验

客户端发送请求 
    ↓
[Spring Boot 应用入口]
    ↓
┌─────────────────────────────────────────┐
│        CacheRequestFilter        		│ │ // 第一步:请求体缓存过滤器
│  ┌────────────────────────────────────┐ │
│  │ 判断请求类型:              	        │ │
│  │ - 非JSON类型请求 → 直接放行			│ │
│  │ - JSON类型请求 (POST/PUT等)   		│ │
│  │   → 用CacheRequestWrapper包装 		│ │
│  │   → 缓存请求体到内存(支持重复读取)	│ │
│  └────────────────────────────────────┘ │
└─────────────────────────────────────────┘
    ↓
[DispatcherServlet 路由分发]  // 匹配到标注 @NoDuplicateSubmit 的控制器方法
    ↓
┌─────────────────────────────────────────────────┐
│    NoDuplicateSubmitAspect     			    │ │ // 第二步:AOP切面拦截
│  ┌────────────────────────────────────────────┐ │
│  │ 构建Redis防重校验Key:       				│ │
│  │ 1. 前缀(@NoDuplicateSubmit.prefix)		│ │
│  │ 2. 请求方法+路径(如POST:/api/submit)		│ │
│  │ 3. 当前用户ID(SecurityContextHolder获取)	│ │
│  │ 4. 参数哈希值:            				    │ │
│  │    - 若指定paramNames → 用SpEL提取对应参数	│ │
│  │    - 若未指定但allParamVerify=true → 所有参数	│ │
│  │    - 否则 → 固定标识"none"					│ │
│  │    → 用MurmurHash计算哈希并转Base64			│ │
│  └────────────────────────────────────────────┘ │
│  ┌────────────────────────────────────────────┐ │
│  │ Redis重复校验:								│ │
│  │ - 执行setIfAbsent(原子操作)				    │ │
│  │   → 若Key不存在 → 正常执行   				    │ │
│  │     (设置Key并指定过期时间)				    │ │
│  │   → 若Key已存在 → 重复提交   				    │ │
│  └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
    ├─ 重复提交 → 抛出ServerException
    │       ↓
    │  ┌─────────────────────────┐
    │  │ GlobalExceptionHandler  │  // 捕获异常,返回友好提示
    │  └─────────────────────────┘
    │       ↓
    │  客户端收到"请勿重复提交"错误
    │
    └─ 正常提交 → 执行控制器方法
            ↓
    控制器处理业务逻辑(可重复读取请求体)
            ↓
    客户端收到处理结果
    

具体操作如下:


Spring Boot 2.x + Spring Framework 5.x 版本

一、添加依赖

        <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>
        <!-- AOP -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>2.0.36</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>30.0-jre</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.14.0</version>
        </dependency>

二、 构建可重复读取inputStream的request

HTTP 请求体的输入流 ( ServletInputStream ) 只能被读取一次。当 AOP 拦截器(如日志切面、参数校验切面)和控制器都需要读取请求体时,如果不做处理,后续读取会抛出异常

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.MediaType;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;

/**
 * HttpServletReqeust使请求输入流支持二次读取
 */
public class CacheRequestFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        ServletRequest requestWrapper = null;
        if (request instanceof HttpServletRequest
                && StringUtils.startsWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)) {
            requestWrapper = new CacheRequestWrapper((HttpServletRequest) request);
        }
        if (null == requestWrapper) {
            chain.doFilter(request, response);
        } else {
            chain.doFilter(requestWrapper, response);
        }
    }


    public static class CacheRequestWrapper extends HttpServletRequestWrapper {
        private final String requestBody;

        public CacheRequestWrapper(HttpServletRequest request) throws IOException {
            super(request);
            StringBuilder sb = new StringBuilder();
            try (BufferedReader reader = request.getReader()) {
                String line;
                while ((line = reader.readLine()) != null) {
                    sb.append(line);
                }
            }
            this.requestBody = sb.toString();
        }

        public String getRequestBody() {
            return this.requestBody;
        }

        @Override
        public ServletInputStream getInputStream() throws IOException {
            ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(requestBody.getBytes());
            return new ServletInputStream() {
                @Override
                public boolean isFinished() {
                    return byteArrayInputStream.available() == 0;
                }

                @Override
                public boolean isReady() {
                    return true;
                }

                @Override
                public void setReadListener(ReadListener readListener) {
                }

                @Override
                public int read() throws IOException {
                    return byteArrayInputStream.read();
                }
            };
        }

        @Override
        public BufferedReader getReader() throws IOException {
            return new BufferedReader(new InputStreamReader(this.getInputStream()));
        }
    }


}

三、使自定义的Filter生效

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean someFilterRegistration() {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(new CacheRequestFilter());
        registration.addUrlPatterns("/*");
        registration.setName("cacheRequestFilter");
        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
        return registration;
    }
}

四、防重复提交常量类


/**
 * 防重复提交常量类
 */
public final class NoDuplicateSubmitConstant {

    public static final String RESUBMIT_MSG = "请勿重复提交数据";

    public static final String REDIS_SEPARATOR = ":";

    public static final String RESUBMIT_CHECK_KEY_PREFIX = "no-duplicate-submit";

}



五、创建防重复提交注解

创建一个自定义注解 @NoDuplicateSubmit ,用于标识需要防重复提交的方法


import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

/**
 * 幂等注解,防止用户重复提交表单信息
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoDuplicateSubmit {

    /**
     * 触发幂等失败逻辑时,返回的错误提示信息
     */
    String message() default NoDuplicateSubmitConstant.RESUBMIT_MSG;

    /**
     * 防重复提交校验的时间间隔
     */
    long interval() default 10;

    /**
     * 防重复提交校验的时间间隔的单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 自定义 Redis Key 前缀
     */
    String prefix() default NoDuplicateSubmitConstant.RESUBMIT_CHECK_KEY_PREFIX;

    /**
     * 指定参与幂等判断的参数名称
     * 例如:Param传参   @NoDuplicateSubmit(paramNames = {"#name"})   表示只使用name参数计算哈希
     *      Body传参    @NoDuplicateSubmit(paramNames = {"#user.name"})  表示只使用user对象下的name参数计算哈希
     */
    String[] paramNames() default {};

    /**
     * 仅当 {@link #paramNames()} 为空时, 开启此开关,选择是否校验全部参数
     */
    boolean allParamVerify() default false;

}

六、创建防止重复提交拦截器

创建一个AOP切面类,用于拦截标注了 @NoDuplicateSubmit 注解的方法,并检查是否重复提交

import com.alibaba.fastjson2.JSON;
import com.google.common.hash.Hashing;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
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.rmi.ServerException;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * 防止用户重复提交表单信息切面控制器
 */
@Aspect
@RequiredArgsConstructor
@Component
public final class NoDuplicateSubmitAspect {
    private static final Logger log = LoggerFactory.getLogger(NoDuplicateSubmitAspect.class);

    private final RedisTemplate<String, Object> redisTemplate;


    /**
     * 增强方法标记 {@link NoDuplicateSubmit} 注解逻辑
     */
    @Around("@annotation(noDuplicateSubmit)")
    public Object noDuplicateSubmit(ProceedingJoinPoint joinPoint, NoDuplicateSubmit noDuplicateSubmit) throws Throwable {
        final String lockKey = buildLockKey(joinPoint, noDuplicateSubmit);
        final String message = noDuplicateSubmit.message();
        final long interval = noDuplicateSubmit.interval();
        final TimeUnit timeUnit = noDuplicateSubmit.timeUnit();

        // 原子操作:如果 key 不存在,则设置 key 并过期;如果存在,直接返回 false
        Boolean isAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, "submit", interval, timeUnit);

        if (Boolean.FALSE.equals(isAbsent)) {
            // key 已存在 → 重复提交,抛异常
            throw new ServerException(message);
        }

        // key 不存在 → 正常执行方法(无需手动删除 key,过期后自动删除)
        return joinPoint.proceed();
    }

    /**
     * @param joinPoint
     * @return 构建重复提交的key
     */
    private String buildLockKey(ProceedingJoinPoint joinPoint, @NonNull NoDuplicateSubmit noDuplicateSubmit) {
        StringBuilder keyBuilder =
                new StringBuilder(noDuplicateSubmit.prefix())
                        .append(NoDuplicateSubmitConstant.REDIS_SEPARATOR)
                        .append(getMethodAndServletPath())
                        .append(NoDuplicateSubmitConstant.REDIS_SEPARATOR)
                        .append(getCurrentUserId())
                        .append(NoDuplicateSubmitConstant.REDIS_SEPARATOR)
                        .append(calcArgsMurmurHash(joinPoint, noDuplicateSubmit));
        return keyBuilder.toString();
    }


    /**
     * @return 获取当前线程上下文 Method + ServletPath
     */
    private String getMethodAndServletPath() {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        return request.getMethod() + NoDuplicateSubmitConstant.REDIS_SEPARATOR + request.getServletPath();
    }

    /**
     * @return 当前操作用户 ID
     */
    private Long getCurrentUserId() {
        return SecurityContextHolder.getUserId();
    }

    /**
     * @return joinPoint 采用google的MurmurHash算法计算哈希做校验
     */
    private String calcArgsMurmurHash(ProceedingJoinPoint joinPoint, NoDuplicateSubmit noDuplicateSubmit) {
        final String[] paramNames = noDuplicateSubmit.paramNames();
        final boolean allParamVerify = noDuplicateSubmit.allParamVerify();
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        Object[] args = joinPoint.getArgs();
        // 处理 paramNames 为空的场景
        if (paramNames.length == 0) {
            if (allParamVerify) {
                byte[] hashBytes = Hashing.murmur3_128().hashBytes(JSON.toJSONBytes(args)).asBytes();
                return Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes);
            } else {
                // 不校验参数,返回固定标识
                return "none";
            }
        }

        Object[] argsForKey = ExpressionUtils.getExpressionValueAliasAble(args, method, paramNames);

        // 使用 Google Guava 的 Hashing 生成 128 位哈希
        byte[] hashBytes = Hashing.murmur3_128().hashBytes(JSON.toJSONBytes(argsForKey)).asBytes();
        // 转为 Base64 编码
        return Base64.getUrlEncoder().withoutPadding().encodeToString(hashBytes);

    }
}


在这个切面类中,我们通过@Around注解拦截所有标注了 @NoDuplicateSubmit 注解的方法。通过Redis,我们为每个请求生成一个唯一的key,并设置一个过期时间。如果在过期时间内再次提交相同的请求,就会被拦截。

七、SpEL工具类

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ExpressionUtils {

    private static final Map<String, Expression> EXPRESSION_CACHE = new ConcurrentHashMap<>(64);

    private static final ExpressionParser SPEL_PARSER = new SpelExpressionParser();

    /**
     * 可以通过别名获取表达式的值,类似于spring cache的用法 可以给参数指定别名
     *
     * @param arguments         方法
     * @param method            参数
     * @param expressionsString Spring EL表达式字符串
     * @param <T>               类型
     * @return 结果集
     */
    @Nullable
    public static <T> T[] getExpressionValueAliasAble(@Nullable Object[] arguments, @NonNull Method method, String... expressionsString) {
        if (ArrayUtils.isEmpty(arguments) || ArrayUtils.isEmpty(expressionsString)) {
            return null;
        }

        Object[] result = new Object[expressionsString.length];
        for (int i = 0; i < result.length; i++) {
            result[i] = getExpressionValueAliasAble(arguments, method, expressionsString[i]);
        }

        //noinspection unchecked
        return (T[]) result;
    }

    /**
     * 可以通过别名获取表达式的值,类似于spring cache的用法 可以给参数指定别名
     *
     * @param arguments        参数
     * @param method           方法
     * @param expressionString Spring EL表达式字符串
     * @param <T>              类型
     * @return 结果
     */
    @Nullable
    public static <T> T getExpressionValueAliasAble(@Nullable Object[] arguments, @NonNull Method method, String expressionString) {
        if (ArrayUtils.isEmpty(arguments) || StringUtils.isBlank(expressionString)) {
            return null;
        }
        Expression expression = getExpression(expressionString);

        if (expression == null) {
            return null;
        }

        MethodBasedEvaluationContext evaluationContext = getEvaluationContextAliasAble(arguments, method);
        return (T) expression.getValue(evaluationContext);
    }

    /**
     * 获取Expression对象
     *
     * @param expressionString Spring EL 表达式字符串 例如 #{param.id}
     * @return Expression
     */
    @Nullable
    public static Expression getExpression(@Nullable String expressionString) {
        if (StringUtils.isBlank(expressionString)) {
            return null;
        }

        if (EXPRESSION_CACHE.containsKey(expressionString)) {
            return EXPRESSION_CACHE.get(expressionString);
        }

        Expression expression = SPEL_PARSER.parseExpression(expressionString);
        EXPRESSION_CACHE.put(expressionString, expression);
        return expression;
    }

    /**
     * 获取可以通过别名查找的EvaluationContext,类似于spring cache的用法 #a0.id,#p1.name
     *
     * @param arguments 方法入参
     * @param method    方法
     * @return MethodBasedEvaluationContext
     */
    @NonNull
    public static MethodBasedEvaluationContext getEvaluationContextAliasAble(@NonNull Object[] arguments, @NonNull Method method) {
        return new MethodBasedEvaluationContext(arguments, method, arguments, new LocalVariableTableParameterNameDiscoverer());
    }



}

八、自定义异常处理

为防重复提交功能添加自定义异常处理,使其返回更加友好的错误信息:


/**
 * 全局异常捕获类
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(value = ServerException.class)
    public ResultInfo abstractExceptionHandle(HttpServletRequest request, AbstractException ex) {
        logger.error("========================================== ServerException-Start ==========================================");
        String params = getRequestParams(request);
        logger.error("RequestURL     : {}", request.getRequestURL());
        logger.error("HTTP Method    : {}", request.getMethod());
        logger.error("Params         : {}", params);
        logger.error("IP             : {}", request.getRemoteAddr());
        logger.error("Cause          : ", ex);
        logger.error("ExMessage      : {}", ex.getMessage());
        logger.info("=========================================== ServerException-End ===========================================");
        return ResultInfo.error(ex);
    }
    


Spring Boot 3.x + Spring Framework6.x版本

只需更新 ExpressionUtils SpEL工具类 ,其他的方法和上面一样

LocalVariableTableParameterNameDiscovererSpring 6.0.1 中被标记为 deprecated(过时) 并计划移除,主要原因是 Spring 引入了更高效的参数名发现机制 StandardReflectionParameterNameDiscoverer
以下是替代方案:


import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.core.StandardReflectionParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ExpressionUtils {

    private static final Map<String, Expression> EXPRESSION_CACHE = new ConcurrentHashMap<>(64);

    private static final ExpressionParser SPEL_PARSER = new SpelExpressionParser();

    /**
     * 可以通过别名获取表达式的值,类似于spring cache的用法 可以给参数指定别名
     *
     * @param arguments         方法
     * @param method            参数
     * @param expressionsString Spring EL表达式字符串
     * @param <T>               类型
     * @return 结果集
     */
    @Nullable
    public static <T> T[] getExpressionValueAliasAble(@Nullable Object[] arguments, @NonNull Method method, String... expressionsString) {
        if (ArrayUtils.isEmpty(arguments) || ArrayUtils.isEmpty(expressionsString)) {
            return null;
        }

        Object[] result = new Object[expressionsString.length];
        for (int i = 0; i < result.length; i++) {
            result[i] = getExpressionValueAliasAble(arguments, method, expressionsString[i]);
        }

        //noinspection unchecked
        return (T[]) result;
    }

    /**
     * 可以通过别名获取表达式的值,类似于spring cache的用法 可以给参数指定别名
     *
     * @param arguments        参数
     * @param method           方法
     * @param expressionString Spring EL表达式字符串
     * @param <T>              类型
     * @return 结果
     */
    @Nullable
    public static <T> T getExpressionValueAliasAble(@Nullable Object[] arguments, @NonNull Method method, String expressionString) {
        if (ArrayUtils.isEmpty(arguments) || StringUtils.isBlank(expressionString)) {
            return null;
        }
        Expression expression = getExpression(expressionString);

        if (expression == null) {
            return null;
        }

        MethodBasedEvaluationContext evaluationContext = getEvaluationContextAliasAble(arguments, method);
        return (T) expression.getValue(evaluationContext);
    }

    /**
     * 获取Expression对象
     *
     * @param expressionString Spring EL 表达式字符串 例如 #{param.id}
     * @return Expression
     */
    @Nullable
    public static Expression getExpression(@Nullable String expressionString) {
        if (StringUtils.isBlank(expressionString)) {
            return null;
        }

        if (EXPRESSION_CACHE.containsKey(expressionString)) {
            return EXPRESSION_CACHE.get(expressionString);
        }

        Expression expression = SPEL_PARSER.parseExpression(expressionString);
        EXPRESSION_CACHE.put(expressionString, expression);
        return expression;
    }

    /**
     * 获取可以通过别名查找的EvaluationContext,类似于spring cache的用法 #a0.id,#p1.name
     *
     * @param arguments 方法入参
     * @param method    方法
     * @return MethodBasedEvaluationContext
     */
    @NonNull
    public static MethodBasedEvaluationContext getEvaluationContextAliasAble(@NonNull Object[] arguments, @NonNull Method method) {
        return new MethodBasedEvaluationContext(arguments, method, arguments, new StandardReflectionParameterNameDiscoverer());
    }



}



创建示例Controller

创建一个简单的Controller,用于测试防重复提交功能

@RestController
@RequestMapping()
public class TestController {
    @PostMapping("/test1")
    @NoDuplicateSubmit(message = "不要在提交了", interval = 120, paramNames = {"#name"})
    public ResultInfo<String> test1(@RequestParam String name) {
        return ResultInfo.success();
    }

    @PostMapping("/test2")
    @NoDuplicateSubmit(message = "不要在提交了", interval = 120, paramNames = {"#user.name"})
    public ResultInfo<String> test2(@RequestBody User user) {
        return ResultInfo.success();
    }

    public static class User {
        private String name;

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }
    }
}

流程图解

非JSON类型请求
JSON类型请求(POST/PUT等)
指定paramNames
未指定但allParamVerify=true
其他
是(重复提交)
否(正常提交)
客户端发送请求
Spring Boot应用入口
CacheRequestFilter过滤器
直接放行
用CacheRequestWrapper包装请求
缓存请求体到内存(支持重复读取)
DispatcherServlet路由分发
匹配到@NoDuplicateSubmit注解方法?
正常执行控制器方法
NoDuplicateSubmitAspect切面拦截
构建Redis防重校验Key
前缀(@NoDuplicateSubmit.prefix)
请求方法+路径(如POST:/api/submit)
当前用户ID(SecurityContextHolder获取)
参数哈希值
参数规则?
用SpEL提取对应参数
所有参数参与计算
固定标识'none'
MurmurHash计算哈希→转Base64
Redis执行setIfAbsent(原子操作)
Key是否存在?
抛出ServerException
设置Key及过期时间→执行控制器方法
GlobalExceptionHandler捕获异常
返回'请勿重复提交'错误信息
客户端接收错误响应
控制器处理业务逻辑(可重复读请求体)
返回处理结果
客户端接收成功响应