Compare commits

...

13 Commits

Author SHA1 Message Date
3eeb5bbd51 feat: 重构 2026-07-07 09:58:42 +08:00
a5fe28e536 feat: 重构游戏房间架构 2026-06-30 11:54:21 +08:00
0d61038e67 feat: 重构游戏房间架构 2026-06-30 11:27:14 +08:00
c1134f48e0 feat: 重构游戏房间架构 2026-06-30 10:32:29 +08:00
ff284e2841 feat: 重构游戏房间架构 2026-06-30 10:20:26 +08:00
d92f7bb581 feat: sse 2026-06-30 08:52:23 +08:00
aa9a0732d6 feat: sse 2026-06-24 12:42:34 +08:00
ab2a2dbf7f feat: 解耦认证逻辑 2026-06-24 12:17:09 +08:00
5d9f5b8ab7 feat: HTTP状态码 + 业务码 2026-06-24 12:00:17 +08:00
df6c7f6b10 feat: HTTP状态码 + 业务码 2026-06-24 11:14:42 +08:00
61ff5d46e6 fix: sse修改 2026-06-24 10:40:28 +08:00
3580ad934e fix: 暂存 2026-06-24 09:50:01 +08:00
842e5c29f9 fix: 暂存 2026-06-24 08:30:22 +08:00
82 changed files with 3193 additions and 2177 deletions

View File

@@ -3,9 +3,11 @@ package com.webgame.webgamebackend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAspectJAutoProxy(proxyTargetClass = true) // 启用基于 AspectJ 的自动代理功能 支持使用AOP功能
@EnableScheduling // 启用定时任务(房间超时清理)
public class WebgameBackendApplication {
public static void main(String[] args) {

View File

@@ -1,4 +1,4 @@
package com.webgame.webgamebackend.sse;
package com.webgame.webgamebackend.adapter.sse;
import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.common.event.SseEvent;
@@ -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

@@ -0,0 +1,185 @@
package com.webgame.webgamebackend.adapter.sse;
import com.webgame.webgamebackend.common.event.SseEvent;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
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;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 线程安全的 SSE 连接管理器
*
* 一个用户可以拥有多个连接,使得不同浏览器标签页和设备之间不会互相踢下线。
* 心跳由单一后台线程统一管理,避免 per-connection 定时器导致的线程膨胀。
*/
@Slf4j
@Component
public class SseConnectionManager {
/** 心跳间隔(秒) */
private static final long HEARTBEAT_INTERVAL_SECONDS = 30;
/** 心跳线程池(单线程,批量 ping 所有连接) */
private final ScheduledExecutorService heartbeatScheduler =
Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "sse-heartbeat"));
private final ConcurrentHashMap<String, ConcurrentHashMap<String, SseEmitter>> emitters =
new ConcurrentHashMap<>();
/**
* 启动统一心跳定时任务
*
* 单一后台线程批量 ping 所有连接O(1) 线程开销。
* 单次 ping 失败自动清理连接,不影响其他连接。
*/
@PostConstruct
public void startHeartbeat() {
heartbeatScheduler.scheduleAtFixedRate(() -> {
if (emitters.isEmpty()) {
return;
}
log.debug("[SSE] 批量心跳, onlineConnections={}", getOnlineCount());
emitters.forEach((userId, connections) ->
connections.forEach((connectionId, emitter) -> {
try {
emitter.send(SseEmitter.event().name("heartbeat").data(""));
} catch (IOException | IllegalStateException e) {
log.debug("[SSE] 心跳失败,清理连接, userId={}, connectionId={}",
userId, connectionId);
remove(userId, connectionId);
}
}));
}, HEARTBEAT_INTERVAL_SECONDS, HEARTBEAT_INTERVAL_SECONDS, TimeUnit.SECONDS);
log.info("[SSE] 心跳定时器已启动, interval={}s", HEARTBEAT_INTERVAL_SECONDS);
}
@PreDestroy
public void shutdown() {
heartbeatScheduler.shutdown();
log.info("[SSE] 心跳定时器已关闭");
}
/**
* 注册新的 SSE 连接
*
* @param userId 用户账号 ID
* @param emitter SseEmitter 实例
* @return 连接 ID
*/
public String register(String userId, SseEmitter emitter) {
String connectionId = UUID.randomUUID().toString();
emitters.computeIfAbsent(userId, key -> new ConcurrentHashMap<>()).put(connectionId, emitter);
emitter.onCompletion(() -> {
log.debug("[SSE] 连接完成, userId={}, connectionId={}", userId, connectionId);
remove(userId, connectionId);
});
emitter.onTimeout(() -> {
log.debug("[SSE] 连接超时, userId={}, connectionId={}", userId, connectionId);
remove(userId, connectionId);
});
emitter.onError(throwable -> {
log.debug("[SSE] 连接异常, userId={}, connectionId={}, reason={}",
userId, connectionId, throwable.getMessage());
remove(userId, connectionId);
});
log.info("[SSE] 用户连接成功, userId={}, connectionId={}, onlineConnections={}",
userId, connectionId, getOnlineCount());
return connectionId;
}
/**
* 移除连接(仅清理内部 Map不操作 SseEmitter
*
* SseEmitter 生命周期由 Spring 管理onCompletion/onTimeout/onError 回调),
* 此方法仅负责从内部映射表中移除引用。不调用 emitter.complete()
* 避免向已断开的客户端写入数据导致 IOException 扩散到 GlobalExceptionHandler。
*
* @param userId 用户账号 ID
* @param connectionId 连接 ID
*/
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) {
log.info("[SSE] 连接已移除, userId={}, connectionId={}, onlineConnections={}",
userId, connectionId, getOnlineCount());
}
}
/**
* 广播事件给所有在线用户的所有连接
*
* @param event SSE 事件
*/
public void broadcast(SseEvent event) {
if (emitters.isEmpty()) {
return;
}
log.debug("[SSE] 广播事件, type={}, onlineConnections={}", event.type(), getOnlineCount());
emitters.forEach((userId, connections) ->
connections.forEach((connectionId, emitter) -> sendEvent(userId, connectionId, emitter, event)));
}
/**
* 发送事件给指定用户的所有连接
*
* @param userId 目标用户账号 ID
* @param event SSE 事件
*/
public void sendTo(String userId, SseEvent event) {
ConcurrentHashMap<String, SseEmitter> connections = emitters.get(userId);
if (connections == null || connections.isEmpty()) {
log.debug("[SSE] 目标用户不在线, userId={}, eventType={}", userId, event.type());
return;
}
connections.forEach((connectionId, emitter) -> sendEvent(userId, connectionId, emitter, event));
}
/**
* 获取当前在线连接总数
*
* @return 连接数
*/
public int getOnlineCount() {
return emitters.values().stream().mapToInt(Map::size).sum();
}
/**
* 向单个 SseEmitter 发送事件
*
* 捕获 IOException客户端已断开但还未触发回调此时安全移除连接。
*/
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 | IllegalStateException e) {
log.debug("[SSE] 推送失败, userId={}, connectionId={}, reason={}",
userId, connectionId, e.getMessage());
remove(userId, connectionId);
}
}
}

View File

@@ -0,0 +1,142 @@
package com.webgame.webgamebackend.adapter.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 订阅端点
*
* 提供 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;
private final SseEventPublisher eventPublisher;
@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;
}
/**
* 测试用消息发布接口
*
* 支持广播(不传 userId和定向推送传 userId两种模式。
* 可传入任意 JSON 数据作为消息体,事件类型固定为 CUSTOM。
*
* 请求体示例:
* <pre>
* // 广播
* { "data": { "msg": "hello" } }
*
* // 定向推送
* { "userId": "123", "data": { "msg": "hello" } }
* </pre>
*
* @param body 请求体,包含可选的 userId 和必填的 data
* @return 统一 API 响应
*/
@PostMapping("/publish")
public ApiResponse<Map<String, Object>> publish(@RequestBody Map<String, Object> body) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) 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 客户端返回认证错误
*
* 直接写入 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;
}
}

View File

@@ -1,4 +1,4 @@
package com.webgame.webgamebackend.ws;
package com.webgame.webgamebackend.adapter.ws;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

View File

@@ -1,12 +1,18 @@
package com.webgame.webgamebackend.ws;
package com.webgame.webgamebackend.adapter.ws;
import cn.dev33.satoken.stp.StpUtil;
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.webgame.webgamebackend.service.GomokuGameService;
import com.webgame.webgamebackend.ws.dto.WsInboundMessage;
import com.webgame.webgamebackend.ws.dto.WsMessage;
import com.webgame.webgamebackend.ws.dto.WsOutboundMessage;
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameRoomEntity;
import com.webgame.webgamebackend.infrastructure.persistence.repository.GameRoomRepository;
import com.webgame.webgamebackend.domain.room.RoomManager;
import com.webgame.webgamebackend.domain.engine.GameEngine;
import com.webgame.webgamebackend.domain.engine.GameEngineRegistry;
import com.webgame.webgamebackend.domain.engine.RoomContext;
import com.webgame.webgamebackend.adapter.ws.dto.WsInboundMessage;
import com.webgame.webgamebackend.adapter.ws.dto.WsMessage;
import com.webgame.webgamebackend.adapter.ws.dto.WsOutboundMessage;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@@ -22,8 +28,14 @@ import java.util.Map;
/**
* 房间 WebSocket 处理器
*
* 处理客户端连接断开与消息路由消息类型路由到不同的业务逻辑
* 通过 URL 参数传递 roomId token 完成认证与房间绑定
* 重构后的职责
* - 连接建立认证 + 注册会话 + 取消断线倒计时通过 RoomManager
* - 消息路由房间管理消息 RoomManager游戏消息 GameEngine
* - 连接关闭启动断线倒计时通过 RoomManager
*
* <p>采用策略路由根据房间当前状态WAITING/PLAYING将消息分发给不同的处理器
* WAITING 状态下只有房间管理消息ready, leave, start有效
* PLAYING 状态下游戏消息place_stone, resign, draw_* GameEngine 处理</p>
*/
@Slf4j
@Component
@@ -31,11 +43,69 @@ import java.util.Map;
public class RoomWebSocketHandler extends TextWebSocketHandler {
private final RoomSessionManager sessionManager;
private final GomokuGameService gomokuGameService;
private final RoomManager roomManager;
private final GameEngineRegistry engineRegistry;
private final GameRoomRepository gameRoomRepository;
private final WsMessageRouter router;
private final AuthenticationProvider authProvider;
@PostConstruct
public void registerHandlers() {
// ===== 房间管理消息所有状态 =====
// 准备/取消准备
router.register(WsInboundMessage.READY,
(roomId, userId, data, sm) ->
roomManager.toggleReady(roomId, userId));
// 离开房间
router.register(WsInboundMessage.LEAVE,
(roomId, userId, data, sm) ->
roomManager.leaveRoom(roomId, userId));
// 开始游戏仅房主
router.register(WsInboundMessage.START,
(roomId, userId, data, sm) ->
roomManager.startGame(roomId, userId));
// ===== 游戏消息PLAYING 状态 =====
// 落子
router.register(WsInboundMessage.PLACE_STONE, (roomId, userId, data, sm) -> {
GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null);
if (room != null && "playing".equals(room.getStatus())) {
// 通过 RoomManager 获取引擎的 getState 不行需要直接找到引擎
// 改为委托模式RoomManager 提供 executeGameAction 方法
roomManager.executeGameAction(roomId, userId, "place_stone", data);
}
});
// 认输
router.register(WsInboundMessage.RESIGN, (roomId, userId, data, sm) -> {
roomManager.executeGameAction(roomId, userId, "resign", null);
});
// 求和请求
router.register(WsInboundMessage.DRAW_REQUEST, (roomId, userId, data, sm) -> {
roomManager.executeGameAction(roomId, userId, "draw_request", null);
});
// 回应求和
router.register(WsInboundMessage.DRAW_RESPONSE, (roomId, userId, data, sm) -> {
roomManager.executeGameAction(roomId, userId, "draw_response", data);
});
// 聊天
router.register(WsInboundMessage.CHAT, (roomId, userId, data, sm) -> {
String content = data != null ? data.getString("content") : null;
handleChat(roomId, userId, content);
});
log.info("[WS] 已注册 {} 个消息处理器", router.size());
}
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// URL 参数中提取 roomId token
URI uri = session.getUri();
if (uri == null) {
session.close(CloseStatus.BAD_DATA);
@@ -57,19 +127,18 @@ public class RoomWebSocketHandler extends TextWebSocketHandler {
}
// 通过 token 获取用户 ID
Object loginId = StpUtil.getLoginIdByToken(token);
if (loginId == null) {
String userId = authProvider.getUserIdByToken(token);
if (userId == null) {
session.close(CloseStatus.POLICY_VIOLATION);
return;
}
String userId = loginId.toString();
// 注册会话内部会清理同用户的旧会话
// 处理重连注册会话之前避免广播到尚未完成握手的当前 session
roomManager.handleReconnect(roomId, userId);
// 注册会话
sessionManager.addSession(roomId, userId, session);
// 取消断线超时定时器重连场景
gomokuGameService.cancelDisconnectTimeout(roomId, userId);
log.info("[WS] 连接建立, roomId={}, userId={}, sessionId={}", roomId, userId, session.getId());
}
@@ -89,40 +158,16 @@ public class RoomWebSocketHandler extends TextWebSocketHandler {
try {
JSONObject json = JSON.parseObject(payload);
String type = json.getString("type");
Object data = json.get("data");
Object rawData = json.get("data");
JSONObject data = (rawData instanceof JSONObject) ? (JSONObject) rawData : null;
switch (type) {
case WsInboundMessage.PLACE_STONE -> {
// data 包含 row, col
int row = ((JSONObject) data).getIntValue("row");
int col = ((JSONObject) data).getIntValue("col");
gomokuGameService.placeStone(roomId, userId, row, col, sessionManager);
}
case WsInboundMessage.CHAT -> {
String content = ((JSONObject) data).getString("content");
handleChat(roomId, userId, content);
}
case WsInboundMessage.READY -> {
gomokuGameService.toggleReady(roomId, userId, sessionManager);
}
case WsInboundMessage.RESIGN -> {
gomokuGameService.resign(roomId, userId, sessionManager);
}
case WsInboundMessage.DRAW_REQUEST -> {
gomokuGameService.requestDraw(roomId, userId, sessionManager);
}
case WsInboundMessage.DRAW_RESPONSE -> {
boolean accept = ((JSONObject) data).getBooleanValue("accept");
gomokuGameService.respondToDraw(roomId, userId, accept, sessionManager);
}
case WsInboundMessage.LEAVE -> {
gomokuGameService.leaveRoom(roomId, userId, sessionManager);
}
case WsInboundMessage.START -> {
gomokuGameService.startGame(roomId, userId, sessionManager);
}
default -> sendError(session, "未知消息类型: " + type);
WsMessageHandler handler = router.get(type);
if (handler == null) {
sendError(session, "未知消息类型: " + type);
return;
}
handler.handle(roomId, userId, data, sessionManager);
} catch (Exception e) {
log.error("[WS] 处理消息异常, roomId={}, userId={}", roomId, userId, e);
sendError(session, e.getMessage());
@@ -137,8 +182,8 @@ public class RoomWebSocketHandler extends TextWebSocketHandler {
roomId, userId, session.getId(), status);
if (userId != null && roomId != null) {
// 通知游戏服务玩家断开
gomokuGameService.handleDisconnect(roomId, userId, sessionManager);
// 通知 RoomManager 处理断线
roomManager.handleDisconnect(roomId, userId);
}
sessionManager.removeSession(session);
}
@@ -162,15 +207,9 @@ public class RoomWebSocketHandler extends TextWebSocketHandler {
private static final int CHAT_MAX_LENGTH = 500;
/**
* 聊天消息处理暂不持久化直接广播
*
* 校验规则
* - content 不能为 null 或空字符串
* - content 长度不能超过 CHAT_MAX_LENGTH500 字符
* - 移除 HTML 标签防止 XSS
* 聊天消息处理
*/
private void handleChat(String roomId, String userId, String content) {
// 内容为空或超长时直接丢弃
if (content == null || content.isBlank()) {
return;
}
@@ -178,12 +217,10 @@ public class RoomWebSocketHandler extends TextWebSocketHandler {
content = content.substring(0, CHAT_MAX_LENGTH);
}
// 简易 XSS 防护移除 HTML 标签
String sanitized = content
.replace("<", "")
.replace(">", "");
// TODO: 后续可增加消息持久化
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.CHAT_BROADCAST, Map.of(
"userId", userId,
"content", sanitized,

View File

@@ -0,0 +1,23 @@
package com.webgame.webgamebackend.adapter.ws;
import com.alibaba.fastjson2.JSONObject;
/**
* WebSocket 消息处理器接口
*
* 每种消息类型对应一个处理器实现,由 WsMessageRouter 统一路由。
* handler 方法抛出的异常由 RoomWebSocketHandler 统一捕获并返回错误消息。
*/
@FunctionalInterface
public interface WsMessageHandler {
/**
* 处理 WebSocket 消息
*
* @param roomId 房间 ID
* @param userId 发送消息的用户 ID
* @param data 消息数据JSONObject可能为 null
* @param sessionManager 房间会话管理器,用于广播
*/
void handle(String roomId, String userId, JSONObject data, RoomSessionManager sessionManager);
}

View File

@@ -0,0 +1,72 @@
package com.webgame.webgamebackend.adapter.ws;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* WebSocket 消息路由器
*
* 维护消息类型 → 处理器的映射表,替代硬编码 switch-case。
* 新增消息类型只需注册对应的 WsMessageHandler 即可,无需修改路由逻辑。
*
* 使用示例:
* <pre>{@code
* router.register("place_stone", gomokuGameService::placeStoneViaWs);
* router.register("chat", chatHandler::handle);
* }</pre>
*/
@Slf4j
@Component
public class WsMessageRouter {
private final Map<String, WsMessageHandler> handlers = new ConcurrentHashMap<>();
/**
* 注册消息处理器
*
* @param type 消息类型(与客户端约定的一致)
* @param handler 处理器
*/
public void register(String type, WsMessageHandler handler) {
if (type == null || type.isBlank()) {
throw new IllegalArgumentException("消息类型不能为空");
}
if (handler == null) {
throw new IllegalArgumentException("处理器不能为 null");
}
handlers.put(type, handler);
log.info("[WS路由] 注册处理器, type={}", type);
}
/**
* 获取消息处理器
*
* @param type 消息类型
* @return 处理器,未找到返回 null
*/
public WsMessageHandler get(String type) {
return handlers.get(type);
}
/**
* 是否已注册指定类型的处理器
*
* @param type 消息类型
* @return 是否已注册
*/
public boolean hasHandler(String type) {
return handlers.containsKey(type);
}
/**
* 获取已注册的消息类型数量(用于监控/调试)
*
* @return 注册数量
*/
public int size() {
return handlers.size();
}
}

View File

@@ -1,4 +1,4 @@
package com.webgame.webgamebackend.ws.dto;
package com.webgame.webgamebackend.adapter.ws.dto;
/**
* 客户端服务端 WebSocket 消息类型

View File

@@ -1,4 +1,4 @@
package com.webgame.webgamebackend.ws.dto;
package com.webgame.webgamebackend.adapter.ws.dto;
/**
* WebSocket 消息通用封装

View File

@@ -1,4 +1,4 @@
package com.webgame.webgamebackend.ws.dto;
package com.webgame.webgamebackend.adapter.ws.dto;
/**
* 服务端客户端 WebSocket 消息类型
@@ -23,10 +23,6 @@ public final class WsOutboundMessage {
public static final String PLAYER_LEAVE = "player_leave";
/** 玩家准备状态变更 */
public static final String READY_CHANGE = "ready_change";
/** 观战者加入 */
public static final String SPECTATOR_JOIN = "spectator_join";
/** 观战者离开 */
public static final String SPECTATOR_LEAVE = "spectator_leave";
/** 走棋记录同步 */
public static final String MOVE_HISTORY = "move_history";
/** 错误消息 */

View File

@@ -1,13 +1,12 @@
package com.webgame.webgamebackend.controller;
package com.webgame.webgamebackend.api;
import cn.dev33.satoken.annotation.SaIgnore;
import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.dto.account.LoginRequest;
import com.webgame.webgamebackend.common.dto.account.LoginResponse;
import com.webgame.webgamebackend.common.dto.account.LogoutRequest;
import com.webgame.webgamebackend.common.dto.account.RefreshTokenRequest;
import com.webgame.webgamebackend.common.dto.account.RegisterRequest;
import com.webgame.webgamebackend.service.AccountService;
import com.webgame.webgamebackend.domain.account.AccountService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
@@ -30,7 +29,6 @@ public class AccountController {
/**
* 登录
*/
@SaIgnore
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
LoginResponse result = accountService.login(request);
@@ -40,7 +38,6 @@ public class AccountController {
/**
* 注册
*/
@SaIgnore
@PostMapping("/register")
public ApiResponse<LoginResponse> register(@Valid @RequestBody RegisterRequest request) {
LoginResponse result = accountService.register(request);
@@ -50,7 +47,6 @@ public class AccountController {
/**
* 刷新令牌 refresh token 换取新的 access token refresh token
*/
@SaIgnore
@PostMapping("/refresh")
public ApiResponse<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
LoginResponse result = accountService.refresh(request.refreshToken());

View File

@@ -0,0 +1,34 @@
package com.webgame.webgamebackend.api;
import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.dto.game.GameListResponse;
import com.webgame.webgamebackend.domain.game.GameService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 游戏控制器
*
* 管理游戏配置查询(游戏列表、游戏详情等)。
* 房间相关操作已迁移到 {@link RoomController}。
*/
@RestController
@RequestMapping("/game")
@RequiredArgsConstructor
public class GameController {
private final GameService gameService;
/**
* 获取游戏列表
*
* 无需登录即可调用。
*/
@GetMapping("/list")
public ApiResponse<GameListResponse> list() {
GameListResponse result = gameService.getGameList();
return ApiResponse.ok(result);
}
}

View File

@@ -0,0 +1,121 @@
package com.webgame.webgamebackend.api;
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.dto.game.*;
import com.webgame.webgamebackend.domain.room.RoomManager;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* 房间控制器
*
* 管理房间的创建、加入、离开、解散、踢人、准备、开始等操作。
* 所有房间内实时操作通过 WebSocket 处理,此控制器仅提供 REST 接口。
*/
@Validated
@RestController
@RequestMapping("/room")
@RequiredArgsConstructor
public class RoomController {
private final RoomManager roomManager;
private final AuthenticationProvider authProvider;
/**
* 获取房间列表
*
* @param gameId 游戏 ID必填
* @param status 状态筛选(可选)
*/
@GetMapping("/list")
public ApiResponse<RoomListResponse> roomList(
@RequestParam String gameId,
@RequestParam(required = false) String status) {
RoomListResponse result = roomManager.getRoomList(gameId, status);
return ApiResponse.ok(result);
}
/**
* 获取房间详情
*/
@GetMapping("/{roomId}")
public ApiResponse<RoomDetailResponse> roomDetail(@PathVariable String roomId) {
RoomDetailResponse result = roomManager.getRoomDetail(roomId);
return ApiResponse.ok(result);
}
/**
* 创建房间
*
* 需登录。房主自动加入房间并设为已准备。
*/
@PostMapping("/create")
public ApiResponse<CreateRoomResponse> createRoom(@Valid @RequestBody CreateRoomRequest request) {
String userId = authProvider.getCurrentUserId();
CreateRoomResponse result = roomManager.createRoom(request, userId);
return ApiResponse.ok(result);
}
/**
* 加入房间
*/
@PostMapping("/join")
public ApiResponse<RoomItemResponse> joinRoom(@Valid @RequestBody JoinRoomRequest request) {
String userId = authProvider.getCurrentUserId();
RoomItemResponse result = roomManager.joinRoom(request, userId);
return ApiResponse.ok(result);
}
/**
* 离开房间
*/
@PostMapping("/leave")
public ApiResponse<Void> leaveRoom(@Valid @RequestBody LeaveRoomRequest request) {
String userId = authProvider.getCurrentUserId();
roomManager.leaveRoom(request.roomId(), userId);
return ApiResponse.ok(null);
}
/**
* 解散房间(仅房主)
*/
@PostMapping("/disband")
public ApiResponse<Void> disbandRoom(@Valid @RequestBody DisbandRoomRequest request) {
String userId = authProvider.getCurrentUserId();
roomManager.disbandRoom(request.roomId(), userId);
return ApiResponse.ok(null);
}
/**
* 踢出玩家(仅房主)
*/
@PostMapping("/kick")
public ApiResponse<Void> kickPlayer(@Valid @RequestBody KickPlayerRequest request) {
String ownerId = authProvider.getCurrentUserId();
roomManager.kickPlayer(request.roomId(), ownerId, request.userId());
return ApiResponse.ok(null);
}
/**
* 切换准备状态
*/
@PostMapping("/ready")
public ApiResponse<Boolean> toggleReady(@Valid @RequestBody ReadyRequest request) {
String userId = authProvider.getCurrentUserId();
boolean ready = roomManager.toggleReady(request.roomId(), userId);
return ApiResponse.ok(ready);
}
/**
* 开始游戏(仅房主)
*/
@PostMapping("/start")
public ApiResponse<Void> startGame(@Valid @RequestBody StartGameRequest request) {
String ownerId = authProvider.getCurrentUserId();
roomManager.startGame(request.roomId(), ownerId);
return ApiResponse.ok(null);
}
}

View File

@@ -1,11 +1,11 @@
package com.webgame.webgamebackend.controller;
package com.webgame.webgamebackend.api;
import cn.dev33.satoken.stp.StpUtil;
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest;
import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
import com.webgame.webgamebackend.service.UserService;
import com.webgame.webgamebackend.domain.user.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
@@ -22,6 +22,7 @@ import org.springframework.web.multipart.MultipartFile;
public class UserController {
private final UserService userService;
private final AuthenticationProvider authProvider;
/**
* 获取当前登录用户信息
@@ -39,7 +40,7 @@ public class UserController {
*/
@PutMapping("/info")
public ApiResponse<UserInfoResponse> updateInfo(@Valid @RequestBody UpdateUserInfoRequest request) {
String accountId = StpUtil.getLoginIdAsString();
String accountId = authProvider.getCurrentUserId();
UserInfoResponse result = userService.updateUserInfo(accountId, request);
return ApiResponse.ok(result);
}
@@ -51,7 +52,7 @@ public class UserController {
*/
@PatchMapping("/status")
public ApiResponse<Void> updateStatus(@Valid @RequestBody UpdateStatusRequest request) {
String accountId = StpUtil.getLoginIdAsString();
String accountId = authProvider.getCurrentUserId();
userService.updateStatus(accountId, request);
return ApiResponse.ok(null);
}
@@ -64,7 +65,7 @@ public class UserController {
*/
@PostMapping("/avatar")
public ApiResponse<UserInfoResponse> uploadAvatar(@RequestParam("file") MultipartFile file) {
String accountId = StpUtil.getLoginIdAsString();
String accountId = authProvider.getCurrentUserId();
UserInfoResponse result = userService.uploadAvatar(accountId, file);
return ApiResponse.ok(result);
}
@@ -76,7 +77,7 @@ public class UserController {
*/
@DeleteMapping("/avatar")
public ApiResponse<UserInfoResponse> deleteAvatar() {
String accountId = StpUtil.getLoginIdAsString();
String accountId = authProvider.getCurrentUserId();
UserInfoResponse result = userService.deleteAvatar(accountId);
return ApiResponse.ok(result);
}

View File

@@ -0,0 +1,43 @@
package com.webgame.webgamebackend.common.auth;
/**
* 当前请求用户上下文
* <p>
* 基于 ThreadLocal 存储当前请求的用户 ID由 AuthInterceptor 在 preHandle 中设置,
* 在 afterCompletion 中清理。业务代码可通过 {@link #get()} 获取当前用户 ID。
* <p>
* 注意:仅在同一请求线程内有效,异步场景需手动传递。
*/
public final class AuthContext {
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
private AuthContext() {
// 工具类,禁止实例化
}
/**
* 设置当前请求的用户 ID
*/
public static void set(String userId) {
USER_ID.set(userId);
}
/**
* 获取当前请求的用户 ID
*
* @return 用户 ID未设置时返回 null
*/
public static String get() {
return USER_ID.get();
}
/**
* 清理当前请求的用户上下文
* <p>
* 必须在请求结束后调用,防止 ThreadLocal 内存泄漏。
*/
public static void clear() {
USER_ID.remove();
}
}

View File

@@ -0,0 +1,130 @@
package com.webgame.webgamebackend.common.auth;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.List;
/**
* 认证拦截器,替代 Sa-Token 的 SaInterceptor
* <p>
* 职责:
* <ol>
* <li>放行公开路径和 OPTIONS 预检请求</li>
* <li>调用 {@link AuthenticationProvider#getCurrentUserId()} 触发登录校验</li>
* <li>将 Sa-Token 框架异常转换为 {@link BusinessException},实现认证框架与业务解耦</li>
* <li>校验通过后将用户 ID 存入 {@link AuthContext}</li>
* </ol>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private final AuthenticationProvider authProvider;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* 公开路由条目
*
* @param path Ant 风格路径模式
* @param methods 允许的 HTTP 方法,空数组表示所有方法
*/
private record PublicRoute(String path, String... methods) {
boolean matches(String requestPath, String requestMethod) {
if (!new AntPathMatcher().match(path, requestPath)) {
return false;
}
if (methods.length == 0) {
return true; // 所有方法放行
}
for (String m : methods) {
if (m.equalsIgnoreCase(requestMethod)) {
return true;
}
}
return false;
}
}
/** 无需登录的公开路由 */
private static final List<PublicRoute> PUBLIC_ROUTES = List.of(
// 框架级路径(所有方法)
new PublicRoute("/actuator/**"),
new PublicRoute("/swagger-ui/**"),
new PublicRoute("/swagger-ui.html"),
new PublicRoute("/v3/api-docs/**"),
new PublicRoute("/webjars/**"),
new PublicRoute("/error"),
new PublicRoute("/sse/**"),
// 账号模块 — 登录/注册/刷新无需认证
new PublicRoute("/account/login", "POST"),
new PublicRoute("/account/register", "POST"),
new PublicRoute("/account/refresh", "POST"),
// 游戏模块 — 查看列表/详情无需认证
new PublicRoute("/game/list", "GET"),
// 房间模块 — 查看列表/详情无需认证
new PublicRoute("/room/list", "GET"),
// GET /room/{roomId} 详情页,排除 POST/PUT/DELETE 等写操作
new PublicRoute("/room/*", "GET")
);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) {
String method = request.getMethod();
String path = request.getRequestURI();
// OPTIONS 预检请求放行
if ("OPTIONS".equalsIgnoreCase(method)) {
return true;
}
// 公开路由放行
if (isPublicRoute(path, method)) {
return true;
}
// 认证校验:调用 authProvider 触发 Sa-Token 的登录检查
try {
String userId = authProvider.getCurrentUserId();
AuthContext.set(userId);
return true;
} catch (NotLoginException e) {
log.debug("未登录访问受保护路径: path={}", path);
throw new BusinessException(ErrorCode.AUTH_NOT_LOGIN);
} catch (NotPermissionException | NotRoleException e) {
log.warn("权限不足: path={}, message={}", path, e.getMessage());
throw new BusinessException(ErrorCode.AUTH_FORBIDDEN);
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
// 请求结束后清理 ThreadLocal防止内存泄漏
AuthContext.clear();
}
/** 判断路由是否为公开路由(无需登录) */
private boolean isPublicRoute(String path, String method) {
for (PublicRoute route : PUBLIC_ROUTES) {
if (route.matches(path, method)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,54 @@
package com.webgame.webgamebackend.common.auth;
/**
* 认证提供者抽象接口
* <p>
* 隔离具体的认证框架实现(当前为 Sa-Token业务代码只依赖此接口。
* 如需切换认证框架,只需提供新的实现类即可,业务代码无需修改。
*/
public interface AuthenticationProvider {
/**
* 用户登录,使 userId 在当前会话中保持登录态
*
* @param userId 用户唯一标识
* @return 生成的 Access Token
*/
String login(Object userId);
/**
* 当前会话登出,使 Access Token 失效
*/
void logout();
/**
* 获取当前已登录的用户 ID
*
* @return 当前用户 ID
* @throws cn.dev33.satoken.exception.NotLoginException 未登录时由实现层抛出
*/
String getCurrentUserId();
/**
* 通过 Token 获取用户 ID不检查当前会话登录态
*
* @param token Access Token
* @return 用户 IDToken 无效时返回 null
*/
String getUserIdByToken(String token);
/**
* 获取当前会话的 Access Token
*
* @return Access Token 字符串
*/
String getCurrentToken();
/**
* 在当前请求上下文中设置 Token使后续 getCurrentUserId() 可用
*
* @param token Access Token
* @param timeout Token 有效期(秒),-1 表示保持原有有效期
*/
void setContextToken(String token, int timeout);
}

View File

@@ -0,0 +1,50 @@
package com.webgame.webgamebackend.common.auth;
import cn.dev33.satoken.stp.StpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* Sa-Token 认证提供者实现
* <p>
* 将 Sa-Token 的 StpUtil 静态方法封装为 AuthenticationProvider 接口,
* 是项目中唯一直接依赖 Sa-Token 的业务代码之外的文件。
*/
@Slf4j
@Component
public class SaTokenAuthenticationProvider implements AuthenticationProvider {
@Override
public String login(Object userId) {
StpUtil.login(userId);
String token = StpUtil.getTokenValue();
log.debug("用户登录成功: userId={}", userId);
return token;
}
@Override
public void logout() {
StpUtil.logout();
}
@Override
public String getCurrentUserId() {
return StpUtil.getLoginIdAsString();
}
@Override
public String getUserIdByToken(String token) {
Object loginId = StpUtil.getLoginIdByToken(token);
return loginId != null ? loginId.toString() : null;
}
@Override
public String getCurrentToken() {
return StpUtil.getTokenValue();
}
@Override
public void setContextToken(String token, int timeout) {
StpUtil.setTokenValue(token, timeout);
}
}

View File

@@ -1,58 +0,0 @@
package com.webgame.webgamebackend.common.config;
import cn.dev33.satoken.fun.strategy.SaCorsHandleFunction;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaHttpMethod;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* [Sa-Token 权限认证] 配置类
*/
@Slf4j
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handler -> {
SaRouter.match("/**")
.notMatch("/actuator/**")
.notMatch("/swagger-ui/**")
.notMatch("/swagger-ui.html")
.notMatch("/v3/api-docs/**")
.notMatch("/webjars/**")
.notMatch("/error")
.notMatch("/sse/**")
.check(r -> StpUtil.checkLogin());
})).addPathPatterns("/**")
.excludePathPatterns("/error", "/sse/**");
}
/**
* CORS 跨域处理策略
*/
@Bean
public SaCorsHandleFunction corsHandle() {
return (req, res, sto) -> {
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();
};
}
}

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

@@ -30,16 +30,6 @@ public record CreateRoomRequest(
/** 最大玩家数 */
@NotNull(message = "最大玩家数不能为空")
@Positive(message = "最大玩家数必须为正数")
Integer maxPlayers,
/** 是否允许观战,默认 true */
Boolean allowSpectate
Integer maxPlayers
) {
/**
* 获取观战开关值,未传时默认为 true
*/
public Boolean allowSpectate() {
return allowSpectate != null ? allowSpectate : true;
}
}

View File

@@ -0,0 +1,11 @@
package com.webgame.webgamebackend.common.dto.game;
import jakarta.validation.constraints.NotBlank;
/**
* 解散房间请求
*/
public record DisbandRoomRequest(
@NotBlank(message = "房间ID不能为空")
String roomId
) {}

View File

@@ -1,7 +1,7 @@
package com.webgame.webgamebackend.common.dto.game;
import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.entities.GameConfigEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameConfigEntity;
import java.util.List;

View File

@@ -20,7 +20,6 @@ public record RoomDetailResponse(
String status,
String creatorId,
String creatorName,
Boolean allowSpectate,
List<RoomPlayerDetail> players,
/** 对局中才有棋盘状态 */
int[][] boardState,

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.common.dto.game;
import com.webgame.webgamebackend.entities.GameRoomEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameRoomEntity;
import java.util.List;
import java.util.Map;
@@ -19,8 +19,7 @@ public record RoomItemResponse(
String statusText,
int stakes,
String mode,
String creatorName,
Boolean allowSpectate
String creatorName
) {
/** 状态 → 展示文本映射 */
private static final Map<String, String> STATUS_TEXT_MAP = Map.of(
@@ -54,8 +53,7 @@ public record RoomItemResponse(
STATUS_TEXT_MAP.getOrDefault(room.getStatus(), "未知"),
room.getStakes(),
room.getMode(),
creatorName,
room.getAllowSpectate()
creatorName
);
}
}

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.common.dto.user;
import com.webgame.webgamebackend.entities.UserEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.UserEntity;
import java.time.LocalDate;
@@ -22,7 +22,7 @@ public record UserInfoResponse(
*/
public static UserInfoResponse from(UserEntity user) {
return new UserInfoResponse(
user.getId(),
user.getAccount().getId(),
user.getNickName(),
user.getAvatar(),
user.getSignature(),

View File

@@ -0,0 +1,78 @@
package com.webgame.webgamebackend.common.event;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
/**
* 内部事件总线
*
* 用于 Room ↔ Game 之间的解耦通信。
* GameEngine 通过此总线发布游戏事件(如 GAME_OVER
* RoomManager 订阅这些事件并执行房间层处理(状态更新、结算等)。
*
* <p>房间层到游戏层的通信通过 {@link com.webgame.webgamebackend.service.engine.GameEngine}
* 接口直接调用,不需要经过事件总线。</p>
*/
@Slf4j
@Component
public class RoomEventBus {
/** 事件类型 → 订阅者列表 */
private final Map<String, List<Consumer<Object>>> subscribers = new ConcurrentHashMap<>();
/**
* 订阅事件
*
* @param eventType 事件类型
* @param handler 事件处理器
*/
public void subscribe(String eventType, Consumer<Object> handler) {
subscribers.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>()).add(handler);
}
/**
* 发布事件
*
* @param eventType 事件类型
* @param payload 事件数据
*/
public void publish(String eventType, Object payload) {
List<Consumer<Object>> handlers = subscribers.get(eventType);
if (handlers == null || handlers.isEmpty()) {
return;
}
for (Consumer<Object> handler : handlers) {
try {
handler.accept(payload);
} catch (Exception e) {
log.error("[事件总线] 处理事件异常, eventType={}", eventType, e);
}
}
}
// ==================== 预定义事件类型 ====================
/** 游戏结束事件payload 为 GameOverEvent */
public static final String GAME_OVER = "game:over";
/** 游戏中玩家弃权(断线超时/被踢payload 为 PlayerForfeitEvent */
public static final String PLAYER_FORFEIT = "game:player_forfeit";
// ==================== 事件数据类 ====================
/**
* 游戏结束事件数据
*/
public record GameOverEvent(String roomId, String winnerId, String resultType) {}
/**
* 玩家弃权事件数据
*/
public record PlayerForfeitEvent(String roomId, String userId, String reason) {}
}

View File

@@ -1,15 +1,17 @@
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,
@@ -18,33 +20,23 @@ public record SseEvent(
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

@@ -1,57 +1,86 @@
package com.webgame.webgamebackend.common.event;
import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
import com.webgame.webgamebackend.adapter.sse.SseConnectionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
/**
* SSE 事件发布器
* 统一 SSE 事件发布器
*
* 统一入口,将业务事件序列化后发送到 Redis Pub/Sub 频道,
* 由所有实例的 RedisSseListener 接收并推送到各自连接的客户端
* 优先通过 Redis Pub/Sub 扇出事件到所有应用实例;
* Redis 不可用时降级为本地直推,并抛出异常让调用方事务回滚
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SseEventPublisher {
/** Redis Pub/Sub 频道名 */
public static final String CHANNEL = "sse:events";
private final RedisTemplate<String, String> redisTemplate;
private final SseConnectionManager connectionManager;
/**
* 发布广播事件(推送给所有在线用户)
*
* @param type 事件类型
* @param data 事件数据对象(将被序列化为 JSON
* @throws BusinessException Redis 和本地降级均失败时抛出
*/
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 userId 目标用户账号 ID
* @param type 事件类型
* @param data 事件数据对象(将被序列化为 JSON
* @throws BusinessException Redis 和本地降级均失败时抛出
*/
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 频道
* 发布事件到 Redis Pub/Sub 频道
*
* 优先走 Redis 扇出到所有实例Redis 不可用时降级为直接推送给本地连接。
* 两次尝试均失败则抛出 BusinessException让调用方事务回滚。
*
* @param event 事件对象
* @throws BusinessException 发布完全失败时抛出
*/
private void publish(SseEvent event) {
// 优先:通过 Redis Pub/Sub 扇出
try {
String json = JSON.toJSONString(event);
log.debug("[SSE] 发布事件到 Redis, channel={}, eventType={}, targetId={}",
CHANNEL, event.type(), event.targetId());
redisTemplate.convertAndSend(CHANNEL, json);
log.debug("[SSE] 事件已发布, channel={}, type={}, targetId={}",
CHANNEL, event.type(), event.targetId());
return;
} catch (Exception redisEx) {
log.warn("[SSE] Redis 不可用,降级为本地直推, type={}, targetId={}, reason={}",
event.type(), event.targetId(), redisEx.getMessage());
}
// 降级:直接推送给本地连接(本实例用户不丢事件)
try {
if (event.targetId() == null) {
connectionManager.broadcast(event);
} else {
connectionManager.sendTo(event.targetId(), event);
}
log.info("[SSE] 已降级为本地直推, type={}, targetId={}", event.type(), event.targetId());
} catch (Exception localEx) {
log.error("[SSE] 本地直推也失败, type={}, targetId={}", event.type(), event.targetId(), localEx);
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "事件推送失败,请重试");
}
}
}

View File

@@ -2,27 +2,14 @@ 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
BALANCE_CHG,
/** 自定义事件类型,用于测试或任意消息推送 */
CUSTOM
}

View File

@@ -3,26 +3,31 @@ package com.webgame.webgamebackend.common.exception;
import lombok.Getter;
/**
* 业务异常Service 层抛出,由全局异常处理器转换为 ApiResponse
* 业务异常Service 层抛出,由全局异常处理器转换为 ResponseEntity&lt;ApiResponse&gt;
* <p>
* 同时携带业务错误码和对应的 HTTP 状态码,两者均从 ErrorCode 枚举自动派生。
*/
@Getter
public class BusinessException extends RuntimeException {
private final int code;
private final int httpStatus;
/**
* 使用 ErrorCode 枚举创建异常,消息使用枚举默认值
* 使用 ErrorCode 枚举创建异常,消息和 HTTP 状态码从枚举默认值派生
*/
public BusinessException(ErrorCode errorCode) {
super(errorCode.getDefaultMessage());
this.code = errorCode.getCode();
this.httpStatus = errorCode.getHttpStatus();
}
/**
* 使用 ErrorCode 枚举 + 自定义消息创建异常
* 使用 ErrorCode 枚举 + 自定义消息创建异常HTTP 状态码从枚举派生
*/
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.code = errorCode.getCode();
this.httpStatus = errorCode.getHttpStatus();
}
}

View File

@@ -3,47 +3,56 @@ package com.webgame.webgamebackend.common.exception;
import lombok.Getter;
/**
* 业务错误码枚举,统一管理所有错误码默认消息
* 业务错误码枚举,统一管理所有错误码默认消息和对应的 HTTP 状态码
* <p>
* HTTP 状态码表达错误大类协议层code 表达具体业务错误(应用层),
* 两者分工协作形成双层错误表达模式。
*/
@Getter
public enum ErrorCode {
// ========== 通用 ==========
SUCCESS(0, "success"),
BAD_REQUEST(400, "参数校验失败"),
INTERNAL_ERROR(5000, "服务器内部错误"),
SUCCESS(0, "成功", 200),
BAD_REQUEST(400, "参数校验失败", 400),
INTERNAL_ERROR(5000, "服务器内部错误", 500),
// ========== 账号模块 ==========
USERNAME_EXISTS(1001, "用户名已存在"),
BAD_CREDENTIALS(1002, "用户名或密码错误"),
REFRESH_TOKEN_INVALID(1003, "刷新令牌无效或已过期"),
REFRESH_TOKEN_MISSING(1004, "缺少刷新令牌"),
USERNAME_EXISTS(1001, "用户名已存在", 409),
BAD_CREDENTIALS(1002, "用户名或密码错误", 401),
REFRESH_TOKEN_INVALID(1003, "刷新令牌无效或已过期", 401),
REFRESH_TOKEN_MISSING(1004, "缺少刷新令牌", 400),
// ========== 用户模块 ==========
USER_NOT_FOUND(2001, "用户不存在"),
AVATAR_UPLOAD_FAILED(2002, "头像上传失败"),
AVATAR_INVALID_TYPE(2003, "头像格式不支持,仅支持 JPG、PNG、WebP、GIF"),
AVATAR_TOO_LARGE(2004, "头像文件大小不能超过 2MB"),
USER_NOT_FOUND(2001, "用户不存在", 404),
AVATAR_UPLOAD_FAILED(2002, "头像上传失败", 500),
AVATAR_INVALID_TYPE(2003, "头像格式不支持,仅支持 JPG、PNG、WebP、GIF", 400),
AVATAR_TOO_LARGE(2004, "头像文件大小不能超过 2MB", 400),
// ========== 认证模块 ==========
AUTH_NOT_LOGIN(4001, "请先登录", 401),
AUTH_FORBIDDEN(4002, "权限不足", 403),
// ========== 游戏模块 ==========
GAME_NOT_FOUND(3001, "游戏不存在或已下架"),
ROOM_NOT_FOUND(3002, "房间不存在"),
ROOM_FULL(3003, "房间已满"),
ROOM_PASSWORD_ERROR(3004, "房间密码错误"),
ROOM_ALREADY_STARTED(3005, "游戏已开始,无法加入"),
NOT_ROOM_OWNER(3006, "非房主无权操作"),
PLAYER_NOT_IN_ROOM(3007, "玩家不在该房间"),
NOT_ALL_READY(3008, "有玩家尚未准备"),
PLAYER_COUNT_INSUFFICIENT(3009, "人数不足,无法开始"),
BALANCE_INSUFFICIENT(3010, "余额不足"),
ALREADY_IN_ROOM(3011, "已在房间中"),
GAME_NOT_FOUND(3001, "游戏不存在或已下架", 404),
ROOM_NOT_FOUND(3002, "房间不存在", 404),
ROOM_FULL(3003, "房间已满", 409),
ROOM_PASSWORD_ERROR(3004, "房间密码错误", 401),
ROOM_ALREADY_STARTED(3005, "游戏已开始,无法加入", 409),
NOT_ROOM_OWNER(3006, "非房主无权操作", 403),
PLAYER_NOT_IN_ROOM(3007, "玩家不在该房间", 404),
NOT_ALL_READY(3008, "有玩家尚未准备", 409),
PLAYER_COUNT_INSUFFICIENT(3009, "人数不足,无法开始", 409),
BALANCE_INSUFFICIENT(3010, "余额不足", 409),
ALREADY_IN_ROOM(3011, "已在房间中", 409),
;
private final int code;
private final String defaultMessage;
private final int httpStatus;
ErrorCode(int code, String defaultMessage) {
ErrorCode(int code, String defaultMessage, int httpStatus) {
this.code = code;
this.defaultMessage = defaultMessage;
this.httpStatus = httpStatus;
}
}

View File

@@ -1,165 +1,170 @@
package com.webgame.webgamebackend.common.handler;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import cn.dev33.satoken.exception.SaTokenContextException;
import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
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.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.io.IOException;
/**
* 全局异常处理,统一返回 ApiResponse JSON 格式。
*
* SSE 端点的 401 由拦截器统一处理后返回 JSON前端 fetch-event-source 通过
* err.response.status 读取,不再需要服务端以 SSE 事件格式写错误
* 全局异常处理器,双层错误表达:
* HTTP 状态码表达错误大类 + ApiResponse.code 表达具体业务错误码
* <p>
* 业务异常(仅来自 REST JSON 请求)返回 ResponseEntity经过 Spring 内容协商
* 框架级异常(可能来自 SSE/浏览器等任意请求类型)直接写 HttpServletResponse 绕过内容协商。
* <p>
* 认证相关异常NotLoginException 等)已由 {@code AuthInterceptor} 转为
* {@link BusinessException},此处不再直接依赖 Sa-Token。
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// ==================== SSE 异步超时 ====================
// ==================== 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());
}
/**
* 异步请求响应已不可用SSE 连接断开后心跳试图写入)
*
* 返回 void 避免 Spring 尝试以 JSON 写入已损坏的 text/event-stream 响应。
*/
@ExceptionHandler(AsyncRequestNotUsableException.class)
public void handleAsyncRequestNotUsable(AsyncRequestNotUsableException ex) {
// SSE 连接已断开后心跳的残留异常,无需处理
// SSE 响应已被客户端关闭,不写响应体
}
// ==================== 业务异常 ====================
/**
* SSE 客户端断开连接时Spring 在 Tomcat 容器线程上抛出的 IOException
* <p>
* 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 请求) ====================
/**
* 处理业务异常
* 业务异常 — HTTP 状态码从 ErrorCode 枚举自动派生
*/
@ExceptionHandler(BusinessException.class)
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage());
return ApiResponse.fail(ex.getCode(), ex.getMessage());
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
log.warn("业务异常: code={}, httpStatus={}, message={}", ex.getCode(), ex.getHttpStatus(), ex.getMessage());
return ResponseEntity
.status(ex.getHttpStatus())
.body(ApiResponse.fail(ex.getCode(), ex.getMessage()));
}
// ==================== 参数校验异常 ====================
// ==================== 参数校验异常 → HTTP 400 ====================
/**
* 处理 @Valid 参数校验失败,合并所有字段的错误消息
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleValidation(MethodArgumentNotValidException ex) {
public ResponseEntity<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);
return ResponseEntity
.badRequest()
.body(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(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) {
public ResponseEntity<ApiResponse<Void>> handleMessageNotReadable(HttpMessageNotReadableException ex) {
log.warn("请求体解析失败: {}", ex.getMessage());
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式错误");
return ResponseEntity
.badRequest()
.body(ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式无效"));
}
/**
* 路径参数类型不匹配
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ApiResponse<Void> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
log.warn("参数类型不匹配: {}", ex.getMessage());
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型错误");
return ResponseEntity
.badRequest()
.body(ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型无效"));
}
/**
* 资源不存在404
*/
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSize(MaxUploadSizeExceededException ex) {
log.warn("文件上传超过大小限制: {}", ex.getMessage());
return ResponseEntity
.badRequest()
.body(ApiResponse.fail(ErrorCode.AVATAR_TOO_LARGE.getCode(), "文件大小不能超过 2MB"));
}
// ==================== 框架级异常(直接写响应,绕过内容协商) ====================
@ExceptionHandler(NoResourceFoundException.class)
public ApiResponse<Void> handleNoResourceFound(NoResourceFoundException ex) {
log.warn("资源不存在: {}", ex.getMessage());
return ApiResponse.fail(404, "资源不存在");
public void handleNoResourceFound(NoResourceFoundException ex, HttpServletResponse response) throws IOException {
log.warn("资源未找到: {}", ex.getMessage());
writeJsonError(response, HttpStatus.NOT_FOUND, 404, "资源未找到");
}
// ==================== 兜底 ====================
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public void handleMethodNotSupported(HttpRequestMethodNotSupportedException ex,
HttpServletResponse response) throws IOException {
log.warn("请求方法不支持: {}", ex.getMessage());
writeJsonError(response, HttpStatus.METHOD_NOT_ALLOWED, 405, "请求方法不支持");
}
// ==================== 兜底 → HTTP 500直接写响应绕过内容协商 ====================
@ExceptionHandler(Exception.class)
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()
);
}
// ==================== 工具方法 ====================
/**
* 处理其他未捕获异常
* 直接向 HttpServletResponse 写入 JSON 格式的错误响应
* <p>
* 不依赖 Spring 的 MessageConverter 和内容协商机制,
* 确保 SSE/浏览器等非 JSON Accept 头的请求也能收到可读的错误信息。
*/
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception ex) {
log.error("服务器内部错误", ex);
return ApiResponse.fail(ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getDefaultMessage());
private static void writeJsonError(HttpServletResponse response, HttpStatus status,
int code, String message) throws IOException {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(JSON.toJSONString(ApiResponse.fail(code, message)));
}
}

View File

@@ -1,139 +0,0 @@
package com.webgame.webgamebackend.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.dev33.satoken.stp.StpUtil;
import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.dto.game.*;
import com.webgame.webgamebackend.service.GameService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* 游戏控制器
*
* 管理游戏列表查询、房间的创建/加入/离开/踢人/准备/开始等操作。
*/
@Validated
@RestController
@RequestMapping("/game")
@RequiredArgsConstructor
public class GameController {
private final GameService gameService;
/**
* 获取游戏列表
*
* 无需登录即可调用。
*/
@SaIgnore
@GetMapping("/list")
public ApiResponse<GameListResponse> list() {
GameListResponse result = gameService.getGameList();
return ApiResponse.ok(result);
}
/**
* 获取房间列表
*
* 支持按状态筛选。无需登录即可调用。
*
* @param gameId 游戏 ID必填
* @param status 状态筛选(可选)
*/
@SaIgnore
@GetMapping("/room/list")
public ApiResponse<RoomListResponse> roomList(
@RequestParam String gameId,
@RequestParam(required = false) String status) {
RoomListResponse result = gameService.getRoomList(gameId, status);
return ApiResponse.ok(result);
}
/**
* 创建房间
*
* 需登录。房主自动加入房间并设为已准备。
*/
@PostMapping("/room/create")
public ApiResponse<CreateRoomResponse> createRoom(@Valid @RequestBody CreateRoomRequest request) {
String userId = StpUtil.getLoginIdAsString();
CreateRoomResponse result = gameService.createRoom(request, userId);
return ApiResponse.ok(result);
}
/**
* 加入房间
*
* 需登录。公开房间无需传密码。
*/
@PostMapping("/room/join")
public ApiResponse<RoomItemResponse> joinRoom(@Valid @RequestBody JoinRoomRequest request) {
String userId = StpUtil.getLoginIdAsString();
RoomItemResponse result = gameService.joinRoom(request, userId);
return ApiResponse.ok(result);
}
/**
* 离开房间
*
* 需登录。房主离开时顺位转让。无人时房间自动结束。
*/
@PostMapping("/room/leave")
public ApiResponse<Void> leaveRoom(@Valid @RequestBody LeaveRoomRequest request) {
String userId = StpUtil.getLoginIdAsString();
gameService.leaveRoom(request.roomId(), userId);
return ApiResponse.ok(null);
}
/**
* 踢出玩家
*
* 需登录。仅房主可操作。
*/
@PostMapping("/room/kick")
public ApiResponse<Void> kickPlayer(@Valid @RequestBody KickPlayerRequest request) {
String ownerId = StpUtil.getLoginIdAsString();
gameService.kickPlayer(request.roomId(), ownerId, request.userId());
return ApiResponse.ok(null);
}
/**
* 切换准备状态
*
* 需登录。在等待中的房间里切换准备/取消准备。
*/
@PostMapping("/room/ready")
public ApiResponse<Boolean> toggleReady(@Valid @RequestBody ReadyRequest request) {
String userId = StpUtil.getLoginIdAsString();
boolean ready = gameService.toggleReady(request.roomId(), userId);
return ApiResponse.ok(ready);
}
/**
* 获取房间详情
*
* 无需登录即可查看(观战者也可查看)。
*/
@SaIgnore
@GetMapping("/room/{roomId}")
public ApiResponse<RoomDetailResponse> roomDetail(@PathVariable String roomId) {
RoomDetailResponse result = gameService.getRoomDetail(roomId);
return ApiResponse.ok(result);
}
/**
* 开始游戏
*
* 需登录。仅房主可操作。需全部玩家准备且人数 >= 2。
* 开始后扣除所有玩家的底注。
*/
@PostMapping("/room/start")
public ApiResponse<Void> startGame(@Valid @RequestBody StartGameRequest request) {
String ownerId = StpUtil.getLoginIdAsString();
gameService.startGame(request.roomId(), ownerId);
return ApiResponse.ok(null);
}
}

View File

@@ -1,18 +1,18 @@
package com.webgame.webgamebackend.service.impl;
package com.webgame.webgamebackend.domain.account;
import cn.dev33.satoken.stp.StpUtil;
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import com.webgame.webgamebackend.common.constants.UserConstants;
import com.webgame.webgamebackend.common.dto.account.LoginRequest;
import com.webgame.webgamebackend.common.dto.account.LoginResponse;
import com.webgame.webgamebackend.common.dto.account.RegisterRequest;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
import com.webgame.webgamebackend.entities.AccountEntity;
import com.webgame.webgamebackend.entities.UserEntity;
import com.webgame.webgamebackend.repository.AccountRepository;
import com.webgame.webgamebackend.repository.UserRepository;
import com.webgame.webgamebackend.service.AccountService;
import com.webgame.webgamebackend.service.RefreshTokenService;
import com.webgame.webgamebackend.infrastructure.persistence.entity.AccountEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.UserEntity;
import com.webgame.webgamebackend.infrastructure.persistence.repository.AccountRepository;
import com.webgame.webgamebackend.infrastructure.persistence.repository.UserRepository;
import com.webgame.webgamebackend.domain.account.AccountService;
import com.webgame.webgamebackend.domain.token.RefreshTokenService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -27,30 +27,28 @@ import java.util.concurrent.ThreadLocalRandom;
@Slf4j
@Service
@RequiredArgsConstructor
public class AccountServiceImpl implements AccountService {
public class AccountService {
private final AccountRepository accountRepository;
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService;
private final AuthenticationProvider authProvider;
@Override
public LoginResponse login(LoginRequest request) {
AccountEntity account = accountRepository.findByUsername(request.username())
.orElseThrow(() -> new BusinessException(ErrorCode.BAD_CREDENTIALS));
if (!passwordEncoder.matches(request.password(), account.getPassword())) {
throw new BusinessException(ErrorCode.BAD_CREDENTIALS);
}
// Sa-Token 登录生成 access token
StpUtil.login(account.getId());
String accessToken = StpUtil.getTokenValue();
// 登录生成 access token
String accessToken = authProvider.login(account.getId());
// 生成 refresh token
String refreshToken = refreshTokenService.create(account.getId());
log.info("用户登录成功: username={}", request.username());
return new LoginResponse(accessToken, refreshToken);
}
@Override
@Transactional
public LoginResponse register(RegisterRequest request) {
// 检查用户名是否已存在
@@ -75,29 +73,25 @@ public class AccountServiceImpl implements AccountService {
log.info("用户注册成功: username={}", request.username());
// 注册后自动登录生成 access token refresh token
StpUtil.login(account.getId());
String accessToken = StpUtil.getTokenValue();
String accessToken = authProvider.login(account.getId());
String refreshToken = refreshTokenService.create(account.getId());
return new LoginResponse(accessToken, refreshToken);
}
@Override
public LoginResponse refresh(String refreshToken) {
// 校验 refresh token有效则用它签发新的 access token
String userId = refreshTokenService.validate(refreshToken);
StpUtil.login(userId);
String accessToken = StpUtil.getTokenValue();
String accessToken = authProvider.login(userId);
log.info("令牌刷新成功: userId={}", userId);
// refresh token 保持不变只返回新的 access token
return new LoginResponse(accessToken, refreshToken);
}
@Override
public void logout(String refreshToken) {
// 获取当前登录用户ID用于日志
String loginId = (String) StpUtil.getLoginId();
// 登出 Sa-Token使 access token 失效
StpUtil.logout();
String loginId = authProvider.getCurrentUserId();
// 登出使 access token 失效
authProvider.logout();
// 撤销 refresh token使其在 Redis 中删除
refreshTokenService.revoke(refreshToken);
log.info("用户已登出: userId={}", loginId);

View File

@@ -0,0 +1,79 @@
package com.webgame.webgamebackend.domain.engine;
import com.fasterxml.jackson.databind.JsonNode;
/**
* 游戏引擎接口
*
* 每种游戏类型提供自己的实现,通过 {@link GameEngineRegistry} 注册。
* 引擎只关心游戏规则,不关心房间管理(创建/加入/离开/踢人等)。
* 房间层通过此接口与游戏层通信。
*
* <h3>生命周期</h3>
* <ol>
* <li>游戏开始时由 RoomManager 创建引擎实例</li>
* <li>调用 {@link #init(RoomContext)} 初始化游戏</li>
* <li>通过 {@link #handleAction} 处理玩家操作</li>
* <li>游戏结束或被解散时调用 {@link #destroy()}</li>
* </ol>
*/
public interface GameEngine {
/**
* 初始化游戏
*
* @param context 房间上下文(玩家列表、配置、广播回调、结束回调)
*/
void init(RoomContext context);
/**
* 处理玩家游戏动作
*
* @param action 动作类型(如 "place_stone", "resign", "draw_request"
* @param data 动作数据
* @param userId 操作者 accountId
*/
void handleAction(String action, JsonNode data, String userId);
/**
* 房间通知:玩家 WebSocket 断线
*
* 游戏可以选择如何处理(如五子棋:启动断线倒计时,超时后自动认输)
*
* @param userId 断线玩家 accountId
*/
void onPlayerDisconnect(String userId);
/**
* 房间通知:玩家重连成功
*
* 游戏应取消断线倒计时,并通过 RoomContext 的 broadcaster 推送
* 当前游戏状态给重连玩家
*
* @param userId 重连玩家 accountId
*/
void onPlayerReconnect(String userId);
/**
* 房间通知:玩家主动离开房间
*
* 游戏中离开视为认输
*
* @param userId 离开玩家 accountId
*/
void onPlayerLeave(String userId);
/**
* 销毁引擎,清理资源(取消定时器、释放引用等)
*/
void destroy();
/**
* 获取游戏完整状态快照
*
* 用于重连时向前端推送当前游戏状态,前端据此恢复 UI。
*
* @return 游戏状态(棋盘、回合、走棋历史等)
*/
GameState getState();
}

View File

@@ -0,0 +1,19 @@
package com.webgame.webgamebackend.domain.engine;
/**
* 游戏引擎工厂接口
*
* 每次游戏开始(房间从 WAITING → PLAYING时调用 {@link #create} 创建新引擎实例。
* 每个房间拥有独立的引擎实例,房间之间不共享。
*/
@FunctionalInterface
public interface GameEngineFactory {
/**
* 创建游戏引擎实例
*
* @param context 房间上下文
* @return 新创建的引擎实例
*/
GameEngine create(RoomContext context);
}

View File

@@ -0,0 +1,66 @@
package com.webgame.webgamebackend.domain.engine;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 游戏引擎注册表
*
* 管理 gameId → GameEngineFactory 的映射。
* Spring Boot 启动时,各游戏模块通过 {@link #register} 注册自己的引擎工厂。
*/
@Slf4j
@Component
public class GameEngineRegistry {
/** gameId → 引擎工厂 */
private final Map<String, GameEngineFactory> factories = new ConcurrentHashMap<>();
/**
* 注册游戏引擎工厂
*
* @param gameId 游戏标识(与 game_config 表中的 game_id 对应)
* @param factory 引擎工厂
*/
public void register(String gameId, GameEngineFactory factory) {
if (gameId == null || gameId.isBlank()) {
throw new IllegalArgumentException("gameId 不能为空");
}
if (factory == null) {
throw new IllegalArgumentException("factory 不能为 null");
}
factories.put(gameId, factory);
log.info("[引擎注册] 注册游戏引擎, gameId={}", gameId);
}
/**
* 创建游戏引擎实例
*
* @param gameId 游戏标识
* @param context 房间上下文
* @return 游戏引擎实例
* @throws IllegalArgumentException 如果 gameId 未注册
*/
public GameEngine create(String gameId, RoomContext context) {
GameEngineFactory factory = factories.get(gameId);
if (factory == null) {
throw new IllegalArgumentException("未注册的游戏类型: " + gameId);
}
GameEngine engine = factory.create(context);
log.info("[引擎注册] 创建引擎实例, gameId={}, roomId={}", gameId, context.getRoomId());
return engine;
}
/**
* 检查游戏类型是否已注册
*
* @param gameId 游戏标识
* @return 是否已注册
*/
public boolean isRegistered(String gameId) {
return factories.containsKey(gameId);
}
}

View File

@@ -0,0 +1,32 @@
package com.webgame.webgamebackend.domain.engine;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
/**
* 游戏状态快照
*
* 由 GameEngine.getState() 返回,用于重连时同步完整游戏状态给前端。
* 具体游戏引擎可以扩展此类,添加游戏特定的状态字段。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GameState {
/** 游戏是否已结束 */
private boolean finished;
/** 胜者 IDnull 表示平局或未结束) */
private String winnerId;
/** 结果类型win / draw / resign */
private String resultType;
/** 当前轮到谁accountId */
private String currentTurn;
/** 游戏特定数据JSON 字符串),由各引擎自行填充 */
private String gameData;
}

View File

@@ -0,0 +1,396 @@
package com.webgame.webgamebackend.domain.engine;
import com.alibaba.fastjson2.JSON;
import com.fasterxml.jackson.databind.JsonNode;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameMoveEntity;
import com.webgame.webgamebackend.infrastructure.persistence.repository.GameMoveRepository;
import com.webgame.webgamebackend.adapter.ws.dto.WsMessage;
import com.webgame.webgamebackend.adapter.ws.dto.WsOutboundMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
/**
* 五子棋游戏引擎
*
* 实现标准的 15×15 五子棋规则:
* <ul>
* <li>黑方先手seat=1</li>
* <li>交替落子</li>
* <li>五连(横/竖/对角)判胜</li>
* <li>支持认输、求和</li>
* <li>断线 60 秒自动判负</li>
* </ul>
*
* <p>不关心房间管理(加入/离开/踢人/房主转移),只关心棋盘上的规则。</p>
*/
@Slf4j
@RequiredArgsConstructor
public class GomokuEngine implements GameEngine {
/** 棋盘大小 */
private static final int BOARD_SIZE = 15;
/** 断线自动认输超时(秒) */
private static final long DISCONNECT_FORFEIT_SECONDS = 60;
/** 断线定时器线程池(每个引擎实例独立,避免影响其他房间) */
private final ScheduledExecutorService disconnectScheduler =
Executors.newSingleThreadScheduledExecutor(r ->
new Thread(r, "gomoku-disconnect-" + System.identityHashCode(this)));
private final GameMoveRepository moveRepository;
// ==================== 运行时状态 ====================
private RoomContext context;
private String currentTurnUserId;
private final List<String> playerIds = new ArrayList<>(); // 按座位号排序
private boolean finished;
/** 断线倒计时: userId → Future */
private final ConcurrentHashMap<String, ScheduledFuture<?>> disconnectTimers = new ConcurrentHashMap<>();
@Override
public void init(RoomContext context) {
this.context = context;
// 按座位号排序的玩家列表seat=1 为黑方先手
List<RoomContext.PlayerInfo> players = context.getPlayers();
if (players.size() < 2) {
throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT);
}
this.playerIds.clear();
for (RoomContext.PlayerInfo p : players) {
this.playerIds.add(p.userId());
}
this.currentTurnUserId = playerIds.get(0); // 黑方先手
this.finished = false;
log.info("[五子棋引擎] 初始化, roomId={}, 玩家={}", context.getRoomId(), playerIds);
// 广播空棋盘 + 先手
context.broadcast(new WsMessage(WsOutboundMessage.BOARD_SYNC, Map.of(
"board", new int[BOARD_SIZE][BOARD_SIZE],
"currentTurn", currentTurnUserId
)));
}
@Override
public void handleAction(String action, JsonNode data, String userId) {
if (finished) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "对局已结束");
}
switch (action) {
case "place_stone" -> {
int row = data.get("row").asInt();
int col = data.get("col").asInt();
placeStone(userId, row, col);
}
case "resign" -> resign(userId);
case "draw_request" -> requestDraw(userId);
case "draw_response" -> {
boolean accept = data.get("accept").asBoolean();
respondToDraw(userId, accept);
}
default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "未知游戏动作: " + action);
}
}
@Override
public void onPlayerDisconnect(String userId) {
// 启动断线倒计时
if (disconnectTimers.containsKey(userId)) {
return; // 已有定时器
}
log.info("[五子棋引擎] 玩家断线,{}秒后自动判负, roomId={}, userId={}",
DISCONNECT_FORFEIT_SECONDS, context.getRoomId(), userId);
ScheduledFuture<?> task = disconnectScheduler.schedule(() -> {
disconnectTimers.remove(userId);
if (!finished) {
log.info("[五子棋引擎] 断线超时,自动判负, roomId={}, userId={}",
context.getRoomId(), userId);
forfeit(userId);
}
}, DISCONNECT_FORFEIT_SECONDS, TimeUnit.SECONDS);
disconnectTimers.put(userId, task);
}
@Override
public void onPlayerReconnect(String userId) {
// 取消断线倒计时
ScheduledFuture<?> task = disconnectTimers.remove(userId);
if (task != null) {
task.cancel(false);
log.info("[五子棋引擎] 玩家重连,取消断线倒计时, roomId={}, userId={}",
context.getRoomId(), userId);
}
// 推送当前游戏状态给重连玩家
GameState state = getState();
String gameDataJson = state.getGameData();
Map<String, Object> boardSyncData;
if (gameDataJson != null) {
boardSyncData = JSON.parseObject(gameDataJson, Map.class);
} else {
boardSyncData = Map.of("board", new int[BOARD_SIZE][BOARD_SIZE]);
}
boardSyncData.put("currentTurn", currentTurnUserId);
context.sendToUser(userId, new WsMessage(WsOutboundMessage.BOARD_SYNC, boardSyncData));
}
@Override
public void onPlayerLeave(String userId) {
// 取消断线倒计时
ScheduledFuture<?> task = disconnectTimers.remove(userId);
if (task != null) {
task.cancel(false);
}
if (!finished) {
// 玩家离开 = 认输
log.info("[五子棋引擎] 玩家离开,自动认输, roomId={}, userId={}",
context.getRoomId(), userId);
forfeit(userId);
}
}
@Override
public void destroy() {
finished = true;
// 取消所有断线计时器
for (ScheduledFuture<?> timer : disconnectTimers.values()) {
timer.cancel(false);
}
disconnectTimers.clear();
disconnectScheduler.shutdownNow();
log.info("[五子棋引擎] 销毁, roomId={}", context != null ? context.getRoomId() : "unknown");
}
@Override
public GameState getState() {
// 重建棋盘
int[][] board = new int[BOARD_SIZE][BOARD_SIZE];
List<GameMoveEntity> moves = moveRepository
.findByRoomIdOrderByMoveIndexAsc(context.getRoomId());
for (GameMoveEntity move : moves) {
int playerValue = (move.getMoveIndex() % 2 == 1) ? 1 : 2;
board[move.getRowNum()][move.getColNum()] = playerValue;
}
// 序列化走棋历史
List<Map<String, Object>> moveHistory = moves.stream()
.map(m -> Map.<String, Object>of(
"moveIndex", m.getMoveIndex(),
"playerId", m.getPlayerId(),
"row", m.getRowNum(),
"col", m.getColNum()
)).toList();
Map<String, Object> gameData = Map.of(
"board", board,
"moves", moveHistory,
"boardSize", BOARD_SIZE
);
GameState state = new GameState();
state.setFinished(finished);
state.setCurrentTurn(currentTurnUserId);
state.setGameData(JSON.toJSONString(gameData));
return state;
}
// ==================== 游戏逻辑 ====================
/**
* 落子
*/
private void placeStone(String userId, int row, int col) {
// 校验坐标
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "坐标超出棋盘范围");
}
// 校验是否轮到自己
if (!userId.equals(currentTurnUserId)) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "还未轮到你落子");
}
// 校验位置是否已有棋子
List<GameMoveEntity> moves = moveRepository
.findByRoomIdOrderByMoveIndexAsc(context.getRoomId());
for (GameMoveEntity m : moves) {
if (m.getRowNum() == row && m.getColNum() == col) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "该位置已有棋子");
}
}
int moveIndex = moves.size() + 1;
// 保存走棋记录
GameMoveEntity move = new GameMoveEntity()
.setRoomId(context.getRoomId())
.setPlayerId(userId)
.setRowNum(row)
.setColNum(col)
.setMoveIndex(moveIndex);
moveRepository.save(move);
log.info("[五子棋引擎] 落子, roomId={}, userId={}, row={}, col={}, moveIndex={}",
context.getRoomId(), userId, row, col, moveIndex);
// 广播落子
context.broadcast(new WsMessage(WsOutboundMessage.MOVE, Map.of(
"row", row,
"col", col,
"playerId", userId,
"moveIndex", moveIndex
)));
// 检查胜负
int[][] board = rebuildBoard();
int playerIndex = playerIds.indexOf(userId);
int stone = (playerIndex == 0) ? 1 : 2;
if (checkWin(board, row, col, stone)) {
endGame(userId, "win");
return;
}
if (moveIndex >= BOARD_SIZE * BOARD_SIZE) {
endGame(null, "draw");
return;
}
// 切换回合
int nextPlayerIndex = (moveIndex) % 2;
currentTurnUserId = playerIds.get(nextPlayerIndex);
context.broadcast(new WsMessage(WsOutboundMessage.TURN_CHANGE, Map.of(
"playerId", currentTurnUserId
)));
}
/**
* 认输
*/
private void resign(String userId) {
if (!playerIds.contains(userId)) {
throw new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM);
}
// 对手获胜
String winnerId = playerIds.stream()
.filter(id -> !id.equals(userId))
.findFirst()
.orElse(null);
endGame(winnerId, "resign");
}
/**
* 断线超时/主动离开导致弃权
*/
private void forfeit(String userId) {
if (!playerIds.contains(userId)) {
return;
}
String winnerId = playerIds.stream()
.filter(id -> !id.equals(userId))
.findFirst()
.orElse(null);
endGame(winnerId, "resign");
}
/**
* 求和请求
*/
private void requestDraw(String userId) {
context.broadcast(new WsMessage("draw_requested", Map.of(
"fromUserId", userId
)));
}
/**
* 回应求和
*/
private void respondToDraw(String userId, boolean accept) {
if (accept) {
endGame(null, "draw");
} else {
context.broadcast(new WsMessage("draw_rejected", Map.of(
"fromUserId", userId
)));
}
}
/**
* 结束对局
*
* @param winnerId 胜者 IDnull 表示平局)
* @param resultType 结果类型
*/
private void endGame(String winnerId, String resultType) {
if (finished) return;
finished = true;
log.info("[五子棋引擎] 对局结束, roomId={}, winnerId={}, resultType={}",
context.getRoomId(), winnerId, resultType);
// 取消所有断线定时器
for (ScheduledFuture<?> timer : disconnectTimers.values()) {
timer.cancel(false);
}
disconnectTimers.clear();
// 通知房间层:游戏已结束
context.gameOver(winnerId, resultType);
}
/**
* 重建棋盘
*/
private int[][] rebuildBoard() {
int[][] board = new int[BOARD_SIZE][BOARD_SIZE];
List<GameMoveEntity> moves = moveRepository
.findByRoomIdOrderByMoveIndexAsc(context.getRoomId());
for (GameMoveEntity move : moves) {
int playerValue = (move.getMoveIndex() % 2 == 1) ? 1 : 2;
board[move.getRowNum()][move.getColNum()] = playerValue;
}
return board;
}
/**
* 检查五连胜利
*/
private boolean checkWin(int[][] board, int row, int col, int stone) {
int[][] dirs = {{0, 1}, {1, 0}, {1, 1}, {1, -1}};
for (int[] dir : dirs) {
int count = 1;
// 正方向
for (int i = 1; i < 5; i++) {
int r = row + dir[0] * i, c = col + dir[1] * i;
if (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == stone) {
count++;
} else break;
}
// 反方向
for (int i = 1; i < 5; i++) {
int r = row - dir[0] * i, c = col - dir[1] * i;
if (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == stone) {
count++;
} else break;
}
if (count >= 5) return true;
}
return false;
}
}

View File

@@ -0,0 +1,113 @@
package com.webgame.webgamebackend.domain.engine;
import com.webgame.webgamebackend.adapter.ws.dto.WsMessage;
import lombok.Getter;
import java.util.List;
import java.util.function.Consumer;
/**
* 房间上下文
*
* 游戏引擎初始化时由 RoomManager 传入,包含引擎所需的房间信息和回调接口。
* 引擎不应持有对 RoomManager 的直接引用,所有与房间层的交互通过此上下文完成。
*/
@Getter
public class RoomContext {
/** 房间 ID */
private final String roomId;
/** 游戏 ID */
private final String gameId;
/** 参与对局的玩家列表(按座位号排序,不可变) */
private final List<PlayerInfo> players;
/** 玩法模式 */
private final String mode;
/** 底注金额 */
private final int stakes;
/** 广播消息到房间内所有 WebSocket 会话(不暴露到 getter */
private final Consumer<WsMessage> broadcaster;
/** 私有消息发送器(不暴露到 getter */
private final PrivateMessageSender privateSender;
/** 游戏结束回调(不暴露到 getter */
private final GameOverCallback onGameOver;
public RoomContext(String roomId, String gameId, List<PlayerInfo> players,
String mode, int stakes,
Consumer<WsMessage> broadcaster,
PrivateMessageSender privateSender,
GameOverCallback onGameOver) {
this.roomId = roomId;
this.gameId = gameId;
this.players = List.copyOf(players); // 防御性拷贝
this.mode = mode;
this.stakes = stakes;
this.broadcaster = broadcaster;
this.privateSender = privateSender;
this.onGameOver = onGameOver;
}
/**
* 广播消息到房间内所有人
*/
public void broadcast(WsMessage message) {
broadcaster.accept(message);
}
/**
* 向指定用户发送私有消息(如重连时的状态同步)
*/
public void sendToUser(String userId, WsMessage message) {
privateSender.send(userId, message);
}
/**
* 游戏结束,通知房间层
*
* @param winnerId 胜者 accountIdnull 表示平局
* @param resultType 结果类型win / draw / resign
*/
public void gameOver(String winnerId, String resultType) {
onGameOver.onGameOver(winnerId, resultType);
}
/**
* 房间内玩家简要信息
*/
public record PlayerInfo(String userId, int seatNumber) {}
/**
* 游戏结束回调接口
*/
@FunctionalInterface
public interface GameOverCallback {
/**
* 游戏结束通知
*
* @param winnerId 胜者 accountIdnull 表示平局
* @param resultType 结果类型
*/
void onGameOver(String winnerId, String resultType);
}
/**
* 私有消息发送接口
*/
@FunctionalInterface
public interface PrivateMessageSender {
/**
* 向指定用户的所有 WebSocket 会话发送消息
*
* @param userId 目标用户 accountId
* @param message 消息
*/
void send(String userId, WsMessage message);
}
}

View File

@@ -0,0 +1,34 @@
package com.webgame.webgamebackend.domain.game;
import com.webgame.webgamebackend.common.dto.game.GameItemResponse;
import com.webgame.webgamebackend.common.dto.game.GameListResponse;
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameConfigEntity;
import com.webgame.webgamebackend.infrastructure.persistence.repository.GameConfigRepository;
import com.webgame.webgamebackend.domain.game.GameService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 游戏服务实现
*
* 仅保留游戏配置查询。房间管理已迁移到 {@link com.webgame.webgamebackend.service.RoomManager}。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GameService {
private final GameConfigRepository gameConfigRepository;
public GameListResponse getGameList() {
List<GameConfigEntity> configs = gameConfigRepository.findByEnabledTrueOrderBySortOrderAsc();
List<GameItemResponse> list = configs.stream()
.map(GameItemResponse::from)
.toList();
log.info("[游戏服务] 获取游戏列表, 共 {} 款", list.size());
return new GameListResponse(list);
}
}

View File

@@ -0,0 +1,979 @@
package com.webgame.webgamebackend.domain.room;
import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.common.dto.game.*;
import com.webgame.webgamebackend.common.dto.game.RoomDetailResponse.RoomPlayerDetail;
import com.webgame.webgamebackend.common.event.RoomEventBus;
import com.webgame.webgamebackend.common.event.SseEventPublisher;
import com.webgame.webgamebackend.common.event.SseEventType;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
import com.webgame.webgamebackend.infrastructure.persistence.entity.*;
import com.webgame.webgamebackend.infrastructure.persistence.repository.*;
import com.webgame.webgamebackend.domain.engine.*;
import com.webgame.webgamebackend.adapter.ws.RoomSessionManager;
import com.webgame.webgamebackend.adapter.ws.dto.WsMessage;
import com.webgame.webgamebackend.adapter.ws.dto.WsOutboundMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
/**
* 房间管理器 — 统一管理所有房间生命周期
*
* <h3>职责</h3>
* <ul>
* <li>房间 CRUD创建、加入、离开、解散、踢人</li>
* <li>状态流转WAITING → PLAYING → FINISHED</li>
* <li>准备/取消准备</li>
* <li>开始游戏(委托给 GameEngine</li>
* <li>断线重连管理60 秒倒计时)</li>
* <li>超时房间清理(定时任务)</li>
* <li>游戏结束结算(房间层负责金币变更)</li>
* </ul>
*
* <p>房间数据持久化到 MySQL运行时状态游戏引擎引用、断线定时器
* 缓存在内存中。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RoomManager {
private final GameConfigRepository gameConfigRepository;
private final GameRoomRepository gameRoomRepository;
private final RoomPlayerRepository roomPlayerRepository;
private final UserRepository userRepository;
private final GameRecordRepository gameRecordRepository;
private final GameMoveRepository gameMoveRepository;
private final RoomSessionManager sessionManager;
private final GameEngineRegistry engineRegistry;
private final SseEventPublisher sseEventPublisher;
private final RoomEventBus eventBus;
// ==================== 内存状态 ====================
/** 断线重连超时时间(秒) */
private static final long DISCONNECT_TIMEOUT_SECONDS = 60;
/** 断线重连定时器线程池 */
private final ScheduledExecutorService reconnectScheduler =
Executors.newSingleThreadScheduledExecutor(r ->
new Thread(r, "room-reconnect"));
/** 房间运行时状态缓存: roomId → RoomRuntime */
private final ConcurrentHashMap<String, RoomRuntime> roomRuntimes = new ConcurrentHashMap<>();
/**
* 房间运行时状态(不可持久化的部分)
*/
private static class RoomRuntime {
/** 活跃的游戏引擎PLAYING 状态下非 null */
volatile GameEngine gameEngine;
/** 断线倒计时任务: userId → Future */
final ConcurrentHashMap<String, ScheduledFuture<?>> disconnectTimers = new ConcurrentHashMap<>();
RoomRuntime() {}
RoomRuntime(GameEngine gameEngine) { this.gameEngine = gameEngine; }
}
// ==================== 房间 CRUD ====================
/**
* 创建房间
*
* @param request 创建房间请求
* @param creatorId 房主 accountId
* @return 创建结果
*/
@Transactional
public CreateRoomResponse createRoom(CreateRoomRequest request, String creatorId) {
// 校验游戏存在且启用
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(request.gameId())
.orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND));
if (!gameConfig.getEnabled()) {
throw new BusinessException(ErrorCode.GAME_NOT_FOUND);
}
// 校验 mode
List<String> modes = JSON.parseArray(gameConfig.getModes(), String.class);
if (!modes.contains(request.mode())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的模式: " + request.mode());
}
// 校验 stakes
List<Integer> stakesOptions = JSON.parseArray(gameConfig.getStakesOptions(), Integer.class);
if (!stakesOptions.contains(request.stakes())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的底注: " + request.stakes());
}
// 检查用户是否已在其他房间
if (roomPlayerRepository.existsByUserId(creatorId)) {
throw new BusinessException(ErrorCode.ALREADY_IN_ROOM);
}
// 生成桌号
Integer maxRoomNumber = gameRoomRepository.findMaxRoomNumberByGameId(request.gameId());
int nextRoomNumber = (maxRoomNumber == null) ? 1 : maxRoomNumber + 1;
// 生成桌名
String roomName = (request.name() != null && !request.name().isBlank())
? request.name()
: "" + String.format("%02d", nextRoomNumber);
// 创建房间实体
GameRoomEntity room = new GameRoomEntity()
.setGameId(request.gameId())
.setRoomNumber(nextRoomNumber)
.setName(roomName)
.setMaxPlayers(request.maxPlayers())
.setMode(request.mode())
.setStakes(request.stakes())
.setStatus("waiting")
.setCreatorId(creatorId);
// 密码明文存储
if (request.password() != null && !request.password().isBlank()) {
room.setPassword(request.password());
}
gameRoomRepository.save(room);
// 房主自动加入(座位 1已准备
RoomPlayerEntity creatorPlayer = new RoomPlayerEntity()
.setRoomId(room.getId())
.setUserId(creatorId)
.setSeatNumber(1)
.setReadyStatus(true)
.setJoinTime(LocalDateTime.now());
roomPlayerRepository.save(creatorPlayer);
log.info("[房间管理] 创建房间, roomId={}, gameId={}, creatorId={}", room.getId(), request.gameId(), creatorId);
// 通知大厅
RoomItemResponse response = buildRoomItemResponse(room);
sseEventPublisher.broadcast(SseEventType.ROOM_CREATED, response);
return new CreateRoomResponse(response);
}
/**
* 加入房间
*
* @param request 加入房间请求
* @param userId 用户 accountId
* @return 房间信息
*/
@Transactional
public RoomItemResponse joinRoom(JoinRoomRequest request, String userId) {
GameRoomEntity room = gameRoomRepository.findById(request.roomId())
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
if (!"waiting".equals(room.getStatus())) {
throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
}
// 密码校验(明文比对)
if (room.getPassword() != null && !room.getPassword().isBlank()) {
if (request.password() == null || request.password().isBlank()) {
throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR, "请输入房间密码");
}
if (!room.getPassword().equals(request.password())) {
throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR);
}
}
// 检查房间是否已满
int currentCount = roomPlayerRepository.countByRoomId(room.getId());
if (currentCount >= room.getMaxPlayers()) {
throw new BusinessException(ErrorCode.ROOM_FULL);
}
// 检查用户是否已在其他房间
if (roomPlayerRepository.existsByUserId(userId)) {
throw new BusinessException(ErrorCode.ALREADY_IN_ROOM);
}
// 分配座位号
List<RoomPlayerEntity> existing = roomPlayerRepository.findByRoomId(room.getId());
int nextSeat = 1;
if (!existing.isEmpty()) {
int maxSeat = existing.stream().mapToInt(RoomPlayerEntity::getSeatNumber).max().orElse(0);
nextSeat = maxSeat + 1;
}
RoomPlayerEntity player = new RoomPlayerEntity()
.setRoomId(room.getId())
.setUserId(userId)
.setSeatNumber(nextSeat)
.setReadyStatus(false)
.setJoinTime(LocalDateTime.now());
roomPlayerRepository.save(player);
log.info("[房间管理] 玩家加入, roomId={}, userId={}, seat={}", room.getId(), userId, nextSeat);
// WS 广播玩家加入
RoomPlayerDetail detail = buildPlayerDetail(userId, nextSeat, false);
broadcastToRoom(room.getId(), new WsMessage(WsOutboundMessage.PLAYER_JOIN, detail));
// SSE 通知大厅
RoomItemResponse response = buildRoomItemResponse(room);
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
return response;
}
/**
* 离开房间
*
* @param roomId 房间 ID
* @param userId 用户 accountId
*/
@Transactional
public void leaveRoom(String roomId, String userId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM));
// 如果游戏进行中,通知引擎玩家离开(视为认输)
RoomRuntime runtime = roomRuntimes.get(roomId);
if ("playing".equals(room.getStatus()) && runtime != null && runtime.gameEngine != null) {
runtime.gameEngine.onPlayerLeave(userId);
}
// 取消断线倒计时
cancelDisconnectTimer(roomId, userId);
roomPlayerRepository.delete(player);
List<RoomPlayerEntity> remaining = roomPlayerRepository.findByRoomId(roomId);
if (remaining.isEmpty()) {
// 无人了,清理并删除房间
cleanupRoom(roomId);
gameRoomRepository.delete(room);
log.info("[房间管理] 房间无人,已删除, roomId={}", roomId);
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId));
} else if (userId.equals(room.getCreatorId())) {
// 房主离开,顺位转让
RoomPlayerEntity nextOwner = remaining.get(0);
room.setCreatorId(nextOwner.getUserId());
nextOwner.setReadyStatus(true);
roomPlayerRepository.save(nextOwner);
gameRoomRepository.save(room);
log.info("[房间管理] 房主转让, roomId={}, newOwner={}", roomId, nextOwner.getUserId());
// 通知更新
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId)));
pushRoomUpdatedSse(room);
} else {
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId)));
pushRoomUpdatedSse(room);
}
log.info("[房间管理] 玩家离开, roomId={}, userId={}", roomId, userId);
}
/**
* 解散房间(仅房主)
*
* @param roomId 房间 ID
* @param ownerId 房主 accountId
*/
@Transactional
public void disbandRoom(String roomId, String ownerId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
if (!ownerId.equals(room.getCreatorId())) {
throw new BusinessException(ErrorCode.NOT_ROOM_OWNER);
}
// 如果游戏进行中,销毁引擎
RoomRuntime runtime = roomRuntimes.get(roomId);
if (runtime != null && runtime.gameEngine != null) {
runtime.gameEngine.destroy();
}
// 通知房间内所有人被踢
broadcastToRoom(roomId, new WsMessage("room_disbanded", Map.of("roomId", roomId)));
// 关闭房间内所有 WS 连接
for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) {
try { ws.close(); } catch (Exception ignored) {}
}
// 清理并删除
cleanupRoom(roomId);
roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(roomId));
gameRoomRepository.delete(room);
log.info("[房间管理] 房间已解散, roomId={}", roomId);
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId));
}
/**
* 踢出玩家(仅房主)
*
* @param roomId 房间 ID
* @param ownerId 房主 accountId
* @param targetUserId 目标用户 accountId
*/
@Transactional
public void kickPlayer(String roomId, String ownerId, String targetUserId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
if (!ownerId.equals(room.getCreatorId())) {
throw new BusinessException(ErrorCode.NOT_ROOM_OWNER);
}
if (ownerId.equals(targetUserId)) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "不能踢出自己,请使用离开房间");
}
RoomPlayerEntity target = roomPlayerRepository.findByRoomIdAndUserId(roomId, targetUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM));
// 如果游戏进行中,通知引擎
RoomRuntime runtime = roomRuntimes.get(roomId);
if ("playing".equals(room.getStatus()) && runtime != null && runtime.gameEngine != null) {
runtime.gameEngine.onPlayerLeave(targetUserId);
}
cancelDisconnectTimer(roomId, targetUserId);
roomPlayerRepository.delete(target);
log.info("[房间管理] 踢出玩家, roomId={}, targetUserId={}", roomId, targetUserId);
// WS 通知
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", targetUserId)));
// SSE 通知被踢者
sseEventPublisher.sendTo(targetUserId, SseEventType.KICKED, Map.of("roomId", roomId));
// 大厅更新
pushRoomUpdatedSse(room);
}
/**
* 切换准备状态
*
* @param roomId 房间 ID
* @param userId 用户 accountId
* @return 新的准备状态
*/
@Transactional
public boolean toggleReady(String roomId, String userId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
if (!"waiting".equals(room.getStatus())) {
throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
}
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM));
boolean newStatus = !player.getReadyStatus();
player.setReadyStatus(newStatus);
roomPlayerRepository.save(player);
log.info("[房间管理] 准备状态变更, roomId={}, userId={}, ready={}", roomId, userId, newStatus);
// WS 广播
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.READY_CHANGE, Map.of(
"userId", userId, "ready", newStatus
)));
// SSE 大厅更新
pushRoomUpdatedSse(room);
return newStatus;
}
/**
* 开始游戏(仅房主)
*
* @param roomId 房间 ID
* @param userId 操作者 accountId必须是房主
*/
@Transactional
public void startGame(String roomId, String userId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
if (!userId.equals(room.getCreatorId())) {
throw new BusinessException(ErrorCode.NOT_ROOM_OWNER);
}
if (!"waiting".equals(room.getStatus())) {
throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
}
List<RoomPlayerEntity> players = roomPlayerRepository.findByRoomId(roomId);
if (players.size() < 2) {
throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT);
}
for (RoomPlayerEntity p : players) {
if (!p.getReadyStatus()) {
throw new BusinessException(ErrorCode.NOT_ALL_READY);
}
}
// 检查引擎是否已注册
if (!engineRegistry.isRegistered(room.getGameId())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "该游戏尚未实现引擎: " + room.getGameId());
}
// 更新房间状态
room.setStatus("playing");
gameRoomRepository.save(room);
// 创建游戏记录resultType 初始为 pending游戏结束时由 onGameOver 更新)
GameRecordEntity record = new GameRecordEntity()
.setRoomId(roomId)
.setResultType("pending")
.setStartedAt(LocalDateTime.now());
gameRecordRepository.save(record);
// 构建房间上下文
List<RoomPlayerEntity> sortedPlayers = players.stream()
.sorted(Comparator.comparingInt(RoomPlayerEntity::getSeatNumber))
.toList();
List<RoomContext.PlayerInfo> playerInfos = sortedPlayers.stream()
.map(p -> new RoomContext.PlayerInfo(p.getUserId(), p.getSeatNumber()))
.toList();
RoomContext context = new RoomContext(
roomId,
room.getGameId(),
playerInfos,
room.getMode(),
room.getStakes(),
msg -> broadcastToRoom(roomId, msg),
(targetUserId, msg) -> sendToUser(roomId, targetUserId, msg),
(winnerId, resultType) -> onGameOver(roomId, winnerId, resultType)
);
// 创建并初始化游戏引擎
GameEngine engine = engineRegistry.create(room.getGameId(), context);
engine.init(context);
// 缓存运行时
RoomRuntime runtime = roomRuntimes.computeIfAbsent(roomId, k -> new RoomRuntime());
runtime.gameEngine = engine;
log.info("[房间管理] 游戏开始, roomId={}, gameId={}, 玩家数={}", roomId, room.getGameId(), players.size());
// SSE 大厅更新
pushRoomUpdatedSse(room);
}
// ==================== 断线重连 ====================
/**
* 处理 WebSocket 断线
*
* 启动 60 秒重连倒计时,超时后根据房间状态处理:
* - WAITING自动离开房间
* - PLAYING通知 GameEngine默认处理自动认输后离开
*
* @param roomId 房间 ID
* @param userId 断线用户 accountId
*/
public void handleDisconnect(String roomId, String userId) {
RoomRuntime runtime = roomRuntimes.get(roomId);
if (runtime == null) {
return;
}
// 避免重复启动定时器
if (runtime.disconnectTimers.containsKey(userId)) {
return;
}
String taskKey = roomId + ":" + userId;
log.info("[房间管理] 玩家断线,启动{}秒重连倒计时, roomId={}, userId={}",
DISCONNECT_TIMEOUT_SECONDS, roomId, userId);
ScheduledFuture<?> task = reconnectScheduler.schedule(() -> {
runtime.disconnectTimers.remove(userId);
try {
onDisconnectTimeout(roomId, userId);
} catch (Exception e) {
log.error("[房间管理] 断线超时处理异常, roomId={}, userId={}", roomId, userId, e);
}
}, DISCONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
runtime.disconnectTimers.put(userId, task);
// 通知游戏引擎
if (runtime.gameEngine != null) {
runtime.gameEngine.onPlayerDisconnect(userId);
}
// WS 广播断线
broadcastToRoom(roomId, new WsMessage("player_disconnected", Map.of("userId", userId)));
}
/**
* 处理 WebSocket 重连
*
* 取消断线倒计时,推送完整状态给重连玩家。
*
* @param roomId 房间 ID
* @param userId 重连用户 accountId
* @return 完整房间状态快照,用于前端恢复
*/
public RoomDetailResponse handleReconnect(String roomId, String userId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElse(null);
if (room == null) {
return null;
}
// 取消倒计时
cancelDisconnectTimer(roomId, userId);
// 通知游戏引擎
RoomRuntime runtime = roomRuntimes.get(roomId);
if (runtime != null && runtime.gameEngine != null) {
runtime.gameEngine.onPlayerReconnect(userId);
}
// WS 广播重连
broadcastToRoom(roomId, new WsMessage("player_reconnected", Map.of("userId", userId)));
log.info("[房间管理] 玩家重连, roomId={}, userId={}", roomId, userId);
// 返回完整房间详情
return buildRoomDetail(room);
}
/**
* 取消断线倒计时
*/
private void cancelDisconnectTimer(String roomId, String userId) {
RoomRuntime runtime = roomRuntimes.get(roomId);
if (runtime == null) return;
ScheduledFuture<?> task = runtime.disconnectTimers.remove(userId);
if (task != null) {
task.cancel(false);
log.info("[房间管理] 取消断线倒计时, roomId={}, userId={}", roomId, userId);
}
}
/**
* 断线超时处理
*/
private void onDisconnectTimeout(String roomId, String userId) {
GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null);
if (room == null) return;
boolean stillInRoom = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId).isPresent();
if (!stillInRoom) return;
if ("playing".equals(room.getStatus())) {
log.info("[房间管理] 断线超时:对局中自动判负, roomId={}, userId={}", roomId, userId);
// 通知引擎,引擎内部处理认输
RoomRuntime runtime = roomRuntimes.get(roomId);
if (runtime != null && runtime.gameEngine != null) {
runtime.gameEngine.onPlayerLeave(userId);
}
// 从房间移除
leaveRoomSilent(roomId, userId);
} else {
log.info("[房间管理] 断线超时:等待中自动离开, roomId={}, userId={}", roomId, userId);
leaveRoomSilent(roomId, userId);
}
}
/**
* 静默离开(不触发额外的游戏事件,超时专用)
*/
private void leaveRoomSilent(String roomId, String userId) {
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId).orElse(null);
if (player == null) return;
cancelDisconnectTimer(roomId, userId);
roomPlayerRepository.delete(player);
GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null);
if (room == null) return;
List<RoomPlayerEntity> remaining = roomPlayerRepository.findByRoomId(roomId);
if (remaining.isEmpty()) {
cleanupRoom(roomId);
gameRoomRepository.delete(room);
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId));
} else if (userId.equals(room.getCreatorId())) {
RoomPlayerEntity nextOwner = remaining.get(0);
room.setCreatorId(nextOwner.getUserId());
nextOwner.setReadyStatus(true);
roomPlayerRepository.save(nextOwner);
gameRoomRepository.save(room);
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId)));
pushRoomUpdatedSse(room);
} else {
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId)));
pushRoomUpdatedSse(room);
}
}
// ==================== 游戏结束回调 ====================
/**
* 游戏结束回调(由 GameEngine 通过 RoomContext 调用)
*
* 房间层负责:
* 1. 更新房间状态为 FINISHED
* 2. 保存对局记录(胜负结果)
* 3. 金币结算TODO
* 4. 广播 game_over + SSE 推送
*
* @param roomId 房间 ID
* @param winnerId 胜者 accountIdnull 表示平局)
* @param resultType 结果类型win/draw/resign
*/
@Transactional
public void onGameOver(String roomId, String winnerId, String resultType) {
GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null);
if (room == null) return;
room.setStatus("finished");
gameRoomRepository.save(room);
// 更新对局记录
GameRecordEntity record = gameRecordRepository.findByRoomId(roomId).orElse(null);
if (record != null) {
record.setWinnerId(winnerId)
.setResultType(resultType)
.setEndedAt(LocalDateTime.now());
gameRecordRepository.save(record);
}
// TODO: 金币结算 — 房间层从玩家余额扣除/增加 stakes
log.info("[房间管理] 游戏结束, roomId={}, winnerId={}, resultType={}", roomId, winnerId, resultType);
// WS 广播游戏结束
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.GAME_OVER, Map.of(
"winnerId", winnerId != null ? winnerId : "",
"resultType", resultType
)));
// SSE 大厅更新
pushRoomUpdatedSse(room);
// 发布事件(供其他模块订阅)
eventBus.publish(RoomEventBus.GAME_OVER, new RoomEventBus.GameOverEvent(roomId, winnerId, resultType));
}
/**
* 执行游戏动作(委托给 GameEngine
*
* 由 WebSocket Handler 调用RoomManager 查找对应房间的活跃引擎并转发动作。
*
* @param roomId 房间 ID
* @param userId 操作者 accountId
* @param action 动作类型
* @param data 动作数据(可为 null
*/
public void executeGameAction(String roomId, String userId, String action, Object data) {
RoomRuntime runtime = roomRuntimes.get(roomId);
if (runtime == null || runtime.gameEngine == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "游戏尚未开始");
}
// 将 data 转换为 JsonNode
com.fasterxml.jackson.databind.JsonNode jsonData = null;
if (data != null) {
String jsonStr = JSON.toJSONString(data);
try {
jsonData = new com.fasterxml.jackson.databind.ObjectMapper().readTree(jsonStr);
} catch (Exception e) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "无效的游戏数据格式");
}
}
runtime.gameEngine.handleAction(action, jsonData, userId);
}
// ==================== 查询 ====================
/**
* 获取房间完整详情(进入房间时调用)
*/
public RoomDetailResponse buildRoomDetail(GameRoomEntity room) {
if (room == null) {
room = gameRoomRepository.findById(room.getId())
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
}
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId())
.orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND));
List<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(room.getId());
List<String> userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList();
Map<String, UserEntity> userMap = userRepository.findByAccountIdIn(userIds).stream()
.collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
List<RoomDetailResponse.RoomPlayerDetail> playerDetails = roomPlayers.stream()
.map(rp -> {
UserEntity user = userMap.get(rp.getUserId());
return new RoomDetailResponse.RoomPlayerDetail(
rp.getUserId(),
user != null ? user.getAvatar() : "",
user != null ? user.getNickName() : "未知玩家",
rp.getSeatNumber(),
rp.getReadyStatus()
);
}).toList();
String creatorName = getNickname(room.getCreatorId());
// 对局中或有对局记录时,查询棋盘状态
int[][] boardState = null;
String currentTurn = null;
List<RoomDetailResponse.MoveItem> moves = List.of();
if ("playing".equals(room.getStatus()) || "finished".equals(room.getStatus())) {
List<GameMoveEntity> moveEntities = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(room.getId());
moves = moveEntities.stream()
.map(m -> new RoomDetailResponse.MoveItem(m.getMoveIndex(), m.getPlayerId(), m.getRowNum(), m.getColNum()))
.toList();
// 从走棋记录重建棋盘
boardState = rebuildBoard(moveEntities, 15);
}
return new RoomDetailResponse(
room.getId(), room.getName(), room.getGameId(),
gameConfig.getIcon(), gameConfig.getName(),
room.getMaxPlayers(), room.getMode(), room.getStakes(),
room.getStatus(), room.getCreatorId(), creatorName,
playerDetails, boardState, currentTurn, moves
);
}
/** 棋盘大小(五子棋默认 15 */
private static final int BOARD_SIZE = 15;
/**
* 从走棋记录重建棋盘
*/
private int[][] rebuildBoard(List<GameMoveEntity> moves, int boardSize) {
int[][] board = new int[boardSize][boardSize];
for (GameMoveEntity move : moves) {
int player = (move.getMoveIndex() % 2 == 1) ? 1 : 2;
board[move.getRowNum()][move.getColNum()] = player;
}
return board;
}
/**
* 获取房间列表
*/
public RoomListResponse getRoomList(String gameId, String status) {
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(gameId)
.orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND));
if (!gameConfig.getEnabled()) {
throw new BusinessException(ErrorCode.GAME_NOT_FOUND);
}
List<GameRoomEntity> rooms;
if (status != null && !status.isBlank()) {
rooms = gameRoomRepository.findByGameIdAndStatus(gameId, status);
} else {
rooms = gameRoomRepository.findByGameId(gameId).stream()
.filter(r -> !"finished".equals(r.getStatus()))
.toList();
}
List<RoomItemResponse> list = rooms.stream()
.map(this::buildRoomItemResponse)
.toList();
return new RoomListResponse(list);
}
// ==================== 定时任务 ====================
/**
* 超时房间清理
*
* 每 5 分钟执行一次:
* - 清理 WAITING 超过 30 分钟无变化的房间
* - 清理 FINISHED 超过 5 分钟的房间
*/
@Scheduled(fixedRate = 300_000)
public void cleanupStaleRooms() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime waitingDeadline = now.minusMinutes(30);
LocalDateTime finishedDeadline = now.minusMinutes(5);
// 清理超时等待中的房间
List<GameRoomEntity> staleWaiting = gameRoomRepository
.findByStatusAndUpdateTimeBefore("waiting", waitingDeadline);
for (GameRoomEntity room : staleWaiting) {
log.info("[房间清理] 清理超时等待房间, roomId={}", room.getId());
cleanupRoom(room.getId());
roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(room.getId()));
gameRoomRepository.delete(room);
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", room.getId()));
}
// 清理已结束的房间
List<GameRoomEntity> staleFinished = gameRoomRepository
.findByStatusAndUpdateTimeBefore("finished", finishedDeadline);
for (GameRoomEntity room : staleFinished) {
log.info("[房间清理] 清理已结束房间, roomId={}", room.getId());
cleanupRoom(room.getId());
roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(room.getId()));
gameRoomRepository.delete(room);
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", room.getId()));
}
if (!staleWaiting.isEmpty() || !staleFinished.isEmpty()) {
log.info("[房间清理] 清理完成, 等待超时={}, 已结束={}",
staleWaiting.size(), staleFinished.size());
}
}
// ==================== 私有辅助方法 ====================
/**
* 清理房间运行时资源(引擎、定时器)
*/
private void cleanupRoom(String roomId) {
RoomRuntime runtime = roomRuntimes.remove(roomId);
if (runtime != null) {
// 销毁游戏引擎
if (runtime.gameEngine != null) {
runtime.gameEngine.destroy();
}
// 取消所有断线定时器
for (ScheduledFuture<?> timer : runtime.disconnectTimers.values()) {
timer.cancel(false);
}
runtime.disconnectTimers.clear();
}
}
/**
* 广播 WS 消息到房间内所有人
*/
private void broadcastToRoom(String roomId, WsMessage message) {
String json = JSON.toJSONString(message);
for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) {
if (ws.isOpen()) {
try {
ws.sendMessage(new TextMessage(json));
} catch (IOException e) {
log.error("[WS] 广播失败, sessionId={}", ws.getId(), e);
}
}
}
}
/**
* 向房间内指定用户发送 WS 消息(私有消息)
*/
private void sendToUser(String roomId, String userId, WsMessage message) {
String json = JSON.toJSONString(message);
for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) {
if (ws.isOpen() && userId.equals(sessionManager.getUserId(ws))) {
try {
ws.sendMessage(new TextMessage(json));
} catch (IOException e) {
log.error("[WS] 发送私有消息失败, userId={}", userId, e);
}
}
}
}
/**
* 构建房间列表项响应
*/
private RoomItemResponse buildRoomItemResponse(GameRoomEntity room) {
List<RoomPlayerResponse> players = buildRoomPlayers(room.getId());
String creatorName = getNickname(room.getCreatorId());
String icon = gameConfigRepository.findByGameId(room.getGameId())
.map(GameConfigEntity::getIcon).orElse("");
return RoomItemResponse.from(room, players, icon, creatorName);
}
/**
* 构建单个玩家详情
*/
private RoomPlayerDetail buildPlayerDetail(String userId, int seatNumber, boolean ready) {
UserEntity user = userRepository.findByAccountId(userId).orElse(null);
return new RoomPlayerDetail(
userId,
user != null ? user.getAvatar() : "",
user != null ? user.getNickName() : "未知玩家",
seatNumber,
ready
);
}
/**
* 组装房间玩家列表
*/
private List<RoomPlayerResponse> buildRoomPlayers(String roomId) {
List<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(roomId);
if (roomPlayers.isEmpty()) return List.of();
List<String> userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList();
List<UserEntity> users = userRepository.findByAccountIdIn(userIds);
Map<String, UserEntity> userMap = users.stream()
.collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
return roomPlayers.stream()
.map(rp -> {
UserEntity user = userMap.get(rp.getUserId());
String avatar = user != null ? user.getAvatar() : "";
String nickname = user != null ? user.getNickName() : "未知玩家";
return new RoomPlayerResponse(avatar, nickname);
}).toList();
}
/**
* 获取用户昵称
*/
private String getNickname(String accountId) {
return userRepository.findByAccountId(accountId)
.map(UserEntity::getNickName)
.orElse("未知玩家");
}
/**
* SSE 推送房间更新
*/
private void pushRoomUpdatedSse(GameRoomEntity room) {
RoomItemResponse response = buildRoomItemResponse(room);
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
}
// ==================== 公共查询方法(供 Controller 使用) ====================
/**
* 获取房间详情
*/
public RoomDetailResponse getRoomDetail(String roomId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
return buildRoomDetail(room);
}
}

View File

@@ -1,8 +1,9 @@
package com.webgame.webgamebackend.service.impl;
package com.webgame.webgamebackend.domain.token;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
import com.webgame.webgamebackend.service.RefreshTokenService;
import com.webgame.webgamebackend.domain.token.RefreshTokenService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@@ -25,7 +26,8 @@ import java.util.concurrent.TimeUnit;
*/
@Slf4j
@Service
public class RefreshTokenServiceImpl implements RefreshTokenService {
@RequiredArgsConstructor
public class RefreshTokenService {
/** Redis key 前缀 */
private static final String KEY_PREFIX = "xzg:refresh:";
@@ -41,11 +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()) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "用户ID不能为空");
@@ -66,7 +63,6 @@ public class RefreshTokenServiceImpl implements RefreshTokenService {
return token;
}
@Override
public String validate(String token) {
if (token == null || token.isBlank()) {
throw new BusinessException(ErrorCode.REFRESH_TOKEN_MISSING);
@@ -78,7 +74,6 @@ public class RefreshTokenServiceImpl implements RefreshTokenService {
return userId;
}
@Override
public String rotate(String oldToken) {
String userId = validate(oldToken);
// 先删反向映射再删正向映射
@@ -90,7 +85,6 @@ public class RefreshTokenServiceImpl implements RefreshTokenService {
return create(userId);
}
@Override
public void revoke(String token) {
if (token == null || token.isBlank()) {
return;

View File

@@ -1,14 +1,14 @@
package com.webgame.webgamebackend.service.impl;
package com.webgame.webgamebackend.domain.user;
import cn.dev33.satoken.stp.StpUtil;
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest;
import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
import com.webgame.webgamebackend.entities.UserEntity;
import com.webgame.webgamebackend.repository.UserRepository;
import com.webgame.webgamebackend.service.UserService;
import com.webgame.webgamebackend.infrastructure.persistence.entity.UserEntity;
import com.webgame.webgamebackend.infrastructure.persistence.repository.UserRepository;
import com.webgame.webgamebackend.domain.user.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -27,7 +27,7 @@ import java.util.Set;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
public class UserService {
/** 头像存储目录(相对于项目工作目录) */
private static final String AVATAR_DIR = "uploads/avatars";
@@ -48,17 +48,16 @@ public class UserServiceImpl implements UserService {
private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024;
private final UserRepository userRepository;
private final AuthenticationProvider authProvider;
@Override
public UserInfoResponse getCurrentUserInfo() {
String accountId = StpUtil.getLoginIdAsString();
String accountId = authProvider.getCurrentUserId();
log.info("[用户服务] 获取当前登录用户信息, accountId: {}", accountId);
UserEntity user = userRepository.findByAccountId(accountId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
return UserInfoResponse.from(user);
}
@Override
@Transactional
public UserInfoResponse updateUserInfo(String accountId, UpdateUserInfoRequest request) {
UserEntity user = userRepository.findByAccountId(accountId)
@@ -89,7 +88,6 @@ public class UserServiceImpl implements UserService {
return UserInfoResponse.from(user);
}
@Override
@Transactional
public void updateStatus(String accountId, UpdateStatusRequest request) {
UserEntity user = userRepository.findByAccountId(accountId)
@@ -100,7 +98,6 @@ public class UserServiceImpl implements UserService {
log.info("[用户服务] 更新在线状态, accountId={}, status={}", accountId, request.status());
}
@Override
@Transactional
public UserInfoResponse uploadAvatar(String accountId, MultipartFile file) {
// 校验文件类型
@@ -143,7 +140,6 @@ public class UserServiceImpl implements UserService {
}
}
@Override
@Transactional
public UserInfoResponse deleteAvatar(String accountId) {
UserEntity user = userRepository.findByAccountId(accountId)

View File

@@ -0,0 +1,35 @@
package com.webgame.webgamebackend.infrastructure.config;
import com.webgame.webgamebackend.infrastructure.persistence.repository.GameMoveRepository;
import com.webgame.webgamebackend.domain.engine.GameEngineRegistry;
import com.webgame.webgamebackend.domain.engine.GomokuEngine;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
/**
* 游戏引擎注册配置
*
* 在 Spring 启动后,将所有已实现的 GameEngine 注册到 GameEngineRegistry。
* 新增游戏只需在此处添加一行注册即可。
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class EngineRegistrationConfig {
private final GameEngineRegistry registry;
private final GameMoveRepository moveRepository;
@PostConstruct
public void registerEngines() {
// 注册五子棋引擎
registry.register("gomoku", context -> new GomokuEngine(moveRepository));
log.info("[引擎注册] 已注册五子棋引擎, gameId=gomoku");
// TODO: 后续新游戏在此注册,例如:
// registry.register("snake", context -> new SnakeEngine(...));
// registry.register("tetris", context -> new TetrisEngine(...));
}
}

View File

@@ -1,4 +1,4 @@
package com.webgame.webgamebackend.common.config;
package com.webgame.webgamebackend.infrastructure.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;

View File

@@ -1,4 +1,4 @@
package com.webgame.webgamebackend.common.config;
package com.webgame.webgamebackend.infrastructure.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -1,4 +1,4 @@
package com.webgame.webgamebackend.common.config;
package com.webgame.webgamebackend.infrastructure.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -0,0 +1,46 @@
package com.webgame.webgamebackend.infrastructure.config;
import cn.dev33.satoken.fun.strategy.SaCorsHandleFunction;
import cn.dev33.satoken.router.SaHttpMethod;
import cn.dev33.satoken.router.SaRouter;
import com.webgame.webgamebackend.common.auth.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web 鉴权配置
* <p>
* 使用自定义 {@link AuthInterceptor} 替代 Sa-Token 的 {@code SaInterceptor}
* 在拦截器层将 Sa-Token 框架异常转为业务异常,实现认证框架与业务代码解耦。
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class SaTokenConfigure implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/error");
}
@Bean
public SaCorsHandleFunction corsHandle() {
return (req, res, sto) -> {
res.setHeader("Access-Control-Allow-Origin", "*")
.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT, PATCH")
.setHeader("Access-Control-Max-Age", "3600")
.setHeader("Access-Control-Allow-Headers", "*");
SaRouter.match(SaHttpMethod.OPTIONS).free(r -> {
}).back();
};
}
}

View File

@@ -1,7 +1,7 @@
package com.webgame.webgamebackend.config;
package com.webgame.webgamebackend.infrastructure.config;
import com.webgame.webgamebackend.common.event.SseEventPublisher;
import com.webgame.webgamebackend.sse.RedisSseListener;
import com.webgame.webgamebackend.adapter.sse.RedisSseListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
@@ -9,9 +9,10 @@ import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
/**
* SSE 相关 Bean 配置
* SSE 基础设施配置
*
* 注册 Redis Pub/Sub 消息监听器订阅频道 "sse:events"
* 注册 Redis Pub/Sub 消息监听订阅频道 "sse:events"
* 心跳由 SseConnectionManager 自管理单线程调度不在此处配置
*/
@Configuration
public class SseConfig {

View File

@@ -1,4 +1,4 @@
package com.webgame.webgamebackend.common.config;
package com.webgame.webgamebackend.infrastructure.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.config;
package com.webgame.webgamebackend.infrastructure.config;
import com.webgame.webgamebackend.ws.RoomWebSocketHandler;
import com.webgame.webgamebackend.adapter.ws.RoomWebSocketHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.entities;
package com.webgame.webgamebackend.infrastructure.persistence.entity;
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.UUIDBaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;

View File

@@ -1,4 +1,4 @@
package com.webgame.webgamebackend.entities.base;
package com.webgame.webgamebackend.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.Getter;

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.entities;
package com.webgame.webgamebackend.infrastructure.persistence.entity;
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.UUIDBaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.entities;
package com.webgame.webgamebackend.infrastructure.persistence.entity;
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.UUIDBaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.entities;
package com.webgame.webgamebackend.infrastructure.persistence.entity;
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.UUIDBaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@@ -34,8 +34,16 @@ public class GameRecordEntity extends UUIDBaseEntity {
@Column(name = "winner_id", length = 64)
private String winnerId;
/** 结果类型win一方获胜、draw平局、resign认输 */
@Column(name = "result_type", nullable = false, length = 16)
/**
* 结果类型
* <ul>
* <li>{@code pending} 对局进行中尚未产生结果游戏开始时设置</li>
* <li>{@code win} 一方获胜</li>
* <li>{@code draw} 平局</li>
* <li>{@code resign} 一方认输</li>
* </ul>
*/
@Column(name = "result_type", length = 16)
private String resultType;
/** 对局开始时间 */

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.entities;
package com.webgame.webgamebackend.infrastructure.persistence.entity;
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.UUIDBaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@@ -62,17 +62,11 @@ public class GameRoomEntity extends UUIDBaseEntity {
private Integer stakes;
/**
* 房间密码bcrypt 哈希null 表示公开房间
* 房间密码明文null 表示公开房间
*/
@Column(name = "password", length = 64)
private String password;
/**
* 是否允许观战
*/
@Column(name = "allow_spectate", nullable = false)
private Boolean allowSpectate = true;
/**
* 房间状态waiting / playing / finished
*/

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.entities;
package com.webgame.webgamebackend.infrastructure.persistence.entity;
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.UUIDBaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;

View File

@@ -1,4 +1,4 @@
package com.webgame.webgamebackend.entities.base;
package com.webgame.webgamebackend.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.Getter;

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.entities;
package com.webgame.webgamebackend.infrastructure.persistence.entity;
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.UUIDBaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.repository;
package com.webgame.webgamebackend.infrastructure.persistence.repository;
import com.webgame.webgamebackend.entities.AccountEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.AccountEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.repository;
package com.webgame.webgamebackend.infrastructure.persistence.repository;
import com.webgame.webgamebackend.entities.GameConfigEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameConfigEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.repository;
package com.webgame.webgamebackend.infrastructure.persistence.repository;
import com.webgame.webgamebackend.entities.GameMoveEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameMoveEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.repository;
package com.webgame.webgamebackend.infrastructure.persistence.repository;
import com.webgame.webgamebackend.entities.GameRecordEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameRecordEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

View File

@@ -1,10 +1,11 @@
package com.webgame.webgamebackend.repository;
package com.webgame.webgamebackend.infrastructure.persistence.repository;
import com.webgame.webgamebackend.entities.GameRoomEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameRoomEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@@ -47,4 +48,13 @@ public interface GameRoomRepository extends JpaRepository<GameRoomEntity, String
*/
@Query("SELECT r FROM GameRoomEntity r WHERE r.id = :id")
Optional<GameRoomEntity> findByIdForUpdate(@Param("id") String id);
/**
* 根据状态和更新时间查询房间用于超时清理
*
* @param status 房间状态
* @param updateTime 更新时间上限
* @return 符合条件的房间列表
*/
List<GameRoomEntity> findByStatusAndUpdateTimeBefore(String status, LocalDateTime updateTime);
}

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.repository;
package com.webgame.webgamebackend.infrastructure.persistence.repository;
import com.webgame.webgamebackend.entities.RoomPlayerEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.RoomPlayerEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.repository;
package com.webgame.webgamebackend.infrastructure.persistence.repository;
import com.webgame.webgamebackend.entities.UserEntity;
import com.webgame.webgamebackend.infrastructure.persistence.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

View File

@@ -1,42 +0,0 @@
package com.webgame.webgamebackend.service;
import com.webgame.webgamebackend.common.dto.account.LoginRequest;
import com.webgame.webgamebackend.common.dto.account.LoginResponse;
import com.webgame.webgamebackend.common.dto.account.RegisterRequest;
/**
* 账号服务接口
*/
public interface AccountService {
/**
* 登录
*
* @param request 登录请求
* @return 登录结果(含 token
*/
LoginResponse login(LoginRequest request);
/**
* 注册
*
* @param request 注册请求
* @return 注册结果(含双 token注册后自动登录
*/
LoginResponse register(RegisterRequest request);
/**
* 刷新令牌
*
* @param refreshToken 刷新令牌
* @return 新的双 tokenaccessToken + refreshToken
*/
LoginResponse refresh(String refreshToken);
/**
* 登出,使 access token 和 refresh token 失效
*
* @param refreshToken 刷新令牌
*/
void logout(String refreshToken);
}

View File

@@ -1,85 +0,0 @@
package com.webgame.webgamebackend.service;
import com.webgame.webgamebackend.common.dto.game.*;
/**
* 游戏服务接口
*/
public interface GameService {
/**
* 获取游戏列表
*
* @return 所有启用的游戏信息
*/
GameListResponse getGameList();
/**
* 获取指定游戏的房间列表
*
* @param gameId 游戏标识
* @param status 状态筛选(可选),不传返回所有未结束的房间
* @return 房间列表
*/
RoomListResponse getRoomList(String gameId, String status);
/**
* 创建房间
*
* @param request 创建房间请求
* @param creatorId 房主 account_id
* @return 新建的房间信息
*/
CreateRoomResponse createRoom(CreateRoomRequest request, String creatorId);
/**
* 加入房间
*
* @param request 加入房间请求
* @param userId 用户 account_id
* @return 房间信息
*/
RoomItemResponse joinRoom(JoinRoomRequest request, String userId);
/**
* 离开房间
*
* @param roomId 房间 ID
* @param userId 用户 account_id
*/
void leaveRoom(String roomId, String userId);
/**
* 房主踢出玩家
*
* @param roomId 房间 ID
* @param ownerId 房主 account_id
* @param targetUserId 目标用户 account_id
*/
void kickPlayer(String roomId, String ownerId, String targetUserId);
/**
* 切换准备状态
*
* @param roomId 房间 ID
* @param userId 用户 account_id
* @return 切换后的准备状态
*/
boolean toggleReady(String roomId, String userId);
/**
* 开始游戏
*
* @param roomId 房间 ID
* @param ownerId 房主 account_id
*/
void startGame(String roomId, String ownerId);
/**
* 获取房间详情(进入房间时获取初始快照)
*
* @param roomId 房间 ID
* @return 房间详情
*/
RoomDetailResponse getRoomDetail(String roomId);
}

View File

@@ -1,88 +0,0 @@
package com.webgame.webgamebackend.service;
import com.webgame.webgamebackend.entities.GameMoveEntity;
import com.webgame.webgamebackend.ws.RoomSessionManager;
import java.util.List;
/**
* 五子棋对局逻辑服务
*
* 管理五子棋对局的完整生命周期:落子校验、胜负判定、走棋记录。
* 通过 RoomSessionManager 向房间内 WebSocket 会话广播状态变更。
*/
public interface GomokuGameService {
/** 棋盘大小 */
int BOARD_SIZE = 15;
/**
* 落子
*
* @param roomId 房间 ID
* @param userId 落子玩家 ID
* @param row 行坐标0 起始)
* @param col 列坐标0 起始)
* @param sessionManager 会话管理器
*/
void placeStone(String roomId, String userId, int row, int col, RoomSessionManager sessionManager);
/**
* 切换准备状态
*/
void toggleReady(String roomId, String userId, RoomSessionManager sessionManager);
/**
* 认输
*/
void resign(String roomId, String userId, RoomSessionManager sessionManager);
/**
* 请求求和
*/
void requestDraw(String roomId, String userId, RoomSessionManager sessionManager);
/**
* 回应求和请求
*/
void respondToDraw(String roomId, String userId, boolean accept, RoomSessionManager sessionManager);
/**
* 离开房间WebSocket 断开时调用)
*/
void leaveRoom(String roomId, String userId, RoomSessionManager sessionManager);
/**
* 开始游戏(房主触发)
*/
void startGame(String roomId, String userId, RoomSessionManager sessionManager);
/**
* 处理 WebSocket 断连
*/
void handleDisconnect(String roomId, String userId, RoomSessionManager sessionManager);
/**
* 取消断连超时定时器(重连时调用)
*
* @param roomId 房间 ID
* @param userId 用户 ID
*/
void cancelDisconnectTimeout(String roomId, String userId);
/**
* 从走棋记录重建棋盘状态(静态工具方法)
*
* @param moves 走棋记录列表
* @param boardSize 棋盘大小
* @return 棋盘二维数组0=空1=第一位玩家黑方2=第二位玩家(白方)
*/
static int[][] rebuildBoard(List<GameMoveEntity> moves, int boardSize) {
int[][] board = new int[boardSize][boardSize];
for (GameMoveEntity move : moves) {
int player = (move.getMoveIndex() % 2 == 1) ? 1 : 2;
board[move.getRowNum()][move.getColNum()] = player;
}
return board;
}
}

View File

@@ -1,40 +0,0 @@
package com.webgame.webgamebackend.service;
/**
* 刷新令牌服务接口,管理 refresh token 的生命周期
*/
public interface RefreshTokenService {
/**
* 为用户创建新的刷新令牌,存入 RedisTTL 30 天
*
* @param userId 用户ID
* @return 生成的 refresh token 字符串
*/
String create(String userId);
/**
* 校验刷新令牌是否有效
*
* @param token 刷新令牌
* @return 对应的用户ID
* @throws com.webgame.webgamebackend.common.exception.BusinessException 令牌无效时抛出
*/
String validate(String token);
/**
* 轮换刷新令牌:删除旧 token生成新 token防重放
*
* @param oldToken 旧的刷新令牌
* @return 新的刷新令牌
* @throws com.webgame.webgamebackend.common.exception.BusinessException 旧令牌无效时抛出
*/
String rotate(String oldToken);
/**
* 撤销刷新令牌(登出时调用)
*
* @param token 要撤销的刷新令牌
*/
void revoke(String token);
}

View File

@@ -1,57 +0,0 @@
package com.webgame.webgamebackend.service;
import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest;
import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
import org.springframework.web.multipart.MultipartFile;
/**
* 用户服务接口
*/
public interface UserService {
/**
* 获取当前登录用户的资料
*
* @return 用户信息
*/
UserInfoResponse getCurrentUserInfo();
/**
* 更新当前登录用户的资料
*
* @param accountId 账号 ID
* @param request 更新请求(所有字段可选)
* @return 更新后的用户信息
*/
UserInfoResponse updateUserInfo(String accountId, UpdateUserInfoRequest request);
/**
* 更新当前登录用户的在线状态
*
* @param accountId 账号 ID
* @param request 状态更新请求
*/
void updateStatus(String accountId, UpdateStatusRequest request);
/**
* 上传并更新当前登录用户的头像
*
* 文件会被保存到 uploads/avatars/{accountId}.jpg并更新 DB 中的 avatar 字段。
*
* @param accountId 账号 ID
* @param file 头像文件(前端已裁剪为 200×200 JPEG
* @return 更新后的用户信息
*/
UserInfoResponse uploadAvatar(String accountId, MultipartFile file);
/**
* 删除当前登录用户的头像
*
* 删除磁盘文件,并将 DB 中 avatar 置为 null前端会回退显示默认头像
*
* @param accountId 账号 ID
* @return 更新后的用户信息
*/
UserInfoResponse deleteAvatar(String accountId);
}

View File

@@ -1,495 +0,0 @@
package com.webgame.webgamebackend.service.impl;
import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.common.dto.game.*;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
import com.webgame.webgamebackend.entities.*;
import com.webgame.webgamebackend.repository.*;
import com.webgame.webgamebackend.common.event.SseEventPublisher;
import com.webgame.webgamebackend.common.event.SseEventType;
import com.webgame.webgamebackend.service.GameService;
import com.webgame.webgamebackend.service.GomokuGameService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 游戏服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GameServiceImpl implements GameService {
private final GameConfigRepository gameConfigRepository;
private final GameRoomRepository gameRoomRepository;
private final RoomPlayerRepository roomPlayerRepository;
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final SseEventPublisher sseEventPublisher;
private final GameMoveRepository gameMoveRepository;
@Override
public GameListResponse getGameList() {
List<GameConfigEntity> configs = gameConfigRepository.findByEnabledTrueOrderBySortOrderAsc();
List<GameItemResponse> list = configs.stream()
.map(GameItemResponse::from)
.toList();
log.info("[游戏服务] 获取游戏列表, 共 {} 款", list.size());
return new GameListResponse(list);
}
@Override
public RoomListResponse getRoomList(String gameId, String status) {
// 校验游戏存在且启用
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(gameId)
.orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND));
if (!gameConfig.getEnabled()) {
throw new BusinessException(ErrorCode.GAME_NOT_FOUND);
}
// 查询房间:有状态筛选则按状态查,否则查所有
List<GameRoomEntity> rooms;
if (status != null && !status.isBlank()) {
rooms = gameRoomRepository.findByGameIdAndStatus(gameId, status);
} else {
// 默认不返回已结束的房间
rooms = gameRoomRepository.findByGameId(gameId).stream()
.filter(r -> !"finished".equals(r.getStatus()))
.toList();
}
// 组装每个房间的玩家信息和房主昵称
List<RoomItemResponse> list = new ArrayList<>();
for (GameRoomEntity room : rooms) {
List<RoomPlayerResponse> players = buildRoomPlayers(room.getId());
String creatorName = getNickname(room.getCreatorId());
list.add(RoomItemResponse.from(room, players, gameConfig.getIcon(), creatorName));
}
log.info("[游戏服务] 获取房间列表, gameId={}, status={}, 共 {} 个", gameId, status, list.size());
return new RoomListResponse(list);
}
@Override
@Transactional
public CreateRoomResponse createRoom(CreateRoomRequest request, String creatorId) {
// 校验游戏存在且启用
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(request.gameId())
.orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND));
if (!gameConfig.getEnabled()) {
throw new BusinessException(ErrorCode.GAME_NOT_FOUND);
}
// 校验 mode 在可选列表中
List<String> modes = JSON.parseArray(gameConfig.getModes(), String.class);
if (!modes.contains(request.mode())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的模式: " + request.mode());
}
// 校验 stakes 在可选列表中
List<Integer> stakesOptions = JSON.parseArray(gameConfig.getStakesOptions(), Integer.class);
if (!stakesOptions.contains(request.stakes())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的底注: " + request.stakes());
}
// 检查用户是否已在其他房间中
if (roomPlayerRepository.existsByUserId(creatorId)) {
throw new BusinessException(ErrorCode.ALREADY_IN_ROOM);
}
// 生成桌号
Integer maxRoomNumber = gameRoomRepository.findMaxRoomNumberByGameId(request.gameId());
int nextRoomNumber = (maxRoomNumber == null) ? 1 : maxRoomNumber + 1;
// 生成桌名
String roomName = (request.name() != null && !request.name().isBlank())
? request.name()
: "" + String.format("%02d", nextRoomNumber);
// 创建房间
GameRoomEntity room = new GameRoomEntity()
.setGameId(request.gameId())
.setRoomNumber(nextRoomNumber)
.setName(roomName)
.setMaxPlayers(request.maxPlayers())
.setMode(request.mode())
.setStakes(request.stakes())
.setStatus("waiting")
.setCreatorId(creatorId)
.setAllowSpectate(request.allowSpectate());
// 密码加密(如果提供了密码)
if (request.password() != null && !request.password().isBlank()) {
room.setPassword(passwordEncoder.encode(request.password()));
}
gameRoomRepository.save(room);
// 房主自动加入房间(座位 1已准备
RoomPlayerEntity creatorPlayer = new RoomPlayerEntity()
.setRoomId(room.getId())
.setUserId(creatorId)
.setSeatNumber(1)
.setReadyStatus(true)
.setJoinTime(LocalDateTime.now());
roomPlayerRepository.save(creatorPlayer);
// 组装响应
List<RoomPlayerResponse> players = buildRoomPlayers(room.getId());
String creatorName = getNickname(creatorId);
RoomItemResponse roomResponse = RoomItemResponse.from(room, players, gameConfig.getIcon(), creatorName);
log.info("[游戏服务] 创建房间成功, roomId={}, gameId={}, creatorId={}", room.getId(), request.gameId(), creatorId);
// 通知所有在线用户:新房间创建
sseEventPublisher.broadcast(SseEventType.ROOM_CREATED, roomResponse);
return new CreateRoomResponse(roomResponse);
}
@Override
@Transactional
public RoomItemResponse joinRoom(JoinRoomRequest request, String userId) {
GameRoomEntity room = gameRoomRepository.findById(request.roomId())
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
// 检查房间状态
if (!"waiting".equals(room.getStatus())) {
throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
}
// 检查密码
if (room.getPassword() != null && !room.getPassword().isBlank()) {
if (request.password() == null || request.password().isBlank()) {
throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR, "请输入房间密码");
}
if (!passwordEncoder.matches(request.password(), room.getPassword())) {
throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR);
}
}
// 检查房间是否已满
int currentCount = roomPlayerRepository.countByRoomId(room.getId());
if (currentCount >= room.getMaxPlayers()) {
throw new BusinessException(ErrorCode.ROOM_FULL);
}
// 检查用户是否已在其他房间
if (roomPlayerRepository.existsByUserId(userId)) {
throw new BusinessException(ErrorCode.ALREADY_IN_ROOM);
}
// 分配座位号
List<RoomPlayerEntity> existingPlayers = roomPlayerRepository.findByRoomId(room.getId());
int nextSeat = 1;
if (!existingPlayers.isEmpty()) {
int maxSeat = existingPlayers.stream()
.mapToInt(RoomPlayerEntity::getSeatNumber)
.max()
.orElse(0);
nextSeat = maxSeat + 1;
}
// 加入房间
RoomPlayerEntity player = new RoomPlayerEntity()
.setRoomId(room.getId())
.setUserId(userId)
.setSeatNumber(nextSeat)
.setReadyStatus(false)
.setJoinTime(LocalDateTime.now());
roomPlayerRepository.save(player);
// 组装响应
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId())
.orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND));
List<RoomPlayerResponse> players = buildRoomPlayers(room.getId());
String creatorName = getNickname(room.getCreatorId());
log.info("[游戏服务] 玩家加入房间, roomId={}, userId={}", room.getId(), userId);
RoomItemResponse response = RoomItemResponse.from(room, players, gameConfig.getIcon(), creatorName);
// 通知所有在线用户:房间信息更新
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
return response;
}
@Override
@Transactional
public void leaveRoom(String roomId, String userId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
// 检查玩家是否在房间中
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM));
roomPlayerRepository.delete(player);
// remaining 需要在后面的 SSE 推送中用到,提取到方法级
List<RoomPlayerEntity> remaining = roomPlayerRepository.findByRoomId(roomId);
if (remaining.isEmpty()) {
// 无人了,物理删除房间(对局记录和走棋记录保留用于历史查询)
gameRoomRepository.delete(room);
log.info("[游戏服务] 房间无人,已删除, roomId={}", roomId);
} else if (userId.equals(room.getCreatorId())) {
// 房主离开,顺位转让给下一个玩家
RoomPlayerEntity nextOwner = remaining.get(0);
room.setCreatorId(nextOwner.getUserId());
// 新房主自动设为已准备
nextOwner.setReadyStatus(true);
roomPlayerRepository.save(nextOwner);
gameRoomRepository.save(room);
log.info("[游戏服务] 房主离开,转让给 userId={}, roomId={}", nextOwner.getUserId(), roomId);
}
log.info("[游戏服务] 玩家离开房间, roomId={}, userId={}", roomId, userId);
// 获取剩余玩家重新组装房间信息并推送事件
if (remaining.isEmpty()) {
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED,
Map.of("roomId", roomId));
} else {
List<RoomPlayerResponse> players = buildRoomPlayers(roomId);
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId())
.orElse(null);
String icon = gameConfig != null ? gameConfig.getIcon() : "";
String creatorName = getNickname(room.getCreatorId());
RoomItemResponse response = RoomItemResponse.from(room, players, icon, creatorName);
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
}
}
@Override
@Transactional
public void kickPlayer(String roomId, String ownerId, String targetUserId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
// 校验操作者是房主
if (!ownerId.equals(room.getCreatorId())) {
throw new BusinessException(ErrorCode.NOT_ROOM_OWNER);
}
// 不能踢自己
if (ownerId.equals(targetUserId)) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "不能踢出自己,请使用离开房间");
}
// 检查目标玩家是否在房间中
RoomPlayerEntity target = roomPlayerRepository.findByRoomIdAndUserId(roomId, targetUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM));
roomPlayerRepository.delete(target);
log.info("[游戏服务] 房主踢出玩家, roomId={}, targetUserId={}", roomId, targetUserId);
// 通知被踢玩家
sseEventPublisher.sendTo(targetUserId, SseEventType.KICKED,
Map.of("roomId", roomId));
// 通知房间内其他玩家:房间更新
List<RoomPlayerResponse> players = buildRoomPlayers(roomId);
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId())
.orElse(null);
String icon = gameConfig != null ? gameConfig.getIcon() : "";
String creatorName = getNickname(room.getCreatorId());
RoomItemResponse response = RoomItemResponse.from(room, players, icon, creatorName);
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
}
@Override
@Transactional
public boolean toggleReady(String roomId, String userId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
if (!"waiting".equals(room.getStatus())) {
throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
}
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM));
boolean newStatus = !player.getReadyStatus();
player.setReadyStatus(newStatus);
roomPlayerRepository.save(player);
log.info("[游戏服务] 玩家{}准备, roomId={}, userId={}", newStatus ? "" : "取消", roomId, userId);
// 通知房间更新
List<RoomPlayerResponse> players = buildRoomPlayers(roomId);
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId())
.orElse(null);
String icon = gameConfig != null ? gameConfig.getIcon() : "";
String creatorName = getNickname(room.getCreatorId());
RoomItemResponse response = RoomItemResponse.from(room, players, icon, creatorName);
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
return newStatus;
}
/**
* 开始游戏HTTP 端点保留,实际开始游戏由 WebSocket 处理)
*
* WS 的 "start" 消息会触发 GomokuGameServiceImpl.startGame()
* 包含完整校验 + GameRecord 创建 + WS BOARD_SYNC + SSE ROOM_UPDATED。
*
* TODO: 金币结算逻辑预留,后期处理
*/
@Override
@Transactional
public void startGame(String roomId, String ownerId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
// 校验操作者是房主
if (!ownerId.equals(room.getCreatorId())) {
throw new BusinessException(ErrorCode.NOT_ROOM_OWNER);
}
// 校验房间状态
if (!"waiting".equals(room.getStatus())) {
throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
}
// 校验玩家人数 >= 2
List<RoomPlayerEntity> players = roomPlayerRepository.findByRoomId(roomId);
if (players.size() < 2) {
throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT);
}
// 校验所有玩家已准备
for (RoomPlayerEntity player : players) {
if (!player.getReadyStatus()) {
throw new BusinessException(ErrorCode.NOT_ALL_READY);
}
}
// 更新房间状态为进行中
room.setStatus("playing");
gameRoomRepository.save(room);
log.info("[游戏服务] 游戏开始, roomId={}, 参与人数={}", roomId, players.size());
// 通知房间状态更新(大厅刷新)
List<RoomPlayerResponse> allPlayers = buildRoomPlayers(roomId);
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId())
.orElse(null);
String icon = gameConfig != null ? gameConfig.getIcon() : "";
String creatorName = getNickname(room.getCreatorId());
RoomItemResponse response = RoomItemResponse.from(room, allPlayers, icon, creatorName);
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
}
@Override
public RoomDetailResponse getRoomDetail(String roomId) {
GameRoomEntity room = gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId())
.orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND));
// 组装玩家详情(含座位号和准备状态)
List<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(roomId);
List<String> userIds = roomPlayers.stream()
.map(RoomPlayerEntity::getUserId).toList();
Map<String, UserEntity> userMap = userRepository.findByAccountIdIn(userIds).stream()
.collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
List<RoomDetailResponse.RoomPlayerDetail> playerDetails = roomPlayers.stream()
.map(rp -> {
UserEntity user = userMap.get(rp.getUserId());
return new RoomDetailResponse.RoomPlayerDetail(
rp.getUserId(),
user != null ? user.getAvatar() : "",
user != null ? user.getNickName() : "未知玩家",
rp.getSeatNumber(),
rp.getReadyStatus()
);
}).toList();
String creatorName = getNickname(room.getCreatorId());
// 对局中或有对局记录时,查询棋盘状态
int[][] boardState = null;
String currentTurn = null;
List<RoomDetailResponse.MoveItem> moves = List.of();
if ("playing".equals(room.getStatus()) || "finished".equals(room.getStatus())) {
// 查询走棋记录
List<GameMoveEntity> moveEntities = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(roomId);
moves = moveEntities.stream()
.map(m -> new RoomDetailResponse.MoveItem(
m.getMoveIndex(), m.getPlayerId(), m.getRowNum(), m.getColNum()))
.toList();
// 从走棋记录重建棋盘
boardState = GomokuGameService.rebuildBoard(moveEntities, GomokuGameService.BOARD_SIZE);
// currentTurn 由服务端运行时管理HTTP 快照中暂不返回
}
log.info("[游戏服务] 获取房间详情, roomId={}, status={}, 玩家数={}",
roomId, room.getStatus(), playerDetails.size());
return new RoomDetailResponse(
room.getId(), room.getName(), room.getGameId(),
gameConfig.getIcon(), gameConfig.getName(),
room.getMaxPlayers(), room.getMode(), room.getStakes(),
room.getStatus(), room.getCreatorId(), creatorName,
room.getAllowSpectate(), playerDetails, boardState, currentTurn, moves
);
}
// ==================== 私有辅助方法 ====================
/**
* 组装房间内的玩家信息列表
*
* @param roomId 房间 ID
* @return 玩家响应列表(含头像和昵称)
*/
private List<RoomPlayerResponse> buildRoomPlayers(String roomId) {
List<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(roomId);
if (roomPlayers.isEmpty()) {
return List.of();
}
// 批量查询玩家用户信息
List<String> userIds = roomPlayers.stream()
.map(RoomPlayerEntity::getUserId)
.toList();
List<UserEntity> users = userRepository.findByAccountIdIn(userIds);
Map<String, UserEntity> userMap = users.stream()
.collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
return roomPlayers.stream()
.map(rp -> {
UserEntity user = userMap.get(rp.getUserId());
String avatar = (user != null) ? user.getAvatar() : "";
String nickname = (user != null) ? user.getNickName() : "未知玩家";
return new RoomPlayerResponse(avatar, nickname);
})
.toList();
}
/**
* 获取用户昵称
*
* @param accountId 账号 ID
* @return 昵称,查不到则返回 "未知玩家"
*/
private String getNickname(String accountId) {
return userRepository.findByAccountId(accountId)
.map(UserEntity::getNickName)
.orElse("未知玩家");
}
}

View File

@@ -1,505 +0,0 @@
package com.webgame.webgamebackend.service.impl;
import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.common.dto.game.RoomItemResponse;
import com.webgame.webgamebackend.common.dto.game.RoomPlayerResponse;
import com.webgame.webgamebackend.common.event.SseEventPublisher;
import com.webgame.webgamebackend.common.event.SseEventType;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
import com.webgame.webgamebackend.entities.*;
import com.webgame.webgamebackend.repository.*;
import com.webgame.webgamebackend.service.GomokuGameService;
import com.webgame.webgamebackend.ws.RoomSessionManager;
import com.webgame.webgamebackend.ws.dto.WsMessage;
import com.webgame.webgamebackend.ws.dto.WsOutboundMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 五子棋对局逻辑服务实现
*
* 管理五子棋对局的完整生命周期:落子校验、胜负判定、走棋记录、断线处理。
* 通过 RoomSessionManager 向房间内 WebSocket 会话广播状态变更。
* 通过 SseEventPublisher 向大厅推送房间状态变更。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GomokuGameServiceImpl implements GomokuGameService {
private final GameRoomRepository gameRoomRepository;
private final GameMoveRepository gameMoveRepository;
private final GameRecordRepository gameRecordRepository;
private final RoomPlayerRepository roomPlayerRepository;
private final UserRepository userRepository;
private final GameConfigRepository gameConfigRepository;
private final SseEventPublisher sseEventPublisher;
/** 断线超时时间(秒) */
private static final long DISCONNECT_TIMEOUT_SECONDS = 60;
/** 断线超时定时器线程池 */
private final ScheduledExecutorService disconnectScheduler =
Executors.newSingleThreadScheduledExecutor(r ->
new Thread(r, "disconnect-timeout"));
/** 断线超时任务映射: "roomId:userId" → ScheduledFuture */
private final ConcurrentHashMap<String, ScheduledFuture<?>> disconnectTasks = new ConcurrentHashMap<>();
@Override
@Transactional
public void placeStone(String roomId, String userId, int row, int col, RoomSessionManager sessionManager) {
GameRoomEntity room = getRoom(roomId);
if (!"playing".equals(room.getStatus())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "对局未开始");
}
// 校验坐标合法性
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "坐标超出棋盘范围");
}
// 校验玩家在房间中
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM));
// 获取走棋记录,判定当前轮到谁
List<GameMoveEntity> moves = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(roomId);
int moveCount = moves.size();
// 按座位号排序,确保黑方/白方分配与座位对应seat=1 为黑方先手)
List<RoomPlayerEntity> players = roomPlayerRepository.findByRoomId(roomId)
.stream()
.sorted(java.util.Comparator.comparingInt(RoomPlayerEntity::getSeatNumber))
.toList();
int playerIndex = -1;
for (int i = 0; i < players.size(); i++) {
if (players.get(i).getUserId().equals(userId)) {
playerIndex = i;
break;
}
}
int expectedPlayer = moveCount % 2; // 0 = 黑方第一个玩家1 = 白方(第二个玩家)
if (playerIndex != expectedPlayer) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "还未轮到你落子");
}
// 校验该位置是否已有棋子
for (GameMoveEntity m : moves) {
if (m.getRowNum() == row && m.getColNum() == col) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "该位置已有棋子");
}
}
// 保存走棋记录
GameMoveEntity move = new GameMoveEntity()
.setRoomId(roomId)
.setPlayerId(userId)
.setRowNum(row)
.setColNum(col)
.setMoveIndex(moveCount + 1);
gameMoveRepository.save(move);
log.info("[五子棋] 落子, roomId={}, userId={}, row={}, col={}, moveIndex={}",
roomId, userId, row, col, moveCount + 1);
// 广播落子
broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.MOVE, Map.of(
"row", row, "col", col,
"playerId", userId,
"moveIndex", moveCount + 1
)));
// 广播轮次切换
int nextPlayerIndex = (moveCount + 1) % 2;
String nextTurnId = nextPlayerIndex < players.size() ? players.get(nextPlayerIndex).getUserId() : null;
broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.TURN_CHANGE, Map.of(
"playerId", nextTurnId
)));
// 检查胜负
int[][] board = GomokuGameService.rebuildBoard(
gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(roomId), BOARD_SIZE);
int stone = playerIndex == 0 ? 1 : 2; // 1=黑2=白
if (checkWin(board, row, col, stone)) {
endGame(roomId, userId, "win", room, sessionManager);
} else if (moveCount + 1 >= BOARD_SIZE * BOARD_SIZE) {
// 棋盘满了,平局
endGame(roomId, null, "draw", room, sessionManager);
}
}
@Override
@Transactional
public void toggleReady(String roomId, String userId, RoomSessionManager sessionManager) {
GameRoomEntity room = getRoom(roomId);
if (!"waiting".equals(room.getStatus())) {
throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
}
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM));
boolean newStatus = !player.getReadyStatus();
player.setReadyStatus(newStatus);
roomPlayerRepository.save(player);
log.info("[五子棋] 准备状态变更, roomId={}, userId={}, ready={}", roomId, userId, newStatus);
// WS 广播准备状态变更(房间内实时同步)
broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.READY_CHANGE, Map.of(
"userId", userId, "ready", newStatus
)));
}
@Override
@Transactional
public void resign(String roomId, String userId, RoomSessionManager sessionManager) {
GameRoomEntity room = getRoom(roomId);
if (!"playing".equals(room.getStatus())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "对局未开始");
}
// 判定对手为胜方
List<RoomPlayerEntity> players = roomPlayerRepository.findByRoomId(roomId);
String winnerId = players.stream()
.filter(p -> !p.getUserId().equals(userId))
.map(RoomPlayerEntity::getUserId)
.findFirst()
.orElse(null);
endGame(roomId, winnerId, "resign", room, sessionManager);
}
@Override
public void requestDraw(String roomId, String userId, RoomSessionManager sessionManager) {
broadcast(roomId, sessionManager, new WsMessage("draw_requested", Map.of(
"fromUserId", userId
)));
}
@Override
@Transactional
public void respondToDraw(String roomId, String userId, boolean accept, RoomSessionManager sessionManager) {
if (accept) {
GameRoomEntity room = getRoom(roomId);
endGame(roomId, null, "draw", room, sessionManager);
} else {
broadcast(roomId, sessionManager, new WsMessage("draw_rejected", Map.of(
"fromUserId", userId
)));
}
}
@Override
@Transactional
public void leaveRoom(String roomId, String userId, RoomSessionManager sessionManager) {
GameRoomEntity room = getRoom(roomId);
// 检查玩家是否在房间中
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId)
.orElse(null);
if (player == null) {
return; // 已不在房间,无需操作
}
roomPlayerRepository.delete(player);
List<RoomPlayerEntity> remaining = roomPlayerRepository.findByRoomId(roomId);
// WS 广播玩家离开
broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of(
"userId", userId
)));
if (remaining.isEmpty()) {
// 无人了,关闭对局记录(如果进行中)并删除房间
if ("playing".equals(room.getStatus())) {
gameRecordRepository.findByRoomId(roomId).ifPresent(record -> {
record.setEndedAt(LocalDateTime.now());
record.setResultType("abandoned");
gameRecordRepository.save(record);
});
}
gameRoomRepository.delete(room);
log.info("[五子棋] 房间无人,已删除, roomId={}", roomId);
// SSE: 大厅移除房间
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId));
} else if (userId.equals(room.getCreatorId())) {
// 房主离开,顺位转让给剩余第一个玩家
RoomPlayerEntity nextOwner = remaining.get(0);
room.setCreatorId(nextOwner.getUserId());
nextOwner.setReadyStatus(true);
roomPlayerRepository.save(nextOwner);
gameRoomRepository.save(room);
log.info("[五子棋] 房主离开,转让给 userId={}, roomId={}", nextOwner.getUserId(), roomId);
// SSE: 大厅更新房间信息
pushRoomUpdated(room);
} else {
// SSE: 大厅更新房间信息
pushRoomUpdated(room);
}
}
@Override
@Transactional
public void startGame(String roomId, String userId, RoomSessionManager sessionManager) {
GameRoomEntity room = getRoom(roomId);
if (!userId.equals(room.getCreatorId())) {
throw new BusinessException(ErrorCode.NOT_ROOM_OWNER);
}
if (!"waiting".equals(room.getStatus())) {
throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
}
List<RoomPlayerEntity> players = roomPlayerRepository.findByRoomId(roomId);
if (players.size() < 2) {
throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT);
}
for (RoomPlayerEntity p : players) {
if (!p.getReadyStatus()) {
throw new BusinessException(ErrorCode.NOT_ALL_READY);
}
}
// 更新房间状态为进行中
room.setStatus("playing");
gameRoomRepository.save(room);
// 记录对局开始
GameRecordEntity record = new GameRecordEntity()
.setRoomId(roomId)
.setStartedAt(LocalDateTime.now());
gameRecordRepository.save(record);
log.info("[五子棋] 对局开始, roomId={}, 玩家数={}", roomId, players.size());
// WS: 广播棋盘同步(空棋盘 + 黑方先行)
broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.BOARD_SYNC, Map.of(
"board", new int[BOARD_SIZE][BOARD_SIZE],
"currentTurn", players.get(0).getUserId() // 第一位玩家先行(黑方)
)));
// SSE: 大厅更新房间状态waiting → playing
pushRoomUpdated(room);
}
@Override
public void handleDisconnect(String roomId, String userId, RoomSessionManager sessionManager) {
String taskKey = roomId + ":" + userId;
log.info("[五子棋] 玩家断连,启动{}秒超时定时器, roomId={}, userId={}",
DISCONNECT_TIMEOUT_SECONDS, roomId, userId);
// 启动断线超时定时器
ScheduledFuture<?> task = disconnectScheduler.schedule(() -> {
disconnectTasks.remove(taskKey);
try {
onDisconnectTimeout(roomId, userId, sessionManager);
} catch (Exception e) {
log.error("[五子棋] 断线超时处理异常, roomId={}, userId={}", roomId, userId, e);
}
}, DISCONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
disconnectTasks.put(taskKey, task);
}
@Override
public void cancelDisconnectTimeout(String roomId, String userId) {
String taskKey = roomId + ":" + userId;
ScheduledFuture<?> task = disconnectTasks.remove(taskKey);
if (task != null) {
boolean cancelled = task.cancel(false);
log.info("[五子棋] 玩家重连,取消断线超时定时器, roomId={}, userId={}, cancelled={}",
roomId, userId, cancelled);
}
}
// ==================== 私有辅助方法 ====================
/**
* 根据房间 ID 查询房间,不存在则抛出异常
*/
private GameRoomEntity getRoom(String roomId) {
return gameRoomRepository.findById(roomId)
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
}
/**
* 向房间内所有 WS 会话广播消息
*/
private void broadcast(String roomId, RoomSessionManager sessionManager, WsMessage message) {
String json = JSON.toJSONString(message);
for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) {
if (ws.isOpen()) {
try {
ws.sendMessage(new TextMessage(json));
} catch (IOException e) {
log.error("[WS] 广播失败, sessionId={}", ws.getId(), e);
}
}
}
}
/**
* 推送房间更新到大厅SSE
*/
private void pushRoomUpdated(GameRoomEntity room) {
List<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(room.getId());
List<RoomPlayerResponse> players = buildRoomPlayerResponses(roomPlayers);
String creatorName = getNickname(room.getCreatorId());
String icon = gameConfigRepository.findByGameId(room.getGameId())
.map(GameConfigEntity::getIcon)
.orElse("");
RoomItemResponse response = RoomItemResponse.from(room, players, icon, creatorName);
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
}
/**
* 断线超时处理:对局中自动认输,等待中自动离开
*/
@Transactional
public void onDisconnectTimeout(String roomId, String userId, RoomSessionManager sessionManager) {
GameRoomEntity room = getRoom(roomId);
// 检查玩家是否还在房间中(可能已被踢出或已离开)
boolean stillInRoom = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId).isPresent();
if (!stillInRoom) {
log.info("[五子棋] 断线超时:玩家已不在房间, roomId={}, userId={}", roomId, userId);
return;
}
if ("playing".equals(room.getStatus())) {
log.info("[五子棋] 断线超时:对局中自动判负, roomId={}, userId={}", roomId, userId);
// 自动认输
resign(roomId, userId, sessionManager);
// 移除断线玩家记录(对手仍留在房间,离开时触发删除)
leaveRoom(roomId, userId, sessionManager);
} else {
log.info("[五子棋] 断线超时:等待中自动离开, roomId={}, userId={}", roomId, userId);
// 自动离开房间
leaveRoom(roomId, userId, sessionManager);
}
}
/**
* 对局结束
*
* TODO: 金币结算逻辑预留,后期处理
*/
@Transactional
public void endGame(String roomId, String winnerId, String resultType,
GameRoomEntity room, RoomSessionManager sessionManager) {
room.setStatus("finished");
gameRoomRepository.save(room);
// 更新对局记录
GameRecordEntity record = gameRecordRepository.findByRoomId(roomId)
.orElse(null);
if (record != null) {
record.setWinnerId(winnerId)
.setResultType(resultType)
.setEndedAt(LocalDateTime.now());
gameRecordRepository.save(record);
}
log.info("[五子棋] 对局结束, roomId={}, winnerId={}, resultType={}",
roomId, winnerId, resultType);
// WS: 广播对局结束
broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.GAME_OVER, Map.of(
"winnerId", winnerId,
"resultType", resultType
)));
// SSE: 大厅更新房间状态
pushRoomUpdated(room);
}
/**
* 检查五连胜利(以指定坐标为中心,检查四个方向)
*
* @param board 棋盘二维数组
* @param row 落子行坐标
* @param col 落子列坐标
* @param stone 当前棋子值1=黑2=白)
* @return 是否形成五连
*/
private boolean checkWin(int[][] board, int row, int col, int stone) {
// 四个方向:水平、垂直、正对角线、反对角线
int[][] dirs = {{0, 1}, {1, 0}, {1, 1}, {1, -1}};
for (int[] dir : dirs) {
int count = 1;
// 正方向延伸
for (int i = 1; i < 5; i++) {
int r = row + dir[0] * i, c = col + dir[1] * i;
if (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == stone) {
count++;
} else {
break;
}
}
// 反方向延伸
for (int i = 1; i < 5; i++) {
int r = row - dir[0] * i, c = col - dir[1] * i;
if (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == stone) {
count++;
} else {
break;
}
}
if (count >= 5) {
return true;
}
}
return false;
}
/**
* 组装房间玩家响应列表(含头像和昵称)
*/
private List<RoomPlayerResponse> buildRoomPlayerResponses(List<RoomPlayerEntity> roomPlayers) {
if (roomPlayers.isEmpty()) {
return List.of();
}
List<String> userIds = roomPlayers.stream()
.map(RoomPlayerEntity::getUserId)
.toList();
List<UserEntity> users = userRepository.findByAccountIdIn(userIds);
Map<String, UserEntity> userMap = users.stream()
.collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
return roomPlayers.stream()
.map(rp -> {
UserEntity user = userMap.get(rp.getUserId());
String avatar = user != null ? user.getAvatar() : "";
String nickname = user != null ? user.getNickName() : "未知玩家";
return new RoomPlayerResponse(avatar, nickname);
})
.toList();
}
/**
* 获取用户昵称
*/
private String getNickname(String accountId) {
return userRepository.findByAccountId(accountId)
.map(UserEntity::getNickName)
.orElse("未知玩家");
}
}

View File

@@ -1,138 +0,0 @@
package com.webgame.webgamebackend.sse;
import com.webgame.webgamebackend.common.event.SseEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* SSE 连接管理器
*
* 维护 userId → SseEmitter 映射,提供广播和定向推送能力。
* 使用 ConcurrentHashMap 保证线程安全。
*/
@Slf4j
@Component
public class SseConnectionManager {
/** userId → SseEmitter 映射表,线程安全 */
private final ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();
/**
* 注册新的 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);
});
emitter.onTimeout(() -> {
log.debug("[SSE] 连接超时, userId={}", userId);
emitters.remove(userId, emitter);
});
emitter.onError(throwable -> {
log.debug("[SSE] 连接异常, userId={}, reason={}", userId, throwable.getMessage());
emitters.remove(userId, emitter);
});
log.info("[SSE] 用户连接成功, userId={}, 当前在线={}", userId, emitters.size());
}
/**
* 移除连接
*
* 先移除映射再尝试关闭,避免回调递归。
* 响应已损坏时 complete() 可能抛出 RuntimeException静默忽略。
*
* @param userId 用户 accountId
*/
public void remove(String userId) {
SseEmitter removed = emitters.remove(userId);
if (removed != null) {
try {
removed.complete();
} catch (Throwable ignored) {
// 响应已损坏AsyncRequestNotUsableException 等),忽略
}
log.info("[SSE] 用户连接已移除, userId={}, 当前在线={}", userId, emitters.size());
}
}
/**
* 广播事件给所有在线用户
*
* @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());
return;
}
log.debug("[SSE] 定向推送, userId={}, eventType={}", userId, event.type());
sendEvent(emitter, event, userId);
}
/**
* 获取当前在线连接数
*
* @return 在线用户数
*/
public int getOnlineCount() {
return emitters.size();
}
/**
* 向单个 SseEmitter 发送事件
*
* 捕获 IOException客户端已断开但还未触发回调
* 此时安全移除连接。
*/
private void sendEvent(SseEmitter emitter, SseEvent event, String userId) {
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) {
}
}
}
}

View File

@@ -1,127 +0,0 @@
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
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;
/**
* SSE 连接端点
*
* 提供 GET /sse/subscribe 端点,建立持久化 SSE 连接。
* 连接成功后立即发送 connected 事件确认握手,之后每 30 秒发送心跳保活。
*
* 鉴权由 SaTokenConfigure 拦截器统一处理,前端通过 fetch-event-source
* 的 headers 传 saToken401 时由前端 err.response.status 直接读取。
*/
@Slf4j
@RestController
@RequestMapping("/sse")
@RequiredArgsConstructor
public class SseController {
private final SseConnectionManager connectionManager;
/**
* 建立 SSE 订阅连接
*
* 鉴权由拦截器处理,此处直接获取登录用户 ID。
*
* @return SseEmitter 实例30 分钟超时)
*/
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe() {
String userId = StpUtil.getLoginIdAsString();
SseEmitter emitter = new SseEmitter(30 * 60 * 1000L); // 30 分钟超时
connectionManager.register(userId, emitter);
// 发送初始连接确认
try {
emitter.send(SseEmitter.event()
.name("connected")
.data("{\"message\":\"连接成功\"}"));
} catch (IOException e) {
log.warn("[SSE] 发送 connected 事件失败, userId={}", userId);
}
// 启动心跳定时任务
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);
// 连接关闭时停止心跳
emitter.onCompletion(heartbeat::shutdown);
emitter.onTimeout(heartbeat::shutdown);
emitter.onError(e -> heartbeat.shutdown());
log.info("[SSE] 新连接建立, userId={}, 当前在线={}", userId, 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);
}
}
}

View File

@@ -74,6 +74,15 @@ logging:
# #如果是true则用的是https而不是http,默认值是true
# secure: false
############## 游戏配置 ##############
game:
# 断线重连超时时间(秒)
disconnect-timeout-seconds: 60
# 聊天消息最大长度
chat-max-length: 500
# 五子棋棋盘大小
board-size: 15
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token 名称(同时也是 cookie 名称)

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB