第二十六节 @RepeatSubmit() 注解详解

亮子 | 2026-04-08 10:55:38 | 35 | 0 | 0 | 0

@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); // 抛出"不允许重复提交"异常
    }
}

关键步骤解析:

  1. 生成唯一标识Token + 请求参数 的 MD5 值
  • 确保同一用户、相同参数的请求才会被判定为重复
  • 不同用户或不同参数不会被误判
  1. Redis 原子操作setObjectIfAbsent(相当于 SETNX
  • 如果 key 不存在,设置 key 并返回 true
  • 如果 key 已存在,返回 false(表示重复提交)
  • 同时设置过期时间,自动清理
  1. 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. 最小间隔限制:间隔时间不能小于 1 秒(代码中有校验)

  2. Token 依赖:需要用户登录状态(从请求头获取 Token),未登录请求可能无法正确防重

  3. 参数敏感性

  • 相同 URL + 相同 Token + 相同参数 = 重复提交
  • 参数不同不会被判定为重复
  1. 业务失败重试:如果业务执行失败,Redis key 会被删除,允许用户重新提交

  2. 国际化支持:提示消息支持国际化配置
    ```properties
    # messages_zh_CN.properties
    repeat.submit.message=不允许重复提交,请稍候再试

# messages_en_US.properties
repeat.submit.message=Repeat submit is not allowed, please try again later
```

  1. 适用场景
  • ✅ 表单提交(新增、修改)
  • ✅ 支付请求
  • ✅ 投票/点赞
  • ❌ 查询接口(GET 请求不需要)
  • ❌ 文件上传(参数被过滤,可能无法正确防重)

🎯 七、总结

@RepeatSubmit 注解的核心优势:

  1. 简单易用:只需一个注解即可启用防重功能
  2. 灵活配置:可自定义时间间隔和提示消息
  3. 高性能:基于 Redis 原子操作,性能优异
  4. 自动化管理:利用 Redis TTL 自动清理,无需手动维护
  5. 精确识别:通过 Token + 参数 MD5 精确识别重复请求
  6. 智能处理:成功保留、失败清理,兼顾安全性和用户体验

这是一个非常实用的幂等性解决方案,特别适合出租车系统中的订单创建、司机接单等关键业务场景!🚕