fix: 暂存
This commit is contained in:
@@ -12,14 +12,18 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* [Sa-Token 权限认证] 配置类
|
||||
* Sa-Token 鉴权配置
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class SaTokenConfigure implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(new SaInterceptor(handler -> {
|
||||
SaRouter.match(SaHttpMethod.OPTIONS).free(r -> {
|
||||
}).back();
|
||||
|
||||
SaRouter.match("/**")
|
||||
.notMatch("/actuator/**")
|
||||
.notMatch("/swagger-ui/**")
|
||||
@@ -27,32 +31,21 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
||||
.notMatch("/v3/api-docs/**")
|
||||
.notMatch("/webjars/**")
|
||||
.notMatch("/error")
|
||||
.notMatch("/sse/**")
|
||||
.check(r -> StpUtil.checkLogin());
|
||||
})).addPathPatterns("/**")
|
||||
.excludePathPatterns("/error", "/sse/**");
|
||||
.excludePathPatterns("/error");
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS 跨域处理策略
|
||||
*/
|
||||
@Bean
|
||||
public SaCorsHandleFunction corsHandle() {
|
||||
return (req, res, sto) -> {
|
||||
res
|
||||
// 允许指定域访问跨域资源
|
||||
.setHeader("Access-Control-Allow-Origin", "*")// 允许指定域访问跨域资源
|
||||
// 允许所有请求方式
|
||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||
.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT, PATCH")
|
||||
// 有效时间
|
||||
.setHeader("Access-Control-Max-Age", "3600")
|
||||
// 允许的header参数
|
||||
.setHeader("Access-Control-Allow-Headers", "*");
|
||||
|
||||
// 如果是预检请求,则立即返回到前端
|
||||
SaRouter.match(SaHttpMethod.OPTIONS)
|
||||
.free(r -> System.out.println("--------OPTIONS预检请求,不做处理"))
|
||||
.back();
|
||||
SaRouter.match(SaHttpMethod.OPTIONS).free(r -> {
|
||||
}).back();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public record ApiResponse<T>(int code, String message, T data) {
|
||||
* 成功响应
|
||||
*/
|
||||
public static <T> ApiResponse<T> ok(T data) {
|
||||
return new ApiResponse<>(0, "success", data);
|
||||
return new ApiResponse<>(0, "成功", data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,50 +1,42 @@
|
||||
package com.webgame.webgamebackend.common.event;
|
||||
|
||||
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 targetId 目标用户 accountId,null 表示广播给所有在线用户
|
||||
* @param data 事件携带的 JSON 数据
|
||||
* @param timestamp 事件时间戳(毫秒)
|
||||
* @param targetId 目标账号 ID,null 表示广播
|
||||
* @param data JSON 数据载荷
|
||||
* @param timestamp 事件创建时间戳(毫秒)
|
||||
*/
|
||||
public record SseEvent(
|
||||
String id,
|
||||
SseEventType type,
|
||||
@Nullable String targetId,
|
||||
String data,
|
||||
long timestamp
|
||||
String id,
|
||||
SseEventType type,
|
||||
@Nullable String targetId,
|
||||
String data,
|
||||
long timestamp
|
||||
) {
|
||||
/**
|
||||
* 创建广播事件(无特定目标用户)
|
||||
*
|
||||
* @param type 事件类型
|
||||
* @param data JSON 数据字符串
|
||||
* @return SseEvent 实例
|
||||
*/
|
||||
public SseEvent {
|
||||
Objects.requireNonNull(id, "id 不能为空");
|
||||
Objects.requireNonNull(type, "type 不能为空");
|
||||
Objects.requireNonNull(data, "data 不能为空");
|
||||
}
|
||||
|
||||
public static SseEvent broadcast(SseEventType type, String data) {
|
||||
long now = System.currentTimeMillis();
|
||||
return new SseEvent("evt_" + now + "_" + randomSuffix(), type, null, data, now);
|
||||
return create(type, null, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建定向事件(推送给特定用户)
|
||||
*
|
||||
* @param type 事件类型
|
||||
* @param targetId 目标用户 accountId
|
||||
* @param data JSON 数据字符串
|
||||
* @return SseEvent 实例
|
||||
*/
|
||||
public static SseEvent targeted(SseEventType type, String targetId, String data) {
|
||||
long now = System.currentTimeMillis();
|
||||
return new SseEvent("evt_" + now + "_" + randomSuffix(), type, targetId, data, now);
|
||||
Objects.requireNonNull(targetId, "targetId 不能为空");
|
||||
return create(type, targetId, data);
|
||||
}
|
||||
|
||||
/** 生成 6 位随机后缀 */
|
||||
private static String randomSuffix() {
|
||||
return String.format("%06x", (long) (Math.random() * 0xFFFFFF));
|
||||
private static SseEvent create(SseEventType type, @Nullable String targetId, String data) {
|
||||
long now = System.currentTimeMillis();
|
||||
return new SseEvent("evt_" + now + "_" + UUID.randomUUID(), type, targetId, data, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,51 +7,36 @@ import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* SSE 事件发布器
|
||||
* 统一 SSE 事件发布器
|
||||
*
|
||||
* 统一入口,将业务事件序列化后发送到 Redis Pub/Sub 频道,
|
||||
* 由所有实例的 RedisSseListener 接收并推送到各自连接的客户端。
|
||||
* 业务代码通过此发布器发布领域无关事件,由 Redis Pub/Sub 扇出到所有应用实例。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SseEventPublisher {
|
||||
|
||||
/** Redis Pub/Sub 频道名 */
|
||||
public static final String CHANNEL = "sse:events";
|
||||
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
/**
|
||||
* 发布广播事件(推送给所有在线用户)
|
||||
*
|
||||
* @param type 事件类型
|
||||
* @param data 事件数据对象(将被序列化为 JSON)
|
||||
*/
|
||||
public void broadcast(SseEventType type, Object data) {
|
||||
SseEvent event = SseEvent.broadcast(type, JSON.toJSONString(data));
|
||||
publish(event);
|
||||
publish(SseEvent.broadcast(type, JSON.toJSONString(data)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布定向事件(只推送给指定用户)
|
||||
*
|
||||
* @param userId 目标用户 accountId
|
||||
* @param type 事件类型
|
||||
* @param data 事件数据对象(将被序列化为 JSON)
|
||||
*/
|
||||
public void sendTo(String userId, SseEventType type, Object data) {
|
||||
SseEvent event = SseEvent.targeted(type, userId, JSON.toJSONString(data));
|
||||
publish(event);
|
||||
publish(SseEvent.targeted(type, userId, JSON.toJSONString(data)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将事件序列化为 JSON 并发布到 Redis Pub/Sub 频道
|
||||
*/
|
||||
private void publish(SseEvent event) {
|
||||
String json = JSON.toJSONString(event);
|
||||
log.debug("[SSE] 发布事件到 Redis, channel={}, eventType={}, targetId={}",
|
||||
CHANNEL, event.type(), event.targetId());
|
||||
redisTemplate.convertAndSend(CHANNEL, json);
|
||||
try {
|
||||
String json = JSON.toJSONString(event);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,12 @@ package com.webgame.webgamebackend.common.event;
|
||||
|
||||
/**
|
||||
* SSE 事件类型枚举
|
||||
* <p>
|
||||
* ROOM_CREATED / ROOM_UPDATED / ROOM_REMOVED 为广播事件(推送给所有在线用户)
|
||||
* KICKED / GAME_STARTED / BALANCE_CHG 为定向事件(推送给特定用户)
|
||||
*/
|
||||
public enum SseEventType {
|
||||
|
||||
/** 新房间创建(广播) */
|
||||
ROOM_CREATED,
|
||||
|
||||
/** 房间状态更新:玩家加入/离开、准备切换、房主变更、游戏开始等(广播) */
|
||||
ROOM_UPDATED,
|
||||
|
||||
/** 房间移除:无人散场或游戏结束关闭(广播) */
|
||||
ROOM_REMOVED,
|
||||
|
||||
/** 被房主踢出房间(定向) */
|
||||
KICKED,
|
||||
|
||||
/** 你所在的房间游戏已开始(定向) */
|
||||
GAME_STARTED,
|
||||
|
||||
/** 余额变动通知(定向) */
|
||||
BALANCE_CHG
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import lombok.Getter;
|
||||
public enum ErrorCode {
|
||||
|
||||
// ========== 通用 ==========
|
||||
SUCCESS(0, "success"),
|
||||
SUCCESS(0, "成功"),
|
||||
BAD_REQUEST(400, "参数校验失败"),
|
||||
INTERNAL_ERROR(5000, "服务器内部错误"),
|
||||
|
||||
|
||||
@@ -13,150 +13,81 @@ import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
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.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
/**
|
||||
* 全局异常处理,统一返回 ApiResponse JSON 格式。
|
||||
*
|
||||
* SSE 端点的 401 由拦截器统一处理后返回 JSON,前端 fetch-event-source 通过
|
||||
* err.response.status 读取,不再需要服务端以 SSE 事件格式写错误。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
// ==================== SSE 异步超时 ====================
|
||||
|
||||
/**
|
||||
* SSE 异步请求超时(SseEmitter timeout 到期的正常行为)
|
||||
*
|
||||
* 返回 void 避免 Spring 尝试写入已提交的响应导致二次异常。
|
||||
*/
|
||||
@ExceptionHandler(AsyncRequestTimeoutException.class)
|
||||
public void handleAsyncTimeout(AsyncRequestTimeoutException ex) {
|
||||
// SSE 长连接超时是正常行为,无需记录日志
|
||||
// SSE 超时在长连接场景下属于正常情况
|
||||
}
|
||||
|
||||
/**
|
||||
* SaToken 上下文未初始化(异步分派时线程局部变量丢失)
|
||||
*
|
||||
* 返回 void 避免 Spring 尝试以 JSON 写入已提交的 text/event-stream 响应导致二次异常。
|
||||
*/
|
||||
@ExceptionHandler(SaTokenContextException.class)
|
||||
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)
|
||||
public void handleAsyncRequestNotUsable(AsyncRequestNotUsableException ex) {
|
||||
// SSE 连接已断开后心跳的残留异常,无需处理
|
||||
// SSE 响应已被客户端关闭
|
||||
}
|
||||
|
||||
// ==================== 业务异常 ====================
|
||||
|
||||
/**
|
||||
* 处理业务异常
|
||||
*/
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
|
||||
log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage());
|
||||
return ApiResponse.fail(ex.getCode(), ex.getMessage());
|
||||
}
|
||||
|
||||
// ==================== 参数校验异常 ====================
|
||||
|
||||
/**
|
||||
* 处理 @Valid 参数校验失败,合并所有字段的错误消息
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ApiResponse<Void> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String message = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(e -> e.getField() + " " + e.getDefaultMessage())
|
||||
.map(error -> error.getField() + " " + error.getDefaultMessage())
|
||||
.reduce((a, b) -> a + "; " + b)
|
||||
.orElse(ErrorCode.BAD_REQUEST.getDefaultMessage());
|
||||
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), message);
|
||||
}
|
||||
|
||||
// ==================== Sa-Token 鉴权异常 ====================
|
||||
|
||||
/**
|
||||
* 未登录 / token 无效
|
||||
*/
|
||||
@ExceptionHandler(NotLoginException.class)
|
||||
public ApiResponse<Void> handleNotLogin(NotLoginException ex) {
|
||||
log.warn("未登录: {}", ex.getMessage());
|
||||
return ApiResponse.fail(401, "请先登录");
|
||||
}
|
||||
|
||||
/**
|
||||
* 无权限
|
||||
*/
|
||||
@ExceptionHandler(NotPermissionException.class)
|
||||
public ApiResponse<Void> handleNotPermission(NotPermissionException ex) {
|
||||
log.warn("无权限: {}", ex.getMessage());
|
||||
return ApiResponse.fail(403, "无权限");
|
||||
@ExceptionHandler({NotPermissionException.class, NotRoleException.class})
|
||||
public ApiResponse<Void> handleForbidden(Exception 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)
|
||||
public ApiResponse<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) {
|
||||
log.warn("请求方法不支持: {}", ex.getMessage());
|
||||
return ApiResponse.fail(405, "请求方法不支持");
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求体不可读(如 JSON 格式错误)
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ApiResponse<Void> handleMessageNotReadable(HttpMessageNotReadableException ex) {
|
||||
log.warn("请求体解析失败: {}", ex.getMessage());
|
||||
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式错误");
|
||||
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式无效");
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径参数类型不匹配
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public ApiResponse<Void> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
log.warn("参数类型不匹配: {}", ex.getMessage());
|
||||
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型错误");
|
||||
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型无效");
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源不存在(404)
|
||||
*/
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public ApiResponse<Void> handleNoResourceFound(NoResourceFoundException ex) {
|
||||
log.warn("资源不存在: {}", ex.getMessage());
|
||||
return ApiResponse.fail(404, "资源不存在");
|
||||
log.warn("资源未找到: {}", ex.getMessage());
|
||||
return ApiResponse.fail(404, "资源未找到");
|
||||
}
|
||||
|
||||
// ==================== 兜底 ====================
|
||||
|
||||
/**
|
||||
* 处理其他未捕获异常
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ApiResponse<Void> handleException(Exception ex) {
|
||||
log.error("服务器内部错误", ex);
|
||||
|
||||
@@ -7,11 +7,11 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.listener.ChannelTopic;
|
||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||
|
||||
/**
|
||||
* SSE 相关 Bean 配置
|
||||
*
|
||||
* 注册 Redis Pub/Sub 消息监听器,订阅频道 "sse:events"。
|
||||
* SSE 基础设施配置
|
||||
*/
|
||||
@Configuration
|
||||
public class SseConfig {
|
||||
@@ -25,4 +25,14 @@ public class SseConfig {
|
||||
container.addMessageListener(listener, new ChannelTopic(SseEventPublisher.CHANNEL));
|
||||
return container;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TaskScheduler sseTaskScheduler() {
|
||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||
scheduler.setPoolSize(2);
|
||||
scheduler.setThreadNamePrefix("sse-heartbeat-");
|
||||
scheduler.setRemoveOnCancelPolicy(true);
|
||||
scheduler.initialize();
|
||||
return scheduler;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,7 @@ import org.springframework.stereotype.Component;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Redis Pub/Sub 消息监听器
|
||||
*
|
||||
* 接收来自 Redis 频道 "sse:events" 的消息,
|
||||
* 反序列化后根据事件类型(广播/定向)转发给本地 SseConnectionManager。
|
||||
* SSE 事件的 Redis Pub/Sub 监听器
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -26,22 +23,21 @@ public class RedisSseListener implements MessageListener {
|
||||
@Override
|
||||
public void onMessage(Message message, byte[] pattern) {
|
||||
String body = new String(message.getBody(), StandardCharsets.UTF_8);
|
||||
log.debug("[SSE] 收到 Redis 消息, channel={}", new String(message.getChannel(), StandardCharsets.UTF_8));
|
||||
|
||||
try {
|
||||
SseEvent event = JSON.parseObject(body, SseEvent.class);
|
||||
if (event == null) {
|
||||
log.warn("[SSE] 无法解析事件消息: {}", body);
|
||||
log.warn("[SSE] 收到空的 Redis 事件, body={}", body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.targetId() != null) {
|
||||
connectionManager.sendTo(event.targetId(), event);
|
||||
} else {
|
||||
if (event.targetId() == null) {
|
||||
connectionManager.broadcast(event);
|
||||
} else {
|
||||
connectionManager.sendTo(event.targetId(), event);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[SSE] 处理 Redis 消息失败: {}", body, e);
|
||||
log.error("[SSE] 处理 Redis 消息失败, body={}", body, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,132 +7,100 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* SSE 连接管理器
|
||||
* 线程安全的 SSE 连接管理器
|
||||
*
|
||||
* 维护 userId → SseEmitter 映射,提供广播和定向推送能力。
|
||||
* 使用 ConcurrentHashMap 保证线程安全。
|
||||
* 一个用户可以拥有多个连接,使得不同浏览器标签页和设备之间不会互相踢下线。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SseConnectionManager {
|
||||
|
||||
/** userId → SseEmitter 映射表,线程安全 */
|
||||
private final ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, ConcurrentHashMap<String, SseEmitter>> emitters =
|
||||
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(() -> {
|
||||
log.debug("[SSE] 连接正常关闭, userId={}", userId);
|
||||
emitters.remove(userId, emitter);
|
||||
log.debug("[SSE] Connection completed, userId={}, connectionId={}", userId, connectionId);
|
||||
remove(userId, connectionId);
|
||||
});
|
||||
emitter.onTimeout(() -> {
|
||||
log.debug("[SSE] 连接超时, userId={}", userId);
|
||||
emitters.remove(userId, emitter);
|
||||
log.debug("[SSE] Connection timeout, userId={}, connectionId={}", userId, connectionId);
|
||||
remove(userId, connectionId);
|
||||
});
|
||||
emitter.onError(throwable -> {
|
||||
log.debug("[SSE] 连接异常, userId={}, reason={}", userId, throwable.getMessage());
|
||||
emitters.remove(userId, emitter);
|
||||
log.debug("[SSE] Connection error, userId={}, connectionId={}, reason={}",
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除连接
|
||||
*
|
||||
* 先移除映射再尝试关闭,避免回调递归。
|
||||
* 响应已损坏时 complete() 可能抛出 RuntimeException,静默忽略。
|
||||
*
|
||||
* @param userId 用户 accountId
|
||||
*/
|
||||
public void remove(String userId) {
|
||||
SseEmitter removed = emitters.remove(userId);
|
||||
public void remove(String userId, String connectionId) {
|
||||
ConcurrentHashMap<String, SseEmitter> userEmitters = emitters.get(userId);
|
||||
if (userEmitters == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
SseEmitter removed = userEmitters.remove(connectionId);
|
||||
if (userEmitters.isEmpty()) {
|
||||
emitters.remove(userId, userEmitters);
|
||||
}
|
||||
|
||||
if (removed != null) {
|
||||
try {
|
||||
removed.complete();
|
||||
} 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) {
|
||||
if (emitters.isEmpty()) return;
|
||||
|
||||
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());
|
||||
if (emitters.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("[SSE] 定向推送, userId={}, eventType={}", userId, event.type());
|
||||
sendEvent(emitter, event, userId);
|
||||
log.debug("[SSE] Broadcast event, type={}, onlineConnections={}", event.type(), getOnlineCount());
|
||||
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() {
|
||||
return emitters.size();
|
||||
return emitters.values().stream().mapToInt(Map::size).sum();
|
||||
}
|
||||
|
||||
/**
|
||||
* 向单个 SseEmitter 发送事件
|
||||
*
|
||||
* 捕获 IOException(客户端已断开但还未触发回调),
|
||||
* 此时安全移除连接。
|
||||
*/
|
||||
private void sendEvent(SseEmitter emitter, SseEvent event, String userId) {
|
||||
private void sendEvent(String userId, String connectionId, SseEmitter emitter, SseEvent event) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.id(event.id())
|
||||
.name(event.type().name().toLowerCase())
|
||||
.data(event.data()));
|
||||
} catch (IOException e) {
|
||||
log.debug("[SSE] 推送失败(客户端可能已断开), userId={}, reason={}", userId, e.getMessage());
|
||||
emitters.remove(userId);
|
||||
try {
|
||||
emitter.completeWithError(e);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
log.debug("[SSE] Push failed, userId={}, connectionId={}, reason={}",
|
||||
userId, connectionId, e.getMessage());
|
||||
remove(userId, connectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
package com.webgame.webgamebackend.sse;
|
||||
|
||||
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.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
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;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
|
||||
/**
|
||||
* SSE 连接端点
|
||||
*
|
||||
* 提供 GET /sse/subscribe 端点,建立持久化 SSE 连接。
|
||||
* 连接成功后立即发送 connected 事件确认握手,之后每 30 秒发送心跳保活。
|
||||
*
|
||||
* 鉴权由 SaTokenConfigure 拦截器统一处理,前端通过 fetch-event-source
|
||||
* 的 headers 传 saToken,401 时由前端 err.response.status 直接读取。
|
||||
* SSE 订阅端点
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@@ -35,93 +24,51 @@ import java.util.concurrent.TimeUnit;
|
||||
@RequiredArgsConstructor
|
||||
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 TaskScheduler sseTaskScheduler;
|
||||
|
||||
/**
|
||||
* 建立 SSE 订阅连接
|
||||
*
|
||||
* 鉴权由拦截器处理,此处直接获取登录用户 ID。
|
||||
*
|
||||
* @return SseEmitter 实例(30 分钟超时)
|
||||
*/
|
||||
@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();
|
||||
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 {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("connected")
|
||||
.data("{\"message\":\"连接成功\"}"));
|
||||
} catch (IOException e) {
|
||||
log.warn("[SSE] 发送 connected 事件失败, userId={}", userId);
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
log.warn("[SSE] 发送 connected 事件失败, userId={}, connectionId={}",
|
||||
userId, connectionId, e);
|
||||
connectionManager.remove(userId, connectionId);
|
||||
}
|
||||
|
||||
// 启动心跳定时任务
|
||||
ScheduledExecutorService heartbeat = Executors.newSingleThreadScheduledExecutor(
|
||||
r -> new Thread(r, "sse-heartbeat-" + userId.substring(0, Math.min(8, userId.length())))
|
||||
);
|
||||
heartbeat.scheduleAtFixedRate(() -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("heartbeat")
|
||||
.data(""));
|
||||
} catch (Exception e) {
|
||||
// 客户端已断开(IOException)或响应已不可用(AsyncRequestNotUsableException)
|
||||
connectionManager.remove(userId);
|
||||
heartbeat.shutdown();
|
||||
}
|
||||
}, 30, 30, TimeUnit.SECONDS);
|
||||
if (lastEventId != null && !lastEventId.isBlank()) {
|
||||
log.debug("[SSE] 客户端重连, Last-Event-ID={}, userId={}, connectionId={}",
|
||||
lastEventId, userId, connectionId);
|
||||
}
|
||||
|
||||
// 连接关闭时停止心跳
|
||||
emitter.onCompletion(heartbeat::shutdown);
|
||||
emitter.onTimeout(heartbeat::shutdown);
|
||||
emitter.onError(e -> heartbeat.shutdown());
|
||||
|
||||
log.info("[SSE] 新连接建立, userId={}, 当前在线={}", userId, connectionManager.getOnlineCount());
|
||||
log.info("[SSE] 连接建立成功, userId={}, connectionId={}, onlineConnections={}",
|
||||
userId, connectionId, connectionManager.getOnlineCount());
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user