fix: 暂存

This commit is contained in:
2026-06-24 08:30:22 +08:00
parent c5e9a149f4
commit 842e5c29f9
11 changed files with 170 additions and 363 deletions

View File

@@ -12,14 +12,18 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/** /**
* [Sa-Token 权限认证] 配置 * Sa-Token 权配置
*/ */
@Slf4j @Slf4j
@Configuration @Configuration
public class SaTokenConfigure implements WebMvcConfigurer { public class SaTokenConfigure implements WebMvcConfigurer {
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handler -> { registry.addInterceptor(new SaInterceptor(handler -> {
SaRouter.match(SaHttpMethod.OPTIONS).free(r -> {
}).back();
SaRouter.match("/**") SaRouter.match("/**")
.notMatch("/actuator/**") .notMatch("/actuator/**")
.notMatch("/swagger-ui/**") .notMatch("/swagger-ui/**")
@@ -27,32 +31,21 @@ public class SaTokenConfigure implements WebMvcConfigurer {
.notMatch("/v3/api-docs/**") .notMatch("/v3/api-docs/**")
.notMatch("/webjars/**") .notMatch("/webjars/**")
.notMatch("/error") .notMatch("/error")
.notMatch("/sse/**")
.check(r -> StpUtil.checkLogin()); .check(r -> StpUtil.checkLogin());
})).addPathPatterns("/**") })).addPathPatterns("/**")
.excludePathPatterns("/error", "/sse/**"); .excludePathPatterns("/error");
} }
/**
* CORS 跨域处理策略
*/
@Bean @Bean
public SaCorsHandleFunction corsHandle() { public SaCorsHandleFunction corsHandle() {
return (req, res, sto) -> { return (req, res, sto) -> {
res res.setHeader("Access-Control-Allow-Origin", "*")
// 允许指定域访问跨域资源
.setHeader("Access-Control-Allow-Origin", "*")// 允许指定域访问跨域资源
// 允许所有请求方式
.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT, PATCH") .setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT, PATCH")
// 有效时间
.setHeader("Access-Control-Max-Age", "3600") .setHeader("Access-Control-Max-Age", "3600")
// 允许的header参数
.setHeader("Access-Control-Allow-Headers", "*"); .setHeader("Access-Control-Allow-Headers", "*");
// 如果是预检请求,则立即返回到前端 SaRouter.match(SaHttpMethod.OPTIONS).free(r -> {
SaRouter.match(SaHttpMethod.OPTIONS) }).back();
.free(r -> System.out.println("--------OPTIONS预检请求不做处理"))
.back();
}; };
} }
} }

View File

@@ -11,7 +11,7 @@ public record ApiResponse<T>(int code, String message, T data) {
* 成功响应 * 成功响应
*/ */
public static <T> ApiResponse<T> ok(T data) { public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(0, "success", data); return new ApiResponse<>(0, "成功", data);
} }
/** /**

View File

@@ -1,50 +1,42 @@
package com.webgame.webgamebackend.common.event; package com.webgame.webgamebackend.common.event;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import java.util.Objects;
import java.util.UUID;
/** /**
* SSE 事件模型(不可变 record * SSE 事件模型
* *
* @param id 事件唯一 ID格式 "evt_{timestamp}_{random}" * @param id 事件 ID客户端用于追踪和断线续传
* @param type 事件类型 * @param type 事件类型
* @param targetId 目标用户 accountIdnull 表示广播给所有在线用户 * @param targetId 目标账号 IDnull 表示广播
* @param data 事件携带的 JSON 数据 * @param data JSON 数据载荷
* @param timestamp 事件时间戳(毫秒) * @param timestamp 事件创建时间戳(毫秒)
*/ */
public record SseEvent( public record SseEvent(
String id, String id,
SseEventType type, SseEventType type,
@Nullable String targetId, @Nullable String targetId,
String data, String data,
long timestamp long timestamp
) { ) {
/** public SseEvent {
* 创建广播事件(无特定目标用户) Objects.requireNonNull(id, "id 不能为空");
* Objects.requireNonNull(type, "type 不能为空");
* @param type 事件类型 Objects.requireNonNull(data, "data 不能为空");
* @param data JSON 数据字符串 }
* @return SseEvent 实例
*/
public static SseEvent broadcast(SseEventType type, String data) { public static SseEvent broadcast(SseEventType type, String data) {
long now = System.currentTimeMillis(); return create(type, null, data);
return new SseEvent("evt_" + now + "_" + randomSuffix(), type, null, data, now);
} }
/**
* 创建定向事件(推送给特定用户)
*
* @param type 事件类型
* @param targetId 目标用户 accountId
* @param data JSON 数据字符串
* @return SseEvent 实例
*/
public static SseEvent targeted(SseEventType type, String targetId, String data) { public static SseEvent targeted(SseEventType type, String targetId, String data) {
long now = System.currentTimeMillis(); Objects.requireNonNull(targetId, "targetId 不能为空");
return new SseEvent("evt_" + now + "_" + randomSuffix(), type, targetId, data, now); return create(type, targetId, data);
} }
/** 生成 6 位随机后缀 */ private static SseEvent create(SseEventType type, @Nullable String targetId, String data) {
private static String randomSuffix() { long now = System.currentTimeMillis();
return String.format("%06x", (long) (Math.random() * 0xFFFFFF)); return new SseEvent("evt_" + now + "_" + UUID.randomUUID(), type, targetId, data, now);
} }
} }

View File

@@ -7,51 +7,36 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
* SSE 事件发布器 * 统一 SSE 事件发布器
* *
* 统一入口,将业务事件序列化后发送到 Redis Pub/Sub 频道, * 业务代码通过此发布器发布领域无关事件,由 Redis Pub/Sub 扇出到所有应用实例。
* 由所有实例的 RedisSseListener 接收并推送到各自连接的客户端。
*/ */
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class SseEventPublisher { public class SseEventPublisher {
/** Redis Pub/Sub 频道名 */
public static final String CHANNEL = "sse:events"; public static final String CHANNEL = "sse:events";
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> redisTemplate;
/**
* 发布广播事件(推送给所有在线用户)
*
* @param type 事件类型
* @param data 事件数据对象(将被序列化为 JSON
*/
public void broadcast(SseEventType type, Object data) { public void broadcast(SseEventType type, Object data) {
SseEvent event = SseEvent.broadcast(type, JSON.toJSONString(data)); publish(SseEvent.broadcast(type, JSON.toJSONString(data)));
publish(event);
} }
/**
* 发布定向事件(只推送给指定用户)
*
* @param userId 目标用户 accountId
* @param type 事件类型
* @param data 事件数据对象(将被序列化为 JSON
*/
public void sendTo(String userId, SseEventType type, Object data) { public void sendTo(String userId, SseEventType type, Object data) {
SseEvent event = SseEvent.targeted(type, userId, JSON.toJSONString(data)); publish(SseEvent.targeted(type, userId, JSON.toJSONString(data)));
publish(event);
} }
/**
* 将事件序列化为 JSON 并发布到 Redis Pub/Sub 频道
*/
private void publish(SseEvent event) { private void publish(SseEvent event) {
String json = JSON.toJSONString(event); try {
log.debug("[SSE] 发布事件到 Redis, channel={}, eventType={}, targetId={}", String json = JSON.toJSONString(event);
CHANNEL, event.type(), event.targetId()); redisTemplate.convertAndSend(CHANNEL, json);
redisTemplate.convertAndSend(CHANNEL, json); log.debug("[SSE] 事件已发布, channel={}, type={}, targetId={}",
CHANNEL, event.type(), event.targetId());
} catch (Exception e) {
log.error("[SSE] 发布事件失败, type={}, targetId={}",
event.type(), event.targetId(), e);
}
} }
} }

View File

@@ -2,27 +2,12 @@ package com.webgame.webgamebackend.common.event;
/** /**
* SSE 事件类型枚举 * SSE 事件类型枚举
* <p>
* ROOM_CREATED / ROOM_UPDATED / ROOM_REMOVED 为广播事件(推送给所有在线用户)
* KICKED / GAME_STARTED / BALANCE_CHG 为定向事件(推送给特定用户)
*/ */
public enum SseEventType { public enum SseEventType {
/** 新房间创建(广播) */
ROOM_CREATED, ROOM_CREATED,
/** 房间状态更新:玩家加入/离开、准备切换、房主变更、游戏开始等(广播) */
ROOM_UPDATED, ROOM_UPDATED,
/** 房间移除:无人散场或游戏结束关闭(广播) */
ROOM_REMOVED, ROOM_REMOVED,
/** 被房主踢出房间(定向) */
KICKED, KICKED,
/** 你所在的房间游戏已开始(定向) */
GAME_STARTED, GAME_STARTED,
/** 余额变动通知(定向) */
BALANCE_CHG BALANCE_CHG
} }

View File

@@ -9,7 +9,7 @@ import lombok.Getter;
public enum ErrorCode { public enum ErrorCode {
// ========== 通用 ========== // ========== 通用 ==========
SUCCESS(0, "success"), SUCCESS(0, "成功"),
BAD_REQUEST(400, "参数校验失败"), BAD_REQUEST(400, "参数校验失败"),
INTERNAL_ERROR(5000, "服务器内部错误"), INTERNAL_ERROR(5000, "服务器内部错误"),

View File

@@ -13,150 +13,81 @@ import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException; import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException;
/**
* 全局异常处理,统一返回 ApiResponse JSON 格式。
*
* SSE 端点的 401 由拦截器统一处理后返回 JSON前端 fetch-event-source 通过
* err.response.status 读取,不再需要服务端以 SSE 事件格式写错误。
*/
@Slf4j @Slf4j
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
// ==================== SSE 异步超时 ====================
/**
* SSE 异步请求超时SseEmitter timeout 到期的正常行为)
*
* 返回 void 避免 Spring 尝试写入已提交的响应导致二次异常。
*/
@ExceptionHandler(AsyncRequestTimeoutException.class) @ExceptionHandler(AsyncRequestTimeoutException.class)
public void handleAsyncTimeout(AsyncRequestTimeoutException ex) { public void handleAsyncTimeout(AsyncRequestTimeoutException ex) {
// SSE 长连接超时是正常行为,无需记录日志 // SSE 超时在长连接场景下属于正常情况
} }
/**
* SaToken 上下文未初始化(异步分派时线程局部变量丢失)
*
* 返回 void 避免 Spring 尝试以 JSON 写入已提交的 text/event-stream 响应导致二次异常。
*/
@ExceptionHandler(SaTokenContextException.class) @ExceptionHandler(SaTokenContextException.class)
public void handleSaTokenContext(SaTokenContextException ex) { public void handleSaTokenContext(SaTokenContextException ex) {
log.warn("SaToken 上下文未初始化(异步分派场景): {}", ex.getMessage()); log.warn("Sa-Token 上下文不可用: {}", ex.getMessage());
} }
/**
* 异步请求响应已不可用SSE 连接断开后心跳试图写入)
*
* 返回 void 避免 Spring 尝试以 JSON 写入已损坏的 text/event-stream 响应。
*/
@ExceptionHandler(AsyncRequestNotUsableException.class) @ExceptionHandler(AsyncRequestNotUsableException.class)
public void handleAsyncRequestNotUsable(AsyncRequestNotUsableException ex) { public void handleAsyncRequestNotUsable(AsyncRequestNotUsableException ex) {
// SSE 连接已断开后心跳的残留异常,无需处理 // SSE 响应已被客户端关闭
} }
// ==================== 业务异常 ====================
/**
* 处理业务异常
*/
@ExceptionHandler(BusinessException.class) @ExceptionHandler(BusinessException.class)
public ApiResponse<Void> handleBusinessException(BusinessException ex) { public ApiResponse<Void> handleBusinessException(BusinessException ex) {
log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage()); log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage());
return ApiResponse.fail(ex.getCode(), ex.getMessage()); return ApiResponse.fail(ex.getCode(), ex.getMessage());
} }
// ==================== 参数校验异常 ====================
/**
* 处理 @Valid 参数校验失败,合并所有字段的错误消息
*/
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleValidation(MethodArgumentNotValidException ex) { public ApiResponse<Void> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream() String message = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + " " + e.getDefaultMessage()) .map(error -> error.getField() + " " + error.getDefaultMessage())
.reduce((a, b) -> a + "; " + b) .reduce((a, b) -> a + "; " + b)
.orElse(ErrorCode.BAD_REQUEST.getDefaultMessage()); .orElse(ErrorCode.BAD_REQUEST.getDefaultMessage());
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), message); return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), message);
} }
// ==================== Sa-Token 鉴权异常 ====================
/**
* 未登录 / token 无效
*/
@ExceptionHandler(NotLoginException.class) @ExceptionHandler(NotLoginException.class)
public ApiResponse<Void> handleNotLogin(NotLoginException ex) { public ApiResponse<Void> handleNotLogin(NotLoginException ex) {
log.warn("未登录: {}", ex.getMessage()); log.warn("未登录: {}", ex.getMessage());
return ApiResponse.fail(401, "请先登录"); return ApiResponse.fail(401, "请先登录");
} }
/** @ExceptionHandler({NotPermissionException.class, NotRoleException.class})
* 无权限 public ApiResponse<Void> handleForbidden(Exception ex) {
*/ log.warn("权限不足: {}", ex.getMessage());
@ExceptionHandler(NotPermissionException.class) return ApiResponse.fail(403, "权限不足");
public ApiResponse<Void> handleNotPermission(NotPermissionException ex) {
log.warn("无权限: {}", ex.getMessage());
return ApiResponse.fail(403, "无权限");
} }
/**
* 无角色
*/
@ExceptionHandler(NotRoleException.class)
public ApiResponse<Void> handleNotRole(NotRoleException ex) {
log.warn("无角色: {}", ex.getMessage());
return ApiResponse.fail(403, "无权限");
}
// ==================== Spring MVC 异常 ====================
/**
* 请求方法不支持(如 GET 访问 POST 接口)
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class) @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ApiResponse<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) { public ApiResponse<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) {
log.warn("请求方法不支持: {}", ex.getMessage()); log.warn("请求方法不支持: {}", ex.getMessage());
return ApiResponse.fail(405, "请求方法不支持"); return ApiResponse.fail(405, "请求方法不支持");
} }
/**
* 请求体不可读(如 JSON 格式错误)
*/
@ExceptionHandler(HttpMessageNotReadableException.class) @ExceptionHandler(HttpMessageNotReadableException.class)
public ApiResponse<Void> handleMessageNotReadable(HttpMessageNotReadableException ex) { public ApiResponse<Void> handleMessageNotReadable(HttpMessageNotReadableException ex) {
log.warn("请求体解析失败: {}", ex.getMessage()); log.warn("请求体解析失败: {}", ex.getMessage());
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式错误"); return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式无效");
} }
/**
* 路径参数类型不匹配
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class) @ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ApiResponse<Void> handleTypeMismatch(MethodArgumentTypeMismatchException ex) { public ApiResponse<Void> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
log.warn("参数类型不匹配: {}", ex.getMessage()); log.warn("参数类型不匹配: {}", ex.getMessage());
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型错误"); return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型无效");
} }
/**
* 资源不存在404
*/
@ExceptionHandler(NoResourceFoundException.class) @ExceptionHandler(NoResourceFoundException.class)
public ApiResponse<Void> handleNoResourceFound(NoResourceFoundException ex) { public ApiResponse<Void> handleNoResourceFound(NoResourceFoundException ex) {
log.warn("资源不存在: {}", ex.getMessage()); log.warn("资源未找到: {}", ex.getMessage());
return ApiResponse.fail(404, "资源不存在"); return ApiResponse.fail(404, "资源未找到");
} }
// ==================== 兜底 ====================
/**
* 处理其他未捕获异常
*/
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception ex) { public ApiResponse<Void> handleException(Exception ex) {
log.error("服务器内部错误", ex); log.error("服务器内部错误", ex);

View File

@@ -7,11 +7,11 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic; import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
/** /**
* SSE 相关 Bean 配置 * SSE 基础设施配置
*
* 注册 Redis Pub/Sub 消息监听器,订阅频道 "sse:events"。
*/ */
@Configuration @Configuration
public class SseConfig { public class SseConfig {
@@ -25,4 +25,14 @@ public class SseConfig {
container.addMessageListener(listener, new ChannelTopic(SseEventPublisher.CHANNEL)); container.addMessageListener(listener, new ChannelTopic(SseEventPublisher.CHANNEL));
return container; return container;
} }
@Bean
public TaskScheduler sseTaskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
scheduler.setThreadNamePrefix("sse-heartbeat-");
scheduler.setRemoveOnCancelPolicy(true);
scheduler.initialize();
return scheduler;
}
} }

View File

@@ -11,10 +11,7 @@ import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
/** /**
* Redis Pub/Sub 消息监听器 * SSE 事件的 Redis Pub/Sub 监听器
*
* 接收来自 Redis 频道 "sse:events" 的消息,
* 反序列化后根据事件类型(广播/定向)转发给本地 SseConnectionManager。
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -26,22 +23,21 @@ public class RedisSseListener implements MessageListener {
@Override @Override
public void onMessage(Message message, byte[] pattern) { public void onMessage(Message message, byte[] pattern) {
String body = new String(message.getBody(), StandardCharsets.UTF_8); String body = new String(message.getBody(), StandardCharsets.UTF_8);
log.debug("[SSE] 收到 Redis 消息, channel={}", new String(message.getChannel(), StandardCharsets.UTF_8));
try { try {
SseEvent event = JSON.parseObject(body, SseEvent.class); SseEvent event = JSON.parseObject(body, SseEvent.class);
if (event == null) { if (event == null) {
log.warn("[SSE] 无法解析事件消息: {}", body); log.warn("[SSE] 收到空的 Redis 事件, body={}", body);
return; return;
} }
if (event.targetId() != null) { if (event.targetId() == null) {
connectionManager.sendTo(event.targetId(), event);
} else {
connectionManager.broadcast(event); connectionManager.broadcast(event);
} else {
connectionManager.sendTo(event.targetId(), event);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("[SSE] 处理 Redis 消息失败: {}", body, e); log.error("[SSE] 处理 Redis 消息失败, body={}", body, e);
} }
} }
} }

View File

@@ -7,132 +7,100 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* SSE 连接管理器 * 线程安全的 SSE 连接管理器
* *
* 维护 userId → SseEmitter 映射,提供广播和定向推送能力 * 一个用户可以拥有多个连接,使得不同浏览器标签页和设备之间不会互相踢下线
* 使用 ConcurrentHashMap 保证线程安全。
*/ */
@Slf4j @Slf4j
@Component @Component
public class SseConnectionManager { public class SseConnectionManager {
/** userId → SseEmitter 映射表,线程安全 */ private final ConcurrentHashMap<String, ConcurrentHashMap<String, SseEmitter>> emitters =
private final ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>(); new ConcurrentHashMap<>();
public String register(String userId, SseEmitter emitter) {
String connectionId = UUID.randomUUID().toString();
emitters.computeIfAbsent(userId, key -> new ConcurrentHashMap<>()).put(connectionId, emitter);
/**
* 注册新的 SSE 连接
*
* @param userId 用户 accountId
* @param emitter SseEmitter 实例
*/
public void register(String userId, SseEmitter emitter) {
// 如果已有旧连接,先关闭再替换
SseEmitter old = emitters.put(userId, emitter);
if (old != null) {
try {
old.complete();
} catch (Exception ignored) {
}
}
// 注册清理回调
emitter.onCompletion(() -> { emitter.onCompletion(() -> {
log.debug("[SSE] 连接正常关闭, userId={}", userId); log.debug("[SSE] Connection completed, userId={}, connectionId={}", userId, connectionId);
emitters.remove(userId, emitter); remove(userId, connectionId);
}); });
emitter.onTimeout(() -> { emitter.onTimeout(() -> {
log.debug("[SSE] 连接超时, userId={}", userId); log.debug("[SSE] Connection timeout, userId={}, connectionId={}", userId, connectionId);
emitters.remove(userId, emitter); remove(userId, connectionId);
}); });
emitter.onError(throwable -> { emitter.onError(throwable -> {
log.debug("[SSE] 连接异常, userId={}, reason={}", userId, throwable.getMessage()); log.debug("[SSE] Connection error, userId={}, connectionId={}, reason={}",
emitters.remove(userId, emitter); userId, connectionId, throwable.getMessage());
remove(userId, connectionId);
}); });
log.info("[SSE] 用户连接成功, userId={}, 当前在线={}", userId, emitters.size()); log.info("[SSE] User connected, userId={}, connectionId={}, onlineConnections={}",
userId, connectionId, getOnlineCount());
return connectionId;
} }
/** public void remove(String userId, String connectionId) {
* 移除连接 ConcurrentHashMap<String, SseEmitter> userEmitters = emitters.get(userId);
* if (userEmitters == null) {
* 先移除映射再尝试关闭,避免回调递归。 return;
* 响应已损坏时 complete() 可能抛出 RuntimeException静默忽略。 }
*
* @param userId 用户 accountId SseEmitter removed = userEmitters.remove(connectionId);
*/ if (userEmitters.isEmpty()) {
public void remove(String userId) { emitters.remove(userId, userEmitters);
SseEmitter removed = emitters.remove(userId); }
if (removed != null) { if (removed != null) {
try { try {
removed.complete(); removed.complete();
} catch (Throwable ignored) { } catch (Throwable ignored) {
// 响应已损坏AsyncRequestNotUsableException 等),忽略 // 客户端断开后响应可能已不可用
} }
log.info("[SSE] 用户连接已移除, userId={}, 当前在线={}", userId, emitters.size()); log.info("[SSE] Connection removed, userId={}, connectionId={}, onlineConnections={}",
userId, connectionId, getOnlineCount());
} }
} }
/**
* 广播事件给所有在线用户
*
* @param event SSE 事件
*/
public void broadcast(SseEvent event) { public void broadcast(SseEvent event) {
if (emitters.isEmpty()) return; if (emitters.isEmpty()) {
log.debug("[SSE] 广播事件, type={}, 接收人数={}", event.type(), emitters.size());
for (Map.Entry<String, SseEmitter> entry : emitters.entrySet()) {
sendEvent(entry.getValue(), event, entry.getKey());
}
}
/**
* 发送事件给指定用户
*
* @param userId 目标用户 accountId
* @param event SSE 事件
*/
public void sendTo(String userId, SseEvent event) {
SseEmitter emitter = emitters.get(userId);
if (emitter == null) {
log.debug("[SSE] 目标用户不在线, userId={}, eventType={}", userId, event.type());
return; return;
} }
log.debug("[SSE] 定向推送, userId={}, eventType={}", userId, event.type()); log.debug("[SSE] Broadcast event, type={}, onlineConnections={}", event.type(), getOnlineCount());
sendEvent(emitter, event, userId); emitters.forEach((userId, connections) ->
connections.forEach((connectionId, emitter) -> sendEvent(userId, connectionId, emitter, event)));
}
public void sendTo(String userId, SseEvent event) {
ConcurrentHashMap<String, SseEmitter> connections = emitters.get(userId);
if (connections == null || connections.isEmpty()) {
log.debug("[SSE] Target user is offline, userId={}, eventType={}", userId, event.type());
return;
}
connections.forEach((connectionId, emitter) -> sendEvent(userId, connectionId, emitter, event));
} }
/**
* 获取当前在线连接数
*
* @return 在线用户数
*/
public int getOnlineCount() { public int getOnlineCount() {
return emitters.size(); return emitters.values().stream().mapToInt(Map::size).sum();
} }
/** private void sendEvent(String userId, String connectionId, SseEmitter emitter, SseEvent event) {
* 向单个 SseEmitter 发送事件
*
* 捕获 IOException客户端已断开但还未触发回调
* 此时安全移除连接。
*/
private void sendEvent(SseEmitter emitter, SseEvent event, String userId) {
try { try {
emitter.send(SseEmitter.event() emitter.send(SseEmitter.event()
.id(event.id()) .id(event.id())
.name(event.type().name().toLowerCase()) .name(event.type().name().toLowerCase())
.data(event.data())); .data(event.data()));
} catch (IOException e) { } catch (IOException | IllegalStateException e) {
log.debug("[SSE] 推送失败(客户端可能已断开), userId={}, reason={}", userId, e.getMessage()); log.debug("[SSE] Push failed, userId={}, connectionId={}, reason={}",
emitters.remove(userId); userId, connectionId, e.getMessage());
try { remove(userId, connectionId);
emitter.completeWithError(e);
} catch (Exception ignored) {
}
} }
} }
} }

View File

@@ -1,33 +1,22 @@
package com.webgame.webgamebackend.sse; package com.webgame.webgamebackend.sse;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.common.event.SseEvent;
import com.webgame.webgamebackend.common.event.SseEventType;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.time.Duration;
import java.util.concurrent.Executors; import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/** /**
* SSE 连接端点 * SSE 订阅端点
*
* 提供 GET /sse/subscribe 端点,建立持久化 SSE 连接。
* 连接成功后立即发送 connected 事件确认握手,之后每 30 秒发送心跳保活。
*
* 鉴权由 SaTokenConfigure 拦截器统一处理,前端通过 fetch-event-source
* 的 headers 传 saToken401 时由前端 err.response.status 直接读取。
*/ */
@Slf4j @Slf4j
@RestController @RestController
@@ -35,93 +24,51 @@ import java.util.concurrent.TimeUnit;
@RequiredArgsConstructor @RequiredArgsConstructor
public class SseController { public class SseController {
private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L;
private static final Duration HEARTBEAT_INTERVAL = Duration.ofSeconds(30);
private final SseConnectionManager connectionManager; private final SseConnectionManager connectionManager;
private final TaskScheduler sseTaskScheduler;
/**
* 建立 SSE 订阅连接
*
* 鉴权由拦截器处理,此处直接获取登录用户 ID。
*
* @return SseEmitter 实例30 分钟超时)
*/
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe() { public SseEmitter subscribe(
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
String userId = StpUtil.getLoginIdAsString(); String userId = StpUtil.getLoginIdAsString();
SseEmitter emitter = new SseEmitter(30 * 60 * 1000L); // 30 分钟超时 SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
String connectionId = connectionManager.register(userId, emitter);
connectionManager.register(userId, emitter); ScheduledFuture<?> heartbeatTask = sseTaskScheduler.scheduleAtFixedRate(() -> {
try {
emitter.send(SseEmitter.event().name("heartbeat").data(""));
} catch (IOException | IllegalStateException e) {
log.debug("[SSE] 心跳发送失败, userId={}, connectionId={}, reason={}",
userId, connectionId, e.getMessage());
connectionManager.remove(userId, connectionId);
}
}, HEARTBEAT_INTERVAL);
Runnable cancelHeartbeat = () -> heartbeatTask.cancel(false);
emitter.onCompletion(cancelHeartbeat);
emitter.onTimeout(cancelHeartbeat);
emitter.onError(error -> cancelHeartbeat.run());
// 发送初始连接确认
try { try {
emitter.send(SseEmitter.event() emitter.send(SseEmitter.event()
.name("connected") .name("connected")
.data("{\"message\":\"连接成功\"}")); .data("{\"message\":\"连接成功\"}"));
} catch (IOException e) { } catch (IOException | IllegalStateException e) {
log.warn("[SSE] 发送 connected 事件失败, userId={}", userId); log.warn("[SSE] 发送 connected 事件失败, userId={}, connectionId={}",
userId, connectionId, e);
connectionManager.remove(userId, connectionId);
} }
// 启动心跳定时任务 if (lastEventId != null && !lastEventId.isBlank()) {
ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor( log.debug("[SSE] 客户端重连, Last-Event-ID={}, userId={}, connectionId={}",
r -> new Thread(r, "sse-heartbeat-" + userId.substring(0, Math.min(8, userId.length()))) lastEventId, userId, connectionId);
); }
heartbeat.scheduleAtFixedRate(() -> {
try {
emitter.send(SseEmitter.event()
.name("heartbeat")
.data(""));
} catch (Exception e) {
// 客户端已断开IOException或响应已不可用AsyncRequestNotUsableException
connectionManager.remove(userId);
heartbeat.shutdown();
}
}, 30, 30, TimeUnit.SECONDS);
// 连接关闭时停止心跳 log.info("[SSE] 连接建立成功, userId={}, connectionId={}, onlineConnections={}",
emitter.onCompletion(heartbeat::shutdown); userId, connectionId, connectionManager.getOnlineCount());
emitter.onTimeout(heartbeat::shutdown);
emitter.onError(e -> heartbeat.shutdown());
log.info("[SSE] 新连接建立, userId={}, 当前在线={}", userId, connectionManager.getOnlineCount());
return emitter; return emitter;
} }
// ==================== 测试接口 ====================
/**
* 查看当前 SSE 在线连接数
*
* GET /sse/test/online
*/
@GetMapping("/test/online")
public Map<String, Object> onlineCount() {
return Map.of("onlineCount", connectionManager.getOnlineCount());
}
/**
* 手动发送广播事件(测试用)
*
* POST /sse/test/broadcast
* Body: { "event": "room_created", "data": { "roomId": "test", ... } }
*
* event 可选值: room_created / room_updated / room_removed / kicked / game_started / balance_chg
*/
@PostMapping("/test/broadcast")
public Map<String, Object> testBroadcast(@RequestBody Map<String, Object> body) {
String eventType = (String) body.get("event");
Object data = body.get("data");
if (eventType == null || data == null) {
return Map.of("success", false, "error", "event 和 data 字段为必填");
}
try {
SseEventType type = SseEventType.valueOf(eventType.toUpperCase());
SseEvent event = SseEvent.broadcast(type, JSON.toJSONString(data));
connectionManager.broadcast(event);
log.info("[SSE] 测试广播, type={}, data={}", eventType, JSON.toJSONString(data));
return Map.of("success", true, "eventType", eventType, "onlineCount", connectionManager.getOnlineCount());
} catch (IllegalArgumentException e) {
return Map.of("success", false, "error", "无效的事件类型: " + eventType);
}
}
} }