在 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"); // 排除无需防重复的接口(如登录)
}
}
4642

被折叠的 条评论
为什么被折叠?



