Prechádzať zdrojové kódy

fix(订单实时): SSE 帧被全局响应包装器吞掉 —— RequestCachingFilter 只包装记日志的方法

ContentCachingResponseWrapper 与异步/流式响应不兼容(6.1.6 字节码实读):响应流 write
只写进内存 byte 数组、flushBuffer() 是空实现,唯一回写真实响应的时机是 filter 退出时的
copyBodyToResponse(),而异步帧全部产生于那一刻之后 ⇒ 一个字节都上不了线;那次回写还会
setContentLength(缓存字节数)。实测:SSE 首帧 retry:3000(12 字节)⇒ 响应头
content-length: 12,客户端读满即 EOF,连接 233ms 就结束,心跳与广播帧全部丢失。

该过滤器只服务于 OperationLogInterceptor,而它按 RECORD_METHODS 早退、GET 从不读 body,
故收窄为只包装那六个方法(GET 直通,文件下载也不再整包进内存)。

新增用例带真实过滤器经 DispatcherServlet:RED(响应体空)→ GREEN 5/5,全量 1078/1078。
alphaH 1 týždeň pred
rodič
commit
b372fa7b26

+ 25 - 3
java/src/main/java/com/payment/platform/core/log/RequestCachingFilter.java

@@ -8,22 +8,44 @@ import org.springframework.web.util.ContentCachingRequestWrapper;
 import org.springframework.web.util.ContentCachingResponseWrapper;
 
 import java.io.IOException;
+import java.util.Set;
 
 /**
  * 包装 request/response 以支持重复读取 body,
  * 并记录请求开始时间供 OperationLogInterceptor 使用
+ *
+ * <p>★ 只包装 {@link #WRAPPED_METHODS} —— 那是 {@code OperationLogInterceptor} 唯一会去读 body
+ * 的方法集合(见其 {@code RECORD_METHODS})。收窄的原因不是省内存,而是
+ * {@link ContentCachingResponseWrapper} 与**异步/流式响应不兼容**(6.1.6 字节码实读):
+ * 它的响应流 {@code write} 只写进内存 byte 数组、{@code flushBuffer()} 是空实现,
+ * 唯一回写真实响应的时机是本 filter 退出时的 {@code copyBodyToResponse()} —— 而异步响应的帧
+ * 全部产生于那一刻之后,等于永远发不出去;同时那次回写会
+ * {@code setContentLength(缓存字节数)},把 Content-Length 钉死在拷贝瞬间的长度上。
+ * 2026-10-05 实测:SSE 首帧 {@code retry:3000}(12 字节)⇒ 响应头 {@code content-length: 12},
+ * 客户端读满即 EOF,连接 233ms 就结束,心跳与广播帧全部丢失。GET 上还挂着文件下载
+ * (整包先进内存)。**不要**把 GET 加回本集合:SSE 端点是 GET。
  */
 @Component
 public class RequestCachingFilter implements Filter {
 
+    /** 与 {@code OperationLogInterceptor.RECORD_METHODS} 一致:只有这些方法的 body 会被读。 */
+    private static final Set<String> WRAPPED_METHODS =
+            Set.of("POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS");
+
     @Override
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
             throws IOException, ServletException {
-        ContentCachingRequestWrapper reqWrapper = new ContentCachingRequestWrapper((HttpServletRequest) request);
-        ContentCachingResponseWrapper respWrapper = new ContentCachingResponseWrapper((HttpServletResponse) response);
-
+        HttpServletRequest httpRequest = (HttpServletRequest) request;
         request.setAttribute("requestStartTime", System.currentTimeMillis());
 
+        if (!WRAPPED_METHODS.contains(httpRequest.getMethod())) {
+            chain.doFilter(request, response);
+            return;
+        }
+
+        ContentCachingRequestWrapper reqWrapper = new ContentCachingRequestWrapper(httpRequest);
+        ContentCachingResponseWrapper respWrapper = new ContentCachingResponseWrapper((HttpServletResponse) response);
+
         try {
             chain.doFilter(reqWrapper, respWrapper);
         } finally {

+ 31 - 0
java/src/test/java/com/payment/platform/module/payment/stream/OrderStreamControllerTest.java

@@ -1,6 +1,7 @@
 package com.payment.platform.module.payment.stream;
 
 import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.log.RequestCachingFilter;
 import com.payment.platform.core.security.LoginUser;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
@@ -91,4 +92,34 @@ class OrderStreamControllerTest {
                         + ",响应体=" + result.getResponse().getContentAsString());
         MockMvcResultMatchers.request().asyncStarted().match(result);
     }
+
+    /**
+     * 第二道门:帧得真的到客户端。生产链路上挂着全局 {@code RequestCachingFilter},它把响应包进
+     * {@code ContentCachingResponseWrapper} —— 该包装器(javap 实测 6.1.6 字节码)的
+     * {@code write} 只进内存 byte 数组、{@code flushBuffer()} 是空实现,唯一回写时机是 filter
+     * 退出时的 {@code copyBodyToResponse()},而它还会顺手 {@code setContentLength(缓存字节数)}。
+     * 异步流式响应的帧全部产生于 filter 退出之后 ⇒ 一个字节都上不了线,且 Content-Length
+     * 被钉死在拷贝瞬间的字节数上(2026-10-05 实测:{@code content-length: 12} 恰是首帧
+     * {@code retry:3000} 的长度,客户端读满即 EOF,连接 233ms 就结束)。
+     */
+    @Test
+    void orders_frameWrittenAfterAsyncStart_reachesClient() throws Exception {
+        login(2L, false);
+        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller)
+                .addFilters(new RequestCachingFilter())
+                .build();
+
+        MvcResult result = mockMvc.perform(MockMvcRequestBuilders
+                        .get("/api/v1/payment/stream/orders")
+                        .contextPath("/api/v1"))
+                .andExpect(MockMvcResultMatchers.request().asyncStarted())
+                .andReturn();
+
+        // 异步开始之后才产生的帧 —— 心跳与广播都发生在这一刻之后
+        hub.broadcast("lsy_order", 2L);
+
+        String body = result.getResponse().getContentAsString();
+        assertTrue(body.contains("\"channel\":\"lsy_order\""),
+                "异步帧必须落到真实响应;实际响应体=[" + body + "]");
+    }
 }