Skip to content

Web 应用中,防止重复提交是一个重要的功能,主要意义如下:

1. 避免重复操作
    用户可能因网络延迟、误操作或多次点击提交按钮,导致同一请求被多次发送到服务器。
    防止重复提交可以避免重复执行相同的业务逻辑(如重复下单、重复支付等),确保数据一致性。
2. 提升系统性能
    重复提交会增加服务器的负载,尤其是对于耗时的操作(如文件上传、数据库写入等)。
    防止重复提交可以减少不必要的资源消耗,提升系统性能。
3. 防止数据错误
    重复提交可能导致数据重复插入或状态不一致(如订单状态错误、库存扣减异常等)。
    防止重复提交可以确保数据的准确性和完整性。
4. 提升用户体验
    通过防止重复提交,可以避免用户因误操作而收到重复的响应或错误提示。
    提供友好的提示(如“请勿重复提交”),提升用户体验。
5. 增强安全性
    防止恶意用户通过重复提交攻击系统(如重复请求消耗资源、刷单等)。
    通过防重机制(如 Token 验证),增强系统的安全性。

AOP优势

在项目中使用 AOP(面向切面编程)注解实现防重复提交具有以下优势:

1. 代码解耦:将日志记录逻辑与业务逻辑分离,避免代码重复,提升代码可读性和可维护性。
2. 灵活性与扩展性:通过注解可以灵活地控制哪些方法需要记录日志,便于扩展和修改日志记录逻辑。
3. 非侵入性:无需修改现有业务代码,只需在方法上添加注解即可实现日志记录。
4. 集中管理:日志记录逻辑集中在切面中,便于统一管理和维护。
5. 提高开发效率:通过注解方式快速实现日志功能,减少重复代码编写。

定义注解

xiaomayi-common/xiaomayi-idempotent 模块中定义的防重复提交的 AOP 切面 RepeatSubmit 文件,内容如下:

js
package com.xiaomayi.idempotent.annotation;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

/**
 * <p>
 * 自定义重复提交注解
 * </p>
 *
 * @author 小蚂蚁云团队
 * @since 2024-05-21
 */
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {

    /**
     * 间隔时间(ms),默认5秒;小于此时间提交被视为重复提交
     */
    int interval() default 5000;

    /**
     * 事件单位
     *
     * @return 返回结果
     */
    TimeUnit timeUnit() default TimeUnit.MILLISECONDS;

    /**
     * 重复提交错误提示
     */
    String message() default "不允许重复提交,请稍候再试";

}

注解实现

js
package com.xiaomayi.idempotent.aspect;

import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.SecureUtil;
import com.alibaba.fastjson2.JSON;
import com.xiaomayi.core.config.TokenConfig;
import com.xiaomayi.core.constant.CacheConstant;
import com.xiaomayi.core.exception.BizException;
import com.xiaomayi.core.utils.MessageUtils;
import com.xiaomayi.core.utils.R;
import com.xiaomayi.core.utils.ServletUtils;
import com.xiaomayi.core.utils.StringUtils;
import com.xiaomayi.idempotent.annotation.RepeatSubmit;
import com.xiaomayi.redis.utils.RedisUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.validation.BindingResult;
import org.springframework.web.multipart.MultipartFile;

import java.time.Duration;
import java.util.Collection;
import java.util.Map;
import java.util.StringJoiner;

/**
 * <p>
 * 自定义重复提交验证AOP切面
 * </p>
 *
 * @author 小蚂蚁云团队
 * @since 2024-05-21
 */
@Aspect
public class RepeatSubmitAspect {

    private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>();

    /**
     * 切点之前
     *
     * @param point        切点
     * @param repeatSubmit 重复提交验证注解
     * @throws Throwable 异常处理
     */
    @Before("@annotation(repeatSubmit)")
    public void doBefore(JoinPoint point, RepeatSubmit repeatSubmit) throws Throwable {
        // 获取注解间隔时间(ms),默认5秒;
        long interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval());
        // 提交间隔时间不能小于1秒
        if (interval < 1000) {
            throw new BizException("重复提交间隔时间不能小于'1'秒");
        }

        // 获取网络请求
        HttpServletRequest request = ServletUtils.getRequest();
        // 设置请求地址作为缓存KEY值
        String url = request.getRequestURI();

        // 请求参数拼接处理
        String params = paramsExecute(point.getArgs());

        // 获取请求头作为唯一键
        String submitKey = StringUtils.trimToEmpty(request.getHeader(TokenConfig.getHeader()));
        // MD5加密
        submitKey = SecureUtil.md5(submitKey + ":" + params);
        // 设置参数拼接作为唯一标识:常量KEY+请求URL+消息头加密值
        String cacheRepeatKey = CacheConstant.REPEAT_SUBMIT_KEY + url + submitKey;
        if (RedisUtils.setObjectIfAbsent(cacheRepeatKey, "", Duration.ofMillis(interval))) {
            // 设置缓存
            KEY_CACHE.set(cacheRepeatKey);
        } else {
            // 重复提交返回提示语
            String message = repeatSubmit.message();
            if (StringUtils.startsWith(message, "{") && StringUtils.endsWith(message, "}")) {
                message = MessageUtils.message(StringUtils.substring(message, 1, message.length() - 1));
            }
            throw new BizException(message);
        }
    }

    /**
     * 请求处理结束后执行处理
     *
     * @param joinPoint    切点
     * @param repeatSubmit 重复请求注解
     * @param jsonResult   返回结果
     */
    @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() == 0) {
                    return;
                }
                RedisUtils.deleteObject(KEY_CACHE.get());
            } finally {
                KEY_CACHE.remove();
            }
        }
    }

    /**
     * 请求处理结束后异常处理
     *
     * @param joinPoint    切点
     * @param repeatSubmit 重复请求注解
     * @param exception    异常处理
     */
    @AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "exception")
    public void doAfterThrowing(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Exception exception) {
        // 根据缓存KEY删除缓存对象
        RedisUtils.deleteObject(KEY_CACHE.get());
        // 删除缓存KEY
        KEY_CACHE.remove();
    }

    /**
     * 请求参数拼接处理
     *
     * @param objects 请求参数
     * @return 返回结果
     */
    private String paramsExecute(Object[] objects) {
        StringJoiner params = new StringJoiner(" ");
        // 数组判空
        if (ArrayUtil.isEmpty(objects)) {
            return params.toString();
        }
        // 遍历参数
        for (Object param : objects) {
            if (ObjectUtil.isNotNull(param) && !isFilterObject(param)) {
                params.add(JSON.toJSONString(param));
            }
        }
        // 返回结果
        return params.toString();
    }

    /**
     * 判断是否需要过滤的对象
     *
     * @param object 对象
     * @return 返回结果
     */
    @SuppressWarnings("rawtypes")
    public boolean isFilterObject(final Object object) {
        Class<?> clazz = object.getClass();
        if (clazz.isArray()) {
            return MultipartFile.class.isAssignableFrom(clazz.getComponentType());
        } else if (Collection.class.isAssignableFrom(clazz)) {
            Collection collection = (Collection) object;
            for (Object value : collection) {
                return value instanceof MultipartFile;
            }
        } else if (Map.class.isAssignableFrom(clazz)) {
            Map map = (Map) object;
            for (Object value : map.values()) {
                return value instanceof MultipartFile;
            }
        }
        return object instanceof MultipartFile || object instanceof HttpServletRequest || object instanceof HttpServletResponse
                || object instanceof BindingResult;
    }

}

添加依赖

pom.xml 配置文件中引入以下依赖:

js
<!-- 幂等性模块 -->
<dependency>
    <groupId>com.xiaomayi</groupId>
    <artifactId>xiaomayi-idempotent</artifactId>
</dependency>

注解使用

在需要设置防重复提交的方法上添加以下注解:

js
@RepeatSubmit(interval = 1, timeUnit = TimeUnit.SECONDS)

使用案例:

js
package com.xiaomayi.admin.controller.demo;


import com.baomidou.dynamic.datasource.annotation.DS;
import com.xiaomayi.core.utils.R;
import com.xiaomayi.idempotent.annotation.RepeatSubmit;
import com.xiaomayi.logger.annotation.RequestLog;
import com.xiaomayi.logger.enums.RequestType;
import com.xiaomayi.security.annotation.RequestIgnore;
import com.xiaomayi.security.utils.SecurityUtils;
import com.xiaomayi.system.dto.user.UserAddDTO;
import com.xiaomayi.system.dto.user.UserUpdateDTO;
import com.xiaomayi.system.entity.User;
import com.xiaomayi.system.service.UserService;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * <p>
 * 测试案例 前端控制器
 * 特别备注:此文件非项目本身有效文件,仅仅是工程师编写测试案例使用,留个备份未删除
 * 实际项目使用时请删除此文件,以免造成其他影响
 * </p>
 *
 * @author 小蚂蚁云团队
 * @since 2024-05-21
 */
@RequestIgnore
@RestController
@RequestMapping("/test")
@AllArgsConstructor
public class TestController {

    /**
     * 幂等性验证
     *
     * @return 返回结果
     */
    @RepeatSubmit(interval = 1, timeUnit = TimeUnit.SECONDS)
    @PostMapping("/idem")
    public R idem() {
        return R.ok();
    }

}

总结

集成防止重复提交功能的意义在于:

避免重复操作,确保数据一致性。
提升系统性能和安全性。
改善用户体验,减少误操作。

通过合理的防重机制(如 幂等性设计 等),可以有效解决重复提交问题。

小蚂蚁云团队 · 提供技术支持

小蚂蚁云 新品首发
新品首发,限时特惠,抢购从速! 全场95折
赋能开发者,助理企业发展,提供全方位数据中台解决方案。
获取官方授权