云计算百科
云计算领域专业知识百科平台

手把手教你实现一个防重复提交注解(Spring Boot + Redis 实战)

视频看了几百小时还迷糊?关注我,几分钟让你秒懂!


🧩 一、为什么需要防重复提交?

用户在以下场景容易多次点击:

  • 提交订单(怕没成功,狂点“支付”);
  • 发送短信验证码(点一次没反应,再点);
  • 表单保存(网络慢,以为没提交)。

后果很严重:

  • 订单重复创建 → 库存超卖、财务对不上;
  • 短信轰炸 → 被运营商封号、用户投诉;
  • 数据重复插入 → 数据库脏数据。

✅ 目标:同一个用户,在短时间内,对同一接口只能提交一次!


🔐 二、设计思路:基于 Token + Redis

我们采用 “请求唯一标识 + 分布式锁” 方案:

  • 前端进入页面时,先请求 /get-token 获取一个唯一 token;
  • 提交表单时,带上这个 token;
  • 后端用 @PreventDuplicateSubmit 注解拦截:
    • 如果 token 不存在 or 已使用 → 拒绝;
    • 如果 token 有效 → 放行,并立即删除 token(防止重用)。
  • 💡 为什么不用 IP + URL + 参数做 key?

    • 用户可能切换网络(IP 变);
    • 参数可能含时间戳(每次不同);
    • Token 机制更精准、可控!

    🛠️ 三、完整实现(Spring Boot + Redis)

    1. 添加依赖

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId> <!– 连接池 –>
    </dependency>

    2. 自定义注解

    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface PreventDuplicateSubmit {
    /**
    * 有效期(秒),默认 10 秒
    */
    int expire() default 10;
    }

    3. 全局异常(用于提示重复提交)

    public class DuplicateSubmitException extends RuntimeException {
    public DuplicateSubmitException(String message) {
    super(message);
    }
    }

    4. AOP 切面实现核心逻辑

    import org.aspectj.lang.ProceedingJoinPoint;
    import org.aspectj.lang.annotation.Around;
    import org.aspectj.lang.annotation.Aspect;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    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.util.concurrent.TimeUnit;

    @Aspect
    @Component
    public class PreventDuplicateSubmitAspect {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Around("@annotation(prevent)")
    public Object around(ProceedingJoinPoint joinPoint, PreventDuplicateSubmit prevent) throws Throwable {
    HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();

    // 1. 从 header 或参数中获取 token
    String token = request.getHeader("X-Submit-Token");
    if (token == null || token.trim().isEmpty()) {
    throw new DuplicateSubmitException("缺少防重提交令牌");
    }

    // 2. 构建 Redis Key(建议加上用户标识,防止跨用户攻击)
    String userId = getCurrentUserId(request); // 伪代码,实际从 token/session 获取
    String redisKey = "submit:token:" + userId + ":" + token;

    // 3. 尝试获取并删除 token(原子操作)
    Boolean exists = redisTemplate.opsForValue().setIfAbsent(redisKey, "used", prevent.expire(), TimeUnit.SECONDS);
    if (Boolean.FALSE.equals(exists)) {
    // token 不存在或已使用
    throw new DuplicateSubmitException("请勿重复提交");
    }

    // 4. 放行
    return joinPoint.proceed();
    }

    // 伪代码:获取当前用户 ID(根据你的认证体系调整)
    private String getCurrentUserId(HttpServletRequest request) {
    // 示例:从 JWT 或 Session 中获取
    // return (String) request.getSession().getAttribute("userId");
    return "user_123"; // 仅用于演示
    }
    }

    🔑 关键点:

    • setIfAbsent = SETNX,原子性保证线程安全;
    • token 使用后立即失效(即使 expire=10s,也只允许用一次);
    • key 中包含 userId,避免 A 用户的 token 被 B 用户使用。

    🧪 四、前端配合流程

    1. 进入页面时获取 token

    // 页面加载时
    fetch('/api/submit-token')
    .then(res => res.json())
    .then(data => {
    localStorage.setItem('submitToken', data.token);
    });

    2. 提交时带上 token

    const token = localStorage.getItem('submitToken');
    fetch('/api/order/create', {
    method: 'POST',
    headers: {
    'Content-Type': 'application/json',
    'X-Submit-Token': token // 👈 关键!
    },
    body: JSON.stringify(orderData)
    })
    .then(res => {
    if (res.ok) {
    // 成功后清空 token,防止再次使用
    localStorage.removeItem('submitToken');
    }
    });


    ⚙️ 五、提供获取 token 的接口

    @RestController
    @RequestMapping("/api")
    public class SubmitTokenController {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @GetMapping("/submit-token")
    public Map<String, String> getSubmitToken() {
    String token = java.util.UUID.randomUUID().toString().replace("-", "");
    // 可选:预存 token(非必须,因为 AOP 会 setIfAbsent)
    // redisTemplate.opsForValue().set("submit:prepare:" + token, "1", 60, TimeUnit.SECONDS);
    return Map.of("token", token);
    }
    }

    💡 token 无需提前存入 Redis!AOP 中的 setIfAbsent 会自动创建。


    ❌ 六、反例 & 常见错误

    反例 1:用 session 存 flag(不支持分布式)

    // ❌ 单机可用,集群环境下 session 不共享!
    session.setAttribute("submitted", true);

    ✅ 正确:必须用 Redis 等分布式存储。


    反例 2:只校验不删除 token

    // ❌ token 10 秒内可无限次使用!
    if (redis.hasKey(token)) {
    return error;
    }

    ✅ 正确:使用即失效(通过 setIfAbsent 原子操作实现)。


    反例 3:token 没绑定用户

    String redisKey = "submit:token:" + token; // ❌ 任何人都能用这个 token!

    💥 风险:恶意用户获取 token 后,可阻止其他用户提交!

    ✅ 正确:key 必须包含用户唯一标识。


    ⚠️ 七、注意事项 & 增强建议

    场景建议
    Token 泄露 HTTPS 传输,header 不要存敏感信息
    用户体验 前端提交后禁用按钮,避免用户狂点
    高并发 Redis 本身支持高并发,SETNX 是原子操作
    降级方案 Redis 宕机时,可临时关闭防重(记录日志告警)
    监控 统计“重复提交”次数,发现异常行为

    🎯 八、总结:防重提交核心流程

    前端 后端
    │ │
    ├─1. GET /submit-token ────→ │
    │ ←── {token: "abc123"} ─────┤
    │ │
    ├─2. POST /order (带 token) →│
    │ ├─ AOP 拦截
    │ ├─ 检查 Redis: submit:token:user123:abc123
    │ ├─ 不存在 → 拒绝(重复提交)
    │ ├─ 存在 → 删除 + 放行
    │ ←── 成功/失败 ──────────────┤

    ✅ 优点:

    • 精准:按用户 + 接口级别控制;
    • 安全:token 一次有效;
    • 高效:Redis 操作微秒级;
    • 通用:通过注解一键开启。

    视频看了几百小时还迷糊?关注我,几分钟让你秒懂!

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 手把手教你实现一个防重复提交注解(Spring Boot + Redis 实战)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!