如何优雅地避免重复提交

在一些新增的场景下,往往都会采取各种方式去防止重复提交。否则当新增逻辑执行较慢时,用户多次点击提交按钮,就会导致数据库中出现多条重复数据或者后端执行出错。

那么防止重复提交最简单的方式就是从前端出发,让前端设置按钮为只可以点击一次,但是前端是可以被跳过的。要想根治还得需要从后端入手。

本篇文章提供了一种通过自定义注解的方式来解决某一场景下某个实体只能被提交一次。

具体使用示例:

less 复制代码
/**
 * @ApiResubmit注解防止重复提交,
 *     prefix - 用来标识场景,不填默认为类名+方法名
 *     key - 限流key,用来辨别是否是一次重复的请求
 *     forbidSeconds - 请求禁止秒数
 *     message - 限流提示信息
 * 
 * 此处表示同一个学生id,在2秒内只能够提交一次作业
 * @param homeWorkCommitReq
 * @return
 */
@PostMapping("/commit/homeWork")
@ApiResubmit(prefix = "commitHomeWork", key = "#homeWorkCommitReq.studentId",forbidSeconds = 2, message = "正在提交中,请稍后再试")
@ApiOperation(value = "学生交作业接口")
public Result<HomeWorkCommitResp> homeWorkCommit(@RequestBody HomeWorkCommitReq homeWorkCommitReq) {
   return Result.success(homeWorkBizService.homeWorkCommit(recheckCommitReq));
}

@ApiResubmit注解具体定义

java 复制代码
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ApiResubmit {

   /**
    * 限流前缀,用来区分不同的限流场景
    */
   String prefix() default "";

   /**
    * 限流key,用来辨别是否是一次重复的请求,支持SpEL,可以从方法的入参中获取
    */
   String key() default "";

   /**
    * 请求禁止秒数,即在多少秒内禁止重复请求
    */
   int forbidSeconds() default 3;

   /**
    * 限流提示信息
    */
   String message() default "请求过于频繁,请稍后再试";

}

注解切面逻辑:

java 复制代码
@Aspect
@Component
@Slf4j
public class ResubmitAspect {

   //默认的提示语
   private static final String DEFAULT_BLOCKING_MESSAGE = "提交的频率过快,请稍后再试";

   //切点
   @Pointcut("@annotation(ApiResubmit)")
   public void resubmitPointcut() {
   }

   @Around(value = "resubmitPointcut()")
   public Object checkResubmit(ProceedingJoinPoint joinPoint) throws Throwable {
      MethodSignature signature = (MethodSignature) joinPoint.getSignature();
      Method method = signature.getMethod();
      ApiResubmit annotation = AnnotationUtils.findAnnotation(method, ApiResubmit.class);
      if (annotation != null) {  //如果这个方法标注这个注解
         //以类名+方法名作为key的默认前缀
         String defaultPrefix = joinPoint.getSignature().getDeclaringTypeName() + "#" + method.getName();

         //获取此方法所传入的参数 map<参数名, 参数值>
         Map<String, Object> methodParam = getMethodParam(joinPoint);

         validate(annotation, defaultPrefix, methodParam);
      }
      return joinPoint.proceed();
   }

   private Map<String, Object> getMethodParam(ProceedingJoinPoint joinPoint) {
      Object[] args = joinPoint.getArgs();
      String[] parameterNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames();
      Map<String, Object> paramMap = new HashMap<>(args.length);
      for (int i = 0; i < args.length; i++) {
         paramMap.put(parameterNames[i], args[i]);
      }
      return paramMap;
   }

   private void validate(ApiResubmit annotation, String defaultPrefix, Map<String, Object> methodParam) {
      try {
         String prefix = StringUtils.isBlank(annotation.prefix()) ? defaultPrefix : annotation.prefix();

         //去解析spel语句
         StandardEvaluationContext context = new StandardEvaluationContext(methodParam);
         context.addPropertyAccessor(new MapAccessor());
         String key = SpELUtils.parseExpression(annotation.key()).getValue(context, String.class);

         //去获取redis锁,锁持有时间为注解属性forbidSeconds
         boolean lock = RedisUtil.tryLock(prefix + key, 0, annotation.forbidSeconds() * 1000L);

         if (!lock) { //如果获取锁失败
            // 拿到注解属性提示语,抛出异常
            String message = StringUtils.isBlank(annotation.message()) ? DEFAULT_BLOCKING_MESSAGE : annotation.message();
            throw new BizException(2001, message);
         }
      } catch (BizException biz) {
         throw biz;
      } catch (Exception ex) {
         //如果不是重复提交的异常,即出现了其他的异常,正常请求,但需要打印error日志感知
         LOGGER.error("ApiResubmitAspectException: ", ex);
      }
   }

}

最后附上切面里使用的一个工具类RedisUtil的方法

arduino 复制代码
/**
* 这里采用的Redssion进行的加锁,
* 大家如果对redission底层不熟悉的可以看我的另一篇文章
* https://juejin.cn/post/7254444906210000955
* 里面具体讲解了Redission分布式锁的底层原理且分析了看门狗机制
*/
public static boolean tryLock(String redisKey, long waitMill, long leaseMill) {
    RLock rLock = redissonClient.getLock(redisKey);
    boolean locked = false;

    try {
        locked = rLock.tryLock(waitMill, leaseMill, TimeUnit.MILLISECONDS);
    } catch (Exception ex) {
        LOGGER.warn("redisson tryLock failed redisKey:{}, e", redisKey, ex);
    }
   
    return locked;
}

测试

假设现在studentId=1001的用户需要调用此接口,那么此时他第一次调用是可以成功的。

如果在我们设置的2秒内再次调用此接口,参数studentId还是1001的话,那么就会按照我们设置的提示语句返回。

好了,现在再遇到需要防止重复提交的场景时,只需要在接口上简单地标注一下注解,就可以按照我们设置地时间间隔,给出特定地提示语啦。简直不要太方便。

相关推荐
傻瓜搬砖人31 分钟前
Spring集成Web环境
java·spring·maven
Java小生不才3 小时前
Spring AI文生音
java·人工智能·spring
永远不会的CC4 小时前
浙江华昱欣实习(4月23日~ 4月19日)
后端·学习
二哈赛车手4 小时前
新人笔记---实现简易版的rag的bm25检索(利用ES),以及RAG上传时的ES与向量数据库双写
java·数据库·笔记·spring·elasticsearch·ai
蜜獾云4 小时前
rocketmq traceId重复问题
spring·rocketmq·java-rocketmq
直奔標竿4 小时前
Java开发者AI转型第二十五课!Spring AI 个人知识库实战(四)——RAG来源追溯落地,拒绝AI幻觉
java·开发语言·人工智能·spring boot·后端·spring
嘟嘟MD4 小时前
程序员副业 | 2026年4月复盘
后端·创业
时空系4 小时前
认识Rust——我的第一个程序 Rust中文编程
开发语言·后端·rust
DevilSeagull5 小时前
Windows 批处理 (Batch) 编程: 从入门到入土. (一) 基础概念与环境配置
开发语言·windows·后端·batch·语言
CAE虚拟与现实5 小时前
五一假期闲来无事,来个前段、后端的说明吧
前端·后端·vtk·three.js·前后端