Spring Boot实现防重复提交案例

wen java案例 2

本文目录导读:

Spring Boot实现防重复提交案例

  1. 项目结构
  2. 依赖配置
  3. 核心代码实现
  4. 测试方式
  5. 优化建议

我来为你提供一个完整的Spring Boot防重复提交的解决方案。

项目结构

src/main/java/com/example/demo/
├── annotation/
│   └── NoRepeatSubmit.java
├── aspect/
│   └── NoRepeatSubmitAspect.java
├── controller/
│   └── TestController.java
├── dto/
│   └── Result.java
└── DemoApplication.java

依赖配置

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.14</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>repeat-submit</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>repeat-submit</name>
    <description>防重复提交示例</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <!-- Spring Boot Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Spring Boot AOP -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <!-- Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- FastJSON -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>2.0.32</version>
        </dependency>
    </dependencies>
</project>

application.yml

server:
  port: 8080
spring:
  redis:
    host: localhost
    port: 6379
    database: 0
    timeout: 3000ms
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0
# 自定义配置
repeat-submit:
  # 是否开启防重复提交
  enabled: true
  # 重复提交检测时间间隔(秒)
  interval: 10
  # 使用哪种存储方式:redis / memory
  storage: redis

核心代码实现

1 自定义注解

package com.example.demo.annotation;
import java.lang.annotation.*;
/**
 * 防重复提交注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoRepeatSubmit {
    /**
     * 重复提交时间间隔(秒)
     * 默认10秒
     */
    int interval() default 10;
    /**
     * 提示消息
     */
    String message() default "请勿重复提交,请稍后再试";
    /**
     * 是否只对登录用户生效
     * 如果为true,则会结合用户ID生成唯一key
     */
    boolean onlyUser() default false;
}

2 统一返回结果

package com.example.demo.dto;
import lombok.Data;
@Data
public class Result<T> {
    private Integer code;
    private String message;
    private T data;
    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMessage("success");
        result.setData(data);
        return result;
    }
    public static <T> Result<T> error(Integer code, String message) {
        Result<T> result = new Result<>();
        result.setCode(code);
        result.setMessage(message);
        return result;
    }
}

3 防重复提交切面实现

package com.example.demo.aspect;
import com.alibaba.fastjson.JSON;
import com.example.demo.annotation.NoRepeatSubmit;
import com.example.demo.dto.Result;
import com.example.demo.util.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.UUID;
@Slf4j
@Aspect
@Component
public class NoRepeatSubmitAspect {
    @Autowired
    private RedisUtil redisUtil;
    /**
     * 环绕通知,拦截带有@NoRepeatSubmit注解的方法
     */
    @Around("@annotation(com.example.demo.annotation.NoRepeatSubmit)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // 获取HttpServletRequest
        ServletRequestAttributes attributes = 
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes == null) {
            return joinPoint.proceed();
        }
        HttpServletRequest request = attributes.getRequest();
        // 获取注解信息
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        NoRepeatSubmit noRepeatSubmit = method.getAnnotation(NoRepeatSubmit.class);
        // 生成请求唯一标识
        String redisKey = generateRedisKey(request, joinPoint, noRepeatSubmit);
        // 生成唯一token
        String token = generateToken(request);
        // 检查是否重复提交
        if (!redisUtil.trySet(redisKey, token, noRepeatSubmit.interval())) {
            log.warn("检测到重复提交,key: {}", redisKey);
            return Result.error(500, noRepeatSubmit.message());
        }
        try {
            // 执行目标方法
            Object result = joinPoint.proceed();
            // 获取请求中的token,用于后续校验
            setRequestToken(request, token);
            return result;
        } catch (Exception e) {
            log.error("请求处理异常", e);
            throw e;
        } finally {
            // 请求完成后删除key(可选)
            // 如果需要立即删除key,可以取消注释下面的代码
            // redisUtil.delete(redisKey);
        }
    }
    /**
     * 生成Redis Key
     */
    private String generateRedisKey(HttpServletRequest request, 
                                   ProceedingJoinPoint joinPoint,
                                   NoRepeatSubmit noRepeatSubmit) {
        // 基础信息
        String uri = request.getRequestURI();
        String methodName = joinPoint.getSignature().getDeclaringTypeName() + "." + 
                           joinPoint.getSignature().getName();
        // 用户ID(可选)
        String userId = "";
        if (noRepeatSubmit.onlyUser()) {
            userId = getUserIdFromRequest(request);
        }
        // 请求参数
        Object[] args = joinPoint.getArgs();
        String params = args.length > 0 ? JSON.toJSONString(args) : "";
        // 生成签名
        String content = uri + methodName + userId + params;
        String hashCode = String.valueOf(content.hashCode());
        // 组合Key
        return "repeat_submit:" + hashCode;
    }
    /**
     * 从请求中获取用户ID
     */
    private String getUserIdFromRequest(HttpServletRequest request) {
        // 从header或session中获取用户信息
        String userId = request.getHeader("userId");
        if (userId == null || userId.isEmpty()) {
            // 从session获取
            Object user = request.getSession().getAttribute("userId");
            if (user != null) {
                userId = user.toString();
            }
        }
        return userId == null ? "" : userId;
    }
    /**
     * 生成请求唯一token
     */
    private String generateToken(HttpServletRequest request) {
        String token = (String) request.getAttribute("repeat_token");
        if (token == null) {
            token = UUID.randomUUID().toString().replace("-", "");
            request.setAttribute("repeat_token", token);
        }
        return token;
    }
    /**
     * 设置请求token到响应header
     */
    private void setRequestToken(HttpServletRequest request, String token) {
        request.setAttribute("repeat_token", token);
    }
}

4 Redis工具类

package com.example.demo.util;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class RedisUtil {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    /**
     * 尝试设置key-value,如果key已存在则返回false
     * 使用SETNX命令实现原子性操作
     */
    public boolean trySet(String key, String value, long timeout) {
        Boolean result = stringRedisTemplate.opsForValue()
            .setIfAbsent(key, value, timeout, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(result);
    }
    /**
     * 设置key-value
     */
    public void set(String key, String value, long timeout) {
        stringRedisTemplate.opsForValue()
            .set(key, value, timeout, TimeUnit.SECONDS);
    }
    /**
     * 获取value
     */
    public String get(String key) {
        return stringRedisTemplate.opsForValue().get(key);
    }
    /**
     * 删除key
     */
    public boolean delete(String key) {
        Boolean result = stringRedisTemplate.delete(key);
        return Boolean.TRUE.equals(result);
    }
    /**
     * 判断key是否存在
     */
    public boolean hasKey(String key) {
        Boolean result = stringRedisTemplate.hasKey(key);
        return Boolean.TRUE.equals(result);
    }
}

5 内存版存储(可选,不依赖Redis时使用)

package com.example.demo.util;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
 * 内存版防重复提交存储
 * 适用于单机部署场景
 */
@Component
public class MemoryCacheUtil {
    @Value("${repeat-submit.interval:10}")
    private long defaultExpireTime;
    private final Map<String, CacheItem> cache = new ConcurrentHashMap<>();
    /**
     * 尝试设置key,如果key已存在则返回false
     */
    public boolean trySet(String key, String value, long expireTime) {
        long now = System.currentTimeMillis();
        CacheItem item = new CacheItem(value, now + expireTime * 1000);
        synchronized (this) {
            // 先清理过期数据
            cleanExpired();
            CacheItem existing = cache.get(key);
            if (existing == null || existing.isExpired()) {
                cache.put(key, item);
                return true;
            }
            return false;
        }
    }
    /**
     * 清理过期数据
     */
    private void cleanExpired() {
        long now = System.currentTimeMillis();
        cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
    }
    /**
     * 缓存条目
     */
    private static class CacheItem {
        private final String value;
        private final long expireTime;
        public CacheItem(String value, long expireTime) {
            this.value = value;
            this.expireTime = expireTime;
        }
        public boolean isExpired() {
            return System.currentTimeMillis() > expireTime;
        }
    }
}

6 测试Controller

package com.example.demo.controller;
import com.example.demo.annotation.NoRepeatSubmit;
import com.example.demo.dto.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RequestMapping("/api")
public class TestController {
    /**
     * 测试防重复提交
     */
    @PostMapping("/submit")
    @NoRepeatSubmit(interval = 10, message = "提交太频繁,请10秒后再试")
    public Result<Map<String, Object>> submit(@RequestBody Map<String, Object> params) {
        log.info("处理请求:{}", params);
        // 模拟业务处理
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        Map<String, Object> result = new HashMap<>();
        result.put("code", "200");
        result.put("message", "请求成功");
        result.put("data", params);
        return Result.success(result);
    }
    /**
     * 测试登录用户防重复提交
     */
    @PostMapping("/user/submit")
    @NoRepeatSubmit(onlyUser = true, interval = 30, message = "操作太频繁,请30秒后再试")
    public Result<Map<String, Object>> userSubmit(@RequestBody Map<String, Object> params) {
        log.info("处理用户请求:{}", params);
        Map<String, Object> result = new HashMap<>();
        result.put("code", "200");
        result.put("message", "用户请求成功");
        result.put("data", params);
        return Result.success(result);
    }
    /**
     * 测试获取请求token
     */
    @GetMapping("/token")
    public Result<String> getToken() {
        // 简单演示,实际项目中token应该从请求中获取
        return Result.success("token_" + System.currentTimeMillis());
    }
}

7 启动类

package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@SpringBootApplication
@EnableAspectJAutoProxy
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

测试方式

1 使用Postman测试

  1. 正常请求

    • POST http://localhost:8080/api/submit
    • Body: {"name":"test", "value":"123"}
    • 期望返回: {"code":200,"message":"success","data":{...}}
  2. 重复请求

    • 短时间内再次发送相同的POST请求
    • 期望返回: {"code":500,"message":"提交太频繁,请10秒后再试"}

2 使用curl测试

# 第一次请求 - 成功
curl -X POST http://localhost:8080/api/submit \
  -H "Content-Type: application/json" \
  -d '{"name":"test","value":"123"}'
# 第二次请求 - 触发防重复
curl -X POST http://localhost:8080/api/submit \
  -H "Content-Type: application/json" \
  -d '{"name":"test","value":"123"}'

优化建议

1 增加全局异常处理

package com.example.demo.config;
import com.example.demo.dto.Result;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    public Result<Object> handleException(Exception e) {
        return Result.error(500, "系统错误:" + e.getMessage());
    }
}

2 使用分布式锁增强

@Aspect
@Component
public class DistributedLockAspect {
    @Around("@annotation(distributedLock)")
    public Object around(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) {
        // 获取锁
        String lockKey = generateLockKey(joinPoint);
        boolean locked = tryLock(lockKey);
        if (!locked) {
            throw new RuntimeException("系统繁忙,请稍后再试");
        }
        try {
            return joinPoint.proceed();
        } finally {
            // 释放锁
            unlock(lockKey);
        }
    }
}

这样实现的防重复提交功能具有以下特点:

  1. 基于Redis实现,支持分布式环境
  2. 使用AOP切面,非侵入式设计
  3. 支持自定义配置,灵活可扩展
  4. 自动生成唯一Key,基于请求参数
  5. 支持用户隔离,可针对不同用户

如果你的应用不需要Redis,也可以使用内存版本实现,只需调整配置即可。

抱歉,评论功能暂时关闭!