From d92f7bb5817caee897ad5e6d075e61cc07399858 Mon Sep 17 00:00:00 2001 From: Azure <983547216@qq.com> Date: Tue, 30 Jun 2026 08:52:23 +0800 Subject: [PATCH] feat: sse --- .../common/event/SseEventPublisher.java | 8 +-- .../common/event/SseEventType.java | 4 +- .../handler/GlobalExceptionHandler.java | 36 +++++++++++-- .../service/impl/RefreshTokenServiceImpl.java | 6 +-- .../webgamebackend/sse/SseController.java | 50 ++++++++++++++++++ .../9301aa11-cbee-4656-abf9-1d60c41011c6.jpg | Bin 0 -> 16276 bytes 6 files changed, 90 insertions(+), 14 deletions(-) create mode 100644 uploads/avatars/9301aa11-cbee-4656-abf9-1d60c41011c6.jpg diff --git a/src/main/java/com/webgame/webgamebackend/common/event/SseEventPublisher.java b/src/main/java/com/webgame/webgamebackend/common/event/SseEventPublisher.java index f9cf518..c5ba77c 100644 --- a/src/main/java/com/webgame/webgamebackend/common/event/SseEventPublisher.java +++ b/src/main/java/com/webgame/webgamebackend/common/event/SseEventPublisher.java @@ -4,6 +4,7 @@ import com.alibaba.fastjson2.JSON; import com.webgame.webgamebackend.common.exception.BusinessException; import com.webgame.webgamebackend.common.exception.ErrorCode; import com.webgame.webgamebackend.sse.SseConnectionManager; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; @@ -16,6 +17,7 @@ import org.springframework.stereotype.Component; */ @Slf4j @Component +@RequiredArgsConstructor public class SseEventPublisher { public static final String CHANNEL = "sse:events"; @@ -23,12 +25,6 @@ public class SseEventPublisher { private final RedisTemplate redisTemplate; private final SseConnectionManager connectionManager; - public SseEventPublisher(RedisTemplate redisTemplate, - SseConnectionManager connectionManager) { - this.redisTemplate = redisTemplate; - this.connectionManager = connectionManager; - } - /** * 发布广播事件(推送给所有在线用户) * diff --git a/src/main/java/com/webgame/webgamebackend/common/event/SseEventType.java b/src/main/java/com/webgame/webgamebackend/common/event/SseEventType.java index 8fe8c18..ac8f3b6 100644 --- a/src/main/java/com/webgame/webgamebackend/common/event/SseEventType.java +++ b/src/main/java/com/webgame/webgamebackend/common/event/SseEventType.java @@ -9,5 +9,7 @@ public enum SseEventType { ROOM_REMOVED, KICKED, GAME_STARTED, - BALANCE_CHG + BALANCE_CHG, + /** 自定义事件类型,用于测试或任意消息推送 */ + CUSTOM } diff --git a/src/main/java/com/webgame/webgamebackend/common/handler/GlobalExceptionHandler.java b/src/main/java/com/webgame/webgamebackend/common/handler/GlobalExceptionHandler.java index 61e3736..f49bc2e 100644 --- a/src/main/java/com/webgame/webgamebackend/common/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/webgame/webgamebackend/common/handler/GlobalExceptionHandler.java @@ -48,6 +48,21 @@ public class GlobalExceptionHandler { // SSE 响应已被客户端关闭,不写响应体 } + /** + * SSE 客户端断开连接时,Spring 在 Tomcat 容器线程上抛出的 IOException + *

+ * SSE 长连接场景下客户端主动断开(关闭标签页、网络切换等)是正常行为, + * 不应作为 ERROR 记录。仅当响应尚未提交时才视为真正的 IO 异常。 + */ + @ExceptionHandler(IOException.class) + public void handleIOException(IOException ex, HttpServletResponse response) { + if (response.isCommitted()) { + log.debug("SSE 客户端已断开: {}", ex.getMessage()); + } else { + log.warn("未提交响应的 IO 异常: {}", ex.getMessage()); + } + } + // ==================== 业务异常(REST JSON 请求) ==================== /** @@ -116,10 +131,25 @@ public class GlobalExceptionHandler { // ==================== 兜底 → HTTP 500(直接写响应,绕过内容协商) ==================== @ExceptionHandler(Exception.class) - public void handleException(Exception ex, HttpServletResponse response) throws IOException { + public void handleException( + Exception ex, + HttpServletResponse response + ) throws IOException { + + // SSE 长连接/已提交的响应 → 客户端已断开,不需要告警 + if (response.isCommitted()) { + log.debug("Response 已提交,客户端已断开: {}", ex.getMessage()); + return; + } + log.error("服务器内部错误", ex); - writeJsonError(response, HttpStatus.INTERNAL_SERVER_ERROR, - ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getDefaultMessage()); + + writeJsonError( + response, + HttpStatus.INTERNAL_SERVER_ERROR, + ErrorCode.INTERNAL_ERROR.getCode(), + ErrorCode.INTERNAL_ERROR.getDefaultMessage() + ); } // ==================== 工具方法 ==================== diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java index ede8d78..ab90681 100644 --- a/src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java +++ b/src/main/java/com/webgame/webgamebackend/service/impl/RefreshTokenServiceImpl.java @@ -3,6 +3,7 @@ package com.webgame.webgamebackend.service.impl; import com.webgame.webgamebackend.common.exception.BusinessException; import com.webgame.webgamebackend.common.exception.ErrorCode; import com.webgame.webgamebackend.service.RefreshTokenService; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -25,6 +26,7 @@ import java.util.concurrent.TimeUnit; */ @Slf4j @Service +@RequiredArgsConstructor public class RefreshTokenServiceImpl implements RefreshTokenService { /** Redis key 前缀 */ @@ -41,10 +43,6 @@ public class RefreshTokenServiceImpl implements RefreshTokenService { private final StringRedisTemplate stringRedisTemplate; - public RefreshTokenServiceImpl(StringRedisTemplate stringRedisTemplate) { - this.stringRedisTemplate = stringRedisTemplate; - } - @Override public String create(String userId) { if (userId == null || userId.isBlank()) { diff --git a/src/main/java/com/webgame/webgamebackend/sse/SseController.java b/src/main/java/com/webgame/webgamebackend/sse/SseController.java index 1a968b9..329ebf5 100644 --- a/src/main/java/com/webgame/webgamebackend/sse/SseController.java +++ b/src/main/java/com/webgame/webgamebackend/sse/SseController.java @@ -1,17 +1,23 @@ package com.webgame.webgamebackend.sse; import com.webgame.webgamebackend.common.auth.AuthenticationProvider; +import com.webgame.webgamebackend.common.dto.ApiResponse; +import com.webgame.webgamebackend.common.event.SseEventPublisher; +import com.webgame.webgamebackend.common.event.SseEventType; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; +import java.util.Map; /** * SSE 订阅端点 @@ -30,6 +36,7 @@ public class SseController { private final SseConnectionManager connectionManager; private final AuthenticationProvider authProvider; + private final SseEventPublisher eventPublisher; @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe( @@ -73,6 +80,49 @@ public class SseController { return emitter; } + /** + * 测试用消息发布接口 + * + * 支持广播(不传 userId)和定向推送(传 userId)两种模式。 + * 可传入任意 JSON 数据作为消息体,事件类型固定为 CUSTOM。 + * + * 请求体示例: + *

+     * // 广播
+     * { "data": { "msg": "hello" } }
+     *
+     * // 定向推送
+     * { "userId": "123", "data": { "msg": "hello" } }
+     * 
+ * + * @param body 请求体,包含可选的 userId 和必填的 data + * @return 统一 API 响应 + */ + @PostMapping("/publish") + public ApiResponse> publish(@RequestBody Map body) { + @SuppressWarnings("unchecked") + Map data = (Map) body.get("data"); + if (data == null || data.isEmpty()) { + return ApiResponse.fail(400, "data 字段不能为空"); + } + + String userId = body.get("userId") != null ? body.get("userId").toString() : null; + + if (userId != null && !userId.isBlank()) { + eventPublisher.sendTo(userId, SseEventType.CUSTOM, data); + log.info("[SSE] 测试定向推送, userId={}, data={}", userId, data); + } else { + eventPublisher.broadcast(SseEventType.CUSTOM, data); + log.info("[SSE] 测试广播推送, data={}", data); + } + + return ApiResponse.ok(Map.of( + "mode", userId != null && !userId.isBlank() ? "targeted" : "broadcast", + "userId", userId != null ? userId : "all", + "data", data + )); + } + /** * 向 SSE 客户端返回认证错误 * diff --git a/uploads/avatars/9301aa11-cbee-4656-abf9-1d60c41011c6.jpg b/uploads/avatars/9301aa11-cbee-4656-abf9-1d60c41011c6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..34b2beb36ef67d2fe2dae09d301568d71e750b2c GIT binary patch literal 16276 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 c-#o(w!rJ$>v