影响范围:application/x-www-form-urlencoded 类型的回调接口
关联代码:支付回调、切面处理流程
一、问题现象
线上多个支付平台的异步通知到达后,控制器拿不到任何参数,直接返回 failure,依靠回调支付状态无法落库,流程最后依赖主动验单落库。
初次故障日志:
INFO aspect.RequestLogAspect : param: arg0=RequestFacade@7567055
WARN c.d.PaymentWebhook : pay notify: empty params, contentType=application/x-www-form-urlencoded; charset=UTF-8, charEncoding=UTF-8, queryString=null
WARN c.d.PaymentWebhook : pay notify: raw body=[unreadable body: IllegalStateException]
加了诊断日志后的日志(关键信息更完整):
INFO aspect.RequestLogAspect : param: arg0=RequestFacade@7659b067
WARN c.d.PaymentWebhook : pay notify: 请求体已被消费 (IllegalStateException),无法再次读取: getInputStream() has already been called for this request
WARN c.d.PaymentWebhook : pay notify: empty params, contentType=application/x-www-form-urlencoded; charset=UTF-8, charEncoding=UTF-8, contentLength=1198, queryString=null, bodyLen=-1 WARN c.d.i.c.w.PointsPaymentWebhookController : Alipay notify: raw body=null
关键信号:
| 字段 | 值 | 含义 |
|---|---|---|
contentLength |
1156 | 支付宝确实发了 1156 字节的 body |
queryString |
null | 没有走 query string |
bodyLen |
-1 | 控制器读 body 时 getReader() 抛了 IllegalStateException |
| 异常信息 | getInputStream() has already been called for this request |
在控制器执行之前,请求体已经被人读光 |
结论:body 在到达控制器之前就被消费掉了。
二、根因分析
2.1 谁在控制器之前读了 body?
可疑链路:
[Tomcat] → [CorsFilter] → [Interceptor] → [RequestLogAspect] → [Controller]
逐个排查:
- CorsFilter:只看 origin/method 这类 header,不读 body;
- JwtTokenUserInterceptor :回调接口已经在
WebMvcConfiguration中通过excludePathPatterns排除掉,不会执行; - RequestLogAspect :注释
@Around("execution(public * com.hello.marktowin.controller..*.*(..))")表明它会拦截所有 controller 方法,会先于业务代码执行。
切入点找到了 → RequestLogAspect。
2.2 切面到底做了什么
切面逻辑核心两段:
1 // 1) 从 ProceedingJoinPoint 拿到 controller 入参
2 Object[] args = joinPoint.getArgs();
3 Map<String, Object> paramMap = buildParameterMap(paramNames, args);
4
5 // 2) buildParameterMap 中过滤"servlet 对象"
6 private Map<String, Object> buildParameterMap(String[] paramNames, Object[] args) {
7 Map<String, Object> paramMap = new HashMap<>();
8 for (int i = 0; i < paramNames.length && i < args.length; i++) {
9 Object arg = args[i];
10 if (arg != null && !isServletObject(arg)) {
11 paramMap.put(paramNames[i], processParameterData(arg));
12 }
13 }
14 return paramMap;
15 }
16
17 // 3) processParameterData 用 Jackson 把对象转成 JSON 字符串后写入日志
18 private Object processParameterData(Object obj) {
19 try {
20 ObjectMapper objectMapper = new ObjectMapper();
21 String jsonStr = objectMapper.writeValueAsString(obj);
22 // ...
23 return jsonStr;
24 } catch (Exception e) {
25 return obj.getClass().getSimpleName() + "@" + Integer.toHexString(obj.hashCode());
26 }
27 }
只要 isServletObject 漏判,HttpServletRequest 就会被丢给 Jackson 序列化。Jackson 序列化方式是反射调用所有 getXxx() 方法,其中包括:
getInputStream()getReader()getParameterMap()- ...
第一个被反射调用的"流型 getter"会消费 InputStream,body 就此丢失。
2.3 isServletObject的原因
1 private boolean isServletObject(Object obj) {
2 String className = obj.getClass().getName();
3 return className.contains("javax.servlet") ||
4 className.contains("jakarta.servlet") ||
5 className.contains("org.springframework.web") ||
6 className.contains("org.springframework.ui");
7 }
这段判断漏掉了关键的实现类包名:
| 容器 | HttpServletRequest 实现类 |
实现类包名 |
|---|---|---|
| Tomcat(本项目) | RequestFacade |
org.apache.catalina.connector |
| Jetty | Request |
org.eclipse.jetty.server |
| Undertow | HttpServletRequestImpl |
io.undertow.servlet.spec |
HttpServletRequest 这个接口 确实在 jakarta.servlet.http 包下,但容器里跑的是它的实现类 ,包名跟 servlet 完全无关。contains("jakarta.servlet") 一个都命中不了。
→ isServletObject(RequestFacade) 错误地返回 false
→ RequestFacade 被丢给 Jackson
→ Jackson 反射 getInputStream() 把 body 读光
→ Jackson 中途抛 IOException 被 catch,fallback 到 RequestFacade@7659b067(这就是日志里那一行的来源)
→ 控制器再调 getReader() → IllegalStateException
→ 控制器再调 getParameterMap() → Tomcat 检测到 usingInputStream=true,跳过 form 解析,返回空表
2.4 控制器侧的次要问题
切面 bug 是主因,但控制器自己也不够健壮:
1 @PostMapping(value = "/webhook", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
2 public String webhook(HttpServletRequest request) {
3 Map<String, String> params = flattenParams(request.getParameterMap());
4 // ...
5 if (params.isEmpty()) {
6 // 这里再去 getReader() 必抛 IllegalStateException
7 log.warn("Alipay notify: raw body={}", readRequestBodySafe(request));
8 return "failure";
9 }
10 }
两个隐患:
- 强依赖容器隐式 form 解析 :调用
request.getParameterMap()之后就再也无法getReader(),一旦 Tomcat 没解析出参数(任何原因),排查信息全部丢失; - 诊断兜底无法工作 :本意是"参数空时打印原始 body 帮助排查",实际上只能打印
[unreadable body: IllegalStateException]。
三、修复方案对比
方式 1: 在控制器加一个"哑形参"提前触发 form 解析
只动控制器一行:
1 @PostMapping(value = "/webhook", produces = MediaType.TEXT_PLAIN_VALUE)
2 public String webhook(HttpServletRequest request, @RequestBody(required = false) String body) {
3 // body 实际不用,但它的存在让 Spring 提前调 getParameterMap()
4 Map<String, String> params = flattenParams(request.getParameterMap());
5 // ... 后续逻辑保持不变
6 }
优点:
- 改动极小,不需要动切面;
缺点:
- 本质是利用 Spring 的副作用 ,在代码里看不出来
body形参为啥要存在,有可能被 review 时会觉得没用顺手删掉; - 切面的根因 bug 没修 ,所有不带
@RequestBody形参的控制器(webhook 之外的接口)依然有"body 被切面读光"的隐患;
原理:
1 @PostMapping("/webhook")
2 public ResponseEntity<String> webhook(@PathVariable String payChannel,
3 HttpServletRequest request,
4 @RequestBody(required = false) String body) {
这里多一个 @RequestBody(required = false) String body 形参。可以触发 Spring 的一段救命代码:
1 // org.springframework.http.server.ServletServerHttpRequest#getBody
2 @Override
3 public InputStream getBody() throws IOException {
4 if (isFormPost(this.servletRequest)) {
5 return getBodyFromServletRequestParameters(this.servletRequest);
6 } else {
7 return this.servletRequest.getInputStream();
8 }
9 }
当 Content-Type=application/x-www-form-urlencoded + method=POST 时,isFormPost() 返回 true,进入 getBodyFromServletRequestParameters 分支,它会先调用一次 request.getParameterMap() 让 Tomcat 把 body 解析进参数表并缓存,再把参数表反向拼成一段 InputStream 喂给 @RequestBody String body。
执行顺序:
- Spring 解析 @RequestBody String body
→ isFormPost == true
→ request.getParameterMap() ← 关键:参数被 Tomcat 缓存
→ 反向拼一个 InputStream 给 body 用
- RequestLogAspect 跑起来
→ Jackson 反射 RequestFacade.getInputStream() ← 这次抛异常被吞掉,但参数表已缓存
- controller.notify() 跑起来
→ request.getParameterMap() ← 直接命中第 1 步的缓存,正常拿到所有参数
是 controller 的 @RequestBody String body 在不经意间提前触发了 Tomcat 的 form 解析,把切面的 bug 给挡住了。 但哪天有人觉得 body 没被使用把它删了,回调立刻出现问题。
适用于:只想用最小代价让某个接口立刻恢复,不打算做系统性修复。
方式 2:切面治根 + 控制器治标
2.1 切面治根
修改文件:
aspect/RequestLogAspect.java
把基于"包名子串"的判断改成基于"接口 instanceof"的判断:
1 private boolean isServletObject(Object obj) {
2 if (obj instanceof ServletRequest
3 || obj instanceof ServletResponse
4 || obj instanceof HttpSession
5 || obj instanceof MultipartFile
6 || obj instanceof Model) {
7 return true;
8 }
9 String className = obj.getClass().getName();
10 return className.startsWith("jakarta.servlet")
11 || className.startsWith("javax.servlet")
12 || className.startsWith("org.apache.catalina")
13 || className.startsWith("org.springframework.web")
14 || className.startsWith("org.springframework.ui");
15 }
修复点:
| 修复项 | 原来 | 现在 |
|---|---|---|
| 容器实现类识别 | 不认识 RequestFacade |
instanceof ServletRequest 直接命中 |
| 误判风险 | contains 子串可能误伤业务包 |
startsWith 包前缀精准匹配 |
| 覆盖范围 | 漏掉 HttpSession、MultipartFile、Model |
全部覆盖 |
| 容器无关性 | 强依赖 Tomcat/Spring 包名 | 任意容器实现都被接口判断兜住 |
2.2 控制器治标
修改文件:PaymentWebhook.java
1 @PostMapping(value = "/webhook", produces = MediaType.TEXT_PLAIN_VALUE)
2 public String webhook(HttpServletRequest request) {
3 String contentType = request.getContentType();
4 String charEncoding = request.getCharacterEncoding();
5 String queryString = request.getQueryString();
6 int contentLength = request.getContentLength();
7
8 // 第一步:先读原始 body 字符串,留作排查日志用
9 String rawBody = readRawBody(request);
10
11 // 第二步:手动 URL-decode 解析 form,不依赖容器隐式 form 解析
12 Map<String, String> params = new HashMap<>();
13 if (queryString != null && !queryString.isEmpty()) {
14 params.putAll(parseUrlEncoded(queryString, "UTF-8"));
15 }
16 if (rawBody != null && !rawBody.isEmpty()) {
17 params.putAll(parseUrlEncoded(rawBody,
18 charEncoding != null && !charEncoding.isBlank() ? charEncoding : "UTF-8"));
19 }
20
21 // 第三步:诊断信息更完整
22 if (params.isEmpty()) {
23 log.warn("Alipay notify: empty params, contentType={}, charEncoding={}, contentLength={}, queryString={}, bodyLen={}",
24 contentType, charEncoding, contentLength, queryString,
25 rawBody != null ? rawBody.length() : -1);
26 log.warn("Alipay notify: raw body={}", abbreviate(rawBody, 4000));
27 return "failure";
28 }
29 // 后续验签 / 入账逻辑不变...
30 }
修复点:
- 第一步先
getReader()读取,杜绝"被切面或其它过滤器消费后再也读不到"的问题; - 第二步手工
URLDecoder.decode解析,不再依赖容器对 form-encoded 的隐式解析; - 第三步即使解析失败,永远能在日志里看到原始 body,方便排查上游问题(nginx 配置、IP 探测、Content-Type 异常等)。
优点:
- 切面 bug 治根,所有 webhook / 文件上传接口同时受益;
- 控制器侧再加一道保险,即使将来切面被新引入的同类问题污染,回调依然能正常工作;
- 任何代理 / 容器 /
Content-Type组合都能拿到原始 body 用于诊断;
缺点:
- 改动量比方式 1 大;
四、总结
4.1 写日志切面几个避免的坑:
- 不要把
HttpServletRequest/HttpServletResponse/HttpSession/MultipartFile丢给 Jackson 等通用序列化器 。它们的getXxx()方法很多有副作用(读流、加锁、触发懒加载)。 - 判断 servlet 对象用
instanceof接口,不要用包名字符串匹配。容器实现类的包名跟 servlet 接口包名无关。 - 如果要打印请求体 ,使用 Spring 的
ContentCachingRequestWrapper或自定义可重复读取的HttpServletRequestWrapper,而不是直接getReader()。 - 切面尽量晚于业务参数解析 ,或干脆只打印
joinPoint.getSignature()、URL、headers,避免反射触碰参数对象。
4.2 写 webhook 接口的几条铁律
- 不要依赖
request.getParameterMap()。容器对 form 解析的处理在不同代理 / Content-Type / Transfer-Encoding 下行为不一致。 - 第一步就读 body 留底。出问题时能看到原文是排查的关键。
- 手动解析 + 验签 比 "容器解析 + 验签" 更可控。
- 诊断日志必须包含 contentType / Content-Length / queryString / bodyLen,这四个字段能区分"上游没发"、"代理截断"、"容器没解析"、"代码 bug" 这几类问题。