瀏覽代碼

feat(订单实时): SSE 订阅端点 /payment/stream/orders(复用 ?token= 鉴权)

身份走 SecurityContextHolder + LoginUser(全仓惯用法,不新造 @AuthenticationPrincipal)。
X-Accel-Buffering: no + Cache-Control: no-cache(nginx 缓冲会让帧攒着,本机直连看不出来)。
匿名兜底:BusinessException(403),不往桶里挂谁都收不到的空连接。

测试:OrderStreamControllerTest 3 例(租户桶/超管桶/匿名);全量回归 85 类 1063 例全绿。
alphaH 1 周之前
父節點
當前提交
1989fb9cb3

+ 45 - 0
java/src/main/java/com/payment/platform/module/payment/stream/OrderStreamController.java

@@ -0,0 +1,45 @@
+package com.payment.platform.module.payment.stream;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+/**
+ * 订单实时信号长连接(SSE)。
+ *
+ * <p>身份取自 Security 上下文(与全仓 6 个控制器同一惯用法)。EventSource 不能自定义请求头,
+ * 所以浏览器那条腿靠 {@code JwtAuthFilter} 对 {@code ?token=} 的现成支持;服务端不额外做令牌轮询
+ * (滑动过期下 JWT 的 exp 不是权威源)。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/payment/stream")
+@RequiredArgsConstructor
+public class OrderStreamController {
+
+    private final OrderEventHub hub;
+
+    @GetMapping(value = "/orders", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public SseEmitter orders(HttpServletResponse response) {
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        LoginUser user = (auth != null && auth.getPrincipal() instanceof LoginUser u) ? u : null;
+        if (user == null) {
+            // anyRequest().authenticated() 已经挡在前面;这里是最后一道,免得「匿名却通过了」
+            // 时往桶里挂一条谁都收不到的空连接
+            throw new BusinessException(403, "未登录,无法建立实时连接");
+        }
+        // nginx 默认会缓冲上游响应:不写这条头,帧会被攒着 —— 本机直连看不出来,部署后才暴露
+        response.setHeader("X-Accel-Buffering", "no");
+        response.setHeader("Cache-Control", "no-cache");
+        return hub.register(user.getTenantId(), Boolean.TRUE.equals(user.getIsSuperuser()));
+    }
+}

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

@@ -0,0 +1,66 @@
+package com.payment.platform.module.payment.stream;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class OrderStreamControllerTest {
+
+    private final OrderEventHub hub = new OrderEventHub();
+    private final OrderStreamController controller = new OrderStreamController(hub);
+    private final MockHttpServletResponse response = new MockHttpServletResponse();
+
+    @AfterEach
+    void tearDown() {
+        SecurityContextHolder.clearContext();
+        hub.shutdown();
+    }
+
+    private static void login(Long tenantId, boolean superuser) {
+        LoginUser user = new LoginUser();
+        user.setTenantId(tenantId);
+        user.setIsSuperuser(superuser);
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(user, null, List.of()));
+    }
+
+    @Test
+    void orders_tenantUser_opensOneTenantConnectionAndDeclaresNoBuffering() {
+        login(2L, false);
+
+        SseEmitter emitter = controller.orders(response);
+
+        assertNotNull(emitter);
+        assertEquals("no", response.getHeader("X-Accel-Buffering"),
+                "不写这条头,nginx 会把 SSE 帧攒着转发,页面看起来「不实时」");
+        assertEquals(1, hub.connectionCount());
+        assertEquals(1, hub.tenantConnectionCount(2L));
+    }
+
+    @Test
+    void orders_superAdmin_goesToSuperAdminBucketOnly() {
+        login(2L, true);
+
+        controller.orders(response);
+
+        assertEquals(1, hub.connectionCount());
+        assertEquals(0, hub.tenantConnectionCount(2L), "超管不进租户桶 —— 否则同帧收两份");
+    }
+
+    @Test
+    void orders_anonymous_throws403AndOpensNothing() {
+        BusinessException ex = assertThrows(BusinessException.class, () -> controller.orders(response));
+
+        assertTrue(ex.getMessage().contains("未登录"), "实际文案=" + ex.getMessage());
+        assertEquals(0, hub.connectionCount(), "匿名不许往桶里挂空连接");
+    }
+}