【防重放】Spring Boot 通过自定义注解实现接口防重放

通过自定义注解实现接口防重放

目标:给指定接口加一个注解 @AntiReplay,即可防止"同一会话在短时间内把完全相同的请求重复提交"(重复提交 / 重放攻击)。

核心思路:自定义注解标记 → 拦截器识别注解 → 用「会话 + 请求摘要」做 Redis 去重 → 命中即拒绝。


一、功能说明

1.1 解决什么问题

有些接口(尤其是"执行/调用类"接口)被重复请求会带来副作用:重复扣款、重复下单、重复调用第三方接口等。这类重复可能来自:

  • 用户手抖连点、网络重试;
  • 前端重复提交;
  • 攻击者抓包后原样重放请求。

本方案解决的是:同一会话在 intervalTime 秒内,提交内容完全一致的请求。

1.2 判定规则

条件 是否判定为重放
同一 sessionId + 请求摘要相同 + 在 intervalTime 秒内 ✅ 拒绝(HTTP 429)
请求体不同 ❌ 放过
请求体为空 ❌ 放过(不参与判重)
超过 intervalTime 秒后再提交 ❌ 放过(缓存已过期)
不同会话提交相同内容 ❌ 放过
不同接口(URL/方法不同)但 body 相同 ❌ 放过(摘要含 method + url)
方法上没有 @AntiReplay ❌ 放过
Redis 不可用 ❌ 放过(降级,避免堵死业务)

请求摘要 = md5(请求方法 + 请求URI + 请求体)。

1.3 使用方式

java 复制代码
@MethodDefine(title = "执行调用接口", path = "/xxxApi", method = HttpMethodConstants.POST,
        params = {@ParamDefine(title = "调用参数", varName = "params")})
@ApiOperation("执行调用接口")
@PostMapping("/xxxApi")
@AntiReplay                                  // ← 只需加这一个注解
public Object xxxApi(@ApiParam @RequestBody String params) {
    return xxxService.xxxApi(params);
}

二、技术栈

技术 作用
自定义注解 (@Target(METHOD) + @Retention(RUNTIME)) 声明式标记需要防重放的接口,业务零侵入
Spring MVC 拦截器 (HandlerInterceptor) 在进入 Controller 前做重放判定,可通过反射拿到方法上的注解
Servlet Filter + HttpServletRequestWrapper 必须在过滤器里把请求体缓存下来 ,否则拦截器读一次 body 后,Controller 的 @RequestBody 就读不到了
Java 反射 handlerMethod.getMethod() + method.getAnnotation(AntiReplay.class) 判断注解是否存在
Redis 存放"已提交过的请求"标记,并靠 TTL 自动过期,天然实现时间窗口
MD5 摘要 把「方法 + URL + 请求体」压成一个短字符串作为缓存 key 的一部分
Spring Boot @ConfigurationProperties 从 application.yml 读取开关与时间窗口,零解析代码

三、类清单与职责

类名 类型 职责
AntiReplay 注解 标记需要防重放的接口方法
CachedBodyRequestWrapper 包装器 把请求体读进内存,使 body 可被重复读取
ReplayBodyCacheFilter 过滤器 用上面的包装器替换原始 request,保证 body 可重复读
AntiReplayFilterConfig 配置类 把过滤器注册到 Servlet 容器,并指定生效的 URL 范围
AntiReplayInterceptor 拦截器 核心:识别注解 → 计算摘要 → 查 Redis → 判重放 → 拒绝或放行
AntiReplayMvcConfig 配置类 把拦截器注册进 Spring MVC,指定拦截路径
AntiReplayProperties 配置类 从 application.yml 读取开关与时间窗口

7 个类的分工可以概括为:1 个注解(声明)+ 1 个包装器 + 1 个过滤器(把 body 变成可重复读)+ 2 个配置类(把过滤器/拦截器装上)+ 1 个拦截器(真正判重)+ 1 个配置类(开关和阈值)。


四、整体流程

#mermaid-svg-S04yc06ZYQDvMhrs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-S04yc06ZYQDvMhrs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-S04yc06ZYQDvMhrs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-S04yc06ZYQDvMhrs .error-icon{fill:#552222;}#mermaid-svg-S04yc06ZYQDvMhrs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-S04yc06ZYQDvMhrs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-S04yc06ZYQDvMhrs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-S04yc06ZYQDvMhrs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-S04yc06ZYQDvMhrs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-S04yc06ZYQDvMhrs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-S04yc06ZYQDvMhrs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-S04yc06ZYQDvMhrs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-S04yc06ZYQDvMhrs .marker.cross{stroke:#333333;}#mermaid-svg-S04yc06ZYQDvMhrs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-S04yc06ZYQDvMhrs p{margin:0;}#mermaid-svg-S04yc06ZYQDvMhrs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-S04yc06ZYQDvMhrs .cluster-label text{fill:#333;}#mermaid-svg-S04yc06ZYQDvMhrs .cluster-label span{color:#333;}#mermaid-svg-S04yc06ZYQDvMhrs .cluster-label span p{background-color:transparent;}#mermaid-svg-S04yc06ZYQDvMhrs .label text,#mermaid-svg-S04yc06ZYQDvMhrs span{fill:#333;color:#333;}#mermaid-svg-S04yc06ZYQDvMhrs .node rect,#mermaid-svg-S04yc06ZYQDvMhrs .node circle,#mermaid-svg-S04yc06ZYQDvMhrs .node ellipse,#mermaid-svg-S04yc06ZYQDvMhrs .node polygon,#mermaid-svg-S04yc06ZYQDvMhrs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-S04yc06ZYQDvMhrs .rough-node .label text,#mermaid-svg-S04yc06ZYQDvMhrs .node .label text,#mermaid-svg-S04yc06ZYQDvMhrs .image-shape .label,#mermaid-svg-S04yc06ZYQDvMhrs .icon-shape .label{text-anchor:middle;}#mermaid-svg-S04yc06ZYQDvMhrs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-S04yc06ZYQDvMhrs .rough-node .label,#mermaid-svg-S04yc06ZYQDvMhrs .node .label,#mermaid-svg-S04yc06ZYQDvMhrs .image-shape .label,#mermaid-svg-S04yc06ZYQDvMhrs .icon-shape .label{text-align:center;}#mermaid-svg-S04yc06ZYQDvMhrs .node.clickable{cursor:pointer;}#mermaid-svg-S04yc06ZYQDvMhrs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-S04yc06ZYQDvMhrs .arrowheadPath{fill:#333333;}#mermaid-svg-S04yc06ZYQDvMhrs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-S04yc06ZYQDvMhrs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-S04yc06ZYQDvMhrs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-S04yc06ZYQDvMhrs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-S04yc06ZYQDvMhrs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-S04yc06ZYQDvMhrs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-S04yc06ZYQDvMhrs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-S04yc06ZYQDvMhrs .cluster text{fill:#333;}#mermaid-svg-S04yc06ZYQDvMhrs .cluster span{color:#333;}#mermaid-svg-S04yc06ZYQDvMhrs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-S04yc06ZYQDvMhrs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-S04yc06ZYQDvMhrs rect.text{fill:none;stroke-width:0;}#mermaid-svg-S04yc06ZYQDvMhrs .icon-shape,#mermaid-svg-S04yc06ZYQDvMhrs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-S04yc06ZYQDvMhrs .icon-shape p,#mermaid-svg-S04yc06ZYQDvMhrs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-S04yc06ZYQDvMhrs .icon-shape .label rect,#mermaid-svg-S04yc06ZYQDvMhrs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-S04yc06ZYQDvMhrs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-S04yc06ZYQDvMhrs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-S04yc06ZYQDvMhrs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
关闭
开启
是
否
存在
不存在
Redis 异常
客户端请求
ReplayBodyCacheFilter

过滤器
用 CachedBodyRequestWrapper

包装 request(body 存入内存)
AntiReplayInterceptor

拦截器 preHandle
方法上有

@AntiReplay ?
放行 → Controller
开关

enabled ?
摘要 = md5(method + uri + body)

key = 前缀 + antiReplay: + sessionId + ':' + 摘要
body 为空 ?
Redis 中

key 存在 ?
HTTP 429

不允许重放请求,请稍后再试
写入 Redis

TTL = intervalTime
降级放行
Controller Redis AntiReplayInterceptor CachedBodyRequestWrapper ReplayBodyCacheFilter 客户端 Controller Redis AntiReplayInterceptor CachedBodyRequestWrapper ReplayBodyCacheFilter 客户端 #mermaid-svg-XDgAUuxPXlaOHD8k{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XDgAUuxPXlaOHD8k .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XDgAUuxPXlaOHD8k .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XDgAUuxPXlaOHD8k .error-icon{fill:#552222;}#mermaid-svg-XDgAUuxPXlaOHD8k .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XDgAUuxPXlaOHD8k .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XDgAUuxPXlaOHD8k .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XDgAUuxPXlaOHD8k .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XDgAUuxPXlaOHD8k .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XDgAUuxPXlaOHD8k .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XDgAUuxPXlaOHD8k .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XDgAUuxPXlaOHD8k .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XDgAUuxPXlaOHD8k .marker.cross{stroke:#333333;}#mermaid-svg-XDgAUuxPXlaOHD8k svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XDgAUuxPXlaOHD8k p{margin:0;}#mermaid-svg-XDgAUuxPXlaOHD8k .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XDgAUuxPXlaOHD8k text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-XDgAUuxPXlaOHD8k .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XDgAUuxPXlaOHD8k .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-XDgAUuxPXlaOHD8k .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-XDgAUuxPXlaOHD8k .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-XDgAUuxPXlaOHD8k #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-XDgAUuxPXlaOHD8k .sequenceNumber{fill:white;}#mermaid-svg-XDgAUuxPXlaOHD8k #sequencenumber{fill:#333;}#mermaid-svg-XDgAUuxPXlaOHD8k #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-XDgAUuxPXlaOHD8k .messageText{fill:#333;stroke:none;}#mermaid-svg-XDgAUuxPXlaOHD8k .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XDgAUuxPXlaOHD8k .labelText,#mermaid-svg-XDgAUuxPXlaOHD8k .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-XDgAUuxPXlaOHD8k .loopText,#mermaid-svg-XDgAUuxPXlaOHD8k .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-XDgAUuxPXlaOHD8k .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XDgAUuxPXlaOHD8k .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-XDgAUuxPXlaOHD8k .noteText,#mermaid-svg-XDgAUuxPXlaOHD8k .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-XDgAUuxPXlaOHD8k .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XDgAUuxPXlaOHD8k .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XDgAUuxPXlaOHD8k .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XDgAUuxPXlaOHD8k .actorPopupMenu{position:absolute;}#mermaid-svg-XDgAUuxPXlaOHD8k .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-XDgAUuxPXlaOHD8k .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XDgAUuxPXlaOHD8k .actor-man circle,#mermaid-svg-XDgAUuxPXlaOHD8k line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-XDgAUuxPXlaOHD8k :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} altkey 已存在key 不存在 altbody 为空body 非空 alt有注解且开关开启无注解或开关关闭 POST /xxxApi(body: {json})new CachedBodyRequestWrapper(request)读取 body 缓存到 byte\[\],重写 getInputStream/getReader继续过滤链(后续读到的都是包装后的 request)反射取方法上的 @AntiReplay 注解直接取已缓存的 body(instanceof 判断,不再重复读流)放行get(antiReplay:sessionId:md5(method+uri+body))HTTP 429 {"success":false,"message":"不允许重放请求,请稍后再试"}setExpire(key, uri, intervalTime)放行直接放行


五、实现步骤

Step 1:定义注解 @AntiReplay

只作为"标记",不需要任何属性:

java 复制代码
package com.redxun.system.interceptor;

import java.lang.annotation.*;

/**
 * 自定义注解:标记需要防重放的接口方法
 */
@Target(ElementType.METHOD)          // 只能标在方法上
@Retention(RetentionPolicy.RUNTIME)  // 运行期保留,拦截器才能反射读到
@Documented
public @interface AntiReplay {
}

Step 2:请求体缓存包装器 CachedBodyRequestWrapper

这是整个方案的关键前置条件 。Servlet 的 InputStream 只能读一次,而拦截器和 Controller 都要读 body,所以必须在最外层把 body 缓存成字节数组:

java 复制代码
package com.redxun.system.interceptor;

import org.apache.commons.collections.MapUtils;

import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Map;

/**
 * 请求包装器:把请求体读出来缓存到内存,
 * 使 getInputStream()/getReader() 可以被重复调用(多次读取 body)。
 */
public class CachedBodyRequestWrapper extends HttpServletRequestWrapper {

    /** 缓存的请求体字节 */
    private final byte[] body;
    /** 缓存的原生参数表(表单参数) */
    private final Map<String, String[]> parameterMap;

    public CachedBodyRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        this.parameterMap = request.getParameterMap();

        // 一次性把 body 以【字节】形式读出来缓存,
        // 避免中转 String 时受平台默认字符集影响导致中文乱码
        try (ServletInputStream in = request.getInputStream();
             ByteArrayOutputStream out = new ByteArrayOutputStream()) {
            byte[] buf = new byte[8192];
            int len;
            while ((len = in.read(buf)) != -1) {
                out.write(buf, 0, len);
            }
            this.body = out.toByteArray();
        }
    }

    @Override
    public String[] getParameterValues(String name) {
        if (MapUtils.isNotEmpty(this.parameterMap)) {
            return this.parameterMap.get(name);
        }
        return null;
    }

    /** 供拦截器取请求体(UTF-8 解码) */
    public String getBody() {
        return new String(this.body, StandardCharsets.UTF_8);
    }

    /** 请求体是否为空 */
    public boolean isBodyEmpty() {
        return this.body == null || this.body.length == 0;
    }

    /** 重写输入流:每次都基于缓存的 body 新建流,实现可重复读 */
    @Override
    public ServletInputStream getInputStream() {
        final ByteArrayInputStream bais = new ByteArrayInputStream(body);
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                // 按流的真实状态返回(剩余可读字节为 0 即已读完)
                return bais.available() == 0;
            }

            @Override
            public boolean isReady() {
                // 内存流始终可读
                return true;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
                // 同步读,无需实现异步回调
            }

            @Override
            public int read() {
                return bais.read();
            }

            @Override
            public int available() {
                return bais.available();
            }
        };
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(this.getInputStream(), StandardCharsets.UTF_8));
    }
}

Step 3:过滤器 ReplayBodyCacheFilter

过滤器先于拦截器执行,在这里把 request 换成包装器,后续所有人读到的都已经是"可重复读"的 request:

java 复制代码
package com.redxun.system.interceptor;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

/**
 * 防重放过滤器:优先于拦截器执行,
 * 在这里把流数据 copy 一份出来,供后面重复读取。
 */
public class ReplayBodyCacheFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;

        HttpServletRequest wrapper;
        try {
            wrapper = new CachedBodyRequestWrapper(req);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }

        // 统一使用包装后的 request,无需区分请求方法
        chain.doFilter(wrapper, response);
    }
}

Step 4:注册过滤器 AntiReplayFilterConfig

用 FilterRegistrationBean 精确控制生效范围------只在需要的 URL 前缀上启用,避免全局影响性能:

java 复制代码
package com.redxun.system.interceptor;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 防重放过滤器配置:注册过滤器并指定生效的 URL 范围
 */
@Configuration
public class AntiReplayFilterConfig {

    @Bean
    public FilterRegistrationBean antiReplayFilter() {
        FilterRegistrationBean registration = new FilterRegistrationBean(new ReplayBodyCacheFilter());
        // 只对需要防重放的接口前缀生效
        registration.addUrlPatterns("/xxx/xxxApi/*");
        return registration;
    }
}

Step 5:拦截器 AntiReplayInterceptor(核心)

职责:识别注解 → 判断开关 → 计算摘要 → Redis 判重 → 拒绝或放行。异常一律降级放行,避免把业务堵死。

java 复制代码
package com.redxun.system.interceptor;

import com.alibaba.fastjson.JSON;
import com.redxun.common.base.entity.JsonResult;
import com.redxun.common.redis.template.RedisRepository;
import com.redxun.common.tool.BeanUtil;
import com.redxun.common.tool.DigestUtil;
import com.redxun.common.tool.constant.SecurityConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.lang.reflect.Method;

/**
 * 防重放拦截器
 */
@Slf4j
@Component
public class AntiReplayInterceptor extends HandlerInterceptorAdapter {

    /** Redis key 前缀 */
    private static final String ANTI_REPLAY_PREFIX = "antiReplay:";

    @Autowired
    private AntiReplayProperties antiReplayProperties;

    @Autowired
    private RedisRepository redisRepository;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        // 非 Controller 方法(静态资源等)直接放行
        if (!(handler instanceof HandlerMethod)) {
            return super.preHandle(request, response, handler);
        }

        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        // 反射获取方法上的注解
        AntiReplay annotation = method.getAnnotation(AntiReplay.class);

        // 无注解 或 开关关闭:直接放行
        if (annotation == null || !antiReplayProperties.isEnabled()) {
            return true;
        }

        if (this.isRepeatSubmit(request)) {
            // 判定为重放:返回 429 Too Many Requests,并告知多久后可重试
            JsonResult ajaxResult = JsonResult.getFailResult("不允许重放请求,请稍后再试");
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setHeader("Retry-After", String.valueOf(antiReplayProperties.getIntervalTime()));
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().print(JSON.toJSONString(ajaxResult));
            return false;
        }
        return true;
    }

    /**
     * 判断是否为重放提交:
     * 同一会话 + 相同「方法 + URL + 请求体」+ 在 intervalTime 秒内 → 视为重放。
     * <p>
     * 出现任何异常都返回 false(放行),防重放属于安全加固,不能影响正常业务。
     */
    public boolean isRepeatSubmit(HttpServletRequest request) {
        // 1. 取请求体:过滤器已缓存,这里直接复用,不再重复读流
        CachedBodyRequestWrapper wrapper;
        if (request instanceof CachedBodyRequestWrapper) {
            wrapper = (CachedBodyRequestWrapper) request;
        } else {
            // 兜底:请求未经过过滤器时,自行包装一次
            try {
                wrapper = new CachedBodyRequestWrapper(request);
            } catch (Exception e) {
                log.warn("读取请求体失败,跳过防重放校验:{}", e.getMessage());
                return false;
            }
        }

        // 2. 空请求体不参与判重(否则连续的空 body 请求会被误判为重放)
        if (wrapper.isBodyEmpty()) {
            return false;
        }

        // 3. 摘要带上 请求方法 + 请求URI,避免"不同接口、相同 body"被误判
        String digest = DigestUtil.md5Hex(
                request.getMethod() + ":" + request.getRequestURI() + ":" + wrapper.getBody());

        HttpSession session = request.getSession();
        String key = SecurityConstants.REDXUN_PREFIX + ANTI_REPLAY_PREFIX + session.getId() + ":" + digest;

        try {
            if (BeanUtil.isNotEmpty(redisRepository.get(key))) {
                // 缓存中已存在 → 重复提交
                return true;
            }
            // 首次提交:写入缓存,过期时间即"防重放时间窗口"
            redisRepository.setExpire(key, request.getRequestURI(), antiReplayProperties.getIntervalTime());
        } catch (Exception e) {
            // Redis 异常时降级放行,保证业务可用
            log.error("防重放校验异常,已放行本次请求:{}", e.getMessage());
            return false;
        }
        return false;
    }
}

Step 6:注册拦截器 AntiReplayMvcConfig

拦截器注册到 /**(真正的"是否生效"由方法上的注解决定,所以拦截范围可以放大):

java 复制代码
package com.redxun.system.interceptor;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 防重放插件配置:注册拦截器
 */
@Configuration
public class AntiReplayMvcConfig implements WebMvcConfigurer {

    @Bean
    public AntiReplayInterceptor antiReplayInterceptor() {
        return new AntiReplayInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(antiReplayInterceptor())
                .addPathPatterns("/**");
        WebMvcConfigurer.super.addInterceptors(registry);
    }
}

Step 7:配置类 AntiReplayProperties

开关与时间窗口直接放 application.yml,用 Spring Boot 的 @ConfigurationProperties 绑定,不需要手动解析、不需要监听器,代码只有两个字段:

java 复制代码
package com.redxun.system.interceptor;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * 防重放参数:从 application.yml 读取。
 * <pre>
 * anti-replay:
 *   enabled: true
 *   interval-time: 5
 * </pre>
 */
@Data
@Component
@ConfigurationProperties(prefix = "anti-replay")
public class AntiReplayProperties {

    /** 是否开启防重放,默认关闭 */
    private boolean enabled = false;

    /**
     * 时间窗口,单位:秒,默认 5 秒。
     * 两次相同请求的间隔大于该值则不认定为重放。
     */
    private int intervalTime = 5;
}

Step 8:yml 配置内容

在服务的 application.yml(或 profile 对应的 application-{env}.yml)中追加:

yaml 复制代码
anti-replay:
  # 是否开启防重放
  enabled: true
  # 防重放时间窗口,单位:秒
  interval-time: 5

如果配置统一由 Nacos 配置中心托管,把上面这段放进 Nacos 里的 yml 配置即可。

Step 9:在接口上使用注解

java 复制代码
@PostMapping("/xxxApi")
@AntiReplay
public Object xxxApi(@ApiParam @RequestBody String params) {
    return xxxService.xxxApi(params);
}

六、关键实现点与注意事项

6.1 为什么一定要用"过滤器 + 包装器"

HttpServletRequest 的输入流是一次性的。如果直接在拦截器里读 body:

  • 拦截器读完 → Controller 的 @RequestBody 拿到空内容 → 参数为 null,接口报错。

所以顺序必须是:

Filter(把 body 读进内存并替换 request)→ Interceptor(读缓存算摘要)→ Controller(再读缓存)。

这也是为什么过滤器要单独配 addUrlPatterns------它比拦截器"更重",只放在需要的接口前缀上。

6.2 key 的设计与适用场景

复制代码
key = [统一前缀] + antiReplay: + sessionId + ":" + md5(请求方法 + 请求URI + 请求体)
TTL = intervalTime(秒)
  • 带 sessionId:只对同一浏览器会话内的重复提交生效;
  • 带 md5(method + uri + body):请求体、接口、请求方法任一不同都不会被判为重复。

⚠️ 如果接口是给第三方系统/服务间调用 的(没有 session,或者需要防"跨会话重放"),这套 key 不适用。那种场景应该改用业界通用方案:appId + nonce + timestamp + sign ------ nonce 做一次性校验(Redis 存 nonce 并设 TTL)、timestamp 限制请求有效期、sign 防篡改。

6.3 拦截路径与注解的关系

  • 过滤器:按 URL 前缀精确生效(/xxx/xxxApi/*);
  • 拦截器:注册在 /**,但只有标了 @AntiReplay 的方法才真正校验。

两者结合的好处:新增接口只要加注解即可,不用再改配置。

6.4 上线与调优注意

  • intervalTime 取值:不宜过大(会误伤正常的连续相同请求),也不宜过小(防不住),一般 3~10 秒;
  • 灰度开关 :先把 anti-replay.enabled 设为 false 上线,确认过滤器与拦截器装配无误后再打开,避免误拦生产流量;
  • 配置生效方式 :yml 属本地配置,调整后需重启服务;若要免重启,改用 Nacos + @RefreshScope;
  • Redis 容量:key 数量 ≈ 窗口内的请求数,TTL 到期自动清理,压力可控;
  • 与网关限流配合:防重放解决"同一请求重复提交",突发大流量仍应交给网关限流/熔断处理;
  • 跨会话重放 :如业务需要,需引入 nonce 机制(见 6.2)。
相关推荐
WeiXin_DZbishe1 小时前
基于Node.js的马拉松赛事管理系统的设计与实现-计算机毕设 附源码【课程设计】90211
java·javascript·vue.js·python·php·课程设计
Wang's Blog1 小时前
Java 项目部署之 Docker工具快速入门: 使用 Docker 镜像命令练习
java·docker
企业数字化笔记1 小时前
多租户系统从哪里开始设计?租户身份、数据隔离、权限与套餐的整体方案
java·spring boot·后端·junit
tiger从容淡定是人生1 小时前
从 Gemini 的任务示例出发:AI 能否从知识中推导工作流?
java·人工智能·aigc·知识图谱·创业创新·零知识证明
Nebula_g2 小时前
JavaSE高级:动态代理
java·开发语言·算法·javase
高级程序源2 小时前
springboot校园外卖平台13676-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·tomcat·maven·课程设计
怦然丶心动2 小时前
用 Java 把干支历法算清楚:从节气切年到可读摘要
java·算法·vue·独立开发·spring boot / spring
杨运交2 小时前
[077][消息模块]基于 Redis List 的轻量级消息队列框架设计解析
spring boot
青春逝如流水2 小时前
Java 线程与操作系统、硬件的关系:状态、映射与差异
java·开发语言·多线程·ai文章