93 lines
3.8 KiB
Java
93 lines
3.8 KiB
Java
package com.webgame.webgamebackend.sse;
|
||
|
||
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
|
||
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.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;
|
||
|
||
/**
|
||
* SSE 订阅端点
|
||
*
|
||
* 提供 GET /sse/subscribe 端点建立持久化 SSE 连接。
|
||
* 心跳由 SseConnectionManager 统一批量管理,此处不再维护 per-connection 定时器。
|
||
*/
|
||
@Slf4j
|
||
@RestController
|
||
@RequestMapping("/sse")
|
||
@RequiredArgsConstructor
|
||
public class SseController {
|
||
|
||
/** SseEmitter 超时时间(30 分钟),超时后由 SseConnectionManager 的 onTimeout 回调清理 */
|
||
private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L;
|
||
|
||
private final SseConnectionManager connectionManager;
|
||
private final AuthenticationProvider authProvider;
|
||
|
||
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||
public SseEmitter subscribe(
|
||
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId,
|
||
HttpServletResponse response) throws IOException {
|
||
|
||
// SSE 路径已排除在 AuthInterceptor 之外,此处手动校验登录态
|
||
String token = authProvider.getCurrentToken();
|
||
if (token == null || token.isBlank()) {
|
||
log.warn("[SSE] 缺少认证 token");
|
||
return writeAuthError(response, HttpServletResponse.SC_UNAUTHORIZED, "缺少认证信息,请先登录");
|
||
}
|
||
|
||
String userId = authProvider.getUserIdByToken(token);
|
||
if (userId == null) {
|
||
log.warn("[SSE] token 无效或已过期");
|
||
return writeAuthError(response, HttpServletResponse.SC_UNAUTHORIZED, "登录已过期,请重新登录");
|
||
}
|
||
|
||
// 设置认证上下文,后续 getCurrentUserId() 可用
|
||
authProvider.setContextToken(token, -1);
|
||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||
String connectionId = connectionManager.register(userId, emitter);
|
||
|
||
// 发送初始连接确认
|
||
try {
|
||
emitter.send(SseEmitter.event()
|
||
.name("connected")
|
||
.data("{\"message\":\"连接成功\"}"));
|
||
} catch (IOException | IllegalStateException e) {
|
||
log.warn("[SSE] 发送 connected 事件失败, userId={}, connectionId={}",
|
||
userId, connectionId, e);
|
||
connectionManager.remove(userId, connectionId);
|
||
}
|
||
|
||
if (lastEventId != null && !lastEventId.isBlank()) {
|
||
log.debug("[SSE] 客户端重连, Last-Event-ID={}, userId={}, connectionId={}",
|
||
lastEventId, userId, connectionId);
|
||
}
|
||
|
||
return emitter;
|
||
}
|
||
|
||
/**
|
||
* 向 SSE 客户端返回认证错误
|
||
*
|
||
* 直接写入 HttpServletResponse 返回 401 + SSE error 事件,
|
||
* 前端 SseClient.onopen 检测到 response.status === 401 后触发 onUnauthorized,
|
||
* 自动刷新 token 并重连。
|
||
*
|
||
* @return null,通知 Spring MVC 响应已处理完毕,无需 MessageConverter
|
||
*/
|
||
private SseEmitter writeAuthError(HttpServletResponse response, int status, String message) throws IOException {
|
||
response.setStatus(status);
|
||
response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
|
||
response.getWriter().write("event: error\ndata: {\"code\":" + status + ",\"message\":\"" + message + "\"}\n\n");
|
||
response.getWriter().flush();
|
||
return null;
|
||
}
|
||
}
|