Compare commits
23 Commits
2467a5097c
...
c5e9a149f4
| Author | SHA1 | Date | |
|---|---|---|---|
| c5e9a149f4 | |||
| 2a311ca25f | |||
| 9be461539c | |||
| 47e586e7f5 | |||
| 15bf6d0531 | |||
| f85e23d545 | |||
| 6e4eb6120a | |||
| 5209df46d1 | |||
| e72fbff6e6 | |||
| 05520a2b1d | |||
| be37d51930 | |||
| 6ef11b8d5b | |||
| d0ade3c469 | |||
| d63ce3326b | |||
| 4c3e3717eb | |||
| 9a2dd66df4 | |||
| 1e281ff8ca | |||
| bf09b7f342 | |||
| 399c5f5bd8 | |||
| 808dff53d2 | |||
| 42ae5ef1af | |||
| 0f240361bb | |||
| 875f2bc073 |
6
pom.xml
6
pom.xml
@@ -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>
|
||||
|
||||
@@ -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/**");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
/** 当前轮到哪方落子(黑方/白方的 userId),null 表示对局未开始 */
|
||||
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
|
||||
) {}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 目标用户 accountId,null 表示广播给所有在线用户
|
||||
* @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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 连接已断开后心跳的残留异常,无需处理
|
||||
}
|
||||
|
||||
// ==================== 业务异常 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("*");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始游戏
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
@@ -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("未知玩家");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
127
src/main/java/com/webgame/webgamebackend/sse/SseController.java
Normal file
127
src/main/java/com/webgame/webgamebackend/sse/SseController.java
Normal 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 传 saToken,401 时由前端 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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_LENGTH(500 字符)
|
||||
* - 移除 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.webgame.webgamebackend.ws.dto;
|
||||
|
||||
/**
|
||||
* WebSocket 消息通用封装
|
||||
*
|
||||
* @param type 消息类型
|
||||
* @param data 消息数据(JSON 对象或基本类型)
|
||||
*/
|
||||
public record WsMessage(
|
||||
String type,
|
||||
Object data
|
||||
) {}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user