@RepeatSubmit() 注解详解
📌 一、注解概述
@RepeatSubmit 是一个**防重复提交注解**,用于防止用户在短时间内多次提交相同的请求。它基于 Redis + AOP 实现,参考了美团 GTIS 防重系统的设计理念。
📝 二、注解定义
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 间隔时间(ms),小于此时间视为重复提交
* 默认值:5000毫秒(5秒)
*/
int interval() default 5000;
/**
* 时间单位
* 默认值:毫秒
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 提示消息,支持国际化,格式为 {code}
* 默认值:{repeat.submit.message}
*/
String message() default "{repeat.submit.message}";
}
属性说明:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
interval |
int | 5000 | 防重复提交的时间间隔 |
timeUnit |
TimeUnit | MILLISECONDS | 时间单位(毫秒、秒等) |
message |
String | {repeat.submit.message} |
重复提交时的提示信息,支持国际化 |
⚙️ 三、核心实现原理
1️⃣ AOP 切面拦截
通过 RepeatSubmitAspect 切面类拦截标注了 @RepeatSubmit 的方法:
@Aspect
public class RepeatSubmitAspect {
// 使用 ThreadLocal 存储 Redis key
private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>();
// 前置通知:检查是否重复提交
@Before("@annotation(repeatSubmit)")
public void doBefore(JoinPoint point, RepeatSubmit repeatSubmit) throws Throwable
// 后置返回通知:处理成功/失败情况
@AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult")
public void doAfterReturning(...)
// 异常通知:发生异常时清理 Redis
@AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "e")
public void doAfterThrowing(...)
}
2️⃣ 防重判断流程
前置通知 doBefore 的执行逻辑:
@Before("@annotation(repeatSubmit)")
public void doBefore(JoinPoint point, RepeatSubmit repeatSubmit) throws Throwable {
// ① 获取配置的时间间隔(转换为毫秒)
long interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval());
// ② 校验间隔时间不能小于1秒
if (interval < 1000) {
throw new ServiceException("重复提交间隔时间不能小于'1'秒");
}
// ③ 获取请求对象和参数
HttpServletRequest request = ServletUtils.getRequest();
String nowParams = argsArrayToString(point.getArgs()); // 序列化请求参数
// ④ 构建唯一标识
String url = request.getRequestURI(); // 请求URL
String submitKey = request.getHeader(SaManager.getConfig().getTokenName()); // Token
submitKey = SecureUtil.md5(submitKey + ":" + nowParams); // MD5加密
// ⑤ 生成 Redis Key
String cacheRepeatKey = GlobalConstants.REPEAT_SUBMIT_KEY + url + submitKey;
// 格式:global:repeat_submit:/api/xxx:md5(token:params)
// ⑥ 尝试设置 Redis(不存在则设置,并设置过期时间)
if (RedisUtils.setObjectIfAbsent(cacheRepeatKey, "", Duration.ofMillis(interval))) {
KEY_CACHE.set(cacheRepeatKey); // 成功:存储key到ThreadLocal
} else {
// 失败:说明在间隔时间内已存在相同请求,抛出异常
String message = repeatSubmit.message();
if (StringUtils.startsWith(message, "{") && StringUtils.endsWith(message, "}")) {
message = MessageUtils.message(...); // 国际化处理
}
throw new ServiceException(message); // 抛出"不允许重复提交"异常
}
}
关键步骤解析:
- 生成唯一标识:
Token + 请求参数的 MD5 值
- 确保同一用户、相同参数的请求才会被判定为重复
- 不同用户或不同参数不会被误判
- Redis 原子操作:
setObjectIfAbsent(相当于SETNX)
- 如果 key 不存在,设置 key 并返回
true - 如果 key 已存在,返回
false(表示重复提交) - 同时设置过期时间,自动清理
- ThreadLocal 存储:保存 Redis key,供后续清理使用
3️⃣ 请求完成后的处理
成功情况(doAfterReturning):
@AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Object jsonResult) {
if (jsonResult instanceof R<?> r) {
try {
// 如果业务执行成功,保留 Redis 数据
// 保证在有效时间内无法重复提交
if (r.getCode() == R.SUCCESS) {
return; // 不删除,让 Redis 自动过期
}
// 如果业务失败,删除 Redis 数据,允许重新提交
RedisUtils.deleteObject(KEY_CACHE.get());
} finally {
KEY_CACHE.remove(); // 清理 ThreadLocal
}
}
}
异常情况(doAfterThrowing):
@AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Exception e) {
// 发生异常时立即删除 Redis 数据,允许重试
RedisUtils.deleteObject(KEY_CACHE.get());
KEY_CACHE.remove();
}
处理策略:
- ✅ 成功:保留 Redis key,在有效期内阻止重复提交
- ❌ 失败/异常:删除 Redis key,允许用户重新提交
4️⃣ 参数过滤机制
为了避免不必要的参数序列化,以下类型的参数会被过滤:
public boolean isFilterObject(final Object o) {
Class<?> clazz = o.getClass();
if (clazz.isArray()) {
return MultipartFile.class.isAssignableFrom(clazz.getComponentType());
} else if (Collection.class.isAssignableFrom(clazz)) {
// 集合中包含 MultipartFile
for (Object value : collection) {
return value instanceof MultipartFile;
}
} else if (Map.class.isAssignableFrom(clazz)) {
// Map 中包含 MultipartFile
for (Object value : map.values()) {
return value instanceof MultipartFile;
}
}
// 过滤文件上传、请求/响应对象、验证结果
return o instanceof MultipartFile
|| o instanceof HttpServletRequest
|| o instanceof HttpServletResponse
|| o instanceof BindingResult;
}
被过滤的对象:
- 📁 MultipartFile(文件上传)
- 🌐 HttpServletRequest / HttpServletResponse
- ✅ BindingResult(表单验证结果)
💡 四、使用示例
示例 1:基本用法(默认 5 秒)
@PostMapping()
@RepeatSubmit()
public R<Void> add(@RequestBody TOrderBo bo) {
return toAjax(tOrderService.insertByBo(bo));
}
示例 2:自定义时间间隔
// 2 秒内不允许重复提交
@RepeatSubmit(interval = 2, timeUnit = TimeUnit.SECONDS)
@PostMapping()
public R<Void> submit(@RequestBody OrderRequest request) {
return orderService.submit(request);
}
示例 3:自定义提示消息
@RepeatSubmit(
interval = 3,
timeUnit = TimeUnit.SECONDS,
message = "订单提交中,请勿重复点击"
)
@PostMapping("/order")
public R<Void> createOrder(@RequestBody OrderBo bo) {
return orderService.create(bo);
}
示例 4:实际项目中的应用
在你的项目中,很多 Controller 都使用了这个注解:
// 司机信息新增
@SaCheckPermission("system:driverinfo:add")
@Log(title = "司机", businessType = BusinessType.INSERT)
@RepeatSubmit()
@PostMapping()
public R<Void> add(@Validated(AddGroup.class) @RequestBody TDriverinfoBo bo) {
return toAjax(tDriverinfoService.insertByBo(bo));
}
// 订单创建
@SaCheckPermission("system:order:add")
@Log(title = "订单", businessType = BusinessType.INSERT)
@RepeatSubmit()
@PostMapping()
public R<Void> add(@Validated(AddGroup.class) @RequestBody TOrderBo bo) {
return toAjax(tOrderService.insertByBo(bo));
}
🔑 五、Redis Key 结构
global:repeat_submit:{url}:{md5(token:params)}
示例:
global:repeat_submit:/system/order/add:a1b2c3d4e5f6...
过期时间: 根据 interval 配置自动过期(默认 5 秒)
⚠️ 六、注意事项
-
最小间隔限制:间隔时间不能小于 1 秒(代码中有校验)
-
Token 依赖:需要用户登录状态(从请求头获取 Token),未登录请求可能无法正确防重
-
参数敏感性:
- 相同 URL + 相同 Token + 相同参数 = 重复提交
- 参数不同不会被判定为重复
-
业务失败重试:如果业务执行失败,Redis key 会被删除,允许用户重新提交
-
国际化支持:提示消息支持国际化配置
```properties
# messages_zh_CN.properties
repeat.submit.message=不允许重复提交,请稍候再试
# messages_en_US.properties
repeat.submit.message=Repeat submit is not allowed, please try again later
```
- 适用场景:
- ✅ 表单提交(新增、修改)
- ✅ 支付请求
- ✅ 投票/点赞
- ❌ 查询接口(GET 请求不需要)
- ❌ 文件上传(参数被过滤,可能无法正确防重)
🎯 七、总结
@RepeatSubmit 注解的核心优势:
- 简单易用:只需一个注解即可启用防重功能
- 灵活配置:可自定义时间间隔和提示消息
- 高性能:基于 Redis 原子操作,性能优异
- 自动化管理:利用 Redis TTL 自动清理,无需手动维护
- 精确识别:通过 Token + 参数 MD5 精确识别重复请求
- 智能处理:成功保留、失败清理,兼顾安全性和用户体验
这是一个非常实用的幂等性解决方案,特别适合出租车系统中的订单创建、司机接单等关键业务场景!🚕