通过自定义注解实现接口防重放
目标:给指定接口加一个注解
@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)。