Compare commits

...

23 Commits

Author SHA1 Message Date
c5e9a149f4 fix: 暂存 2026-06-23 14:44:07 +08:00
2a311ca25f fix: 暂存 2026-06-23 14:32:19 +08:00
9be461539c fix: 修改房间逻辑 2026-06-23 13:49:15 +08:00
47e586e7f5 feat: satoken修改 2026-06-23 12:53:01 +08:00
15bf6d0531 fix: 代码审查问题修复 — rebuildBoard 参数修正 + 玩家座位号排序 2026-06-23 11:28:27 +08:00
f85e23d545 feat: 添加五子棋对局逻辑服务 2026-06-23 11:07:31 +08:00
6e4eb6120a feat: 添加房间详情 API 2026-06-23 11:05:17 +08:00
5209df46d1 feat: CreateRoomRequest 和 RoomItemResponse 新增 allowSpectate 字段 2026-06-23 11:03:17 +08:00
e72fbff6e6 feat: 添加走棋记录和对局记录 Repository 2026-06-23 11:01:40 +08:00
05520a2b1d feat: 添加走棋记录、对局记录实体,房间新增观战开关 2026-06-23 10:59:49 +08:00
be37d51930 feat: 添加 WebSocket Handler 与会话管理器 2026-06-23 10:55:06 +08:00
6ef11b8d5b feat: 添加 WebSocket 消息 DTO 2026-06-23 10:53:41 +08:00
d0ade3c469 feat: 添加 WebSocket 基础设施 2026-06-23 10:49:23 +08:00
d63ce3326b feat: satoken修改 2026-06-23 10:14:14 +08:00
4c3e3717eb feat: satoken修改 2026-06-22 14:48:14 +08:00
9a2dd66df4 feat: 添加 SSE 测试接口(在线人数查询 + 手动广播事件) 2026-06-22 14:09:31 +08:00
1e281ff8ca test: 添加 SSE 集成测试
验证 SseEventPublisher 的 Bean 注入、broadcast 和 sendTo 方法。
共 3 个测试用例全部通过。
2026-06-22 13:50:02 +08:00
bf09b7f342 feat: GameServiceImpl 集成 SSE 事件发布 2026-06-22 13:45:26 +08:00
399c5f5bd8 feat: 添加 SseController SSE 订阅端点 2026-06-22 13:43:25 +08:00
808dff53d2 feat: 添加 RedisSseListener 和 SseConfig Redis Pub/Sub 配置 2026-06-22 13:42:37 +08:00
42ae5ef1af feat: 添加 SseEventPublisher Redis Pub/Sub 事件发布器 2026-06-22 13:41:36 +08:00
0f240361bb feat: 添加 SseConnectionManager SSE 连接管理器 2026-06-22 13:40:56 +08:00
875f2bc073 feat: 添加 SseEventType 枚举和 SseEvent record 事件模型 2026-06-22 13:40:06 +08:00
30 changed files with 1993 additions and 38 deletions

View File

@@ -27,6 +27,12 @@
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- JPA 持久层 -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -26,8 +26,11 @@ public class SaTokenConfigure implements WebMvcConfigurer {
.notMatch("/swagger-ui.html")
.notMatch("/v3/api-docs/**")
.notMatch("/webjars/**")
.notMatch("/error")
.notMatch("/sse/**")
.check(r -> StpUtil.checkLogin());
})).addPathPatterns("/**");
})).addPathPatterns("/**")
.excludePathPatterns("/error", "/sse/**");
}
/**

View File

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

View File

@@ -0,0 +1,53 @@
package com.webgame.webgamebackend.common.dto.game;
import java.util.List;
/**
* 房间详情响应
*
* 包含房间基本信息、玩家信息(含座位号和准备状态)、对局状态。
* 用于前端进入房间页时获取初始快照。
*/
public record RoomDetailResponse(
String roomId,
String name,
String gameId,
String gameIcon,
String gameName,
Integer maxPlayers,
String mode,
Integer stakes,
String status,
String creatorId,
String creatorName,
Boolean allowSpectate,
List<RoomPlayerDetail> players,
/** 对局中才有棋盘状态 */
int[][] boardState,
/** 当前轮到哪方落子(黑方/白方的 userIdnull 表示对局未开始 */
String currentTurn,
/** 走棋记录(对局中才有) */
List<MoveItem> moves
) {
/**
* 带座位号和准备状态的玩家信息
*/
public record RoomPlayerDetail(
String userId,
String avatar,
String nickname,
int seatNumber,
boolean ready
) {}
/**
* 走棋记录项
*/
public record MoveItem(
int moveIndex,
String playerId,
int row,
int col
) {}
}

View File

@@ -19,7 +19,8 @@ public record RoomItemResponse(
String statusText,
int stakes,
String mode,
String creatorName
String creatorName,
Boolean allowSpectate
) {
/** 状态 → 展示文本映射 */
private static final Map<String, String> STATUS_TEXT_MAP = Map.of(
@@ -53,7 +54,8 @@ public record RoomItemResponse(
STATUS_TEXT_MAP.getOrDefault(room.getStatus(), "未知"),
room.getStakes(),
room.getMode(),
creatorName
creatorName,
room.getAllowSpectate()
);
}
}

View File

@@ -0,0 +1,50 @@
package com.webgame.webgamebackend.common.event;
import jakarta.annotation.Nullable;
/**
* SSE 事件模型(不可变 record
*
* @param id 事件唯一 ID格式 "evt_{timestamp}_{random}"
* @param type 事件类型
* @param targetId 目标用户 accountIdnull 表示广播给所有在线用户
* @param data 事件携带的 JSON 数据
* @param timestamp 事件时间戳(毫秒)
*/
public record SseEvent(
String id,
SseEventType type,
@Nullable String targetId,
String data,
long timestamp
) {
/**
* 创建广播事件(无特定目标用户)
*
* @param type 事件类型
* @param data JSON 数据字符串
* @return SseEvent 实例
*/
public static SseEvent broadcast(SseEventType type, String data) {
long now = System.currentTimeMillis();
return new SseEvent("evt_" + now + "_" + randomSuffix(), type, null, data, now);
}
/**
* 创建定向事件(推送给特定用户)
*
* @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);
}
/** 生成 6 位随机后缀 */
private static String randomSuffix() {
return String.format("%06x", (long) (Math.random() * 0xFFFFFF));
}
}

View File

@@ -0,0 +1,57 @@
package com.webgame.webgamebackend.common.event;
import com.alibaba.fastjson2.JSON;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
/**
* SSE 事件发布器
*
* 统一入口,将业务事件序列化后发送到 Redis Pub/Sub 频道,
* 由所有实例的 RedisSseListener 接收并推送到各自连接的客户端。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SseEventPublisher {
/** Redis Pub/Sub 频道名 */
public static final String CHANNEL = "sse:events";
private final RedisTemplate<String, String> redisTemplate;
/**
* 发布广播事件(推送给所有在线用户)
*
* @param type 事件类型
* @param data 事件数据对象(将被序列化为 JSON
*/
public void broadcast(SseEventType type, Object data) {
SseEvent event = SseEvent.broadcast(type, JSON.toJSONString(data));
publish(event);
}
/**
* 发布定向事件(只推送给指定用户)
*
* @param userId 目标用户 accountId
* @param type 事件类型
* @param data 事件数据对象(将被序列化为 JSON
*/
public void sendTo(String userId, SseEventType type, Object data) {
SseEvent event = SseEvent.targeted(type, userId, JSON.toJSONString(data));
publish(event);
}
/**
* 将事件序列化为 JSON 并发布到 Redis Pub/Sub 频道
*/
private void publish(SseEvent event) {
String json = JSON.toJSONString(event);
log.debug("[SSE] 发布事件到 Redis, channel={}, eventType={}, targetId={}",
CHANNEL, event.type(), event.targetId());
redisTemplate.convertAndSend(CHANNEL, json);
}
}

View File

@@ -0,0 +1,28 @@
package com.webgame.webgamebackend.common.event;
/**
* SSE 事件类型枚举
* <p>
* ROOM_CREATED / ROOM_UPDATED / ROOM_REMOVED 为广播事件(推送给所有在线用户)
* KICKED / GAME_STARTED / BALANCE_CHG 为定向事件(推送给特定用户)
*/
public enum SseEventType {
/** 新房间创建(广播) */
ROOM_CREATED,
/** 房间状态更新:玩家加入/离开、准备切换、房主变更、游戏开始等(广播) */
ROOM_UPDATED,
/** 房间移除:无人散场或游戏结束关闭(广播) */
ROOM_REMOVED,
/** 被房主踢出房间(定向) */
KICKED,
/** 你所在的房间游戏已开始(定向) */
GAME_STARTED,
/** 余额变动通知(定向) */
BALANCE_CHG
}

View File

@@ -3,6 +3,7 @@ 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.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
@@ -12,16 +13,53 @@ 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.AsyncRequestTimeoutException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/**
* 全局异常处理,统一返回 ApiResponse 格式
* 全局异常处理,统一返回 ApiResponse JSON 格式
*
* SSE 端点的 401 由拦截器统一处理后返回 JSON前端 fetch-event-source 通过
* err.response.status 读取,不再需要服务端以 SSE 事件格式写错误。
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// ==================== SSE 异步超时 ====================
/**
* SSE 异步请求超时SseEmitter timeout 到期的正常行为)
*
* 返回 void 避免 Spring 尝试写入已提交的响应导致二次异常。
*/
@ExceptionHandler(AsyncRequestTimeoutException.class)
public void handleAsyncTimeout(AsyncRequestTimeoutException ex) {
// SSE 长连接超时是正常行为,无需记录日志
}
/**
* 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 连接已断开后心跳的残留异常,无需处理
}
// ==================== 业务异常 ====================
/**

View File

@@ -0,0 +1,28 @@
package com.webgame.webgamebackend.config;
import com.webgame.webgamebackend.common.event.SseEventPublisher;
import com.webgame.webgamebackend.sse.RedisSseListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
/**
* SSE 相关 Bean 配置
*
* 注册 Redis Pub/Sub 消息监听器,订阅频道 "sse:events"。
*/
@Configuration
public class SseConfig {
@Bean
public RedisMessageListenerContainer sseMessageListenerContainer(
RedisConnectionFactory connectionFactory,
RedisSseListener listener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(listener, new ChannelTopic(SseEventPublisher.CHANNEL));
return container;
}
}

View File

@@ -0,0 +1,28 @@
package com.webgame.webgamebackend.config;
import com.webgame.webgamebackend.ws.RoomWebSocketHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
/**
* WebSocket 配置
*
* 注册房间 WebSocket 端点,路径为 /ws/room。
* 客户端通过 ws://host/ws/room?roomId=xxx&token=xxx 连接。
*/
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final RoomWebSocketHandler roomWebSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(roomWebSocketHandler, "/ws/room")
.setAllowedOrigins("*");
}
}

View File

@@ -112,6 +112,18 @@ public class GameController {
return ApiResponse.ok(ready);
}
/**
* 获取房间详情
*
* 无需登录即可查看(观战者也可查看)。
*/
@SaIgnore
@GetMapping("/room/{roomId}")
public ApiResponse<RoomDetailResponse> roomDetail(@PathVariable String roomId) {
RoomDetailResponse result = gameService.getRoomDetail(roomId);
return ApiResponse.ok(result);
}
/**
* 开始游戏
*

View File

@@ -0,0 +1,47 @@
package com.webgame.webgamebackend.entities;
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.Accessors;
/**
* 走棋记录实体
*
* 记录每一步走棋:所属房间、下棋玩家、棋盘坐标、步序号。
* 坐标系约定:(0,0) 为棋盘左上角。
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
@Table(name = "game_move")
public class GameMoveEntity extends UUIDBaseEntity {
/** 所属房间 ID */
@Column(name = "room_id", nullable = false, length = 64)
private String roomId;
/** 下棋玩家的 account_id */
@Column(name = "player_id", nullable = false, length = 64)
private String playerId;
/** 落子行坐标0 起始,从上往下) */
@Column(name = "row_num", nullable = false)
private Integer rowNum;
/** 落子列坐标0 起始,从左往右) */
@Column(name = "col_num", nullable = false)
private Integer colNum;
/** 步序号(从 1 开始递增) */
@Column(name = "move_index", nullable = false)
private Integer moveIndex;
}

View File

@@ -0,0 +1,48 @@
package com.webgame.webgamebackend.entities;
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/**
* 对局记录实体
*
* 每局游戏结束后生成一条记录,包含胜负结果。
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
@Table(name = "game_record")
public class GameRecordEntity extends UUIDBaseEntity {
/** 所属房间 ID */
@Column(name = "room_id", nullable = false, length = 64)
private String roomId;
/** 胜方 account_id平局时为 null */
@Column(name = "winner_id", length = 64)
private String winnerId;
/** 结果类型win一方获胜、draw平局、resign认输 */
@Column(name = "result_type", nullable = false, length = 16)
private String resultType;
/** 对局开始时间 */
@Column(name = "started_at", nullable = false)
private LocalDateTime startedAt;
/** 对局结束时间 */
@Column(name = "ended_at")
private LocalDateTime endedAt;
}

View File

@@ -67,6 +67,12 @@ public class GameRoomEntity extends UUIDBaseEntity {
@Column(name = "password", length = 64)
private String password;
/**
* 是否允许观战
*/
@Column(name = "allow_spectate", nullable = false)
private Boolean allowSpectate = true;
/**
* 房间状态waiting / playing / finished
*/

View File

@@ -0,0 +1,28 @@
package com.webgame.webgamebackend.repository;
import com.webgame.webgamebackend.entities.GameMoveEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 走棋记录 Repository
*/
public interface GameMoveRepository extends JpaRepository<GameMoveEntity, String> {
/**
* 按房间 ID 查询走棋记录,按步序号升序
*
* @param roomId 房间 ID
* @return 走棋记录列表
*/
List<GameMoveEntity> findByRoomIdOrderByMoveIndexAsc(String roomId);
/**
* 统计房间走棋步数
*
* @param roomId 房间 ID
* @return 步数
*/
int countByRoomId(String roomId);
}

View File

@@ -0,0 +1,20 @@
package com.webgame.webgamebackend.repository;
import com.webgame.webgamebackend.entities.GameRecordEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* 对局记录 Repository
*/
public interface GameRecordRepository extends JpaRepository<GameRecordEntity, String> {
/**
* 按房间 ID 查询对局记录
*
* @param roomId 房间 ID
* @return 对局记录(可能为空)
*/
Optional<GameRecordEntity> findByRoomId(String roomId);
}

View File

@@ -74,4 +74,12 @@ public interface GameService {
* @param ownerId 房主 account_id
*/
void startGame(String roomId, String ownerId);
/**
* 获取房间详情(进入房间时获取初始快照)
*
* @param roomId 房间 ID
* @return 房间详情
*/
RoomDetailResponse getRoomDetail(String roomId);
}

View File

@@ -0,0 +1,88 @@
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

@@ -6,7 +6,10 @@ 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;
@@ -32,6 +35,8 @@ public class GameServiceImpl implements GameService {
private final RoomPlayerRepository roomPlayerRepository;
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final SseEventPublisher sseEventPublisher;
private final GameMoveRepository gameMoveRepository;
@Override
public GameListResponse getGameList() {
@@ -120,7 +125,8 @@ public class GameServiceImpl implements GameService {
.setMode(request.mode())
.setStakes(request.stakes())
.setStatus("waiting")
.setCreatorId(creatorId);
.setCreatorId(creatorId)
.setAllowSpectate(request.allowSpectate());
// 密码加密(如果提供了密码)
if (request.password() != null && !request.password().isBlank()) {
@@ -144,6 +150,10 @@ public class GameServiceImpl implements GameService {
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);
}
@@ -206,7 +216,11 @@ public class GameServiceImpl implements GameService {
String creatorName = getNickname(room.getCreatorId());
log.info("[游戏服务] 玩家加入房间, roomId={}, userId={}", room.getId(), userId);
return RoomItemResponse.from(room, players, gameConfig.getIcon(), creatorName);
RoomItemResponse response = RoomItemResponse.from(room, players, gameConfig.getIcon(), creatorName);
// 通知所有在线用户:房间信息更新
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
return response;
}
@Override
@@ -221,13 +235,13 @@ public class GameServiceImpl implements GameService {
roomPlayerRepository.delete(player);
// remaining 需要在后面的 SSE 推送中用到,提取到方法级
List<RoomPlayerEntity> remaining = roomPlayerRepository.findByRoomId(roomId);
if (remaining.isEmpty()) {
// 无人了,房间标记为结束
room.setStatus("finished");
gameRoomRepository.save(room);
log.info("[游戏服务] 房间无人,标记为结束, roomId={}", roomId);
// 无人了,物理删除房间(对局记录和走棋记录保留用于历史查询)
gameRoomRepository.delete(room);
log.info("[游戏服务] 房间无人,已删除, roomId={}", roomId);
} else if (userId.equals(room.getCreatorId())) {
// 房主离开,顺位转让给下一个玩家
RoomPlayerEntity nextOwner = remaining.get(0);
@@ -240,6 +254,20 @@ public class GameServiceImpl implements GameService {
}
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
@@ -264,6 +292,18 @@ public class GameServiceImpl implements GameService {
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
@@ -284,9 +324,27 @@ public class GameServiceImpl implements GameService {
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) {
@@ -309,41 +367,86 @@ 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<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
);
}
// ==================== 私有辅助方法 ====================

View File

@@ -0,0 +1,505 @@
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

@@ -0,0 +1,47 @@
package com.webgame.webgamebackend.sse;
import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.common.event.SseEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
/**
* Redis Pub/Sub 消息监听器
*
* 接收来自 Redis 频道 "sse:events" 的消息,
* 反序列化后根据事件类型(广播/定向)转发给本地 SseConnectionManager。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisSseListener implements MessageListener {
private final SseConnectionManager connectionManager;
@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);
return;
}
if (event.targetId() != null) {
connectionManager.sendTo(event.targetId(), event);
} else {
connectionManager.broadcast(event);
}
} catch (Exception e) {
log.error("[SSE] 处理 Redis 消息失败: {}", body, e);
}
}
}

View File

@@ -0,0 +1,138 @@
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

@@ -0,0 +1,127 @@
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

@@ -0,0 +1,129 @@
package com.webgame.webgamebackend.ws;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 房间 WebSocket 会话管理器
*
* 维护 roomId → sessions 的映射,支持房间内广播(向房间内所有连接发送消息)。
* 同时维护 sessionId → userId 的映射,用于识别会话所属用户。
*/
@Slf4j
@Component
public class RoomSessionManager {
/** roomId → 该房间的所有 WebSocket 会话 */
private final Map<String, Set<WebSocketSession>> roomSessions = new ConcurrentHashMap<>();
/** sessionId → userId */
private final Map<String, String> sessionUsers = new ConcurrentHashMap<>();
/** sessionId → roomId */
private final Map<String, String> sessionRooms = new ConcurrentHashMap<>();
/**
* 将用户会话加入指定房间
*
* 如果同一用户在此房间已有旧会话(重连场景),先关闭旧会话再注册新的。
*
* @param roomId 房间 ID
* @param userId 用户 ID
* @param session WebSocket 会话
*/
public void addSession(String roomId, String userId, WebSocketSession session) {
// 清理同一用户在此房间的旧会话(重连场景)
Set<WebSocketSession> sessions = roomSessions.get(roomId);
if (sessions != null) {
for (WebSocketSession existing : sessions) {
if (userId.equals(sessionUsers.get(existing.getId()))) {
// 关闭旧连接(静默关闭,不触发 leave 逻辑)
try {
existing.close();
} catch (Exception ignored) {
}
sessions.remove(existing);
sessionUsers.remove(existing.getId());
sessionRooms.remove(existing.getId());
log.info("[WS会话] 清理旧会话, roomId={}, userId={}, oldSessionId={}",
roomId, userId, existing.getId());
break;
}
}
}
roomSessions.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()).add(session);
sessionUsers.put(session.getId(), userId);
sessionRooms.put(session.getId(), roomId);
log.info("[WS会话] 加入房间, roomId={}, userId={}, sessionId={}, 当前房间连接数={}",
roomId, userId, session.getId(),
roomSessions.get(roomId) != null ? roomSessions.get(roomId).size() : 0);
}
/**
* 移除会话
*
* @param session WebSocket 会话
*/
public void removeSession(WebSocketSession session) {
String roomId = sessionRooms.remove(session.getId());
String userId = sessionUsers.remove(session.getId());
if (roomId != null) {
Set<WebSocketSession> sessions = roomSessions.get(roomId);
if (sessions != null) {
sessions.remove(session);
if (sessions.isEmpty()) {
roomSessions.remove(roomId);
}
}
}
log.info("[WS会话] 离开房间, roomId={}, userId={}, sessionId={}",
roomId, userId, session.getId());
}
/**
* 获取指定房间的所有会话
*
* @param roomId 房间 ID
* @return 会话集合(可能为空)
*/
public Set<WebSocketSession> getRoomSessions(String roomId) {
return roomSessions.getOrDefault(roomId, Set.of());
}
/**
* 获取会话对应的用户 ID
*
* @param session WebSocket 会话
* @return 用户 ID未找到返回 null
*/
public String getUserId(WebSocketSession session) {
return sessionUsers.get(session.getId());
}
/**
* 获取会话所在的房间 ID
*
* @param session WebSocket 会话
* @return 房间 ID未找到返回 null
*/
public String getRoomId(WebSocketSession session) {
return sessionRooms.get(session.getId());
}
/**
* 获取房间内连接总数
*
* @param roomId 房间 ID
* @return 连接数
*/
public int getRoomSessionCount(String roomId) {
Set<WebSocketSession> sessions = roomSessions.get(roomId);
return sessions != null ? sessions.size() : 0;
}
}

View File

@@ -0,0 +1,224 @@
package com.webgame.webgamebackend.ws;
import cn.dev33.satoken.stp.StpUtil;
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.net.URI;
import java.util.Map;
/**
* 房间 WebSocket 处理器
*
* 处理客户端连接、断开与消息路由。消息类型路由到不同的业务逻辑。
* 通过 URL 参数传递 roomId 和 token 完成认证与房间绑定。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RoomWebSocketHandler extends TextWebSocketHandler {
private final RoomSessionManager sessionManager;
private final GomokuGameService gomokuGameService;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 从 URL 参数中提取 roomId 和 token
URI uri = session.getUri();
if (uri == null) {
session.close(CloseStatus.BAD_DATA);
return;
}
String query = uri.getQuery();
if (query == null) {
session.close(CloseStatus.BAD_DATA);
return;
}
String roomId = getQueryParam(query, "roomId");
String token = getQueryParam(query, "token");
if (roomId == null || token == null) {
session.close(CloseStatus.BAD_DATA);
return;
}
// 通过 token 获取用户 ID
Object loginId = StpUtil.getLoginIdByToken(token);
if (loginId == null) {
session.close(CloseStatus.POLICY_VIOLATION);
return;
}
String userId = loginId.toString();
// 注册会话(内部会清理同用户的旧会话)
sessionManager.addSession(roomId, userId, session);
// 取消断线超时定时器(重连场景)
gomokuGameService.cancelDisconnectTimeout(roomId, userId);
log.info("[WS] 连接建立, roomId={}, userId={}, sessionId={}", roomId, userId, session.getId());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
String userId = sessionManager.getUserId(session);
String roomId = sessionManager.getRoomId(session);
if (userId == null || roomId == null) {
sendError(session, "未加入房间");
return;
}
log.info("[WS] 收到消息, roomId={}, userId={}, payload={}", roomId, userId, payload);
try {
JSONObject json = JSON.parseObject(payload);
String type = json.getString("type");
Object data = json.get("data");
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);
}
} catch (Exception e) {
log.error("[WS] 处理消息异常, roomId={}, userId={}", roomId, userId, e);
sendError(session, e.getMessage());
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
String userId = sessionManager.getUserId(session);
String roomId = sessionManager.getRoomId(session);
log.info("[WS] 连接关闭, roomId={}, userId={}, sessionId={}, status={}",
roomId, userId, session.getId(), status);
if (userId != null && roomId != null) {
// 通知游戏服务玩家断开
gomokuGameService.handleDisconnect(roomId, userId, sessionManager);
}
sessionManager.removeSession(session);
}
// ==================== 辅助方法 ====================
/**
* 解析 URL Query 参数
*/
private String getQueryParam(String query, String key) {
for (String param : query.split("&")) {
String[] pair = param.split("=", 2);
if (pair.length == 2 && pair[0].equals(key)) {
return pair[1];
}
}
return null;
}
/** 聊天消息最大长度 */
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;
}
if (content.length() > CHAT_MAX_LENGTH) {
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,
"timestamp", System.currentTimeMillis()
)));
}
/**
* 向房间内所有会话广播消息
*/
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);
}
}
}
}
/**
* 向指定会话发送错误消息
*/
private void sendError(WebSocketSession session, String errorMsg) {
if (session.isOpen()) {
try {
String json = JSON.toJSONString(
new WsMessage(WsOutboundMessage.ERROR, Map.of("message", errorMsg)));
session.sendMessage(new TextMessage(json));
} catch (IOException e) {
log.error("[WS] 发送错误消息失败", e);
}
}
}
}

View File

@@ -0,0 +1,26 @@
package com.webgame.webgamebackend.ws.dto;
/**
* 客户端→服务端 WebSocket 消息类型
*/
public final class WsInboundMessage {
private WsInboundMessage() {}
/** 落子 */
public static final String PLACE_STONE = "place_stone";
/** 发送聊天 */
public static final String CHAT = "chat";
/** 准备/取消准备 */
public static final String READY = "ready";
/** 认输 */
public static final String RESIGN = "resign";
/** 求和请求 */
public static final String DRAW_REQUEST = "draw_request";
/** 同意/拒绝求和 */
public static final String DRAW_RESPONSE = "draw_response";
/** 离开房间 */
public static final String LEAVE = "leave";
/** 开始游戏(仅房主) */
public static final String START = "start";
}

View File

@@ -0,0 +1,12 @@
package com.webgame.webgamebackend.ws.dto;
/**
* WebSocket 消息通用封装
*
* @param type 消息类型
* @param data 消息数据JSON 对象或基本类型)
*/
public record WsMessage(
String type,
Object data
) {}

View File

@@ -0,0 +1,34 @@
package com.webgame.webgamebackend.ws.dto;
/**
* 服务端→客户端 WebSocket 消息类型
*/
public final class WsOutboundMessage {
private WsOutboundMessage() {}
/** 棋盘全量同步 */
public static final String BOARD_SYNC = "board_sync";
/** 单步落子通知 */
public static final String MOVE = "move";
/** 轮次切换 */
public static final String TURN_CHANGE = "turn_change";
/** 聊天广播 */
public static final String CHAT_BROADCAST = "chat_broadcast";
/** 对局结束 */
public static final String GAME_OVER = "game_over";
/** 玩家加入 */
public static final String PLAYER_JOIN = "player_join";
/** 玩家离开 */
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";
/** 错误消息 */
public static final String ERROR = "error";
}

View File

@@ -0,0 +1,49 @@
package com.webgame.webgamebackend.sse;
import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.common.event.SseEventPublisher;
import com.webgame.webgamebackend.common.event.SseEventType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* SSE 集成测试
*
* 验证 SSE 事件发布器的基本功能。
* 需要 Redis 和 MySQL 运行。
*/
@SpringBootTest
@DisplayName("SSE 集成测试")
class SseIntegrationTest {
@Autowired
private SseEventPublisher publisher;
@Test
@DisplayName("SseEventPublisher Bean 应被正确注入")
void publisher_shouldBeInjected() {
assertThat(publisher).isNotNull();
}
@Test
@DisplayName("broadcast 方法不应抛异常")
void broadcast_shouldNotThrowException() {
// Act & Assert: 发布广播事件不应抛异常
publisher.broadcast(SseEventType.ROOM_CREATED,
Map.of("roomId", "test-001", "name", "测试房间"));
}
@Test
@DisplayName("sendTo 方法不应抛异常")
void sendTo_shouldNotThrowException() {
// Act & Assert: 发布定向事件不应抛异常
publisher.sendTo("test-user-id", SseEventType.KICKED,
Map.of("roomId", "test-001"));
}
}