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;
/**
* [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();
};
}
}

View File

@@ -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);
}
/**

View File

@@ -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 目标用户 accountIdnull 表示广播给所有在线用户
* @param data 事件携带的 JSON 数据
* @param timestamp 事件时间戳(毫秒)
* @param targetId 目标账号 IDnull 表示广播
* @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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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
}

View File

@@ -9,7 +9,7 @@ import lombok.Getter;
public enum ErrorCode {
// ========== 通用 ==========
SUCCESS(0, "success"),
SUCCESS(0, "成功"),
BAD_REQUEST(400, "参数校验失败"),
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.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);

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 传 saToken401 时由前端 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);
}
}
}