From 9be461539c5c85087b46845d0069833361aaf798 Mon Sep 17 00:00:00 2001 From: Azure <983547216@qq.com> Date: Tue, 23 Jun 2026 13:49:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=94=B9=E6=88=BF=E9=97=B4?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/GomokuGameService.java | 8 + .../service/impl/GameServiceImpl.java | 61 ++---- .../service/impl/GomokuGameServiceImpl.java | 176 ++++++++++++++++-- .../ws/RoomWebSocketHandler.java | 6 +- 4 files changed, 187 insertions(+), 64 deletions(-) diff --git a/src/main/java/com/webgame/webgamebackend/service/GomokuGameService.java b/src/main/java/com/webgame/webgamebackend/service/GomokuGameService.java index e840b98..3274581 100644 --- a/src/main/java/com/webgame/webgamebackend/service/GomokuGameService.java +++ b/src/main/java/com/webgame/webgamebackend/service/GomokuGameService.java @@ -62,6 +62,14 @@ public interface GomokuGameService { */ void handleDisconnect(String roomId, String userId, RoomSessionManager sessionManager); + /** + * 取消断连超时定时器(重连时调用) + * + * @param roomId 房间 ID + * @param userId 用户 ID + */ + void cancelDisconnectTimeout(String roomId, String userId); + /** * 从走棋记录重建棋盘状态(静态工具方法) * diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java index ae580b8..e90b033 100644 --- a/src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java +++ b/src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java @@ -338,6 +338,14 @@ public class GameServiceImpl implements GameService { 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) { @@ -360,43 +368,20 @@ public class GameServiceImpl implements GameService { throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT); } - // 校验所有玩家已准备(房主除外,房主默认准备) + // 校验所有玩家已准备 for (RoomPlayerEntity player : players) { - if (!player.getReadyStatus() && !player.getUserId().equals(room.getCreatorId())) { + if (!player.getReadyStatus()) { throw new BusinessException(ErrorCode.NOT_ALL_READY); } } - // 扣除房主底注 - UserEntity ownerUser = userRepository.findByAccountId(ownerId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - if (ownerUser.getBalance() < room.getStakes()) { - throw new BusinessException(ErrorCode.BALANCE_INSUFFICIENT); - } - ownerUser.setBalance(ownerUser.getBalance() - room.getStakes()); - userRepository.save(ownerUser); - - // 扣除其他玩家底注 - for (RoomPlayerEntity player : players) { - if (!player.getUserId().equals(ownerId)) { - UserEntity user = userRepository.findByAccountId(player.getUserId()) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - if (user.getBalance() < room.getStakes()) { - throw new BusinessException(ErrorCode.BALANCE_INSUFFICIENT, - "玩家 " + user.getNickName() + " 余额不足"); - } - user.setBalance(user.getBalance() - room.getStakes()); - userRepository.save(user); - } - } - // 更新房间状态为进行中 room.setStatus("playing"); gameRoomRepository.save(room); - log.info("[游戏服务] 游戏开始, roomId={}, 参与人数={}, 底注={}", roomId, players.size(), room.getStakes()); + log.info("[游戏服务] 游戏开始, roomId={}, 参与人数={}", roomId, players.size()); - // 通知房间状态更新 + // 通知房间状态更新(大厅刷新) List allPlayers = buildRoomPlayers(roomId); GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId()) .orElse(null); @@ -404,28 +389,6 @@ public class GameServiceImpl implements GameService { String creatorName = getNickname(room.getCreatorId()); RoomItemResponse response = RoomItemResponse.from(room, allPlayers, icon, creatorName); sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response); - - // 通知房间内每个玩家游戏已开始 - for (RoomPlayerEntity player : players) { - sseEventPublisher.sendTo(player.getUserId(), SseEventType.GAME_STARTED, - Map.of("roomId", roomId)); - } - - // 通知余额变动(房主已在前面扣除了底注) - ownerUser = userRepository.findByAccountId(ownerId).orElse(null); - if (ownerUser != null) { - sseEventPublisher.sendTo(ownerId, SseEventType.BALANCE_CHG, - Map.of("balance", ownerUser.getBalance(), "delta", -room.getStakes())); - } - for (RoomPlayerEntity player : players) { - if (!player.getUserId().equals(ownerId)) { - UserEntity user = userRepository.findByAccountId(player.getUserId()).orElse(null); - if (user != null) { - sseEventPublisher.sendTo(player.getUserId(), SseEventType.BALANCE_CHG, - Map.of("balance", user.getBalance(), "delta", -room.getStakes())); - } - } - } } @Override diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/GomokuGameServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/GomokuGameServiceImpl.java index 080b227..69cc45c 100644 --- a/src/main/java/com/webgame/webgamebackend/service/impl/GomokuGameServiceImpl.java +++ b/src/main/java/com/webgame/webgamebackend/service/impl/GomokuGameServiceImpl.java @@ -1,6 +1,10 @@ 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.*; @@ -18,14 +22,22 @@ 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 @@ -37,6 +49,19 @@ public class GomokuGameServiceImpl implements GomokuGameService { 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> disconnectTasks = new ConcurrentHashMap<>(); @Override @Transactional @@ -59,7 +84,6 @@ public class GomokuGameServiceImpl implements GomokuGameService { List moves = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(roomId); int moveCount = moves.size(); - // 判定当前玩家应该下哪一方(黑先白后) // 按座位号排序,确保黑方/白方分配与座位对应(seat=1 为黑方先手) List players = roomPlayerRepository.findByRoomId(roomId) .stream() @@ -139,6 +163,7 @@ public class GomokuGameServiceImpl implements GomokuGameService { log.info("[五子棋] 准备状态变更, roomId={}, userId={}, ready={}", roomId, userId, newStatus); + // WS 广播准备状态变更(房间内实时同步) broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.READY_CHANGE, Map.of( "userId", userId, "ready", newStatus ))); @@ -186,24 +211,43 @@ public class GomokuGameServiceImpl implements GomokuGameService { @Override @Transactional public void leaveRoom(String roomId, String userId, RoomSessionManager sessionManager) { - // 复用 GameService 离开逻辑,通过 GameController 的 leaveRoom 复用 - // 这里简单处理:删除玩家记录 + GameRoomEntity room = getRoom(roomId); + + // 检查玩家是否在房间中 RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId) .orElse(null); - if (player != null) { - roomPlayerRepository.delete(player); + if (player == null) { + return; // 已不在房间,无需操作 } + roomPlayerRepository.delete(player); + List remaining = roomPlayerRepository.findByRoomId(roomId); + + // WS 广播玩家离开 broadcast(roomId, sessionManager, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of( "userId", userId ))); - // 检查是否还有玩家 - List remaining = roomPlayerRepository.findByRoomId(roomId); if (remaining.isEmpty()) { - GameRoomEntity room = getRoom(roomId); + // 无人了,房间标记为结束 room.setStatus("finished"); gameRoomRepository.save(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); } } @@ -228,6 +272,7 @@ public class GomokuGameServiceImpl implements GomokuGameService { } } + // 更新房间状态为进行中 room.setStatus("playing"); gameRoomRepository.save(room); @@ -239,18 +284,44 @@ public class GomokuGameServiceImpl implements GomokuGameService { 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) { - // 断连不立即离开房间,等重连超时后再清理 - // 首期简单处理:延迟 30 秒后如果玩家未重连则离开 - log.info("[五子棋] 玩家断连, roomId={}, userId={}", roomId, userId); + 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); + } } // ==================== 私有辅助方法 ==================== @@ -279,8 +350,49 @@ public class GomokuGameServiceImpl implements GomokuGameService { } } + /** + * 推送房间更新到大厅(SSE) + */ + private void pushRoomUpdated(GameRoomEntity room) { + List roomPlayers = roomPlayerRepository.findByRoomId(room.getId()); + List 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); + } else { + log.info("[五子棋] 断线超时:等待中自动离开, roomId={}, userId={}", roomId, userId); + // 自动离开房间 + leaveRoom(roomId, userId, sessionManager); + } + } + /** * 对局结束 + * + * TODO: 金币结算逻辑预留,后期处理 */ @Transactional public void endGame(String roomId, String winnerId, String resultType, @@ -301,11 +413,14 @@ public class GomokuGameServiceImpl implements GomokuGameService { 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); } /** @@ -346,4 +461,37 @@ public class GomokuGameServiceImpl implements GomokuGameService { } return false; } + + /** + * 组装房间玩家响应列表(含头像和昵称) + */ + private List buildRoomPlayerResponses(List roomPlayers) { + if (roomPlayers.isEmpty()) { + return List.of(); + } + List userIds = roomPlayers.stream() + .map(RoomPlayerEntity::getUserId) + .toList(); + List users = userRepository.findByAccountIdIn(userIds); + Map 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("未知玩家"); + } } diff --git a/src/main/java/com/webgame/webgamebackend/ws/RoomWebSocketHandler.java b/src/main/java/com/webgame/webgamebackend/ws/RoomWebSocketHandler.java index 84e4fae..0b9dcfd 100644 --- a/src/main/java/com/webgame/webgamebackend/ws/RoomWebSocketHandler.java +++ b/src/main/java/com/webgame/webgamebackend/ws/RoomWebSocketHandler.java @@ -64,8 +64,12 @@ public class RoomWebSocketHandler extends TextWebSocketHandler { } String userId = loginId.toString(); - // 注册会话 + // 注册会话(内部会清理同用户的旧会话) sessionManager.addSession(roomId, userId, session); + + // 取消断线超时定时器(重连场景) + gomokuGameService.cancelDisconnectTimeout(roomId, userId); + log.info("[WS] 连接建立, roomId={}, userId={}, sessionId={}", roomId, userId, session.getId()); }