RuoYi 防重复提交方案详解:基于拦截器 + Redis 实现@RepeatSubmit 注解

在 Web 开发中,“重复提交” 是常见问题(如用户快速点击提交按钮、网络延迟导致的重试),可能引发数据重复插入、订单重复创建等严重问题。Ruoyi 框架通过@RepeatSubmit注解,结合拦截器 + Redis实现接口幂等性保障,核心是 “拦截请求→生成唯一标识→缓存校验→拒绝重复”,下文详细解析其设计与实现。

    一、核心目标

    • 精准拦截:仅对标注@RepeatSubmit的接口生效,不影响其他接口;​
    • 灵活配置:支持自定义 “防重复时间间隔”(如 10 秒内禁止重复提交)和 “提示消息”;​
    • 高效校验:基于 Redis 实现分布式环境下的唯一标识存储,兼顾性能与一致性。
    //interval  间隔时间(ms),小于此时间视为重复提交 
    //提示消息
    @RepeatSubmit(interval = 10000,message = "不能重复提交")

    二、方案设计

    为实现上述目标,方案通过 “唯一标识生成 - 双重校验 - 自动缓存清理” 三层逻辑,形成闭环的防重复提交流程:

    1. 唯一标识生成:精准区分 “重复请求” 与 “正常请求”​

    核心思路:通过 “请求 URL + 用户 Token + 请求参数” 三者组合,生成全局唯一的请求标识(cacheRepeatKey),确保两类场景不被误判:​

    避免 “不同用户的相同请求” 误判:用户 Token 区分身份,即使两个用户请求同一 URL、携带相同参数,也会因 Token 不同生成不同标识;​

    避免 “同一用户的不同参数请求” 误判:请求参数(如订单金额、商品 ID)转换为字符串后参与标识生成,同一用户对同一接口的不同参数请求,会被判定为不同请求。​​

    2. 双重校验机制:降低误判率,提升可靠性​

    仅靠 “时间间隔” 或 “参数一致” 单一条件校验,易出现误判(如同一用户 10 秒内对同一接口提交不同参数,若只校验时间会误拦;不同用户同一时间提交相同参数,若只校验参数会误拦)。因此设计双重校验逻辑:​

    校验 1:参数一致性校验​

    对比当前请求参数快照(字符串格式)与 Redis 中存储的历史参数快照,确保请求内容完全一致;​

    校验 2:时间间隔校验​

    计算当前时间戳与 Redis 中存储的历史请求时间戳的差值,判断是否小于 @RepeatSubmit 注解配置的 interval。​

    仅当两项校验均通过时,才判定为重复提交,既避免 “误拦正常请求”,也防止 “漏拦重复请求”。​

    3. 缓存自动清理:低维护成本的内存管理​

    Redis 缓存的过期时间与 @RepeatSubmit 注解的 interval 绑定(如 interval=10000ms,则缓存过期时间设为 10000ms)。当缓存到期后,Redis 会自动删除该请求标识,无需开发人员手动编写清理逻辑:​

    • 优势 1:减少内存占用,避免无效缓存长期堆积;​
    • 优势 2:降低维护成本,无需担心 “缓存残留导致后续请求误判” 的问题;​
    • 优势 3:契合业务逻辑,过期时间与防重时间间隔同步,确保 “超过防重时长后,请求可正常提交”。

    三、代码实现:从注解定义到拦截器落地

    1. 第一步:定义 @RepeatSubmit 注解​

    首先通过自定义注解,声明防重复提交的配置参数(时间间隔、提示消息),作为拦截器的 “判断标记”。

    /**​
     * 防重复提交注解​
     * @author Ruoyi​
     */​
    @Target(ElementType.METHOD) // 仅作用于方法(接口)​
    @Retention(RetentionPolicy.RUNTIME) // 运行时生效,供拦截器反射读取​
    @Documented​
    public @interface RepeatSubmit {​
        /**​
         * 防重复时间间隔(单位:ms)​
         * 小于该时间的相同请求视为重复提交​
         */​
        int interval() default 5000; // 默认5秒​
        ​
        /**​
         * 重复提交时的提示消息​
         */​
        String message() default "请勿重复提交,请稍后再试!";​
    }

    2. 第二步:实现拦截器 RepeatSubmitInterceptor​

    拦截器是核心执行组件,通过preHandle方法拦截请求,判断是否需要执行防重复校验(仅对标注@RepeatSubmit的方法生效)。​

    (1)拦截器核心逻辑:preHandle 方法​

    拦截器先判断 “当前请求是否对应标注注解的控制器方法”,再调用isRepeatSubmit执行具体校验。

        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
        {
            //确保只对标注了注解的控制器方法进行拦截
            if (handler instanceof HandlerMethod)
            {
                HandlerMethod handlerMethod = (HandlerMethod) handler;
                //获取当前请求对应的控制器方法
                Method method = handlerMethod.getMethod();
                RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
                //判断是否有 RepeatSubmit
                if (annotation != null)
                {
                    //验证是否重复提交由子类实现具体的防重复提交的规则
                    if (this.isRepeatSubmit(request, annotation))
                    {
                        AjaxResult ajaxResult = AjaxResult.error(annotation.message());
                        //将后端处理结果(封装在 AjaxResult 对象中)转换为 JSON 字符串,并写入到 HTTP 响应中,返回给前端
                        ServletUtils.renderString(response, JSON.toJSONString(ajaxResult));
                        return false;
                    }
                }
                return true;
            }
            else
            {
                return true;
            }
        }
    (2)关键:isRepeatSubmit 校验逻辑(Redis 实现)​

    该方法是防重复的核心,负责 “生成唯一 key→Redis 校验→缓存写入”,具体步骤拆解如下:

      @Override
        public boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation)
        {
            String nowParams = "";
            //检查当前请求是否是 RepeatedlyRequestWrapper 类型的包装类
            if (request instanceof RepeatedlyRequestWrapper)
            {
                RepeatedlyRequestWrapper repeatedlyRequest = (RepeatedlyRequestWrapper) request;
                //获取请求体的字符串内容
                nowParams = HttpHelper.getBodyString(repeatedlyRequest);
            }
    
            // body参数为空,获取Parameter的数据
            if (StringUtils.isEmpty(nowParams))
            {
                nowParams = JSON.toJSONString(request.getParameterMap());
            }
            Map<String, Object> nowDataMap = new HashMap<String, Object>();
            //获取请求参数
            nowDataMap.put(REPEAT_PARAMS, nowParams);
            //获取当前时间戳
            nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());
    
            // 请求地址(作为存放cache的key值)
            String url = request.getRequestURI();
    
            // 唯一值(没有消息头则使用请求地址)用户token
            String submitKey = StringUtils.trimToEmpty(request.getHeader(header));
    
            // 唯一标识(指定key + url + 消息头)
            String cacheRepeatKey = CacheConstants.REPEAT_SUBMIT_KEY + url + submitKey;
    
            Object sessionObj = redisCache.getCacheObject(cacheRepeatKey);
            //查询Redis是否有请求记录
            if (sessionObj != null)
            {
                //将缓存的对象转换为Map类型
                Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;
                //查看map中是否用对应的url
                if (sessionMap.containsKey(url))
                {
                    Map<String, Object> preDataMap = (Map<String, Object>) sessionMap.get(url);
                    // 1. 比对当前请求与历史请求的参数是否相同
                    // 2. 比对两次请求的时间间隔是否在注解指定的防重时间内
                    // 如果两个条件都满足,则判定为重复提交
                    if (compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap, annotation.interval()))
                    {
                        return true;
                    }
                }
            }
            //创建一个新的HashMap用于存储缓存数据
            Map<String, Object> cacheMap = new HashMap<String, Object>();
            //向缓存Map中添加当前请求的URL作为键,当前请求数据(nowDataMap)作为值
            cacheMap.put(url, nowDataMap);
            // 将缓存数据存入Redis
            redisCache.setCacheObject(cacheRepeatKey, cacheMap, annotation.interval(), TimeUnit.MILLISECONDS);
            return false;
        }

    3. 第三步:注册拦截器(让拦截器生效)​

    通过WebMvcConfigurer将拦截器注册到 Spring MVC,指定拦截的路径(通常拦截所有业务接口,如/api/**)

    @Configuration​
    public class WebMvcConfig implements WebMvcConfigurer {​
        @Autowired​
        private RepeatSubmitInterceptor repeatSubmitInterceptor;​
    ​
        @Override​
        public void addInterceptors(InterceptorRegistry registry) {​
            // 注册防重复提交拦截器,拦截所有/api/**路径的请求​
            registry.addInterceptor(repeatSubmitInterceptor)​
                    .addPathPatterns("/api/**") // 业务接口路径(根据项目调整)​
                    .excludePathPatterns("/api/login", "/api/captcha"); // 排除无需防重复的接口(如登录)​
        }​
    }

    gitee项目笔记:https://gitee.com/boring8/ruo-yi-annotation.git

    评论
    成就一亿技术人!
    拼手气红包6.0元
    还能输入1000个字符
     
    红包 添加红包
    表情包 插入表情
     条评论被折叠 查看
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值