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 0000000..34b2beb Binary files /dev/null and b/uploads/avatars/9301aa11-cbee-4656-abf9-1d60c41011c6.jpg differ