Compare commits
40 Commits
b512d50c78
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3eeb5bbd51 | |||
| a5fe28e536 | |||
| 0d61038e67 | |||
| c1134f48e0 | |||
| ff284e2841 | |||
| d92f7bb581 | |||
| aa9a0732d6 | |||
| ab2a2dbf7f | |||
| 5d9f5b8ab7 | |||
| df6c7f6b10 | |||
| 61ff5d46e6 | |||
| 3580ad934e | |||
| 842e5c29f9 | |||
| c5e9a149f4 | |||
| 2a311ca25f | |||
| 9be461539c | |||
| 47e586e7f5 | |||
| 15bf6d0531 | |||
| f85e23d545 | |||
| 6e4eb6120a | |||
| 5209df46d1 | |||
| e72fbff6e6 | |||
| 05520a2b1d | |||
| be37d51930 | |||
| 6ef11b8d5b | |||
| d0ade3c469 | |||
| d63ce3326b | |||
| 4c3e3717eb | |||
| 9a2dd66df4 | |||
| 1e281ff8ca | |||
| bf09b7f342 | |||
| 399c5f5bd8 | |||
| 808dff53d2 | |||
| 42ae5ef1af | |||
| 0f240361bb | |||
| 875f2bc073 | |||
| 2467a5097c | |||
| b7e8b02606 | |||
| 6d90c21b44 | |||
| 4267445256 |
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>
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.webgame.webgamebackend;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableAspectJAutoProxy(proxyTargetClass = true) // 启用基于 AspectJ 的自动代理功能 支持使用AOP功能
|
||||
@EnableScheduling // 启用定时任务(房间超时清理)
|
||||
public class WebgameBackendApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.webgame.webgamebackend.adapter.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;
|
||||
|
||||
/**
|
||||
* SSE 事件的 Redis Pub/Sub 监听器
|
||||
*/
|
||||
@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);
|
||||
|
||||
try {
|
||||
SseEvent event = JSON.parseObject(body, SseEvent.class);
|
||||
if (event == null) {
|
||||
log.warn("[SSE] 收到空的 Redis 事件, body={}", body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.targetId() == null) {
|
||||
connectionManager.broadcast(event);
|
||||
} else {
|
||||
connectionManager.sendTo(event.targetId(), event);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[SSE] 处理 Redis 消息失败, body={}", body, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.webgame.webgamebackend.adapter.sse;
|
||||
|
||||
import com.webgame.webgamebackend.common.event.SseEvent;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 线程安全的 SSE 连接管理器
|
||||
*
|
||||
* 一个用户可以拥有多个连接,使得不同浏览器标签页和设备之间不会互相踢下线。
|
||||
* 心跳由单一后台线程统一管理,避免 per-connection 定时器导致的线程膨胀。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SseConnectionManager {
|
||||
|
||||
/** 心跳间隔(秒) */
|
||||
private static final long HEARTBEAT_INTERVAL_SECONDS = 30;
|
||||
|
||||
/** 心跳线程池(单线程,批量 ping 所有连接) */
|
||||
private final ScheduledExecutorService heartbeatScheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "sse-heartbeat"));
|
||||
|
||||
private final ConcurrentHashMap<String, ConcurrentHashMap<String, SseEmitter>> emitters =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 启动统一心跳定时任务
|
||||
*
|
||||
* 单一后台线程批量 ping 所有连接,O(1) 线程开销。
|
||||
* 单次 ping 失败自动清理连接,不影响其他连接。
|
||||
*/
|
||||
@PostConstruct
|
||||
public void startHeartbeat() {
|
||||
heartbeatScheduler.scheduleAtFixedRate(() -> {
|
||||
if (emitters.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
log.debug("[SSE] 批量心跳, onlineConnections={}", getOnlineCount());
|
||||
emitters.forEach((userId, connections) ->
|
||||
connections.forEach((connectionId, emitter) -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("heartbeat").data(""));
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
log.debug("[SSE] 心跳失败,清理连接, userId={}, connectionId={}",
|
||||
userId, connectionId);
|
||||
remove(userId, connectionId);
|
||||
}
|
||||
}));
|
||||
}, HEARTBEAT_INTERVAL_SECONDS, HEARTBEAT_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
||||
log.info("[SSE] 心跳定时器已启动, interval={}s", HEARTBEAT_INTERVAL_SECONDS);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
heartbeatScheduler.shutdown();
|
||||
log.info("[SSE] 心跳定时器已关闭");
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册新的 SSE 连接
|
||||
*
|
||||
* @param userId 用户账号 ID
|
||||
* @param emitter SseEmitter 实例
|
||||
* @return 连接 ID
|
||||
*/
|
||||
public String register(String userId, SseEmitter emitter) {
|
||||
String connectionId = UUID.randomUUID().toString();
|
||||
emitters.computeIfAbsent(userId, key -> new ConcurrentHashMap<>()).put(connectionId, emitter);
|
||||
|
||||
emitter.onCompletion(() -> {
|
||||
log.debug("[SSE] 连接完成, userId={}, connectionId={}", userId, connectionId);
|
||||
remove(userId, connectionId);
|
||||
});
|
||||
emitter.onTimeout(() -> {
|
||||
log.debug("[SSE] 连接超时, userId={}, connectionId={}", userId, connectionId);
|
||||
remove(userId, connectionId);
|
||||
});
|
||||
emitter.onError(throwable -> {
|
||||
log.debug("[SSE] 连接异常, userId={}, connectionId={}, reason={}",
|
||||
userId, connectionId, throwable.getMessage());
|
||||
remove(userId, connectionId);
|
||||
});
|
||||
|
||||
log.info("[SSE] 用户连接成功, userId={}, connectionId={}, onlineConnections={}",
|
||||
userId, connectionId, getOnlineCount());
|
||||
return connectionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除连接(仅清理内部 Map,不操作 SseEmitter)
|
||||
*
|
||||
* SseEmitter 生命周期由 Spring 管理(onCompletion/onTimeout/onError 回调),
|
||||
* 此方法仅负责从内部映射表中移除引用。不调用 emitter.complete(),
|
||||
* 避免向已断开的客户端写入数据导致 IOException 扩散到 GlobalExceptionHandler。
|
||||
*
|
||||
* @param userId 用户账号 ID
|
||||
* @param connectionId 连接 ID
|
||||
*/
|
||||
public void remove(String userId, String connectionId) {
|
||||
ConcurrentHashMap<String, SseEmitter> userEmitters = emitters.get(userId);
|
||||
if (userEmitters == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
SseEmitter removed = userEmitters.remove(connectionId);
|
||||
if (userEmitters.isEmpty()) {
|
||||
emitters.remove(userId, userEmitters);
|
||||
}
|
||||
|
||||
if (removed != null) {
|
||||
log.info("[SSE] 连接已移除, userId={}, connectionId={}, onlineConnections={}",
|
||||
userId, connectionId, getOnlineCount());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件给所有在线用户的所有连接
|
||||
*
|
||||
* @param event SSE 事件
|
||||
*/
|
||||
public void broadcast(SseEvent event) {
|
||||
if (emitters.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("[SSE] 广播事件, type={}, onlineConnections={}", event.type(), getOnlineCount());
|
||||
emitters.forEach((userId, connections) ->
|
||||
connections.forEach((connectionId, emitter) -> sendEvent(userId, connectionId, emitter, event)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送事件给指定用户的所有连接
|
||||
*
|
||||
* @param userId 目标用户账号 ID
|
||||
* @param event SSE 事件
|
||||
*/
|
||||
public void sendTo(String userId, SseEvent event) {
|
||||
ConcurrentHashMap<String, SseEmitter> connections = emitters.get(userId);
|
||||
if (connections == null || connections.isEmpty()) {
|
||||
log.debug("[SSE] 目标用户不在线, userId={}, eventType={}", userId, event.type());
|
||||
return;
|
||||
}
|
||||
|
||||
connections.forEach((connectionId, emitter) -> sendEvent(userId, connectionId, emitter, event));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前在线连接总数
|
||||
*
|
||||
* @return 连接数
|
||||
*/
|
||||
public int getOnlineCount() {
|
||||
return emitters.values().stream().mapToInt(Map::size).sum();
|
||||
}
|
||||
|
||||
/**
|
||||
* 向单个 SseEmitter 发送事件
|
||||
*
|
||||
* 捕获 IOException(客户端已断开但还未触发回调),此时安全移除连接。
|
||||
*/
|
||||
private void sendEvent(String userId, String connectionId, SseEmitter emitter, SseEvent event) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.id(event.id())
|
||||
.name(event.type().name().toLowerCase())
|
||||
.data(event.data()));
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
log.debug("[SSE] 推送失败, userId={}, connectionId={}, reason={}",
|
||||
userId, connectionId, e.getMessage());
|
||||
remove(userId, connectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.webgame.webgamebackend.adapter.sse;
|
||||
|
||||
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
|
||||
import com.webgame.webgamebackend.common.dto.ApiResponse;
|
||||
import com.webgame.webgamebackend.common.event.SseEventPublisher;
|
||||
import com.webgame.webgamebackend.common.event.SseEventType;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SSE 订阅端点
|
||||
*
|
||||
* 提供 GET /sse/subscribe 端点建立持久化 SSE 连接。
|
||||
* 心跳由 SseConnectionManager 统一批量管理,此处不再维护 per-connection 定时器。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/sse")
|
||||
@RequiredArgsConstructor
|
||||
public class SseController {
|
||||
|
||||
/** SseEmitter 超时时间(30 分钟),超时后由 SseConnectionManager 的 onTimeout 回调清理 */
|
||||
private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L;
|
||||
|
||||
private final SseConnectionManager connectionManager;
|
||||
private final AuthenticationProvider authProvider;
|
||||
private final SseEventPublisher eventPublisher;
|
||||
|
||||
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter subscribe(
|
||||
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId,
|
||||
HttpServletResponse response) throws IOException {
|
||||
|
||||
// SSE 路径已排除在 AuthInterceptor 之外,此处手动校验登录态
|
||||
String token = authProvider.getCurrentToken();
|
||||
if (token == null || token.isBlank()) {
|
||||
log.warn("[SSE] 缺少认证 token");
|
||||
return writeAuthError(response, HttpServletResponse.SC_UNAUTHORIZED, "缺少认证信息,请先登录");
|
||||
}
|
||||
|
||||
String userId = authProvider.getUserIdByToken(token);
|
||||
if (userId == null) {
|
||||
log.warn("[SSE] token 无效或已过期");
|
||||
return writeAuthError(response, HttpServletResponse.SC_UNAUTHORIZED, "登录已过期,请重新登录");
|
||||
}
|
||||
|
||||
// 设置认证上下文,后续 getCurrentUserId() 可用
|
||||
authProvider.setContextToken(token, -1);
|
||||
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||||
String connectionId = connectionManager.register(userId, emitter);
|
||||
|
||||
// 发送初始连接确认
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("connected")
|
||||
.data("{\"message\":\"连接成功\"}"));
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
log.warn("[SSE] 发送 connected 事件失败, userId={}, connectionId={}",
|
||||
userId, connectionId, e);
|
||||
connectionManager.remove(userId, connectionId);
|
||||
}
|
||||
|
||||
if (lastEventId != null && !lastEventId.isBlank()) {
|
||||
log.debug("[SSE] 客户端重连, Last-Event-ID={}, userId={}, connectionId={}",
|
||||
lastEventId, userId, connectionId);
|
||||
}
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用消息发布接口
|
||||
*
|
||||
* 支持广播(不传 userId)和定向推送(传 userId)两种模式。
|
||||
* 可传入任意 JSON 数据作为消息体,事件类型固定为 CUSTOM。
|
||||
*
|
||||
* 请求体示例:
|
||||
* <pre>
|
||||
* // 广播
|
||||
* { "data": { "msg": "hello" } }
|
||||
*
|
||||
* // 定向推送
|
||||
* { "userId": "123", "data": { "msg": "hello" } }
|
||||
* </pre>
|
||||
*
|
||||
* @param body 请求体,包含可选的 userId 和必填的 data
|
||||
* @return 统一 API 响应
|
||||
*/
|
||||
@PostMapping("/publish")
|
||||
public ApiResponse<Map<String, Object>> publish(@RequestBody Map<String, Object> body) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) body.get("data");
|
||||
if (data == null || data.isEmpty()) {
|
||||
return ApiResponse.fail(400, "data 字段不能为空");
|
||||
}
|
||||
|
||||
String userId = body.get("userId") != null ? body.get("userId").toString() : null;
|
||||
|
||||
if (userId != null && !userId.isBlank()) {
|
||||
eventPublisher.sendTo(userId, SseEventType.CUSTOM, data);
|
||||
log.info("[SSE] 测试定向推送, userId={}, data={}", userId, data);
|
||||
} else {
|
||||
eventPublisher.broadcast(SseEventType.CUSTOM, data);
|
||||
log.info("[SSE] 测试广播推送, data={}", data);
|
||||
}
|
||||
|
||||
return ApiResponse.ok(Map.of(
|
||||
"mode", userId != null && !userId.isBlank() ? "targeted" : "broadcast",
|
||||
"userId", userId != null ? userId : "all",
|
||||
"data", data
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 向 SSE 客户端返回认证错误
|
||||
*
|
||||
* 直接写入 HttpServletResponse 返回 401 + SSE error 事件,
|
||||
* 前端 SseClient.onopen 检测到 response.status === 401 后触发 onUnauthorized,
|
||||
* 自动刷新 token 并重连。
|
||||
*
|
||||
* @return null,通知 Spring MVC 响应已处理完毕,无需 MessageConverter
|
||||
*/
|
||||
private SseEmitter writeAuthError(HttpServletResponse response, int status, String message) throws IOException {
|
||||
response.setStatus(status);
|
||||
response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||
response.getWriter().write("event: error\ndata: {\"code\":" + status + ",\"message\":\"" + message + "\"}\n\n");
|
||||
response.getWriter().flush();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.webgame.webgamebackend.adapter.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,261 @@
|
||||
package com.webgame.webgamebackend.adapter.ws;
|
||||
|
||||
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameRoomEntity;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.repository.GameRoomRepository;
|
||||
import com.webgame.webgamebackend.domain.room.RoomManager;
|
||||
import com.webgame.webgamebackend.domain.engine.GameEngine;
|
||||
import com.webgame.webgamebackend.domain.engine.GameEngineRegistry;
|
||||
import com.webgame.webgamebackend.domain.engine.RoomContext;
|
||||
import com.webgame.webgamebackend.adapter.ws.dto.WsInboundMessage;
|
||||
import com.webgame.webgamebackend.adapter.ws.dto.WsMessage;
|
||||
import com.webgame.webgamebackend.adapter.ws.dto.WsOutboundMessage;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
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 处理器
|
||||
*
|
||||
* 重构后的职责:
|
||||
* - 连接建立:认证 + 注册会话 + 取消断线倒计时(通过 RoomManager)
|
||||
* - 消息路由:房间管理消息 → RoomManager,游戏消息 → GameEngine
|
||||
* - 连接关闭:启动断线倒计时(通过 RoomManager)
|
||||
*
|
||||
* <p>采用策略路由:根据房间当前状态(WAITING/PLAYING)将消息分发给不同的处理器。
|
||||
* WAITING 状态下只有房间管理消息(ready, leave, start)有效;
|
||||
* PLAYING 状态下游戏消息(place_stone, resign, draw_*)由 GameEngine 处理。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RoomWebSocketHandler extends TextWebSocketHandler {
|
||||
|
||||
private final RoomSessionManager sessionManager;
|
||||
private final RoomManager roomManager;
|
||||
private final GameEngineRegistry engineRegistry;
|
||||
private final GameRoomRepository gameRoomRepository;
|
||||
private final WsMessageRouter router;
|
||||
private final AuthenticationProvider authProvider;
|
||||
|
||||
@PostConstruct
|
||||
public void registerHandlers() {
|
||||
// ===== 房间管理消息(所有状态) =====
|
||||
|
||||
// 准备/取消准备
|
||||
router.register(WsInboundMessage.READY,
|
||||
(roomId, userId, data, sm) ->
|
||||
roomManager.toggleReady(roomId, userId));
|
||||
|
||||
// 离开房间
|
||||
router.register(WsInboundMessage.LEAVE,
|
||||
(roomId, userId, data, sm) ->
|
||||
roomManager.leaveRoom(roomId, userId));
|
||||
|
||||
// 开始游戏(仅房主)
|
||||
router.register(WsInboundMessage.START,
|
||||
(roomId, userId, data, sm) ->
|
||||
roomManager.startGame(roomId, userId));
|
||||
|
||||
// ===== 游戏消息(PLAYING 状态) =====
|
||||
|
||||
// 落子
|
||||
router.register(WsInboundMessage.PLACE_STONE, (roomId, userId, data, sm) -> {
|
||||
GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null);
|
||||
if (room != null && "playing".equals(room.getStatus())) {
|
||||
// 通过 RoomManager 获取引擎的 getState 不行,需要直接找到引擎
|
||||
// 改为委托模式:RoomManager 提供 executeGameAction 方法
|
||||
roomManager.executeGameAction(roomId, userId, "place_stone", data);
|
||||
}
|
||||
});
|
||||
|
||||
// 认输
|
||||
router.register(WsInboundMessage.RESIGN, (roomId, userId, data, sm) -> {
|
||||
roomManager.executeGameAction(roomId, userId, "resign", null);
|
||||
});
|
||||
|
||||
// 求和请求
|
||||
router.register(WsInboundMessage.DRAW_REQUEST, (roomId, userId, data, sm) -> {
|
||||
roomManager.executeGameAction(roomId, userId, "draw_request", null);
|
||||
});
|
||||
|
||||
// 回应求和
|
||||
router.register(WsInboundMessage.DRAW_RESPONSE, (roomId, userId, data, sm) -> {
|
||||
roomManager.executeGameAction(roomId, userId, "draw_response", data);
|
||||
});
|
||||
|
||||
// 聊天
|
||||
router.register(WsInboundMessage.CHAT, (roomId, userId, data, sm) -> {
|
||||
String content = data != null ? data.getString("content") : null;
|
||||
handleChat(roomId, userId, content);
|
||||
});
|
||||
|
||||
log.info("[WS] 已注册 {} 个消息处理器", router.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||
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
|
||||
String userId = authProvider.getUserIdByToken(token);
|
||||
if (userId == null) {
|
||||
session.close(CloseStatus.POLICY_VIOLATION);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理重连(在注册会话之前,避免广播到尚未完成握手的当前 session)
|
||||
roomManager.handleReconnect(roomId, userId);
|
||||
|
||||
// 注册会话
|
||||
sessionManager.addSession(roomId, userId, session);
|
||||
|
||||
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 rawData = json.get("data");
|
||||
JSONObject data = (rawData instanceof JSONObject) ? (JSONObject) rawData : null;
|
||||
|
||||
WsMessageHandler handler = router.get(type);
|
||||
if (handler == null) {
|
||||
sendError(session, "未知消息类型: " + type);
|
||||
return;
|
||||
}
|
||||
|
||||
handler.handle(roomId, userId, data, sessionManager);
|
||||
} catch (Exception e) {
|
||||
log.error("[WS] 处理消息异常, roomId={}, userId={}", roomId, userId, e);
|
||||
sendError(session, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
// 通知 RoomManager 处理断线
|
||||
roomManager.handleDisconnect(roomId, userId);
|
||||
}
|
||||
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;
|
||||
|
||||
/**
|
||||
* 聊天消息处理
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
String sanitized = content
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
|
||||
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,23 @@
|
||||
package com.webgame.webgamebackend.adapter.ws;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
|
||||
/**
|
||||
* WebSocket 消息处理器接口
|
||||
*
|
||||
* 每种消息类型对应一个处理器实现,由 WsMessageRouter 统一路由。
|
||||
* handler 方法抛出的异常由 RoomWebSocketHandler 统一捕获并返回错误消息。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface WsMessageHandler {
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 消息
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param userId 发送消息的用户 ID
|
||||
* @param data 消息数据(JSONObject,可能为 null)
|
||||
* @param sessionManager 房间会话管理器,用于广播
|
||||
*/
|
||||
void handle(String roomId, String userId, JSONObject data, RoomSessionManager sessionManager);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.webgame.webgamebackend.adapter.ws;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* WebSocket 消息路由器
|
||||
*
|
||||
* 维护消息类型 → 处理器的映射表,替代硬编码 switch-case。
|
||||
* 新增消息类型只需注册对应的 WsMessageHandler 即可,无需修改路由逻辑。
|
||||
*
|
||||
* 使用示例:
|
||||
* <pre>{@code
|
||||
* router.register("place_stone", gomokuGameService::placeStoneViaWs);
|
||||
* router.register("chat", chatHandler::handle);
|
||||
* }</pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class WsMessageRouter {
|
||||
|
||||
private final Map<String, WsMessageHandler> handlers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 注册消息处理器
|
||||
*
|
||||
* @param type 消息类型(与客户端约定的一致)
|
||||
* @param handler 处理器
|
||||
*/
|
||||
public void register(String type, WsMessageHandler handler) {
|
||||
if (type == null || type.isBlank()) {
|
||||
throw new IllegalArgumentException("消息类型不能为空");
|
||||
}
|
||||
if (handler == null) {
|
||||
throw new IllegalArgumentException("处理器不能为 null");
|
||||
}
|
||||
handlers.put(type, handler);
|
||||
log.info("[WS路由] 注册处理器, type={}", type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息处理器
|
||||
*
|
||||
* @param type 消息类型
|
||||
* @return 处理器,未找到返回 null
|
||||
*/
|
||||
public WsMessageHandler get(String type) {
|
||||
return handlers.get(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已注册指定类型的处理器
|
||||
*
|
||||
* @param type 消息类型
|
||||
* @return 是否已注册
|
||||
*/
|
||||
public boolean hasHandler(String type) {
|
||||
return handlers.containsKey(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已注册的消息类型数量(用于监控/调试)
|
||||
*
|
||||
* @return 注册数量
|
||||
*/
|
||||
public int size() {
|
||||
return handlers.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.webgame.webgamebackend.adapter.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.adapter.ws.dto;
|
||||
|
||||
/**
|
||||
* WebSocket 消息通用封装
|
||||
*
|
||||
* @param type 消息类型
|
||||
* @param data 消息数据(JSON 对象或基本类型)
|
||||
*/
|
||||
public record WsMessage(
|
||||
String type,
|
||||
Object data
|
||||
) {}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.webgame.webgamebackend.adapter.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 MOVE_HISTORY = "move_history";
|
||||
/** 错误消息 */
|
||||
public static final String ERROR = "error";
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
package com.webgame.webgamebackend.controller;
|
||||
package com.webgame.webgamebackend.api;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import com.webgame.webgamebackend.common.dto.ApiResponse;
|
||||
import com.webgame.webgamebackend.common.dto.account.LoginRequest;
|
||||
import com.webgame.webgamebackend.common.dto.account.LoginResponse;
|
||||
import com.webgame.webgamebackend.common.dto.account.LogoutRequest;
|
||||
import com.webgame.webgamebackend.common.dto.account.RefreshTokenRequest;
|
||||
import com.webgame.webgamebackend.common.dto.account.RegisterRequest;
|
||||
import com.webgame.webgamebackend.service.AccountService;
|
||||
import com.webgame.webgamebackend.domain.account.AccountService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -30,7 +29,6 @@ public class AccountController {
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
@SaIgnore
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
LoginResponse result = accountService.login(request);
|
||||
@@ -40,7 +38,6 @@ public class AccountController {
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
@SaIgnore
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<LoginResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
LoginResponse result = accountService.register(request);
|
||||
@@ -50,7 +47,6 @@ public class AccountController {
|
||||
/**
|
||||
* 刷新令牌,用 refresh token 换取新的 access token 和 refresh token
|
||||
*/
|
||||
@SaIgnore
|
||||
@PostMapping("/refresh")
|
||||
public ApiResponse<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
|
||||
LoginResponse result = accountService.refresh(request.refreshToken());
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.webgame.webgamebackend.api;
|
||||
|
||||
import com.webgame.webgamebackend.common.dto.ApiResponse;
|
||||
import com.webgame.webgamebackend.common.dto.game.GameListResponse;
|
||||
import com.webgame.webgamebackend.domain.game.GameService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 游戏控制器
|
||||
*
|
||||
* 管理游戏配置查询(游戏列表、游戏详情等)。
|
||||
* 房间相关操作已迁移到 {@link RoomController}。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/game")
|
||||
@RequiredArgsConstructor
|
||||
public class GameController {
|
||||
|
||||
private final GameService gameService;
|
||||
|
||||
/**
|
||||
* 获取游戏列表
|
||||
*
|
||||
* 无需登录即可调用。
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public ApiResponse<GameListResponse> list() {
|
||||
GameListResponse result = gameService.getGameList();
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
}
|
||||
121
src/main/java/com/webgame/webgamebackend/api/RoomController.java
Normal file
121
src/main/java/com/webgame/webgamebackend/api/RoomController.java
Normal file
@@ -0,0 +1,121 @@
|
||||
package com.webgame.webgamebackend.api;
|
||||
|
||||
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
|
||||
import com.webgame.webgamebackend.common.dto.ApiResponse;
|
||||
import com.webgame.webgamebackend.common.dto.game.*;
|
||||
import com.webgame.webgamebackend.domain.room.RoomManager;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 房间控制器
|
||||
*
|
||||
* 管理房间的创建、加入、离开、解散、踢人、准备、开始等操作。
|
||||
* 所有房间内实时操作通过 WebSocket 处理,此控制器仅提供 REST 接口。
|
||||
*/
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/room")
|
||||
@RequiredArgsConstructor
|
||||
public class RoomController {
|
||||
|
||||
private final RoomManager roomManager;
|
||||
private final AuthenticationProvider authProvider;
|
||||
|
||||
/**
|
||||
* 获取房间列表
|
||||
*
|
||||
* @param gameId 游戏 ID(必填)
|
||||
* @param status 状态筛选(可选)
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public ApiResponse<RoomListResponse> roomList(
|
||||
@RequestParam String gameId,
|
||||
@RequestParam(required = false) String status) {
|
||||
RoomListResponse result = roomManager.getRoomList(gameId, status);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取房间详情
|
||||
*/
|
||||
@GetMapping("/{roomId}")
|
||||
public ApiResponse<RoomDetailResponse> roomDetail(@PathVariable String roomId) {
|
||||
RoomDetailResponse result = roomManager.getRoomDetail(roomId);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建房间
|
||||
*
|
||||
* 需登录。房主自动加入房间并设为已准备。
|
||||
*/
|
||||
@PostMapping("/create")
|
||||
public ApiResponse<CreateRoomResponse> createRoom(@Valid @RequestBody CreateRoomRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
CreateRoomResponse result = roomManager.createRoom(request, userId);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入房间
|
||||
*/
|
||||
@PostMapping("/join")
|
||||
public ApiResponse<RoomItemResponse> joinRoom(@Valid @RequestBody JoinRoomRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
RoomItemResponse result = roomManager.joinRoom(request, userId);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开房间
|
||||
*/
|
||||
@PostMapping("/leave")
|
||||
public ApiResponse<Void> leaveRoom(@Valid @RequestBody LeaveRoomRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
roomManager.leaveRoom(request.roomId(), userId);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解散房间(仅房主)
|
||||
*/
|
||||
@PostMapping("/disband")
|
||||
public ApiResponse<Void> disbandRoom(@Valid @RequestBody DisbandRoomRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
roomManager.disbandRoom(request.roomId(), userId);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 踢出玩家(仅房主)
|
||||
*/
|
||||
@PostMapping("/kick")
|
||||
public ApiResponse<Void> kickPlayer(@Valid @RequestBody KickPlayerRequest request) {
|
||||
String ownerId = authProvider.getCurrentUserId();
|
||||
roomManager.kickPlayer(request.roomId(), ownerId, request.userId());
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换准备状态
|
||||
*/
|
||||
@PostMapping("/ready")
|
||||
public ApiResponse<Boolean> toggleReady(@Valid @RequestBody ReadyRequest request) {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
boolean ready = roomManager.toggleReady(request.roomId(), userId);
|
||||
return ApiResponse.ok(ready);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始游戏(仅房主)
|
||||
*/
|
||||
@PostMapping("/start")
|
||||
public ApiResponse<Void> startGame(@Valid @RequestBody StartGameRequest request) {
|
||||
String ownerId = authProvider.getCurrentUserId();
|
||||
roomManager.startGame(request.roomId(), ownerId);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.webgame.webgamebackend.api;
|
||||
|
||||
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
|
||||
import com.webgame.webgamebackend.common.dto.ApiResponse;
|
||||
import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest;
|
||||
import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
|
||||
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
|
||||
import com.webgame.webgamebackend.domain.user.UserService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 用户控制器
|
||||
*/
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/user")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
private final AuthenticationProvider authProvider;
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
*/
|
||||
@GetMapping("/info")
|
||||
public ApiResponse<UserInfoResponse> info() {
|
||||
UserInfoResponse result = userService.getCurrentUserInfo();
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前登录用户信息
|
||||
*
|
||||
* 所有字段可选,传什么更新什么。
|
||||
*/
|
||||
@PutMapping("/info")
|
||||
public ApiResponse<UserInfoResponse> updateInfo(@Valid @RequestBody UpdateUserInfoRequest request) {
|
||||
String accountId = authProvider.getCurrentUserId();
|
||||
UserInfoResponse result = userService.updateUserInfo(accountId, request);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前登录用户在线状态
|
||||
*
|
||||
* 入参 status 可选值:ONLINE、AWAY、DND、INVISIBLE
|
||||
*/
|
||||
@PatchMapping("/status")
|
||||
public ApiResponse<Void> updateStatus(@Valid @RequestBody UpdateStatusRequest request) {
|
||||
String accountId = authProvider.getCurrentUserId();
|
||||
userService.updateStatus(accountId, request);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传用户头像
|
||||
*
|
||||
* 接收裁剪后的图片文件(JPEG/PNG/WebP/GIF,≤2MB),保存后返回更新后的用户信息。
|
||||
* 旧头像文件会被自动删除。
|
||||
*/
|
||||
@PostMapping("/avatar")
|
||||
public ApiResponse<UserInfoResponse> uploadAvatar(@RequestParam("file") MultipartFile file) {
|
||||
String accountId = authProvider.getCurrentUserId();
|
||||
UserInfoResponse result = userService.uploadAvatar(accountId, file);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户头像
|
||||
*
|
||||
* 删除磁盘文件并将 DB 中 avatar 置空,前端会回退显示默认头像。
|
||||
*/
|
||||
@DeleteMapping("/avatar")
|
||||
public ApiResponse<UserInfoResponse> deleteAvatar() {
|
||||
String accountId = authProvider.getCurrentUserId();
|
||||
UserInfoResponse result = userService.deleteAvatar(accountId);
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.webgame.webgamebackend.common.auth;
|
||||
|
||||
/**
|
||||
* 当前请求用户上下文
|
||||
* <p>
|
||||
* 基于 ThreadLocal 存储当前请求的用户 ID,由 AuthInterceptor 在 preHandle 中设置,
|
||||
* 在 afterCompletion 中清理。业务代码可通过 {@link #get()} 获取当前用户 ID。
|
||||
* <p>
|
||||
* 注意:仅在同一请求线程内有效,异步场景需手动传递。
|
||||
*/
|
||||
public final class AuthContext {
|
||||
|
||||
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
|
||||
|
||||
private AuthContext() {
|
||||
// 工具类,禁止实例化
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前请求的用户 ID
|
||||
*/
|
||||
public static void set(String userId) {
|
||||
USER_ID.set(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求的用户 ID
|
||||
*
|
||||
* @return 用户 ID,未设置时返回 null
|
||||
*/
|
||||
public static String get() {
|
||||
return USER_ID.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理当前请求的用户上下文
|
||||
* <p>
|
||||
* 必须在请求结束后调用,防止 ThreadLocal 内存泄漏。
|
||||
*/
|
||||
public static void clear() {
|
||||
USER_ID.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.webgame.webgamebackend.common.auth;
|
||||
|
||||
import cn.dev33.satoken.exception.NotLoginException;
|
||||
import cn.dev33.satoken.exception.NotPermissionException;
|
||||
import cn.dev33.satoken.exception.NotRoleException;
|
||||
import com.webgame.webgamebackend.common.exception.BusinessException;
|
||||
import com.webgame.webgamebackend.common.exception.ErrorCode;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证拦截器,替代 Sa-Token 的 SaInterceptor
|
||||
* <p>
|
||||
* 职责:
|
||||
* <ol>
|
||||
* <li>放行公开路径和 OPTIONS 预检请求</li>
|
||||
* <li>调用 {@link AuthenticationProvider#getCurrentUserId()} 触发登录校验</li>
|
||||
* <li>将 Sa-Token 框架异常转换为 {@link BusinessException},实现认证框架与业务解耦</li>
|
||||
* <li>校验通过后将用户 ID 存入 {@link AuthContext}</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final AuthenticationProvider authProvider;
|
||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
|
||||
/**
|
||||
* 公开路由条目
|
||||
*
|
||||
* @param path Ant 风格路径模式
|
||||
* @param methods 允许的 HTTP 方法,空数组表示所有方法
|
||||
*/
|
||||
private record PublicRoute(String path, String... methods) {
|
||||
boolean matches(String requestPath, String requestMethod) {
|
||||
if (!new AntPathMatcher().match(path, requestPath)) {
|
||||
return false;
|
||||
}
|
||||
if (methods.length == 0) {
|
||||
return true; // 所有方法放行
|
||||
}
|
||||
for (String m : methods) {
|
||||
if (m.equalsIgnoreCase(requestMethod)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 无需登录的公开路由 */
|
||||
private static final List<PublicRoute> PUBLIC_ROUTES = List.of(
|
||||
// 框架级路径(所有方法)
|
||||
new PublicRoute("/actuator/**"),
|
||||
new PublicRoute("/swagger-ui/**"),
|
||||
new PublicRoute("/swagger-ui.html"),
|
||||
new PublicRoute("/v3/api-docs/**"),
|
||||
new PublicRoute("/webjars/**"),
|
||||
new PublicRoute("/error"),
|
||||
new PublicRoute("/sse/**"),
|
||||
|
||||
// 账号模块 — 登录/注册/刷新无需认证
|
||||
new PublicRoute("/account/login", "POST"),
|
||||
new PublicRoute("/account/register", "POST"),
|
||||
new PublicRoute("/account/refresh", "POST"),
|
||||
|
||||
// 游戏模块 — 查看列表/详情无需认证
|
||||
new PublicRoute("/game/list", "GET"),
|
||||
// 房间模块 — 查看列表/详情无需认证
|
||||
new PublicRoute("/room/list", "GET"),
|
||||
// GET /room/{roomId} 详情页,排除 POST/PUT/DELETE 等写操作
|
||||
new PublicRoute("/room/*", "GET")
|
||||
);
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
|
||||
Object handler) {
|
||||
String method = request.getMethod();
|
||||
String path = request.getRequestURI();
|
||||
|
||||
// OPTIONS 预检请求放行
|
||||
if ("OPTIONS".equalsIgnoreCase(method)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 公开路由放行
|
||||
if (isPublicRoute(path, method)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 认证校验:调用 authProvider 触发 Sa-Token 的登录检查
|
||||
try {
|
||||
String userId = authProvider.getCurrentUserId();
|
||||
AuthContext.set(userId);
|
||||
return true;
|
||||
} catch (NotLoginException e) {
|
||||
log.debug("未登录访问受保护路径: path={}", path);
|
||||
throw new BusinessException(ErrorCode.AUTH_NOT_LOGIN);
|
||||
} catch (NotPermissionException | NotRoleException e) {
|
||||
log.warn("权限不足: path={}, message={}", path, e.getMessage());
|
||||
throw new BusinessException(ErrorCode.AUTH_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
|
||||
Object handler, Exception ex) {
|
||||
// 请求结束后清理 ThreadLocal,防止内存泄漏
|
||||
AuthContext.clear();
|
||||
}
|
||||
|
||||
/** 判断路由是否为公开路由(无需登录) */
|
||||
private boolean isPublicRoute(String path, String method) {
|
||||
for (PublicRoute route : PUBLIC_ROUTES) {
|
||||
if (route.matches(path, method)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.webgame.webgamebackend.common.auth;
|
||||
|
||||
/**
|
||||
* 认证提供者抽象接口
|
||||
* <p>
|
||||
* 隔离具体的认证框架实现(当前为 Sa-Token),业务代码只依赖此接口。
|
||||
* 如需切换认证框架,只需提供新的实现类即可,业务代码无需修改。
|
||||
*/
|
||||
public interface AuthenticationProvider {
|
||||
|
||||
/**
|
||||
* 用户登录,使 userId 在当前会话中保持登录态
|
||||
*
|
||||
* @param userId 用户唯一标识
|
||||
* @return 生成的 Access Token
|
||||
*/
|
||||
String login(Object userId);
|
||||
|
||||
/**
|
||||
* 当前会话登出,使 Access Token 失效
|
||||
*/
|
||||
void logout();
|
||||
|
||||
/**
|
||||
* 获取当前已登录的用户 ID
|
||||
*
|
||||
* @return 当前用户 ID
|
||||
* @throws cn.dev33.satoken.exception.NotLoginException 未登录时由实现层抛出
|
||||
*/
|
||||
String getCurrentUserId();
|
||||
|
||||
/**
|
||||
* 通过 Token 获取用户 ID(不检查当前会话登录态)
|
||||
*
|
||||
* @param token Access Token
|
||||
* @return 用户 ID,Token 无效时返回 null
|
||||
*/
|
||||
String getUserIdByToken(String token);
|
||||
|
||||
/**
|
||||
* 获取当前会话的 Access Token
|
||||
*
|
||||
* @return Access Token 字符串
|
||||
*/
|
||||
String getCurrentToken();
|
||||
|
||||
/**
|
||||
* 在当前请求上下文中设置 Token,使后续 getCurrentUserId() 可用
|
||||
*
|
||||
* @param token Access Token
|
||||
* @param timeout Token 有效期(秒),-1 表示保持原有有效期
|
||||
*/
|
||||
void setContextToken(String token, int timeout);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.webgame.webgamebackend.common.auth;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Sa-Token 认证提供者实现
|
||||
* <p>
|
||||
* 将 Sa-Token 的 StpUtil 静态方法封装为 AuthenticationProvider 接口,
|
||||
* 是项目中唯一直接依赖 Sa-Token 的业务代码之外的文件。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SaTokenAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
@Override
|
||||
public String login(Object userId) {
|
||||
StpUtil.login(userId);
|
||||
String token = StpUtil.getTokenValue();
|
||||
log.debug("用户登录成功: userId={}", userId);
|
||||
return token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logout() {
|
||||
StpUtil.logout();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCurrentUserId() {
|
||||
return StpUtil.getLoginIdAsString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUserIdByToken(String token) {
|
||||
Object loginId = StpUtil.getLoginIdByToken(token);
|
||||
return loginId != null ? loginId.toString() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCurrentToken() {
|
||||
return StpUtil.getTokenValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContextToken(String token, int timeout) {
|
||||
StpUtil.setTokenValue(token, timeout);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.webgame.webgamebackend.common.config;
|
||||
|
||||
import cn.dev33.satoken.fun.strategy.SaCorsHandleFunction;
|
||||
import cn.dev33.satoken.interceptor.SaInterceptor;
|
||||
import cn.dev33.satoken.router.SaHttpMethod;
|
||||
import cn.dev33.satoken.router.SaRouter;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* [Sa-Token 权限认证] 配置类
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class SaTokenConfigure implements WebMvcConfigurer {
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(new SaInterceptor(handler -> {
|
||||
SaRouter.match("/**")
|
||||
.notMatch("/actuator/**")
|
||||
.notMatch("/swagger-ui/**")
|
||||
.notMatch("/swagger-ui.html")
|
||||
.notMatch("/v3/api-docs/**")
|
||||
.notMatch("/webjars/**")
|
||||
.check(r -> StpUtil.checkLogin());
|
||||
})).addPathPatterns("/**");
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS 跨域处理策略
|
||||
*/
|
||||
@Bean
|
||||
public SaCorsHandleFunction corsHandle() {
|
||||
return (req, res, sto) -> {
|
||||
res
|
||||
// 允许指定域访问跨域资源
|
||||
.setHeader("Access-Control-Allow-Origin", "*")// 允许指定域访问跨域资源
|
||||
// 允许所有请求方式
|
||||
.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE")
|
||||
// 有效时间
|
||||
.setHeader("Access-Control-Max-Age", "3600")
|
||||
// 允许的header参数
|
||||
.setHeader("Access-Control-Allow-Headers", "*");
|
||||
|
||||
// 如果是预检请求,则立即返回到前端
|
||||
SaRouter.match(SaHttpMethod.OPTIONS)
|
||||
.free(r -> System.out.println("--------OPTIONS预检请求,不做处理"))
|
||||
.back();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.webgame.webgamebackend.common.constants;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 用户相关常量
|
||||
*/
|
||||
public final class UserConstants {
|
||||
|
||||
private UserConstants() {
|
||||
// 工具类禁止实例化
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像背景色板(14 种 Material Design 颜色)
|
||||
*/
|
||||
private static final String[] AVATAR_COLORS = {
|
||||
"#E53935", "#D81B60", "#8E24AA", "#5E35B1",
|
||||
"#3949AB", "#1E88E5", "#039BE5", "#00ACC1",
|
||||
"#00897B", "#43A047", "#7CB342", "#C0CA33",
|
||||
"#FB8C00", "#F4511E"
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认昵称(当用户昵称为空时使用)
|
||||
*/
|
||||
private static final String DEFAULT_NICKNAME = "玩家";
|
||||
|
||||
/**
|
||||
* 根据昵称生成默认头像 data URI(本地 SVG,无需外部 API)
|
||||
*
|
||||
* @param nickName 用户昵称
|
||||
* @return data:image/svg+xml;base64,... 格式的头像
|
||||
*/
|
||||
public static String generateDefaultAvatar(String nickName) {
|
||||
String name = (nickName == null || nickName.isBlank()) ? DEFAULT_NICKNAME : nickName.trim();
|
||||
// 取首字符作为头像文字
|
||||
String initial = name.substring(0, 1);
|
||||
// 根据昵称 hash 选颜色,同一昵称颜色不变
|
||||
int colorIndex = Math.abs(name.hashCode()) % AVATAR_COLORS.length;
|
||||
String bgColor = AVATAR_COLORS[colorIndex];
|
||||
|
||||
String svg = """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="50" fill="%s"/>
|
||||
<text x="50" y="62" text-anchor="middle" font-size="40"
|
||||
fill="#FFFFFF" font-family="Arial, sans-serif">%s</text>
|
||||
</svg>
|
||||
""".formatted(bgColor, escapeXml(initial));
|
||||
|
||||
String base64 = Base64.getEncoder().encodeToString(svg.getBytes(StandardCharsets.UTF_8));
|
||||
return "data:image/svg+xml;base64," + base64;
|
||||
}
|
||||
|
||||
/**
|
||||
* XML 特殊字符转义(防止文字中特殊字符破坏 SVG 结构)
|
||||
*/
|
||||
private static String escapeXml(String text) {
|
||||
return text
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ public record ApiResponse<T>(int code, String message, T data) {
|
||||
* 成功响应
|
||||
*/
|
||||
public static <T> ApiResponse<T> ok(T data) {
|
||||
return new ApiResponse<>(0, "success", data);
|
||||
return new ApiResponse<>(0, "成功", data);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
/**
|
||||
* 创建房间请求,对应前端 CreateRoomRequest
|
||||
*/
|
||||
public record CreateRoomRequest(
|
||||
/** 游戏 ID */
|
||||
@NotBlank(message = "游戏ID不能为空")
|
||||
String gameId,
|
||||
|
||||
/** 房间名称(可选,留空由后端自动生成) */
|
||||
String name,
|
||||
|
||||
/** 房间密码(可选,留空为公开房间) */
|
||||
String password,
|
||||
|
||||
/** 玩法模式 */
|
||||
@NotBlank(message = "玩法模式不能为空")
|
||||
String mode,
|
||||
|
||||
/** 底注金额 */
|
||||
@NotNull(message = "底注金额不能为空")
|
||||
@Positive(message = "底注金额必须为正数")
|
||||
Integer stakes,
|
||||
|
||||
/** 最大玩家数 */
|
||||
@NotNull(message = "最大玩家数不能为空")
|
||||
@Positive(message = "最大玩家数必须为正数")
|
||||
Integer maxPlayers
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
/**
|
||||
* 创建房间响应
|
||||
*/
|
||||
public record CreateRoomResponse(RoomItemResponse room) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 解散房间请求
|
||||
*/
|
||||
public record DisbandRoomRequest(
|
||||
@NotBlank(message = "房间ID不能为空")
|
||||
String roomId
|
||||
) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 游戏配置 DTO,对应前端 GameConfig
|
||||
*/
|
||||
public record GameConfigDto(
|
||||
int maxPlayers,
|
||||
List<String> modes,
|
||||
List<Integer> stakesOptions
|
||||
) {}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameConfigEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 游戏列表项响应,对应前端 GameItem
|
||||
*/
|
||||
public record GameItemResponse(
|
||||
String id,
|
||||
String icon,
|
||||
String name,
|
||||
String description,
|
||||
String gameplay,
|
||||
GameConfigDto config
|
||||
) {
|
||||
/**
|
||||
* 从实体转换
|
||||
*
|
||||
* @param entity 游戏配置实体
|
||||
* @return 响应 DTO
|
||||
*/
|
||||
public static GameItemResponse from(GameConfigEntity entity) {
|
||||
List<String> modes = JSON.parseArray(entity.getModes(), String.class);
|
||||
List<Integer> stakesOptions = JSON.parseArray(entity.getStakesOptions(), Integer.class);
|
||||
return new GameItemResponse(
|
||||
entity.getGameId(),
|
||||
entity.getIcon(),
|
||||
entity.getName(),
|
||||
entity.getDescription(),
|
||||
entity.getGameplay(),
|
||||
new GameConfigDto(entity.getMaxPlayers(), modes, stakesOptions)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 游戏列表响应
|
||||
*/
|
||||
public record GameListResponse(List<GameItemResponse> list) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 加入房间请求
|
||||
*/
|
||||
public record JoinRoomRequest(
|
||||
/** 房间 ID */
|
||||
@NotBlank(message = "房间ID不能为空")
|
||||
String roomId,
|
||||
|
||||
/** 房间密码(公开房间无需传) */
|
||||
String password
|
||||
) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 踢出玩家请求
|
||||
*/
|
||||
public record KickPlayerRequest(
|
||||
@NotBlank(message = "房间ID不能为空")
|
||||
String roomId,
|
||||
|
||||
@NotBlank(message = "目标用户ID不能为空")
|
||||
String userId
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 离开房间请求
|
||||
*/
|
||||
public record LeaveRoomRequest(
|
||||
@NotBlank(message = "房间ID不能为空")
|
||||
String roomId
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 准备/取消准备请求
|
||||
*/
|
||||
public record ReadyRequest(
|
||||
@NotBlank(message = "房间ID不能为空")
|
||||
String roomId
|
||||
) {}
|
||||
@@ -0,0 +1,52 @@
|
||||
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,
|
||||
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
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameRoomEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 房间信息响应,对应前端 RoomItem
|
||||
*/
|
||||
public record RoomItemResponse(
|
||||
String id,
|
||||
String name,
|
||||
String gameIcon,
|
||||
String gameId,
|
||||
int maxPlayers,
|
||||
List<RoomPlayerResponse> players,
|
||||
String status,
|
||||
String statusText,
|
||||
int stakes,
|
||||
String mode,
|
||||
String creatorName
|
||||
) {
|
||||
/** 状态 → 展示文本映射 */
|
||||
private static final Map<String, String> STATUS_TEXT_MAP = Map.of(
|
||||
"waiting", "等待中",
|
||||
"playing", "进行中",
|
||||
"finished", "已结束"
|
||||
);
|
||||
|
||||
/**
|
||||
* 从实体和玩家列表组装响应
|
||||
*
|
||||
* @param room 房间实体
|
||||
* @param players 房间内玩家列表
|
||||
* @param gameIcon 游戏图标
|
||||
* @param creatorName 房主昵称
|
||||
* @return 响应 DTO
|
||||
*/
|
||||
public static RoomItemResponse from(
|
||||
GameRoomEntity room,
|
||||
List<RoomPlayerResponse> players,
|
||||
String gameIcon,
|
||||
String creatorName) {
|
||||
return new RoomItemResponse(
|
||||
room.getId(),
|
||||
room.getName(),
|
||||
gameIcon,
|
||||
room.getGameId(),
|
||||
room.getMaxPlayers(),
|
||||
players,
|
||||
room.getStatus(),
|
||||
STATUS_TEXT_MAP.getOrDefault(room.getStatus(), "未知"),
|
||||
room.getStakes(),
|
||||
room.getMode(),
|
||||
creatorName
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 房间列表响应
|
||||
*/
|
||||
public record RoomListResponse(List<RoomItemResponse> list) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
/**
|
||||
* 房间内玩家信息,对应前端 RoomPlayer
|
||||
*/
|
||||
public record RoomPlayerResponse(
|
||||
String avatar,
|
||||
String nickname
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.webgame.webgamebackend.common.dto.game;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 开始游戏请求
|
||||
*/
|
||||
public record StartGameRequest(
|
||||
@NotBlank(message = "房间ID不能为空")
|
||||
String roomId
|
||||
) {}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.webgame.webgamebackend.common.dto.user;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
/**
|
||||
* 更新在线状态请求
|
||||
*/
|
||||
public record UpdateStatusRequest(
|
||||
@NotBlank(message = "状态不能为空")
|
||||
@Pattern(
|
||||
regexp = "ONLINE|AWAY|DND|INVISIBLE",
|
||||
message = "状态值无效,可选值:ONLINE、AWAY、DND、INVISIBLE"
|
||||
)
|
||||
String status
|
||||
) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.webgame.webgamebackend.common.dto.user;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 更新用户信息请求
|
||||
*
|
||||
* 所有字段可选,传什么更新什么。
|
||||
*/
|
||||
public record UpdateUserInfoRequest(
|
||||
String nickName,
|
||||
String avatar,
|
||||
String signature,
|
||||
Integer age,
|
||||
Integer sex,
|
||||
LocalDate birthday
|
||||
) {}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.webgame.webgamebackend.common.dto.user;
|
||||
|
||||
import com.webgame.webgamebackend.entities.UserEntity;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.UserEntity;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@@ -8,24 +8,28 @@ import java.time.LocalDate;
|
||||
* 用户信息响应
|
||||
*/
|
||||
public record UserInfoResponse(
|
||||
String userId,
|
||||
String nickName,
|
||||
String avatar,
|
||||
String signature,
|
||||
Integer age,
|
||||
Integer sex,
|
||||
LocalDate birthday
|
||||
LocalDate birthday,
|
||||
String status
|
||||
) {
|
||||
/**
|
||||
* 从实体转换
|
||||
*/
|
||||
public static UserInfoResponse from(UserEntity user) {
|
||||
return new UserInfoResponse(
|
||||
user.getAccount().getId(),
|
||||
user.getNickName(),
|
||||
user.getAvatar(),
|
||||
user.getSignature(),
|
||||
user.getAge(),
|
||||
user.getSex(),
|
||||
user.getBirthday()
|
||||
user.getBirthday(),
|
||||
user.getStatus()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.webgame.webgamebackend.common.event;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 内部事件总线
|
||||
*
|
||||
* 用于 Room ↔ Game 之间的解耦通信。
|
||||
* GameEngine 通过此总线发布游戏事件(如 GAME_OVER),
|
||||
* RoomManager 订阅这些事件并执行房间层处理(状态更新、结算等)。
|
||||
*
|
||||
* <p>房间层到游戏层的通信通过 {@link com.webgame.webgamebackend.service.engine.GameEngine}
|
||||
* 接口直接调用,不需要经过事件总线。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class RoomEventBus {
|
||||
|
||||
/** 事件类型 → 订阅者列表 */
|
||||
private final Map<String, List<Consumer<Object>>> subscribers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
*
|
||||
* @param eventType 事件类型
|
||||
* @param handler 事件处理器
|
||||
*/
|
||||
public void subscribe(String eventType, Consumer<Object> handler) {
|
||||
subscribers.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>()).add(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布事件
|
||||
*
|
||||
* @param eventType 事件类型
|
||||
* @param payload 事件数据
|
||||
*/
|
||||
public void publish(String eventType, Object payload) {
|
||||
List<Consumer<Object>> handlers = subscribers.get(eventType);
|
||||
if (handlers == null || handlers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Consumer<Object> handler : handlers) {
|
||||
try {
|
||||
handler.accept(payload);
|
||||
} catch (Exception e) {
|
||||
log.error("[事件总线] 处理事件异常, eventType={}", eventType, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 预定义事件类型 ====================
|
||||
|
||||
/** 游戏结束事件,payload 为 GameOverEvent */
|
||||
public static final String GAME_OVER = "game:over";
|
||||
|
||||
/** 游戏中玩家弃权(断线超时/被踢),payload 为 PlayerForfeitEvent */
|
||||
public static final String PLAYER_FORFEIT = "game:player_forfeit";
|
||||
|
||||
// ==================== 事件数据类 ====================
|
||||
|
||||
/**
|
||||
* 游戏结束事件数据
|
||||
*/
|
||||
public record GameOverEvent(String roomId, String winnerId, String resultType) {}
|
||||
|
||||
/**
|
||||
* 玩家弃权事件数据
|
||||
*/
|
||||
public record PlayerForfeitEvent(String roomId, String userId, String reason) {}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.webgame.webgamebackend.common.event;
|
||||
|
||||
import jakarta.annotation.Nullable;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* SSE 事件模型
|
||||
*
|
||||
* @param id 事件 ID,客户端用于追踪和断线续传
|
||||
* @param type 事件类型
|
||||
* @param targetId 目标账号 ID,null 表示广播
|
||||
* @param data JSON 数据载荷
|
||||
* @param timestamp 事件创建时间戳(毫秒)
|
||||
*/
|
||||
public record SseEvent(
|
||||
String id,
|
||||
SseEventType type,
|
||||
@Nullable String targetId,
|
||||
String data,
|
||||
long timestamp
|
||||
) {
|
||||
public SseEvent {
|
||||
Objects.requireNonNull(id, "id 不能为空");
|
||||
Objects.requireNonNull(type, "type 不能为空");
|
||||
Objects.requireNonNull(data, "data 不能为空");
|
||||
}
|
||||
|
||||
public static SseEvent broadcast(SseEventType type, String data) {
|
||||
return create(type, null, data);
|
||||
}
|
||||
|
||||
public static SseEvent targeted(SseEventType type, String targetId, String data) {
|
||||
Objects.requireNonNull(targetId, "targetId 不能为空");
|
||||
return create(type, targetId, data);
|
||||
}
|
||||
|
||||
private static SseEvent create(SseEventType type, @Nullable String targetId, String data) {
|
||||
long now = System.currentTimeMillis();
|
||||
return new SseEvent("evt_" + now + "_" + UUID.randomUUID(), type, targetId, data, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.webgame.webgamebackend.common.event;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.webgame.webgamebackend.common.exception.BusinessException;
|
||||
import com.webgame.webgamebackend.common.exception.ErrorCode;
|
||||
import com.webgame.webgamebackend.adapter.sse.SseConnectionManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 统一 SSE 事件发布器
|
||||
*
|
||||
* 优先通过 Redis Pub/Sub 扇出事件到所有应用实例;
|
||||
* Redis 不可用时降级为本地直推,并抛出异常让调用方事务回滚。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SseEventPublisher {
|
||||
|
||||
public static final String CHANNEL = "sse:events";
|
||||
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
private final SseConnectionManager connectionManager;
|
||||
|
||||
/**
|
||||
* 发布广播事件(推送给所有在线用户)
|
||||
*
|
||||
* @param type 事件类型
|
||||
* @param data 事件数据对象(将被序列化为 JSON)
|
||||
* @throws BusinessException Redis 和本地降级均失败时抛出
|
||||
*/
|
||||
public void broadcast(SseEventType type, Object data) {
|
||||
publish(SseEvent.broadcast(type, JSON.toJSONString(data)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布定向事件(只推送给指定用户)
|
||||
*
|
||||
* @param userId 目标用户账号 ID
|
||||
* @param type 事件类型
|
||||
* @param data 事件数据对象(将被序列化为 JSON)
|
||||
* @throws BusinessException Redis 和本地降级均失败时抛出
|
||||
*/
|
||||
public void sendTo(String userId, SseEventType type, Object data) {
|
||||
publish(SseEvent.targeted(type, userId, JSON.toJSONString(data)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布事件到 Redis Pub/Sub 频道
|
||||
*
|
||||
* 优先走 Redis 扇出到所有实例;Redis 不可用时降级为直接推送给本地连接。
|
||||
* 两次尝试均失败则抛出 BusinessException,让调用方事务回滚。
|
||||
*
|
||||
* @param event 事件对象
|
||||
* @throws BusinessException 发布完全失败时抛出
|
||||
*/
|
||||
private void publish(SseEvent event) {
|
||||
// 优先:通过 Redis Pub/Sub 扇出
|
||||
try {
|
||||
String json = JSON.toJSONString(event);
|
||||
redisTemplate.convertAndSend(CHANNEL, json);
|
||||
log.debug("[SSE] 事件已发布, channel={}, type={}, targetId={}",
|
||||
CHANNEL, event.type(), event.targetId());
|
||||
return;
|
||||
} catch (Exception redisEx) {
|
||||
log.warn("[SSE] Redis 不可用,降级为本地直推, type={}, targetId={}, reason={}",
|
||||
event.type(), event.targetId(), redisEx.getMessage());
|
||||
}
|
||||
|
||||
// 降级:直接推送给本地连接(本实例用户不丢事件)
|
||||
try {
|
||||
if (event.targetId() == null) {
|
||||
connectionManager.broadcast(event);
|
||||
} else {
|
||||
connectionManager.sendTo(event.targetId(), event);
|
||||
}
|
||||
log.info("[SSE] 已降级为本地直推, type={}, targetId={}", event.type(), event.targetId());
|
||||
} catch (Exception localEx) {
|
||||
log.error("[SSE] 本地直推也失败, type={}, targetId={}", event.type(), event.targetId(), localEx);
|
||||
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "事件推送失败,请重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.webgame.webgamebackend.common.event;
|
||||
|
||||
/**
|
||||
* SSE 事件类型枚举
|
||||
*/
|
||||
public enum SseEventType {
|
||||
ROOM_CREATED,
|
||||
ROOM_UPDATED,
|
||||
ROOM_REMOVED,
|
||||
KICKED,
|
||||
GAME_STARTED,
|
||||
BALANCE_CHG,
|
||||
/** 自定义事件类型,用于测试或任意消息推送 */
|
||||
CUSTOM
|
||||
}
|
||||
@@ -3,26 +3,31 @@ package com.webgame.webgamebackend.common.exception;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 业务异常,Service 层抛出,由全局异常处理器转换为 ApiResponse
|
||||
* 业务异常,Service 层抛出,由全局异常处理器转换为 ResponseEntity<ApiResponse>
|
||||
* <p>
|
||||
* 同时携带业务错误码和对应的 HTTP 状态码,两者均从 ErrorCode 枚举自动派生。
|
||||
*/
|
||||
@Getter
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private final int code;
|
||||
private final int httpStatus;
|
||||
|
||||
/**
|
||||
* 使用 ErrorCode 枚举创建异常,消息使用枚举默认值
|
||||
* 使用 ErrorCode 枚举创建异常,消息和 HTTP 状态码从枚举默认值派生
|
||||
*/
|
||||
public BusinessException(ErrorCode errorCode) {
|
||||
super(errorCode.getDefaultMessage());
|
||||
this.code = errorCode.getCode();
|
||||
this.httpStatus = errorCode.getHttpStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ErrorCode 枚举 + 自定义消息创建异常
|
||||
* 使用 ErrorCode 枚举 + 自定义消息创建异常,HTTP 状态码从枚举派生
|
||||
*/
|
||||
public BusinessException(ErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.code = errorCode.getCode();
|
||||
this.httpStatus = errorCode.getHttpStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,56 @@ package com.webgame.webgamebackend.common.exception;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 业务错误码枚举,统一管理所有错误码和默认消息
|
||||
* 业务错误码枚举,统一管理所有错误码、默认消息和对应的 HTTP 状态码
|
||||
* <p>
|
||||
* HTTP 状态码表达错误大类(协议层),code 表达具体业务错误(应用层),
|
||||
* 两者分工协作形成双层错误表达模式。
|
||||
*/
|
||||
@Getter
|
||||
public enum ErrorCode {
|
||||
|
||||
// ========== 通用 ==========
|
||||
SUCCESS(0, "success"),
|
||||
BAD_REQUEST(400, "参数校验失败"),
|
||||
INTERNAL_ERROR(5000, "服务器内部错误"),
|
||||
SUCCESS(0, "成功", 200),
|
||||
BAD_REQUEST(400, "参数校验失败", 400),
|
||||
INTERNAL_ERROR(5000, "服务器内部错误", 500),
|
||||
|
||||
// ========== 账号模块 ==========
|
||||
USERNAME_EXISTS(1001, "用户名已存在"),
|
||||
BAD_CREDENTIALS(1002, "用户名或密码错误"),
|
||||
REFRESH_TOKEN_INVALID(1003, "刷新令牌无效或已过期"),
|
||||
REFRESH_TOKEN_MISSING(1004, "缺少刷新令牌"),
|
||||
USERNAME_EXISTS(1001, "用户名已存在", 409),
|
||||
BAD_CREDENTIALS(1002, "用户名或密码错误", 401),
|
||||
REFRESH_TOKEN_INVALID(1003, "刷新令牌无效或已过期", 401),
|
||||
REFRESH_TOKEN_MISSING(1004, "缺少刷新令牌", 400),
|
||||
|
||||
// ========== 用户模块 ==========
|
||||
USER_NOT_FOUND(2001, "用户不存在"),
|
||||
USER_NOT_FOUND(2001, "用户不存在", 404),
|
||||
AVATAR_UPLOAD_FAILED(2002, "头像上传失败", 500),
|
||||
AVATAR_INVALID_TYPE(2003, "头像格式不支持,仅支持 JPG、PNG、WebP、GIF", 400),
|
||||
AVATAR_TOO_LARGE(2004, "头像文件大小不能超过 2MB", 400),
|
||||
|
||||
// ========== 认证模块 ==========
|
||||
AUTH_NOT_LOGIN(4001, "请先登录", 401),
|
||||
AUTH_FORBIDDEN(4002, "权限不足", 403),
|
||||
|
||||
// ========== 游戏模块 ==========
|
||||
GAME_NOT_FOUND(3001, "游戏不存在或已下架", 404),
|
||||
ROOM_NOT_FOUND(3002, "房间不存在", 404),
|
||||
ROOM_FULL(3003, "房间已满", 409),
|
||||
ROOM_PASSWORD_ERROR(3004, "房间密码错误", 401),
|
||||
ROOM_ALREADY_STARTED(3005, "游戏已开始,无法加入", 409),
|
||||
NOT_ROOM_OWNER(3006, "非房主无权操作", 403),
|
||||
PLAYER_NOT_IN_ROOM(3007, "玩家不在该房间", 404),
|
||||
NOT_ALL_READY(3008, "有玩家尚未准备", 409),
|
||||
PLAYER_COUNT_INSUFFICIENT(3009, "人数不足,无法开始", 409),
|
||||
BALANCE_INSUFFICIENT(3010, "余额不足", 409),
|
||||
ALREADY_IN_ROOM(3011, "已在房间中", 409),
|
||||
;
|
||||
|
||||
private final int code;
|
||||
private final String defaultMessage;
|
||||
private final int httpStatus;
|
||||
|
||||
ErrorCode(int code, String defaultMessage) {
|
||||
ErrorCode(int code, String defaultMessage, int httpStatus) {
|
||||
this.code = code;
|
||||
this.defaultMessage = defaultMessage;
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,127 +1,170 @@
|
||||
package com.webgame.webgamebackend.common.handler;
|
||||
|
||||
import cn.dev33.satoken.exception.NotLoginException;
|
||||
import cn.dev33.satoken.exception.NotPermissionException;
|
||||
import cn.dev33.satoken.exception.NotRoleException;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.webgame.webgamebackend.common.dto.ApiResponse;
|
||||
import com.webgame.webgamebackend.common.exception.BusinessException;
|
||||
import com.webgame.webgamebackend.common.exception.ErrorCode;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 全局异常处理,统一返回 ApiResponse 格式
|
||||
* 全局异常处理器,双层错误表达:
|
||||
* HTTP 状态码表达错误大类 + ApiResponse.code 表达具体业务错误码
|
||||
* <p>
|
||||
* 业务异常(仅来自 REST JSON 请求)返回 ResponseEntity,经过 Spring 内容协商。
|
||||
* 框架级异常(可能来自 SSE/浏览器等任意请求类型)直接写 HttpServletResponse 绕过内容协商。
|
||||
* <p>
|
||||
* 认证相关异常(NotLoginException 等)已由 {@code AuthInterceptor} 转为
|
||||
* {@link BusinessException},此处不再直接依赖 Sa-Token。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
// ==================== 业务异常 ====================
|
||||
// ==================== SSE / 长连接异常(不写响应体) ====================
|
||||
|
||||
@ExceptionHandler(AsyncRequestTimeoutException.class)
|
||||
public void handleAsyncTimeout(AsyncRequestTimeoutException ex) {
|
||||
// SSE 超时在长连接场景下属于正常情况,不写响应体
|
||||
}
|
||||
|
||||
@ExceptionHandler(AsyncRequestNotUsableException.class)
|
||||
public void handleAsyncRequestNotUsable(AsyncRequestNotUsableException ex) {
|
||||
// SSE 响应已被客户端关闭,不写响应体
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理业务异常
|
||||
* SSE 客户端断开连接时,Spring 在 Tomcat 容器线程上抛出的 IOException
|
||||
* <p>
|
||||
* SSE 长连接场景下客户端主动断开(关闭标签页、网络切换等)是正常行为,
|
||||
* 不应作为 ERROR 记录。仅当响应尚未提交时才视为真正的 IO 异常。
|
||||
*/
|
||||
@ExceptionHandler(IOException.class)
|
||||
public void handleIOException(IOException ex, HttpServletResponse response) {
|
||||
if (response.isCommitted()) {
|
||||
log.debug("SSE 客户端已断开: {}", ex.getMessage());
|
||||
} else {
|
||||
log.warn("未提交响应的 IO 异常: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 业务异常(REST JSON 请求) ====================
|
||||
|
||||
/**
|
||||
* 业务异常 — HTTP 状态码从 ErrorCode 枚举自动派生
|
||||
*/
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
|
||||
log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage());
|
||||
return ApiResponse.fail(ex.getCode(), ex.getMessage());
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
|
||||
log.warn("业务异常: code={}, httpStatus={}, message={}", ex.getCode(), ex.getHttpStatus(), ex.getMessage());
|
||||
return ResponseEntity
|
||||
.status(ex.getHttpStatus())
|
||||
.body(ApiResponse.fail(ex.getCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
// ==================== 参数校验异常 ====================
|
||||
// ==================== 参数校验异常 → HTTP 400 ====================
|
||||
|
||||
/**
|
||||
* 处理 @Valid 参数校验失败,合并所有字段的错误消息
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ApiResponse<Void> handleValidation(MethodArgumentNotValidException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String message = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(e -> e.getField() + " " + e.getDefaultMessage())
|
||||
.map(error -> error.getField() + " " + error.getDefaultMessage())
|
||||
.reduce((a, b) -> a + "; " + b)
|
||||
.orElse(ErrorCode.BAD_REQUEST.getDefaultMessage());
|
||||
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), message);
|
||||
return ResponseEntity
|
||||
.badRequest()
|
||||
.body(ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), message));
|
||||
}
|
||||
|
||||
// ==================== Sa-Token 鉴权异常 ====================
|
||||
|
||||
/**
|
||||
* 未登录 / token 无效
|
||||
*/
|
||||
@ExceptionHandler(NotLoginException.class)
|
||||
public ApiResponse<Void> handleNotLogin(NotLoginException ex) {
|
||||
log.warn("未登录: {}", ex.getMessage());
|
||||
return ApiResponse.fail(401, "请先登录");
|
||||
}
|
||||
|
||||
/**
|
||||
* 无权限
|
||||
*/
|
||||
@ExceptionHandler(NotPermissionException.class)
|
||||
public ApiResponse<Void> handleNotPermission(NotPermissionException ex) {
|
||||
log.warn("无权限: {}", ex.getMessage());
|
||||
return ApiResponse.fail(403, "无权限");
|
||||
}
|
||||
|
||||
/**
|
||||
* 无角色
|
||||
*/
|
||||
@ExceptionHandler(NotRoleException.class)
|
||||
public ApiResponse<Void> handleNotRole(NotRoleException ex) {
|
||||
log.warn("无角色: {}", ex.getMessage());
|
||||
return ApiResponse.fail(403, "无权限");
|
||||
}
|
||||
|
||||
// ==================== Spring MVC 异常 ====================
|
||||
|
||||
/**
|
||||
* 请求方法不支持(如 GET 访问 POST 接口)
|
||||
*/
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public ApiResponse<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) {
|
||||
log.warn("请求方法不支持: {}", ex.getMessage());
|
||||
return ApiResponse.fail(405, "请求方法不支持");
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求体不可读(如 JSON 格式错误)
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ApiResponse<Void> handleMessageNotReadable(HttpMessageNotReadableException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> handleMessageNotReadable(HttpMessageNotReadableException ex) {
|
||||
log.warn("请求体解析失败: {}", ex.getMessage());
|
||||
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式错误");
|
||||
return ResponseEntity
|
||||
.badRequest()
|
||||
.body(ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式无效"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径参数类型不匹配
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public ApiResponse<Void> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
log.warn("参数类型不匹配: {}", ex.getMessage());
|
||||
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型错误");
|
||||
return ResponseEntity
|
||||
.badRequest()
|
||||
.body(ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型无效"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源不存在(404)
|
||||
*/
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSize(MaxUploadSizeExceededException ex) {
|
||||
log.warn("文件上传超过大小限制: {}", ex.getMessage());
|
||||
return ResponseEntity
|
||||
.badRequest()
|
||||
.body(ApiResponse.fail(ErrorCode.AVATAR_TOO_LARGE.getCode(), "文件大小不能超过 2MB"));
|
||||
}
|
||||
|
||||
// ==================== 框架级异常(直接写响应,绕过内容协商) ====================
|
||||
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public ApiResponse<Void> handleNoResourceFound(NoResourceFoundException ex) {
|
||||
log.warn("资源不存在: {}", ex.getMessage());
|
||||
return ApiResponse.fail(404, "资源不存在");
|
||||
public void handleNoResourceFound(NoResourceFoundException ex, HttpServletResponse response) throws IOException {
|
||||
log.warn("资源未找到: {}", ex.getMessage());
|
||||
writeJsonError(response, HttpStatus.NOT_FOUND, 404, "资源未找到");
|
||||
}
|
||||
|
||||
// ==================== 兜底 ====================
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public void handleMethodNotSupported(HttpRequestMethodNotSupportedException ex,
|
||||
HttpServletResponse response) throws IOException {
|
||||
log.warn("请求方法不支持: {}", ex.getMessage());
|
||||
writeJsonError(response, HttpStatus.METHOD_NOT_ALLOWED, 405, "请求方法不支持");
|
||||
}
|
||||
|
||||
// ==================== 兜底 → HTTP 500(直接写响应,绕过内容协商) ====================
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public void handleException(
|
||||
Exception ex,
|
||||
HttpServletResponse response
|
||||
) throws IOException {
|
||||
|
||||
// SSE 长连接/已提交的响应 → 客户端已断开,不需要告警
|
||||
if (response.isCommitted()) {
|
||||
log.debug("Response 已提交,客户端已断开: {}", ex.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
log.error("服务器内部错误", ex);
|
||||
|
||||
writeJsonError(
|
||||
response,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
ErrorCode.INTERNAL_ERROR.getCode(),
|
||||
ErrorCode.INTERNAL_ERROR.getDefaultMessage()
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
/**
|
||||
* 处理其他未捕获异常
|
||||
* 直接向 HttpServletResponse 写入 JSON 格式的错误响应
|
||||
* <p>
|
||||
* 不依赖 Spring 的 MessageConverter 和内容协商机制,
|
||||
* 确保 SSE/浏览器等非 JSON Accept 头的请求也能收到可读的错误信息。
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ApiResponse<Void> handleException(Exception ex) {
|
||||
log.error("服务器内部错误", ex);
|
||||
return ApiResponse.fail(ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getDefaultMessage());
|
||||
private static void writeJsonError(HttpServletResponse response, HttpStatus status,
|
||||
int code, String message) throws IOException {
|
||||
response.setStatus(status.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.getWriter().write(JSON.toJSONString(ApiResponse.fail(code, message)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.webgame.webgamebackend.controller;
|
||||
|
||||
import com.webgame.webgamebackend.common.dto.ApiResponse;
|
||||
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
|
||||
import com.webgame.webgamebackend.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 用户控制器
|
||||
*/
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/user")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
*/
|
||||
@GetMapping("/info")
|
||||
public ApiResponse<UserInfoResponse> info() {
|
||||
UserInfoResponse result = userService.getCurrentUserInfo();
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
package com.webgame.webgamebackend.service.impl;
|
||||
package com.webgame.webgamebackend.domain.account;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
|
||||
import com.webgame.webgamebackend.common.constants.UserConstants;
|
||||
import com.webgame.webgamebackend.common.dto.account.LoginRequest;
|
||||
import com.webgame.webgamebackend.common.dto.account.LoginResponse;
|
||||
import com.webgame.webgamebackend.common.dto.account.RegisterRequest;
|
||||
import com.webgame.webgamebackend.common.exception.BusinessException;
|
||||
import com.webgame.webgamebackend.common.exception.ErrorCode;
|
||||
import com.webgame.webgamebackend.entities.AccountEntity;
|
||||
import com.webgame.webgamebackend.entities.UserEntity;
|
||||
import com.webgame.webgamebackend.repository.AccountRepository;
|
||||
import com.webgame.webgamebackend.repository.UserRepository;
|
||||
import com.webgame.webgamebackend.service.AccountService;
|
||||
import com.webgame.webgamebackend.service.RefreshTokenService;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.AccountEntity;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.UserEntity;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.repository.AccountRepository;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.repository.UserRepository;
|
||||
import com.webgame.webgamebackend.domain.account.AccountService;
|
||||
import com.webgame.webgamebackend.domain.token.RefreshTokenService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
@@ -26,30 +27,28 @@ import java.util.concurrent.ThreadLocalRandom;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AccountServiceImpl implements AccountService {
|
||||
public class AccountService {
|
||||
|
||||
private final AccountRepository accountRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final AuthenticationProvider authProvider;
|
||||
|
||||
@Override
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
AccountEntity account = accountRepository.findByUsername(request.username())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.BAD_CREDENTIALS));
|
||||
if (!passwordEncoder.matches(request.password(), account.getPassword())) {
|
||||
throw new BusinessException(ErrorCode.BAD_CREDENTIALS);
|
||||
}
|
||||
// Sa-Token 登录,生成 access token
|
||||
StpUtil.login(account.getId());
|
||||
String accessToken = StpUtil.getTokenValue();
|
||||
// 登录,生成 access token
|
||||
String accessToken = authProvider.login(account.getId());
|
||||
// 生成 refresh token
|
||||
String refreshToken = refreshTokenService.create(account.getId());
|
||||
log.info("用户登录成功: username={}", request.username());
|
||||
return new LoginResponse(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public LoginResponse register(RegisterRequest request) {
|
||||
// 检查用户名是否已存在
|
||||
@@ -64,37 +63,35 @@ public class AccountServiceImpl implements AccountService {
|
||||
// 创建用户资料(默认值)
|
||||
UserEntity user = new UserEntity();
|
||||
user.setAccount(account);
|
||||
user.setNickName("玩家" + generateRandomCode());
|
||||
String nickName = "玩家" + generateRandomCode();
|
||||
user.setNickName(nickName);
|
||||
user.setSex(0);
|
||||
user.setSignature("");
|
||||
user.setAvatar("");
|
||||
// 根据昵称生成默认 DiceBear 头像
|
||||
user.setAvatar(UserConstants.generateDefaultAvatar(nickName));
|
||||
userRepository.save(user);
|
||||
|
||||
log.info("用户注册成功: username={}", request.username());
|
||||
// 注册后自动登录,生成 access token 和 refresh token
|
||||
StpUtil.login(account.getId());
|
||||
String accessToken = StpUtil.getTokenValue();
|
||||
String accessToken = authProvider.login(account.getId());
|
||||
String refreshToken = refreshTokenService.create(account.getId());
|
||||
return new LoginResponse(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoginResponse refresh(String refreshToken) {
|
||||
// 校验 refresh token,有效则用它签发新的 access token
|
||||
String userId = refreshTokenService.validate(refreshToken);
|
||||
StpUtil.login(userId);
|
||||
String accessToken = StpUtil.getTokenValue();
|
||||
String accessToken = authProvider.login(userId);
|
||||
log.info("令牌刷新成功: userId={}", userId);
|
||||
// refresh token 保持不变,只返回新的 access token
|
||||
return new LoginResponse(accessToken, refreshToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logout(String refreshToken) {
|
||||
// 获取当前登录用户ID(用于日志)
|
||||
String loginId = (String) StpUtil.getLoginId();
|
||||
// 登出 Sa-Token,使 access token 失效
|
||||
StpUtil.logout();
|
||||
String loginId = authProvider.getCurrentUserId();
|
||||
// 登出,使 access token 失效
|
||||
authProvider.logout();
|
||||
// 撤销 refresh token,使其在 Redis 中删除
|
||||
refreshTokenService.revoke(refreshToken);
|
||||
log.info("用户已登出: userId={}", loginId);
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.webgame.webgamebackend.domain.engine;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* 游戏引擎接口
|
||||
*
|
||||
* 每种游戏类型提供自己的实现,通过 {@link GameEngineRegistry} 注册。
|
||||
* 引擎只关心游戏规则,不关心房间管理(创建/加入/离开/踢人等)。
|
||||
* 房间层通过此接口与游戏层通信。
|
||||
*
|
||||
* <h3>生命周期</h3>
|
||||
* <ol>
|
||||
* <li>游戏开始时由 RoomManager 创建引擎实例</li>
|
||||
* <li>调用 {@link #init(RoomContext)} 初始化游戏</li>
|
||||
* <li>通过 {@link #handleAction} 处理玩家操作</li>
|
||||
* <li>游戏结束或被解散时调用 {@link #destroy()}</li>
|
||||
* </ol>
|
||||
*/
|
||||
public interface GameEngine {
|
||||
|
||||
/**
|
||||
* 初始化游戏
|
||||
*
|
||||
* @param context 房间上下文(玩家列表、配置、广播回调、结束回调)
|
||||
*/
|
||||
void init(RoomContext context);
|
||||
|
||||
/**
|
||||
* 处理玩家游戏动作
|
||||
*
|
||||
* @param action 动作类型(如 "place_stone", "resign", "draw_request")
|
||||
* @param data 动作数据
|
||||
* @param userId 操作者 accountId
|
||||
*/
|
||||
void handleAction(String action, JsonNode data, String userId);
|
||||
|
||||
/**
|
||||
* 房间通知:玩家 WebSocket 断线
|
||||
*
|
||||
* 游戏可以选择如何处理(如五子棋:启动断线倒计时,超时后自动认输)
|
||||
*
|
||||
* @param userId 断线玩家 accountId
|
||||
*/
|
||||
void onPlayerDisconnect(String userId);
|
||||
|
||||
/**
|
||||
* 房间通知:玩家重连成功
|
||||
*
|
||||
* 游戏应取消断线倒计时,并通过 RoomContext 的 broadcaster 推送
|
||||
* 当前游戏状态给重连玩家
|
||||
*
|
||||
* @param userId 重连玩家 accountId
|
||||
*/
|
||||
void onPlayerReconnect(String userId);
|
||||
|
||||
/**
|
||||
* 房间通知:玩家主动离开房间
|
||||
*
|
||||
* 游戏中离开视为认输
|
||||
*
|
||||
* @param userId 离开玩家 accountId
|
||||
*/
|
||||
void onPlayerLeave(String userId);
|
||||
|
||||
/**
|
||||
* 销毁引擎,清理资源(取消定时器、释放引用等)
|
||||
*/
|
||||
void destroy();
|
||||
|
||||
/**
|
||||
* 获取游戏完整状态快照
|
||||
*
|
||||
* 用于重连时向前端推送当前游戏状态,前端据此恢复 UI。
|
||||
*
|
||||
* @return 游戏状态(棋盘、回合、走棋历史等)
|
||||
*/
|
||||
GameState getState();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.webgame.webgamebackend.domain.engine;
|
||||
|
||||
/**
|
||||
* 游戏引擎工厂接口
|
||||
*
|
||||
* 每次游戏开始(房间从 WAITING → PLAYING)时调用 {@link #create} 创建新引擎实例。
|
||||
* 每个房间拥有独立的引擎实例,房间之间不共享。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GameEngineFactory {
|
||||
|
||||
/**
|
||||
* 创建游戏引擎实例
|
||||
*
|
||||
* @param context 房间上下文
|
||||
* @return 新创建的引擎实例
|
||||
*/
|
||||
GameEngine create(RoomContext context);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.webgame.webgamebackend.domain.engine;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 游戏引擎注册表
|
||||
*
|
||||
* 管理 gameId → GameEngineFactory 的映射。
|
||||
* Spring Boot 启动时,各游戏模块通过 {@link #register} 注册自己的引擎工厂。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class GameEngineRegistry {
|
||||
|
||||
/** gameId → 引擎工厂 */
|
||||
private final Map<String, GameEngineFactory> factories = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 注册游戏引擎工厂
|
||||
*
|
||||
* @param gameId 游戏标识(与 game_config 表中的 game_id 对应)
|
||||
* @param factory 引擎工厂
|
||||
*/
|
||||
public void register(String gameId, GameEngineFactory factory) {
|
||||
if (gameId == null || gameId.isBlank()) {
|
||||
throw new IllegalArgumentException("gameId 不能为空");
|
||||
}
|
||||
if (factory == null) {
|
||||
throw new IllegalArgumentException("factory 不能为 null");
|
||||
}
|
||||
factories.put(gameId, factory);
|
||||
log.info("[引擎注册] 注册游戏引擎, gameId={}", gameId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建游戏引擎实例
|
||||
*
|
||||
* @param gameId 游戏标识
|
||||
* @param context 房间上下文
|
||||
* @return 游戏引擎实例
|
||||
* @throws IllegalArgumentException 如果 gameId 未注册
|
||||
*/
|
||||
public GameEngine create(String gameId, RoomContext context) {
|
||||
GameEngineFactory factory = factories.get(gameId);
|
||||
if (factory == null) {
|
||||
throw new IllegalArgumentException("未注册的游戏类型: " + gameId);
|
||||
}
|
||||
GameEngine engine = factory.create(context);
|
||||
log.info("[引擎注册] 创建引擎实例, gameId={}, roomId={}", gameId, context.getRoomId());
|
||||
return engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查游戏类型是否已注册
|
||||
*
|
||||
* @param gameId 游戏标识
|
||||
* @return 是否已注册
|
||||
*/
|
||||
public boolean isRegistered(String gameId) {
|
||||
return factories.containsKey(gameId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.webgame.webgamebackend.domain.engine;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
/**
|
||||
* 游戏状态快照
|
||||
*
|
||||
* 由 GameEngine.getState() 返回,用于重连时同步完整游戏状态给前端。
|
||||
* 具体游戏引擎可以扩展此类,添加游戏特定的状态字段。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class GameState {
|
||||
|
||||
/** 游戏是否已结束 */
|
||||
private boolean finished;
|
||||
|
||||
/** 胜者 ID(null 表示平局或未结束) */
|
||||
private String winnerId;
|
||||
|
||||
/** 结果类型(win / draw / resign) */
|
||||
private String resultType;
|
||||
|
||||
/** 当前轮到谁(accountId) */
|
||||
private String currentTurn;
|
||||
|
||||
/** 游戏特定数据(JSON 字符串),由各引擎自行填充 */
|
||||
private String gameData;
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package com.webgame.webgamebackend.domain.engine;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.webgame.webgamebackend.common.exception.BusinessException;
|
||||
import com.webgame.webgamebackend.common.exception.ErrorCode;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameMoveEntity;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.repository.GameMoveRepository;
|
||||
import com.webgame.webgamebackend.adapter.ws.dto.WsMessage;
|
||||
import com.webgame.webgamebackend.adapter.ws.dto.WsOutboundMessage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/**
|
||||
* 五子棋游戏引擎
|
||||
*
|
||||
* 实现标准的 15×15 五子棋规则:
|
||||
* <ul>
|
||||
* <li>黑方先手(seat=1)</li>
|
||||
* <li>交替落子</li>
|
||||
* <li>五连(横/竖/对角)判胜</li>
|
||||
* <li>支持认输、求和</li>
|
||||
* <li>断线 60 秒自动判负</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>不关心房间管理(加入/离开/踢人/房主转移),只关心棋盘上的规则。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class GomokuEngine implements GameEngine {
|
||||
|
||||
/** 棋盘大小 */
|
||||
private static final int BOARD_SIZE = 15;
|
||||
|
||||
/** 断线自动认输超时(秒) */
|
||||
private static final long DISCONNECT_FORFEIT_SECONDS = 60;
|
||||
|
||||
/** 断线定时器线程池(每个引擎实例独立,避免影响其他房间) */
|
||||
private final ScheduledExecutorService disconnectScheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(r ->
|
||||
new Thread(r, "gomoku-disconnect-" + System.identityHashCode(this)));
|
||||
|
||||
private final GameMoveRepository moveRepository;
|
||||
|
||||
// ==================== 运行时状态 ====================
|
||||
|
||||
private RoomContext context;
|
||||
private String currentTurnUserId;
|
||||
private final List<String> playerIds = new ArrayList<>(); // 按座位号排序
|
||||
private boolean finished;
|
||||
|
||||
/** 断线倒计时: userId → Future */
|
||||
private final ConcurrentHashMap<String, ScheduledFuture<?>> disconnectTimers = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void init(RoomContext context) {
|
||||
this.context = context;
|
||||
// 按座位号排序的玩家列表,seat=1 为黑方先手
|
||||
List<RoomContext.PlayerInfo> players = context.getPlayers();
|
||||
if (players.size() < 2) {
|
||||
throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT);
|
||||
}
|
||||
this.playerIds.clear();
|
||||
for (RoomContext.PlayerInfo p : players) {
|
||||
this.playerIds.add(p.userId());
|
||||
}
|
||||
this.currentTurnUserId = playerIds.get(0); // 黑方先手
|
||||
this.finished = false;
|
||||
|
||||
log.info("[五子棋引擎] 初始化, roomId={}, 玩家={}", context.getRoomId(), playerIds);
|
||||
|
||||
// 广播空棋盘 + 先手
|
||||
context.broadcast(new WsMessage(WsOutboundMessage.BOARD_SYNC, Map.of(
|
||||
"board", new int[BOARD_SIZE][BOARD_SIZE],
|
||||
"currentTurn", currentTurnUserId
|
||||
)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleAction(String action, JsonNode data, String userId) {
|
||||
if (finished) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "对局已结束");
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "place_stone" -> {
|
||||
int row = data.get("row").asInt();
|
||||
int col = data.get("col").asInt();
|
||||
placeStone(userId, row, col);
|
||||
}
|
||||
case "resign" -> resign(userId);
|
||||
case "draw_request" -> requestDraw(userId);
|
||||
case "draw_response" -> {
|
||||
boolean accept = data.get("accept").asBoolean();
|
||||
respondToDraw(userId, accept);
|
||||
}
|
||||
default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "未知游戏动作: " + action);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerDisconnect(String userId) {
|
||||
// 启动断线倒计时
|
||||
if (disconnectTimers.containsKey(userId)) {
|
||||
return; // 已有定时器
|
||||
}
|
||||
|
||||
log.info("[五子棋引擎] 玩家断线,{}秒后自动判负, roomId={}, userId={}",
|
||||
DISCONNECT_FORFEIT_SECONDS, context.getRoomId(), userId);
|
||||
|
||||
ScheduledFuture<?> task = disconnectScheduler.schedule(() -> {
|
||||
disconnectTimers.remove(userId);
|
||||
if (!finished) {
|
||||
log.info("[五子棋引擎] 断线超时,自动判负, roomId={}, userId={}",
|
||||
context.getRoomId(), userId);
|
||||
forfeit(userId);
|
||||
}
|
||||
}, DISCONNECT_FORFEIT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
disconnectTimers.put(userId, task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerReconnect(String userId) {
|
||||
// 取消断线倒计时
|
||||
ScheduledFuture<?> task = disconnectTimers.remove(userId);
|
||||
if (task != null) {
|
||||
task.cancel(false);
|
||||
log.info("[五子棋引擎] 玩家重连,取消断线倒计时, roomId={}, userId={}",
|
||||
context.getRoomId(), userId);
|
||||
}
|
||||
|
||||
// 推送当前游戏状态给重连玩家
|
||||
GameState state = getState();
|
||||
String gameDataJson = state.getGameData();
|
||||
Map<String, Object> boardSyncData;
|
||||
if (gameDataJson != null) {
|
||||
boardSyncData = JSON.parseObject(gameDataJson, Map.class);
|
||||
} else {
|
||||
boardSyncData = Map.of("board", new int[BOARD_SIZE][BOARD_SIZE]);
|
||||
}
|
||||
boardSyncData.put("currentTurn", currentTurnUserId);
|
||||
|
||||
context.sendToUser(userId, new WsMessage(WsOutboundMessage.BOARD_SYNC, boardSyncData));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerLeave(String userId) {
|
||||
// 取消断线倒计时
|
||||
ScheduledFuture<?> task = disconnectTimers.remove(userId);
|
||||
if (task != null) {
|
||||
task.cancel(false);
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
// 玩家离开 = 认输
|
||||
log.info("[五子棋引擎] 玩家离开,自动认输, roomId={}, userId={}",
|
||||
context.getRoomId(), userId);
|
||||
forfeit(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
finished = true;
|
||||
// 取消所有断线计时器
|
||||
for (ScheduledFuture<?> timer : disconnectTimers.values()) {
|
||||
timer.cancel(false);
|
||||
}
|
||||
disconnectTimers.clear();
|
||||
disconnectScheduler.shutdownNow();
|
||||
log.info("[五子棋引擎] 销毁, roomId={}", context != null ? context.getRoomId() : "unknown");
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameState getState() {
|
||||
// 重建棋盘
|
||||
int[][] board = new int[BOARD_SIZE][BOARD_SIZE];
|
||||
List<GameMoveEntity> moves = moveRepository
|
||||
.findByRoomIdOrderByMoveIndexAsc(context.getRoomId());
|
||||
for (GameMoveEntity move : moves) {
|
||||
int playerValue = (move.getMoveIndex() % 2 == 1) ? 1 : 2;
|
||||
board[move.getRowNum()][move.getColNum()] = playerValue;
|
||||
}
|
||||
|
||||
// 序列化走棋历史
|
||||
List<Map<String, Object>> moveHistory = moves.stream()
|
||||
.map(m -> Map.<String, Object>of(
|
||||
"moveIndex", m.getMoveIndex(),
|
||||
"playerId", m.getPlayerId(),
|
||||
"row", m.getRowNum(),
|
||||
"col", m.getColNum()
|
||||
)).toList();
|
||||
|
||||
Map<String, Object> gameData = Map.of(
|
||||
"board", board,
|
||||
"moves", moveHistory,
|
||||
"boardSize", BOARD_SIZE
|
||||
);
|
||||
|
||||
GameState state = new GameState();
|
||||
state.setFinished(finished);
|
||||
state.setCurrentTurn(currentTurnUserId);
|
||||
state.setGameData(JSON.toJSONString(gameData));
|
||||
return state;
|
||||
}
|
||||
|
||||
// ==================== 游戏逻辑 ====================
|
||||
|
||||
/**
|
||||
* 落子
|
||||
*/
|
||||
private void placeStone(String userId, int row, int col) {
|
||||
// 校验坐标
|
||||
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "坐标超出棋盘范围");
|
||||
}
|
||||
|
||||
// 校验是否轮到自己
|
||||
if (!userId.equals(currentTurnUserId)) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "还未轮到你落子");
|
||||
}
|
||||
|
||||
// 校验位置是否已有棋子
|
||||
List<GameMoveEntity> moves = moveRepository
|
||||
.findByRoomIdOrderByMoveIndexAsc(context.getRoomId());
|
||||
for (GameMoveEntity m : moves) {
|
||||
if (m.getRowNum() == row && m.getColNum() == col) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "该位置已有棋子");
|
||||
}
|
||||
}
|
||||
|
||||
int moveIndex = moves.size() + 1;
|
||||
|
||||
// 保存走棋记录
|
||||
GameMoveEntity move = new GameMoveEntity()
|
||||
.setRoomId(context.getRoomId())
|
||||
.setPlayerId(userId)
|
||||
.setRowNum(row)
|
||||
.setColNum(col)
|
||||
.setMoveIndex(moveIndex);
|
||||
moveRepository.save(move);
|
||||
|
||||
log.info("[五子棋引擎] 落子, roomId={}, userId={}, row={}, col={}, moveIndex={}",
|
||||
context.getRoomId(), userId, row, col, moveIndex);
|
||||
|
||||
// 广播落子
|
||||
context.broadcast(new WsMessage(WsOutboundMessage.MOVE, Map.of(
|
||||
"row", row,
|
||||
"col", col,
|
||||
"playerId", userId,
|
||||
"moveIndex", moveIndex
|
||||
)));
|
||||
|
||||
// 检查胜负
|
||||
int[][] board = rebuildBoard();
|
||||
int playerIndex = playerIds.indexOf(userId);
|
||||
int stone = (playerIndex == 0) ? 1 : 2;
|
||||
|
||||
if (checkWin(board, row, col, stone)) {
|
||||
endGame(userId, "win");
|
||||
return;
|
||||
}
|
||||
|
||||
if (moveIndex >= BOARD_SIZE * BOARD_SIZE) {
|
||||
endGame(null, "draw");
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换回合
|
||||
int nextPlayerIndex = (moveIndex) % 2;
|
||||
currentTurnUserId = playerIds.get(nextPlayerIndex);
|
||||
context.broadcast(new WsMessage(WsOutboundMessage.TURN_CHANGE, Map.of(
|
||||
"playerId", currentTurnUserId
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 认输
|
||||
*/
|
||||
private void resign(String userId) {
|
||||
if (!playerIds.contains(userId)) {
|
||||
throw new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM);
|
||||
}
|
||||
// 对手获胜
|
||||
String winnerId = playerIds.stream()
|
||||
.filter(id -> !id.equals(userId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
endGame(winnerId, "resign");
|
||||
}
|
||||
|
||||
/**
|
||||
* 断线超时/主动离开导致弃权
|
||||
*/
|
||||
private void forfeit(String userId) {
|
||||
if (!playerIds.contains(userId)) {
|
||||
return;
|
||||
}
|
||||
String winnerId = playerIds.stream()
|
||||
.filter(id -> !id.equals(userId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
endGame(winnerId, "resign");
|
||||
}
|
||||
|
||||
/**
|
||||
* 求和请求
|
||||
*/
|
||||
private void requestDraw(String userId) {
|
||||
context.broadcast(new WsMessage("draw_requested", Map.of(
|
||||
"fromUserId", userId
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 回应求和
|
||||
*/
|
||||
private void respondToDraw(String userId, boolean accept) {
|
||||
if (accept) {
|
||||
endGame(null, "draw");
|
||||
} else {
|
||||
context.broadcast(new WsMessage("draw_rejected", Map.of(
|
||||
"fromUserId", userId
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束对局
|
||||
*
|
||||
* @param winnerId 胜者 ID(null 表示平局)
|
||||
* @param resultType 结果类型
|
||||
*/
|
||||
private void endGame(String winnerId, String resultType) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
|
||||
log.info("[五子棋引擎] 对局结束, roomId={}, winnerId={}, resultType={}",
|
||||
context.getRoomId(), winnerId, resultType);
|
||||
|
||||
// 取消所有断线定时器
|
||||
for (ScheduledFuture<?> timer : disconnectTimers.values()) {
|
||||
timer.cancel(false);
|
||||
}
|
||||
disconnectTimers.clear();
|
||||
|
||||
// 通知房间层:游戏已结束
|
||||
context.gameOver(winnerId, resultType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建棋盘
|
||||
*/
|
||||
private int[][] rebuildBoard() {
|
||||
int[][] board = new int[BOARD_SIZE][BOARD_SIZE];
|
||||
List<GameMoveEntity> moves = moveRepository
|
||||
.findByRoomIdOrderByMoveIndexAsc(context.getRoomId());
|
||||
for (GameMoveEntity move : moves) {
|
||||
int playerValue = (move.getMoveIndex() % 2 == 1) ? 1 : 2;
|
||||
board[move.getRowNum()][move.getColNum()] = playerValue;
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查五连胜利
|
||||
*/
|
||||
private boolean checkWin(int[][] board, int row, int col, int stone) {
|
||||
int[][] dirs = {{0, 1}, {1, 0}, {1, 1}, {1, -1}};
|
||||
for (int[] dir : dirs) {
|
||||
int count = 1;
|
||||
// 正方向
|
||||
for (int i = 1; i < 5; i++) {
|
||||
int r = row + dir[0] * i, c = col + dir[1] * i;
|
||||
if (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == stone) {
|
||||
count++;
|
||||
} else break;
|
||||
}
|
||||
// 反方向
|
||||
for (int i = 1; i < 5; i++) {
|
||||
int r = row - dir[0] * i, c = col - dir[1] * i;
|
||||
if (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] == stone) {
|
||||
count++;
|
||||
} else break;
|
||||
}
|
||||
if (count >= 5) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.webgame.webgamebackend.domain.engine;
|
||||
|
||||
import com.webgame.webgamebackend.adapter.ws.dto.WsMessage;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 房间上下文
|
||||
*
|
||||
* 游戏引擎初始化时由 RoomManager 传入,包含引擎所需的房间信息和回调接口。
|
||||
* 引擎不应持有对 RoomManager 的直接引用,所有与房间层的交互通过此上下文完成。
|
||||
*/
|
||||
@Getter
|
||||
public class RoomContext {
|
||||
|
||||
/** 房间 ID */
|
||||
private final String roomId;
|
||||
|
||||
/** 游戏 ID */
|
||||
private final String gameId;
|
||||
|
||||
/** 参与对局的玩家列表(按座位号排序,不可变) */
|
||||
private final List<PlayerInfo> players;
|
||||
|
||||
/** 玩法模式 */
|
||||
private final String mode;
|
||||
|
||||
/** 底注金额 */
|
||||
private final int stakes;
|
||||
|
||||
/** 广播消息到房间内所有 WebSocket 会话(不暴露到 getter) */
|
||||
private final Consumer<WsMessage> broadcaster;
|
||||
|
||||
/** 私有消息发送器(不暴露到 getter) */
|
||||
private final PrivateMessageSender privateSender;
|
||||
|
||||
/** 游戏结束回调(不暴露到 getter) */
|
||||
private final GameOverCallback onGameOver;
|
||||
|
||||
public RoomContext(String roomId, String gameId, List<PlayerInfo> players,
|
||||
String mode, int stakes,
|
||||
Consumer<WsMessage> broadcaster,
|
||||
PrivateMessageSender privateSender,
|
||||
GameOverCallback onGameOver) {
|
||||
this.roomId = roomId;
|
||||
this.gameId = gameId;
|
||||
this.players = List.copyOf(players); // 防御性拷贝
|
||||
this.mode = mode;
|
||||
this.stakes = stakes;
|
||||
this.broadcaster = broadcaster;
|
||||
this.privateSender = privateSender;
|
||||
this.onGameOver = onGameOver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播消息到房间内所有人
|
||||
*/
|
||||
public void broadcast(WsMessage message) {
|
||||
broadcaster.accept(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向指定用户发送私有消息(如重连时的状态同步)
|
||||
*/
|
||||
public void sendToUser(String userId, WsMessage message) {
|
||||
privateSender.send(userId, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 游戏结束,通知房间层
|
||||
*
|
||||
* @param winnerId 胜者 accountId,null 表示平局
|
||||
* @param resultType 结果类型(win / draw / resign)
|
||||
*/
|
||||
public void gameOver(String winnerId, String resultType) {
|
||||
onGameOver.onGameOver(winnerId, resultType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 房间内玩家简要信息
|
||||
*/
|
||||
public record PlayerInfo(String userId, int seatNumber) {}
|
||||
|
||||
/**
|
||||
* 游戏结束回调接口
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GameOverCallback {
|
||||
/**
|
||||
* 游戏结束通知
|
||||
*
|
||||
* @param winnerId 胜者 accountId,null 表示平局
|
||||
* @param resultType 结果类型
|
||||
*/
|
||||
void onGameOver(String winnerId, String resultType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 私有消息发送接口
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface PrivateMessageSender {
|
||||
/**
|
||||
* 向指定用户的所有 WebSocket 会话发送消息
|
||||
*
|
||||
* @param userId 目标用户 accountId
|
||||
* @param message 消息
|
||||
*/
|
||||
void send(String userId, WsMessage message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.webgame.webgamebackend.domain.game;
|
||||
|
||||
import com.webgame.webgamebackend.common.dto.game.GameItemResponse;
|
||||
import com.webgame.webgamebackend.common.dto.game.GameListResponse;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameConfigEntity;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.repository.GameConfigRepository;
|
||||
import com.webgame.webgamebackend.domain.game.GameService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 游戏服务实现
|
||||
*
|
||||
* 仅保留游戏配置查询。房间管理已迁移到 {@link com.webgame.webgamebackend.service.RoomManager}。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GameService {
|
||||
|
||||
private final GameConfigRepository gameConfigRepository;
|
||||
|
||||
public GameListResponse getGameList() {
|
||||
List<GameConfigEntity> configs = gameConfigRepository.findByEnabledTrueOrderBySortOrderAsc();
|
||||
List<GameItemResponse> list = configs.stream()
|
||||
.map(GameItemResponse::from)
|
||||
.toList();
|
||||
log.info("[游戏服务] 获取游戏列表, 共 {} 款", list.size());
|
||||
return new GameListResponse(list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
package com.webgame.webgamebackend.domain.room;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.webgame.webgamebackend.common.dto.game.*;
|
||||
import com.webgame.webgamebackend.common.dto.game.RoomDetailResponse.RoomPlayerDetail;
|
||||
import com.webgame.webgamebackend.common.event.RoomEventBus;
|
||||
import com.webgame.webgamebackend.common.event.SseEventPublisher;
|
||||
import com.webgame.webgamebackend.common.event.SseEventType;
|
||||
import com.webgame.webgamebackend.common.exception.BusinessException;
|
||||
import com.webgame.webgamebackend.common.exception.ErrorCode;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.*;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.repository.*;
|
||||
import com.webgame.webgamebackend.domain.engine.*;
|
||||
import com.webgame.webgamebackend.adapter.ws.RoomSessionManager;
|
||||
import com.webgame.webgamebackend.adapter.ws.dto.WsMessage;
|
||||
import com.webgame.webgamebackend.adapter.ws.dto.WsOutboundMessage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 房间管理器 — 统一管理所有房间生命周期
|
||||
*
|
||||
* <h3>职责</h3>
|
||||
* <ul>
|
||||
* <li>房间 CRUD:创建、加入、离开、解散、踢人</li>
|
||||
* <li>状态流转:WAITING → PLAYING → FINISHED</li>
|
||||
* <li>准备/取消准备</li>
|
||||
* <li>开始游戏(委托给 GameEngine)</li>
|
||||
* <li>断线重连管理(60 秒倒计时)</li>
|
||||
* <li>超时房间清理(定时任务)</li>
|
||||
* <li>游戏结束结算(房间层负责金币变更)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>房间数据持久化到 MySQL,运行时状态(游戏引擎引用、断线定时器)
|
||||
* 缓存在内存中。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RoomManager {
|
||||
|
||||
private final GameConfigRepository gameConfigRepository;
|
||||
private final GameRoomRepository gameRoomRepository;
|
||||
private final RoomPlayerRepository roomPlayerRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final GameRecordRepository gameRecordRepository;
|
||||
private final GameMoveRepository gameMoveRepository;
|
||||
private final RoomSessionManager sessionManager;
|
||||
private final GameEngineRegistry engineRegistry;
|
||||
private final SseEventPublisher sseEventPublisher;
|
||||
private final RoomEventBus eventBus;
|
||||
|
||||
// ==================== 内存状态 ====================
|
||||
|
||||
/** 断线重连超时时间(秒) */
|
||||
private static final long DISCONNECT_TIMEOUT_SECONDS = 60;
|
||||
|
||||
/** 断线重连定时器线程池 */
|
||||
private final ScheduledExecutorService reconnectScheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(r ->
|
||||
new Thread(r, "room-reconnect"));
|
||||
|
||||
/** 房间运行时状态缓存: roomId → RoomRuntime */
|
||||
private final ConcurrentHashMap<String, RoomRuntime> roomRuntimes = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 房间运行时状态(不可持久化的部分)
|
||||
*/
|
||||
private static class RoomRuntime {
|
||||
/** 活跃的游戏引擎(PLAYING 状态下非 null) */
|
||||
volatile GameEngine gameEngine;
|
||||
/** 断线倒计时任务: userId → Future */
|
||||
final ConcurrentHashMap<String, ScheduledFuture<?>> disconnectTimers = new ConcurrentHashMap<>();
|
||||
|
||||
RoomRuntime() {}
|
||||
RoomRuntime(GameEngine gameEngine) { this.gameEngine = gameEngine; }
|
||||
}
|
||||
|
||||
// ==================== 房间 CRUD ====================
|
||||
|
||||
/**
|
||||
* 创建房间
|
||||
*
|
||||
* @param request 创建房间请求
|
||||
* @param creatorId 房主 accountId
|
||||
* @return 创建结果
|
||||
*/
|
||||
@Transactional
|
||||
public CreateRoomResponse createRoom(CreateRoomRequest request, String creatorId) {
|
||||
// 校验游戏存在且启用
|
||||
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(request.gameId())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND));
|
||||
if (!gameConfig.getEnabled()) {
|
||||
throw new BusinessException(ErrorCode.GAME_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 校验 mode
|
||||
List<String> modes = JSON.parseArray(gameConfig.getModes(), String.class);
|
||||
if (!modes.contains(request.mode())) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的模式: " + request.mode());
|
||||
}
|
||||
|
||||
// 校验 stakes
|
||||
List<Integer> stakesOptions = JSON.parseArray(gameConfig.getStakesOptions(), Integer.class);
|
||||
if (!stakesOptions.contains(request.stakes())) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的底注: " + request.stakes());
|
||||
}
|
||||
|
||||
// 检查用户是否已在其他房间
|
||||
if (roomPlayerRepository.existsByUserId(creatorId)) {
|
||||
throw new BusinessException(ErrorCode.ALREADY_IN_ROOM);
|
||||
}
|
||||
|
||||
// 生成桌号
|
||||
Integer maxRoomNumber = gameRoomRepository.findMaxRoomNumberByGameId(request.gameId());
|
||||
int nextRoomNumber = (maxRoomNumber == null) ? 1 : maxRoomNumber + 1;
|
||||
|
||||
// 生成桌名
|
||||
String roomName = (request.name() != null && !request.name().isBlank())
|
||||
? request.name()
|
||||
: "桌 " + String.format("%02d", nextRoomNumber);
|
||||
|
||||
// 创建房间实体
|
||||
GameRoomEntity room = new GameRoomEntity()
|
||||
.setGameId(request.gameId())
|
||||
.setRoomNumber(nextRoomNumber)
|
||||
.setName(roomName)
|
||||
.setMaxPlayers(request.maxPlayers())
|
||||
.setMode(request.mode())
|
||||
.setStakes(request.stakes())
|
||||
.setStatus("waiting")
|
||||
.setCreatorId(creatorId);
|
||||
|
||||
// 密码明文存储
|
||||
if (request.password() != null && !request.password().isBlank()) {
|
||||
room.setPassword(request.password());
|
||||
}
|
||||
|
||||
gameRoomRepository.save(room);
|
||||
|
||||
// 房主自动加入(座位 1,已准备)
|
||||
RoomPlayerEntity creatorPlayer = new RoomPlayerEntity()
|
||||
.setRoomId(room.getId())
|
||||
.setUserId(creatorId)
|
||||
.setSeatNumber(1)
|
||||
.setReadyStatus(true)
|
||||
.setJoinTime(LocalDateTime.now());
|
||||
roomPlayerRepository.save(creatorPlayer);
|
||||
|
||||
log.info("[房间管理] 创建房间, roomId={}, gameId={}, creatorId={}", room.getId(), request.gameId(), creatorId);
|
||||
|
||||
// 通知大厅
|
||||
RoomItemResponse response = buildRoomItemResponse(room);
|
||||
sseEventPublisher.broadcast(SseEventType.ROOM_CREATED, response);
|
||||
|
||||
return new CreateRoomResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入房间
|
||||
*
|
||||
* @param request 加入房间请求
|
||||
* @param userId 用户 accountId
|
||||
* @return 房间信息
|
||||
*/
|
||||
@Transactional
|
||||
public RoomItemResponse joinRoom(JoinRoomRequest request, String userId) {
|
||||
GameRoomEntity room = gameRoomRepository.findById(request.roomId())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
|
||||
|
||||
if (!"waiting".equals(room.getStatus())) {
|
||||
throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
|
||||
}
|
||||
|
||||
// 密码校验(明文比对)
|
||||
if (room.getPassword() != null && !room.getPassword().isBlank()) {
|
||||
if (request.password() == null || request.password().isBlank()) {
|
||||
throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR, "请输入房间密码");
|
||||
}
|
||||
if (!room.getPassword().equals(request.password())) {
|
||||
throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查房间是否已满
|
||||
int currentCount = roomPlayerRepository.countByRoomId(room.getId());
|
||||
if (currentCount >= room.getMaxPlayers()) {
|
||||
throw new BusinessException(ErrorCode.ROOM_FULL);
|
||||
}
|
||||
|
||||
// 检查用户是否已在其他房间
|
||||
if (roomPlayerRepository.existsByUserId(userId)) {
|
||||
throw new BusinessException(ErrorCode.ALREADY_IN_ROOM);
|
||||
}
|
||||
|
||||
// 分配座位号
|
||||
List<RoomPlayerEntity> existing = roomPlayerRepository.findByRoomId(room.getId());
|
||||
int nextSeat = 1;
|
||||
if (!existing.isEmpty()) {
|
||||
int maxSeat = existing.stream().mapToInt(RoomPlayerEntity::getSeatNumber).max().orElse(0);
|
||||
nextSeat = maxSeat + 1;
|
||||
}
|
||||
|
||||
RoomPlayerEntity player = new RoomPlayerEntity()
|
||||
.setRoomId(room.getId())
|
||||
.setUserId(userId)
|
||||
.setSeatNumber(nextSeat)
|
||||
.setReadyStatus(false)
|
||||
.setJoinTime(LocalDateTime.now());
|
||||
roomPlayerRepository.save(player);
|
||||
|
||||
log.info("[房间管理] 玩家加入, roomId={}, userId={}, seat={}", room.getId(), userId, nextSeat);
|
||||
|
||||
// WS 广播玩家加入
|
||||
RoomPlayerDetail detail = buildPlayerDetail(userId, nextSeat, false);
|
||||
broadcastToRoom(room.getId(), new WsMessage(WsOutboundMessage.PLAYER_JOIN, detail));
|
||||
|
||||
// SSE 通知大厅
|
||||
RoomItemResponse response = buildRoomItemResponse(room);
|
||||
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开房间
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param userId 用户 accountId
|
||||
*/
|
||||
@Transactional
|
||||
public void leaveRoom(String roomId, String userId) {
|
||||
GameRoomEntity room = gameRoomRepository.findById(roomId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
|
||||
|
||||
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM));
|
||||
|
||||
// 如果游戏进行中,通知引擎玩家离开(视为认输)
|
||||
RoomRuntime runtime = roomRuntimes.get(roomId);
|
||||
if ("playing".equals(room.getStatus()) && runtime != null && runtime.gameEngine != null) {
|
||||
runtime.gameEngine.onPlayerLeave(userId);
|
||||
}
|
||||
|
||||
// 取消断线倒计时
|
||||
cancelDisconnectTimer(roomId, userId);
|
||||
|
||||
roomPlayerRepository.delete(player);
|
||||
List<RoomPlayerEntity> remaining = roomPlayerRepository.findByRoomId(roomId);
|
||||
|
||||
if (remaining.isEmpty()) {
|
||||
// 无人了,清理并删除房间
|
||||
cleanupRoom(roomId);
|
||||
gameRoomRepository.delete(room);
|
||||
log.info("[房间管理] 房间无人,已删除, roomId={}", roomId);
|
||||
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId));
|
||||
} else if (userId.equals(room.getCreatorId())) {
|
||||
// 房主离开,顺位转让
|
||||
RoomPlayerEntity nextOwner = remaining.get(0);
|
||||
room.setCreatorId(nextOwner.getUserId());
|
||||
nextOwner.setReadyStatus(true);
|
||||
roomPlayerRepository.save(nextOwner);
|
||||
gameRoomRepository.save(room);
|
||||
log.info("[房间管理] 房主转让, roomId={}, newOwner={}", roomId, nextOwner.getUserId());
|
||||
// 通知更新
|
||||
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId)));
|
||||
pushRoomUpdatedSse(room);
|
||||
} else {
|
||||
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId)));
|
||||
pushRoomUpdatedSse(room);
|
||||
}
|
||||
|
||||
log.info("[房间管理] 玩家离开, roomId={}, userId={}", roomId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解散房间(仅房主)
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param ownerId 房主 accountId
|
||||
*/
|
||||
@Transactional
|
||||
public void disbandRoom(String roomId, String ownerId) {
|
||||
GameRoomEntity room = gameRoomRepository.findById(roomId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
|
||||
|
||||
if (!ownerId.equals(room.getCreatorId())) {
|
||||
throw new BusinessException(ErrorCode.NOT_ROOM_OWNER);
|
||||
}
|
||||
|
||||
// 如果游戏进行中,销毁引擎
|
||||
RoomRuntime runtime = roomRuntimes.get(roomId);
|
||||
if (runtime != null && runtime.gameEngine != null) {
|
||||
runtime.gameEngine.destroy();
|
||||
}
|
||||
|
||||
// 通知房间内所有人被踢
|
||||
broadcastToRoom(roomId, new WsMessage("room_disbanded", Map.of("roomId", roomId)));
|
||||
|
||||
// 关闭房间内所有 WS 连接
|
||||
for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) {
|
||||
try { ws.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
// 清理并删除
|
||||
cleanupRoom(roomId);
|
||||
roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(roomId));
|
||||
gameRoomRepository.delete(room);
|
||||
|
||||
log.info("[房间管理] 房间已解散, roomId={}", roomId);
|
||||
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 踢出玩家(仅房主)
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param ownerId 房主 accountId
|
||||
* @param targetUserId 目标用户 accountId
|
||||
*/
|
||||
@Transactional
|
||||
public void kickPlayer(String roomId, String ownerId, String targetUserId) {
|
||||
GameRoomEntity room = gameRoomRepository.findById(roomId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
|
||||
|
||||
if (!ownerId.equals(room.getCreatorId())) {
|
||||
throw new BusinessException(ErrorCode.NOT_ROOM_OWNER);
|
||||
}
|
||||
if (ownerId.equals(targetUserId)) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "不能踢出自己,请使用离开房间");
|
||||
}
|
||||
|
||||
RoomPlayerEntity target = roomPlayerRepository.findByRoomIdAndUserId(roomId, targetUserId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM));
|
||||
|
||||
// 如果游戏进行中,通知引擎
|
||||
RoomRuntime runtime = roomRuntimes.get(roomId);
|
||||
if ("playing".equals(room.getStatus()) && runtime != null && runtime.gameEngine != null) {
|
||||
runtime.gameEngine.onPlayerLeave(targetUserId);
|
||||
}
|
||||
|
||||
cancelDisconnectTimer(roomId, targetUserId);
|
||||
roomPlayerRepository.delete(target);
|
||||
|
||||
log.info("[房间管理] 踢出玩家, roomId={}, targetUserId={}", roomId, targetUserId);
|
||||
|
||||
// WS 通知
|
||||
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", targetUserId)));
|
||||
|
||||
// SSE 通知被踢者
|
||||
sseEventPublisher.sendTo(targetUserId, SseEventType.KICKED, Map.of("roomId", roomId));
|
||||
|
||||
// 大厅更新
|
||||
pushRoomUpdatedSse(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换准备状态
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param userId 用户 accountId
|
||||
* @return 新的准备状态
|
||||
*/
|
||||
@Transactional
|
||||
public boolean toggleReady(String roomId, String userId) {
|
||||
GameRoomEntity room = gameRoomRepository.findById(roomId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
|
||||
|
||||
if (!"waiting".equals(room.getStatus())) {
|
||||
throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
|
||||
}
|
||||
|
||||
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM));
|
||||
|
||||
boolean newStatus = !player.getReadyStatus();
|
||||
player.setReadyStatus(newStatus);
|
||||
roomPlayerRepository.save(player);
|
||||
|
||||
log.info("[房间管理] 准备状态变更, roomId={}, userId={}, ready={}", roomId, userId, newStatus);
|
||||
|
||||
// WS 广播
|
||||
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.READY_CHANGE, Map.of(
|
||||
"userId", userId, "ready", newStatus
|
||||
)));
|
||||
|
||||
// SSE 大厅更新
|
||||
pushRoomUpdatedSse(room);
|
||||
|
||||
return newStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始游戏(仅房主)
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param userId 操作者 accountId(必须是房主)
|
||||
*/
|
||||
@Transactional
|
||||
public void startGame(String roomId, String userId) {
|
||||
GameRoomEntity room = gameRoomRepository.findById(roomId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
|
||||
|
||||
if (!userId.equals(room.getCreatorId())) {
|
||||
throw new BusinessException(ErrorCode.NOT_ROOM_OWNER);
|
||||
}
|
||||
if (!"waiting".equals(room.getStatus())) {
|
||||
throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
|
||||
}
|
||||
|
||||
List<RoomPlayerEntity> players = roomPlayerRepository.findByRoomId(roomId);
|
||||
if (players.size() < 2) {
|
||||
throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT);
|
||||
}
|
||||
for (RoomPlayerEntity p : players) {
|
||||
if (!p.getReadyStatus()) {
|
||||
throw new BusinessException(ErrorCode.NOT_ALL_READY);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查引擎是否已注册
|
||||
if (!engineRegistry.isRegistered(room.getGameId())) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "该游戏尚未实现引擎: " + room.getGameId());
|
||||
}
|
||||
|
||||
// 更新房间状态
|
||||
room.setStatus("playing");
|
||||
gameRoomRepository.save(room);
|
||||
|
||||
// 创建游戏记录(resultType 初始为 pending,游戏结束时由 onGameOver 更新)
|
||||
GameRecordEntity record = new GameRecordEntity()
|
||||
.setRoomId(roomId)
|
||||
.setResultType("pending")
|
||||
.setStartedAt(LocalDateTime.now());
|
||||
gameRecordRepository.save(record);
|
||||
|
||||
// 构建房间上下文
|
||||
List<RoomPlayerEntity> sortedPlayers = players.stream()
|
||||
.sorted(Comparator.comparingInt(RoomPlayerEntity::getSeatNumber))
|
||||
.toList();
|
||||
|
||||
List<RoomContext.PlayerInfo> playerInfos = sortedPlayers.stream()
|
||||
.map(p -> new RoomContext.PlayerInfo(p.getUserId(), p.getSeatNumber()))
|
||||
.toList();
|
||||
|
||||
RoomContext context = new RoomContext(
|
||||
roomId,
|
||||
room.getGameId(),
|
||||
playerInfos,
|
||||
room.getMode(),
|
||||
room.getStakes(),
|
||||
msg -> broadcastToRoom(roomId, msg),
|
||||
(targetUserId, msg) -> sendToUser(roomId, targetUserId, msg),
|
||||
(winnerId, resultType) -> onGameOver(roomId, winnerId, resultType)
|
||||
);
|
||||
|
||||
// 创建并初始化游戏引擎
|
||||
GameEngine engine = engineRegistry.create(room.getGameId(), context);
|
||||
engine.init(context);
|
||||
|
||||
// 缓存运行时
|
||||
RoomRuntime runtime = roomRuntimes.computeIfAbsent(roomId, k -> new RoomRuntime());
|
||||
runtime.gameEngine = engine;
|
||||
|
||||
log.info("[房间管理] 游戏开始, roomId={}, gameId={}, 玩家数={}", roomId, room.getGameId(), players.size());
|
||||
|
||||
// SSE 大厅更新
|
||||
pushRoomUpdatedSse(room);
|
||||
}
|
||||
|
||||
// ==================== 断线重连 ====================
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 断线
|
||||
*
|
||||
* 启动 60 秒重连倒计时,超时后根据房间状态处理:
|
||||
* - WAITING:自动离开房间
|
||||
* - PLAYING:通知 GameEngine(默认处理:自动认输后离开)
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param userId 断线用户 accountId
|
||||
*/
|
||||
public void handleDisconnect(String roomId, String userId) {
|
||||
RoomRuntime runtime = roomRuntimes.get(roomId);
|
||||
if (runtime == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 避免重复启动定时器
|
||||
if (runtime.disconnectTimers.containsKey(userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String taskKey = roomId + ":" + userId;
|
||||
log.info("[房间管理] 玩家断线,启动{}秒重连倒计时, roomId={}, userId={}",
|
||||
DISCONNECT_TIMEOUT_SECONDS, roomId, userId);
|
||||
|
||||
ScheduledFuture<?> task = reconnectScheduler.schedule(() -> {
|
||||
runtime.disconnectTimers.remove(userId);
|
||||
try {
|
||||
onDisconnectTimeout(roomId, userId);
|
||||
} catch (Exception e) {
|
||||
log.error("[房间管理] 断线超时处理异常, roomId={}, userId={}", roomId, userId, e);
|
||||
}
|
||||
}, DISCONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
runtime.disconnectTimers.put(userId, task);
|
||||
|
||||
// 通知游戏引擎
|
||||
if (runtime.gameEngine != null) {
|
||||
runtime.gameEngine.onPlayerDisconnect(userId);
|
||||
}
|
||||
|
||||
// WS 广播断线
|
||||
broadcastToRoom(roomId, new WsMessage("player_disconnected", Map.of("userId", userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 重连
|
||||
*
|
||||
* 取消断线倒计时,推送完整状态给重连玩家。
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param userId 重连用户 accountId
|
||||
* @return 完整房间状态快照,用于前端恢复
|
||||
*/
|
||||
public RoomDetailResponse handleReconnect(String roomId, String userId) {
|
||||
GameRoomEntity room = gameRoomRepository.findById(roomId)
|
||||
.orElse(null);
|
||||
if (room == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 取消倒计时
|
||||
cancelDisconnectTimer(roomId, userId);
|
||||
|
||||
// 通知游戏引擎
|
||||
RoomRuntime runtime = roomRuntimes.get(roomId);
|
||||
if (runtime != null && runtime.gameEngine != null) {
|
||||
runtime.gameEngine.onPlayerReconnect(userId);
|
||||
}
|
||||
|
||||
// WS 广播重连
|
||||
broadcastToRoom(roomId, new WsMessage("player_reconnected", Map.of("userId", userId)));
|
||||
|
||||
log.info("[房间管理] 玩家重连, roomId={}, userId={}", roomId, userId);
|
||||
|
||||
// 返回完整房间详情
|
||||
return buildRoomDetail(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消断线倒计时
|
||||
*/
|
||||
private void cancelDisconnectTimer(String roomId, String userId) {
|
||||
RoomRuntime runtime = roomRuntimes.get(roomId);
|
||||
if (runtime == null) return;
|
||||
|
||||
ScheduledFuture<?> task = runtime.disconnectTimers.remove(userId);
|
||||
if (task != null) {
|
||||
task.cancel(false);
|
||||
log.info("[房间管理] 取消断线倒计时, roomId={}, userId={}", roomId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断线超时处理
|
||||
*/
|
||||
private void onDisconnectTimeout(String roomId, String userId) {
|
||||
GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null);
|
||||
if (room == null) return;
|
||||
|
||||
boolean stillInRoom = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId).isPresent();
|
||||
if (!stillInRoom) return;
|
||||
|
||||
if ("playing".equals(room.getStatus())) {
|
||||
log.info("[房间管理] 断线超时:对局中自动判负, roomId={}, userId={}", roomId, userId);
|
||||
// 通知引擎,引擎内部处理认输
|
||||
RoomRuntime runtime = roomRuntimes.get(roomId);
|
||||
if (runtime != null && runtime.gameEngine != null) {
|
||||
runtime.gameEngine.onPlayerLeave(userId);
|
||||
}
|
||||
// 从房间移除
|
||||
leaveRoomSilent(roomId, userId);
|
||||
} else {
|
||||
log.info("[房间管理] 断线超时:等待中自动离开, roomId={}, userId={}", roomId, userId);
|
||||
leaveRoomSilent(roomId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默离开(不触发额外的游戏事件,超时专用)
|
||||
*/
|
||||
private void leaveRoomSilent(String roomId, String userId) {
|
||||
RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId).orElse(null);
|
||||
if (player == null) return;
|
||||
|
||||
cancelDisconnectTimer(roomId, userId);
|
||||
roomPlayerRepository.delete(player);
|
||||
GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null);
|
||||
if (room == null) return;
|
||||
|
||||
List<RoomPlayerEntity> remaining = roomPlayerRepository.findByRoomId(roomId);
|
||||
|
||||
if (remaining.isEmpty()) {
|
||||
cleanupRoom(roomId);
|
||||
gameRoomRepository.delete(room);
|
||||
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId));
|
||||
} else if (userId.equals(room.getCreatorId())) {
|
||||
RoomPlayerEntity nextOwner = remaining.get(0);
|
||||
room.setCreatorId(nextOwner.getUserId());
|
||||
nextOwner.setReadyStatus(true);
|
||||
roomPlayerRepository.save(nextOwner);
|
||||
gameRoomRepository.save(room);
|
||||
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId)));
|
||||
pushRoomUpdatedSse(room);
|
||||
} else {
|
||||
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId)));
|
||||
pushRoomUpdatedSse(room);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 游戏结束回调 ====================
|
||||
|
||||
/**
|
||||
* 游戏结束回调(由 GameEngine 通过 RoomContext 调用)
|
||||
*
|
||||
* 房间层负责:
|
||||
* 1. 更新房间状态为 FINISHED
|
||||
* 2. 保存对局记录(胜负结果)
|
||||
* 3. 金币结算(TODO)
|
||||
* 4. 广播 game_over + SSE 推送
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param winnerId 胜者 accountId(null 表示平局)
|
||||
* @param resultType 结果类型(win/draw/resign)
|
||||
*/
|
||||
@Transactional
|
||||
public void onGameOver(String roomId, String winnerId, String resultType) {
|
||||
GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null);
|
||||
if (room == null) return;
|
||||
|
||||
room.setStatus("finished");
|
||||
gameRoomRepository.save(room);
|
||||
|
||||
// 更新对局记录
|
||||
GameRecordEntity record = gameRecordRepository.findByRoomId(roomId).orElse(null);
|
||||
if (record != null) {
|
||||
record.setWinnerId(winnerId)
|
||||
.setResultType(resultType)
|
||||
.setEndedAt(LocalDateTime.now());
|
||||
gameRecordRepository.save(record);
|
||||
}
|
||||
|
||||
// TODO: 金币结算 — 房间层从玩家余额扣除/增加 stakes
|
||||
|
||||
log.info("[房间管理] 游戏结束, roomId={}, winnerId={}, resultType={}", roomId, winnerId, resultType);
|
||||
|
||||
// WS 广播游戏结束
|
||||
broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.GAME_OVER, Map.of(
|
||||
"winnerId", winnerId != null ? winnerId : "",
|
||||
"resultType", resultType
|
||||
)));
|
||||
|
||||
// SSE 大厅更新
|
||||
pushRoomUpdatedSse(room);
|
||||
|
||||
// 发布事件(供其他模块订阅)
|
||||
eventBus.publish(RoomEventBus.GAME_OVER, new RoomEventBus.GameOverEvent(roomId, winnerId, resultType));
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行游戏动作(委托给 GameEngine)
|
||||
*
|
||||
* 由 WebSocket Handler 调用,RoomManager 查找对应房间的活跃引擎并转发动作。
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param userId 操作者 accountId
|
||||
* @param action 动作类型
|
||||
* @param data 动作数据(可为 null)
|
||||
*/
|
||||
public void executeGameAction(String roomId, String userId, String action, Object data) {
|
||||
RoomRuntime runtime = roomRuntimes.get(roomId);
|
||||
if (runtime == null || runtime.gameEngine == null) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "游戏尚未开始");
|
||||
}
|
||||
|
||||
// 将 data 转换为 JsonNode
|
||||
com.fasterxml.jackson.databind.JsonNode jsonData = null;
|
||||
if (data != null) {
|
||||
String jsonStr = JSON.toJSONString(data);
|
||||
try {
|
||||
jsonData = new com.fasterxml.jackson.databind.ObjectMapper().readTree(jsonStr);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "无效的游戏数据格式");
|
||||
}
|
||||
}
|
||||
|
||||
runtime.gameEngine.handleAction(action, jsonData, userId);
|
||||
}
|
||||
|
||||
// ==================== 查询 ====================
|
||||
|
||||
/**
|
||||
* 获取房间完整详情(进入房间时调用)
|
||||
*/
|
||||
public RoomDetailResponse buildRoomDetail(GameRoomEntity room) {
|
||||
if (room == null) {
|
||||
room = gameRoomRepository.findById(room.getId())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
|
||||
}
|
||||
|
||||
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND));
|
||||
|
||||
List<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(room.getId());
|
||||
List<String> userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList();
|
||||
Map<String, UserEntity> userMap = userRepository.findByAccountIdIn(userIds).stream()
|
||||
.collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
|
||||
|
||||
List<RoomDetailResponse.RoomPlayerDetail> playerDetails = roomPlayers.stream()
|
||||
.map(rp -> {
|
||||
UserEntity user = userMap.get(rp.getUserId());
|
||||
return new RoomDetailResponse.RoomPlayerDetail(
|
||||
rp.getUserId(),
|
||||
user != null ? user.getAvatar() : "",
|
||||
user != null ? user.getNickName() : "未知玩家",
|
||||
rp.getSeatNumber(),
|
||||
rp.getReadyStatus()
|
||||
);
|
||||
}).toList();
|
||||
|
||||
String creatorName = getNickname(room.getCreatorId());
|
||||
|
||||
// 对局中或有对局记录时,查询棋盘状态
|
||||
int[][] boardState = null;
|
||||
String currentTurn = null;
|
||||
List<RoomDetailResponse.MoveItem> moves = List.of();
|
||||
|
||||
if ("playing".equals(room.getStatus()) || "finished".equals(room.getStatus())) {
|
||||
List<GameMoveEntity> moveEntities = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(room.getId());
|
||||
moves = moveEntities.stream()
|
||||
.map(m -> new RoomDetailResponse.MoveItem(m.getMoveIndex(), m.getPlayerId(), m.getRowNum(), m.getColNum()))
|
||||
.toList();
|
||||
|
||||
// 从走棋记录重建棋盘
|
||||
boardState = rebuildBoard(moveEntities, 15);
|
||||
}
|
||||
|
||||
return new RoomDetailResponse(
|
||||
room.getId(), room.getName(), room.getGameId(),
|
||||
gameConfig.getIcon(), gameConfig.getName(),
|
||||
room.getMaxPlayers(), room.getMode(), room.getStakes(),
|
||||
room.getStatus(), room.getCreatorId(), creatorName,
|
||||
playerDetails, boardState, currentTurn, moves
|
||||
);
|
||||
}
|
||||
|
||||
/** 棋盘大小(五子棋默认 15) */
|
||||
private static final int BOARD_SIZE = 15;
|
||||
|
||||
/**
|
||||
* 从走棋记录重建棋盘
|
||||
*/
|
||||
private int[][] rebuildBoard(List<GameMoveEntity> moves, int boardSize) {
|
||||
int[][] board = new int[boardSize][boardSize];
|
||||
for (GameMoveEntity move : moves) {
|
||||
int player = (move.getMoveIndex() % 2 == 1) ? 1 : 2;
|
||||
board[move.getRowNum()][move.getColNum()] = player;
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取房间列表
|
||||
*/
|
||||
public RoomListResponse getRoomList(String gameId, String status) {
|
||||
GameConfigEntity gameConfig = gameConfigRepository.findByGameId(gameId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND));
|
||||
if (!gameConfig.getEnabled()) {
|
||||
throw new BusinessException(ErrorCode.GAME_NOT_FOUND);
|
||||
}
|
||||
|
||||
List<GameRoomEntity> rooms;
|
||||
if (status != null && !status.isBlank()) {
|
||||
rooms = gameRoomRepository.findByGameIdAndStatus(gameId, status);
|
||||
} else {
|
||||
rooms = gameRoomRepository.findByGameId(gameId).stream()
|
||||
.filter(r -> !"finished".equals(r.getStatus()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<RoomItemResponse> list = rooms.stream()
|
||||
.map(this::buildRoomItemResponse)
|
||||
.toList();
|
||||
|
||||
return new RoomListResponse(list);
|
||||
}
|
||||
|
||||
// ==================== 定时任务 ====================
|
||||
|
||||
/**
|
||||
* 超时房间清理
|
||||
*
|
||||
* 每 5 分钟执行一次:
|
||||
* - 清理 WAITING 超过 30 分钟无变化的房间
|
||||
* - 清理 FINISHED 超过 5 分钟的房间
|
||||
*/
|
||||
@Scheduled(fixedRate = 300_000)
|
||||
public void cleanupStaleRooms() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime waitingDeadline = now.minusMinutes(30);
|
||||
LocalDateTime finishedDeadline = now.minusMinutes(5);
|
||||
|
||||
// 清理超时等待中的房间
|
||||
List<GameRoomEntity> staleWaiting = gameRoomRepository
|
||||
.findByStatusAndUpdateTimeBefore("waiting", waitingDeadline);
|
||||
for (GameRoomEntity room : staleWaiting) {
|
||||
log.info("[房间清理] 清理超时等待房间, roomId={}", room.getId());
|
||||
cleanupRoom(room.getId());
|
||||
roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(room.getId()));
|
||||
gameRoomRepository.delete(room);
|
||||
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", room.getId()));
|
||||
}
|
||||
|
||||
// 清理已结束的房间
|
||||
List<GameRoomEntity> staleFinished = gameRoomRepository
|
||||
.findByStatusAndUpdateTimeBefore("finished", finishedDeadline);
|
||||
for (GameRoomEntity room : staleFinished) {
|
||||
log.info("[房间清理] 清理已结束房间, roomId={}", room.getId());
|
||||
cleanupRoom(room.getId());
|
||||
roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(room.getId()));
|
||||
gameRoomRepository.delete(room);
|
||||
sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", room.getId()));
|
||||
}
|
||||
|
||||
if (!staleWaiting.isEmpty() || !staleFinished.isEmpty()) {
|
||||
log.info("[房间清理] 清理完成, 等待超时={}, 已结束={}",
|
||||
staleWaiting.size(), staleFinished.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 清理房间运行时资源(引擎、定时器)
|
||||
*/
|
||||
private void cleanupRoom(String roomId) {
|
||||
RoomRuntime runtime = roomRuntimes.remove(roomId);
|
||||
if (runtime != null) {
|
||||
// 销毁游戏引擎
|
||||
if (runtime.gameEngine != null) {
|
||||
runtime.gameEngine.destroy();
|
||||
}
|
||||
// 取消所有断线定时器
|
||||
for (ScheduledFuture<?> timer : runtime.disconnectTimers.values()) {
|
||||
timer.cancel(false);
|
||||
}
|
||||
runtime.disconnectTimers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播 WS 消息到房间内所有人
|
||||
*/
|
||||
private void broadcastToRoom(String roomId, WsMessage message) {
|
||||
String json = JSON.toJSONString(message);
|
||||
for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) {
|
||||
if (ws.isOpen()) {
|
||||
try {
|
||||
ws.sendMessage(new TextMessage(json));
|
||||
} catch (IOException e) {
|
||||
log.error("[WS] 广播失败, sessionId={}", ws.getId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向房间内指定用户发送 WS 消息(私有消息)
|
||||
*/
|
||||
private void sendToUser(String roomId, String userId, WsMessage message) {
|
||||
String json = JSON.toJSONString(message);
|
||||
for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) {
|
||||
if (ws.isOpen() && userId.equals(sessionManager.getUserId(ws))) {
|
||||
try {
|
||||
ws.sendMessage(new TextMessage(json));
|
||||
} catch (IOException e) {
|
||||
log.error("[WS] 发送私有消息失败, userId={}", userId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建房间列表项响应
|
||||
*/
|
||||
private RoomItemResponse buildRoomItemResponse(GameRoomEntity room) {
|
||||
List<RoomPlayerResponse> players = buildRoomPlayers(room.getId());
|
||||
String creatorName = getNickname(room.getCreatorId());
|
||||
String icon = gameConfigRepository.findByGameId(room.getGameId())
|
||||
.map(GameConfigEntity::getIcon).orElse("");
|
||||
return RoomItemResponse.from(room, players, icon, creatorName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建单个玩家详情
|
||||
*/
|
||||
private RoomPlayerDetail buildPlayerDetail(String userId, int seatNumber, boolean ready) {
|
||||
UserEntity user = userRepository.findByAccountId(userId).orElse(null);
|
||||
return new RoomPlayerDetail(
|
||||
userId,
|
||||
user != null ? user.getAvatar() : "",
|
||||
user != null ? user.getNickName() : "未知玩家",
|
||||
seatNumber,
|
||||
ready
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装房间玩家列表
|
||||
*/
|
||||
private List<RoomPlayerResponse> buildRoomPlayers(String roomId) {
|
||||
List<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(roomId);
|
||||
if (roomPlayers.isEmpty()) return List.of();
|
||||
|
||||
List<String> userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList();
|
||||
List<UserEntity> users = userRepository.findByAccountIdIn(userIds);
|
||||
Map<String, UserEntity> userMap = users.stream()
|
||||
.collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
|
||||
|
||||
return roomPlayers.stream()
|
||||
.map(rp -> {
|
||||
UserEntity user = userMap.get(rp.getUserId());
|
||||
String avatar = user != null ? user.getAvatar() : "";
|
||||
String nickname = user != null ? user.getNickName() : "未知玩家";
|
||||
return new RoomPlayerResponse(avatar, nickname);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户昵称
|
||||
*/
|
||||
private String getNickname(String accountId) {
|
||||
return userRepository.findByAccountId(accountId)
|
||||
.map(UserEntity::getNickName)
|
||||
.orElse("未知玩家");
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 推送房间更新
|
||||
*/
|
||||
private void pushRoomUpdatedSse(GameRoomEntity room) {
|
||||
RoomItemResponse response = buildRoomItemResponse(room);
|
||||
sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response);
|
||||
}
|
||||
|
||||
// ==================== 公共查询方法(供 Controller 使用) ====================
|
||||
|
||||
/**
|
||||
* 获取房间详情
|
||||
*/
|
||||
public RoomDetailResponse getRoomDetail(String roomId) {
|
||||
GameRoomEntity room = gameRoomRepository.findById(roomId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND));
|
||||
return buildRoomDetail(room);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.webgame.webgamebackend.service.impl;
|
||||
package com.webgame.webgamebackend.domain.token;
|
||||
|
||||
import com.webgame.webgamebackend.common.exception.BusinessException;
|
||||
import com.webgame.webgamebackend.common.exception.ErrorCode;
|
||||
import com.webgame.webgamebackend.service.RefreshTokenService;
|
||||
import com.webgame.webgamebackend.domain.token.RefreshTokenService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -25,7 +26,8 @@ import java.util.concurrent.TimeUnit;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RefreshTokenServiceImpl implements RefreshTokenService {
|
||||
@RequiredArgsConstructor
|
||||
public class RefreshTokenService {
|
||||
|
||||
/** Redis key 前缀 */
|
||||
private static final String KEY_PREFIX = "xzg:refresh:";
|
||||
@@ -41,11 +43,6 @@ public class RefreshTokenServiceImpl implements RefreshTokenService {
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
public RefreshTokenServiceImpl(StringRedisTemplate stringRedisTemplate) {
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String create(String userId) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.BAD_REQUEST, "用户ID不能为空");
|
||||
@@ -66,7 +63,6 @@ public class RefreshTokenServiceImpl implements RefreshTokenService {
|
||||
return token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String validate(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.REFRESH_TOKEN_MISSING);
|
||||
@@ -78,7 +74,6 @@ public class RefreshTokenServiceImpl implements RefreshTokenService {
|
||||
return userId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String rotate(String oldToken) {
|
||||
String userId = validate(oldToken);
|
||||
// 先删反向映射,再删正向映射:
|
||||
@@ -90,7 +85,6 @@ public class RefreshTokenServiceImpl implements RefreshTokenService {
|
||||
return create(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void revoke(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return;
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.webgame.webgamebackend.domain.user;
|
||||
|
||||
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
|
||||
import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest;
|
||||
import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
|
||||
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
|
||||
import com.webgame.webgamebackend.common.exception.BusinessException;
|
||||
import com.webgame.webgamebackend.common.exception.ErrorCode;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.UserEntity;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.repository.UserRepository;
|
||||
import com.webgame.webgamebackend.domain.user.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 用户服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserService {
|
||||
|
||||
/** 头像存储目录(相对于项目工作目录) */
|
||||
private static final String AVATAR_DIR = "uploads/avatars";
|
||||
|
||||
/**
|
||||
* 获取头像存储的绝对路径目录
|
||||
*
|
||||
* 使用 user.dir 构建绝对路径,避免 Tomcat 临时工作目录导致 FileNotFoundException。
|
||||
*/
|
||||
private Path getAvatarDir() {
|
||||
return Paths.get(System.getProperty("user.dir"), AVATAR_DIR).toAbsolutePath();
|
||||
}
|
||||
/** 允许的头像文件类型 */
|
||||
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||
"image/jpeg", "image/png", "image/webp", "image/gif"
|
||||
);
|
||||
/** 头像文件大小上限(2MB) */
|
||||
private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final AuthenticationProvider authProvider;
|
||||
|
||||
public UserInfoResponse getCurrentUserInfo() {
|
||||
String accountId = authProvider.getCurrentUserId();
|
||||
log.info("[用户服务] 获取当前登录用户信息, accountId: {}", accountId);
|
||||
UserEntity user = userRepository.findByAccountId(accountId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
return UserInfoResponse.from(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserInfoResponse updateUserInfo(String accountId, UpdateUserInfoRequest request) {
|
||||
UserEntity user = userRepository.findByAccountId(accountId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
|
||||
// 仅更新非 null 字段
|
||||
if (request.nickName() != null) {
|
||||
user.setNickName(request.nickName());
|
||||
}
|
||||
if (request.avatar() != null) {
|
||||
user.setAvatar(request.avatar());
|
||||
}
|
||||
if (request.signature() != null) {
|
||||
user.setSignature(request.signature());
|
||||
}
|
||||
if (request.age() != null) {
|
||||
user.setAge(request.age());
|
||||
}
|
||||
if (request.sex() != null) {
|
||||
user.setSex(request.sex());
|
||||
}
|
||||
if (request.birthday() != null) {
|
||||
user.setBirthday(request.birthday());
|
||||
}
|
||||
|
||||
userRepository.save(user);
|
||||
log.info("[用户服务] 更新用户信息, accountId={}", accountId);
|
||||
return UserInfoResponse.from(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateStatus(String accountId, UpdateStatusRequest request) {
|
||||
UserEntity user = userRepository.findByAccountId(accountId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
|
||||
user.setStatus(request.status());
|
||||
userRepository.save(user);
|
||||
log.info("[用户服务] 更新在线状态, accountId={}, status={}", accountId, request.status());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserInfoResponse uploadAvatar(String accountId, MultipartFile file) {
|
||||
// 校验文件类型
|
||||
String contentType = file.getContentType();
|
||||
if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
|
||||
throw new BusinessException(ErrorCode.AVATAR_INVALID_TYPE);
|
||||
}
|
||||
|
||||
// 校验文件大小
|
||||
if (file.getSize() > MAX_AVATAR_SIZE) {
|
||||
throw new BusinessException(ErrorCode.AVATAR_TOO_LARGE);
|
||||
}
|
||||
|
||||
UserEntity user = userRepository.findByAccountId(accountId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
|
||||
try {
|
||||
// 确保目录存在(使用绝对路径,避免 Tomcat 临时目录问题)
|
||||
Path avatarDir = getAvatarDir();
|
||||
Files.createDirectories(avatarDir);
|
||||
|
||||
// 删除旧头像文件(如果存在且不是默认 SVG)
|
||||
deleteOldAvatarFile(user.getAvatar());
|
||||
|
||||
// 保存新头像:uploads/avatars/{accountId}.jpg
|
||||
String filename = accountId + ".jpg";
|
||||
Path targetPath = avatarDir.resolve(filename);
|
||||
file.transferTo(targetPath.toFile());
|
||||
|
||||
// 更新数据库
|
||||
String avatarUrl = "/" + AVATAR_DIR + "/" + filename;
|
||||
user.setAvatar(avatarUrl);
|
||||
userRepository.save(user);
|
||||
|
||||
log.info("[用户服务] 头像上传成功, accountId={}, path={}", accountId, avatarUrl);
|
||||
return UserInfoResponse.from(user);
|
||||
} catch (IOException e) {
|
||||
log.error("[用户服务] 头像上传失败, accountId={}", accountId, e);
|
||||
throw new BusinessException(ErrorCode.AVATAR_UPLOAD_FAILED, "头像保存失败,请重试");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserInfoResponse deleteAvatar(String accountId) {
|
||||
UserEntity user = userRepository.findByAccountId(accountId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
|
||||
// 删除旧头像文件
|
||||
deleteOldAvatarFile(user.getAvatar());
|
||||
|
||||
// 数据库置空(前端会回退显示默认头像)
|
||||
user.setAvatar(null);
|
||||
userRepository.save(user);
|
||||
|
||||
log.info("[用户服务] 头像已删除, accountId={}", accountId);
|
||||
return UserInfoResponse.from(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除旧的用户头像文件
|
||||
*
|
||||
* 仅删除 uploads/avatars/ 下的文件,跳过默认 SVG 和非本地路径。
|
||||
*
|
||||
* @param avatarPath 头像路径,可能为 null
|
||||
*/
|
||||
private void deleteOldAvatarFile(String avatarPath) {
|
||||
if (avatarPath == null || !avatarPath.startsWith("/" + AVATAR_DIR + "/")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 从 URL 路径中提取文件名,使用绝对路径删除
|
||||
String filename = avatarPath.substring(avatarPath.lastIndexOf('/') + 1);
|
||||
Path oldFile = getAvatarDir().resolve(filename);
|
||||
Files.deleteIfExists(oldFile);
|
||||
log.debug("[用户服务] 旧头像已删除: {}", oldFile);
|
||||
} catch (IOException e) {
|
||||
log.warn("[用户服务] 旧头像删除失败: {}", avatarPath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.webgame.webgamebackend.infrastructure.config;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.repository.GameMoveRepository;
|
||||
import com.webgame.webgamebackend.domain.engine.GameEngineRegistry;
|
||||
import com.webgame.webgamebackend.domain.engine.GomokuEngine;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 游戏引擎注册配置
|
||||
*
|
||||
* 在 Spring 启动后,将所有已实现的 GameEngine 注册到 GameEngineRegistry。
|
||||
* 新增游戏只需在此处添加一行注册即可。
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class EngineRegistrationConfig {
|
||||
|
||||
private final GameEngineRegistry registry;
|
||||
private final GameMoveRepository moveRepository;
|
||||
|
||||
@PostConstruct
|
||||
public void registerEngines() {
|
||||
// 注册五子棋引擎
|
||||
registry.register("gomoku", context -> new GomokuEngine(moveRepository));
|
||||
log.info("[引擎注册] 已注册五子棋引擎, gameId=gomoku");
|
||||
|
||||
// TODO: 后续新游戏在此注册,例如:
|
||||
// registry.register("snake", context -> new SnakeEngine(...));
|
||||
// registry.register("tetris", context -> new TetrisEngine(...));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.webgame.webgamebackend.common.config;
|
||||
package com.webgame.webgamebackend.infrastructure.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.webgame.webgamebackend.common.config;
|
||||
package com.webgame.webgamebackend.infrastructure.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.webgame.webgamebackend.common.config;
|
||||
package com.webgame.webgamebackend.infrastructure.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.webgame.webgamebackend.infrastructure.config;
|
||||
|
||||
import cn.dev33.satoken.fun.strategy.SaCorsHandleFunction;
|
||||
import cn.dev33.satoken.router.SaHttpMethod;
|
||||
import cn.dev33.satoken.router.SaRouter;
|
||||
import com.webgame.webgamebackend.common.auth.AuthInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* Web 鉴权配置
|
||||
* <p>
|
||||
* 使用自定义 {@link AuthInterceptor} 替代 Sa-Token 的 {@code SaInterceptor},
|
||||
* 在拦截器层将 Sa-Token 框架异常转为业务异常,实现认证框架与业务代码解耦。
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class SaTokenConfigure implements WebMvcConfigurer {
|
||||
|
||||
private final AuthInterceptor authInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/error");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SaCorsHandleFunction corsHandle() {
|
||||
return (req, res, sto) -> {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||
.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT, PATCH")
|
||||
.setHeader("Access-Control-Max-Age", "3600")
|
||||
.setHeader("Access-Control-Allow-Headers", "*");
|
||||
|
||||
SaRouter.match(SaHttpMethod.OPTIONS).free(r -> {
|
||||
}).back();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.webgame.webgamebackend.infrastructure.config;
|
||||
|
||||
import com.webgame.webgamebackend.common.event.SseEventPublisher;
|
||||
import com.webgame.webgamebackend.adapter.sse.RedisSseListener;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.listener.ChannelTopic;
|
||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
||||
|
||||
/**
|
||||
* SSE 基础设施配置
|
||||
*
|
||||
* 注册 Redis Pub/Sub 消息监听容器,订阅频道 "sse:events"。
|
||||
* 心跳由 SseConnectionManager 自管理单线程调度,不在此处配置。
|
||||
*/
|
||||
@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,32 @@
|
||||
package com.webgame.webgamebackend.infrastructure.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* 静态资源配置
|
||||
*
|
||||
* 将本地 uploads/ 目录映射为 /uploads/** URL,用于头像等用户上传文件的访问。
|
||||
* 使用绝对路径避免 Tomcat 临时工作目录导致的路径不一致问题。
|
||||
*/
|
||||
@Configuration
|
||||
public class StaticResourceConfig implements WebMvcConfigurer {
|
||||
|
||||
/** 上传文件根目录(相对于项目工作目录) */
|
||||
private static final String UPLOAD_DIR = "uploads/";
|
||||
|
||||
/**
|
||||
* 注册静态资源处理器
|
||||
*
|
||||
* /uploads/avatars/xxx.jpg → file:<user.dir>/uploads/avatars/xxx.jpg
|
||||
*/
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// 使用正斜杠替换反斜杠,确保 Windows 下 file: URL 格式正确
|
||||
String userDir = System.getProperty("user.dir").replace("\\", "/");
|
||||
String absoluteUploadPath = "file:" + userDir + "/" + UPLOAD_DIR;
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations(absoluteUploadPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.webgame.webgamebackend.infrastructure.config;
|
||||
|
||||
import com.webgame.webgamebackend.adapter.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("*");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.webgame.webgamebackend.entities;
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.entity;
|
||||
|
||||
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.UUIDBaseEntity;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.webgame.webgamebackend.entities.base;
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.entity;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.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;
|
||||
|
||||
/**
|
||||
* 游戏配置实体
|
||||
*
|
||||
* 存储每款游戏的元信息(图标、名称、玩法介绍)和可选配置(模式、底注档位)。
|
||||
* 采用独立表而非硬编码,支持运营后台动态上下架游戏。
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Accessors(chain = true)
|
||||
@Table(name = "game_config")
|
||||
public class GameConfigEntity extends UUIDBaseEntity {
|
||||
|
||||
/**
|
||||
* 游戏唯一标识,如 snake、tetris
|
||||
*/
|
||||
@Column(name = "game_id", unique = true, nullable = false, length = 32)
|
||||
private String gameId;
|
||||
|
||||
/**
|
||||
* 游戏图标 emoji
|
||||
*/
|
||||
@Column(name = "icon", nullable = false, length = 10)
|
||||
private String icon;
|
||||
|
||||
/**
|
||||
* 游戏名称
|
||||
*/
|
||||
@Column(name = "name", nullable = false, length = 32)
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 简短描述
|
||||
*/
|
||||
@Column(name = "description", nullable = false, length = 256)
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 玩法介绍(长文本)
|
||||
*/
|
||||
@Column(name = "gameplay", nullable = false, columnDefinition = "TEXT")
|
||||
private String gameplay;
|
||||
|
||||
/**
|
||||
* 默认最大玩家数
|
||||
*/
|
||||
@Column(name = "max_players", nullable = false)
|
||||
private Integer maxPlayers;
|
||||
|
||||
/**
|
||||
* 可选玩法模式列表(JSON 数组字符串)
|
||||
*/
|
||||
@Column(name = "modes", nullable = false, length = 512)
|
||||
private String modes;
|
||||
|
||||
/**
|
||||
* 可选底注档位列表(JSON 数组字符串)
|
||||
*/
|
||||
@Column(name = "stakes_options", nullable = false, length = 256)
|
||||
private String stakesOptions;
|
||||
|
||||
/**
|
||||
* 排序权重,越大越靠前
|
||||
*/
|
||||
@Column(name = "sort_order", nullable = false)
|
||||
private Integer sortOrder;
|
||||
|
||||
/**
|
||||
* 是否启用
|
||||
*/
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private Boolean enabled;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.entity;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.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,56 @@
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.entity;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.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;
|
||||
|
||||
/**
|
||||
* 结果类型
|
||||
* <ul>
|
||||
* <li>{@code pending} — 对局进行中,尚未产生结果(游戏开始时设置)</li>
|
||||
* <li>{@code win} — 一方获胜</li>
|
||||
* <li>{@code draw} — 平局</li>
|
||||
* <li>{@code resign} — 一方认输</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Column(name = "result_type", length = 16)
|
||||
private String resultType;
|
||||
|
||||
/** 对局开始时间 */
|
||||
@Column(name = "started_at", nullable = false)
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
/** 对局结束时间 */
|
||||
@Column(name = "ended_at")
|
||||
private LocalDateTime endedAt;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.entity;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.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;
|
||||
|
||||
/**
|
||||
* 游戏房间(桌台)实体
|
||||
*
|
||||
* 每个房间属于某款游戏,包含桌号、人数上限、玩法模式、底注等信息。
|
||||
* 房间有三种状态:waiting(等待中)、playing(进行中)、finished(已结束)。
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Accessors(chain = true)
|
||||
@Table(name = "game_room")
|
||||
public class GameRoomEntity extends UUIDBaseEntity {
|
||||
|
||||
/**
|
||||
* 关联的游戏标识
|
||||
*/
|
||||
@Column(name = "game_id", nullable = false, length = 32)
|
||||
private String gameId;
|
||||
|
||||
/**
|
||||
* 该游戏下的桌号序号(自增),与 gameId 联合唯一
|
||||
*/
|
||||
@Column(name = "room_number", nullable = false)
|
||||
private Integer roomNumber;
|
||||
|
||||
/**
|
||||
* 桌号显示名,如 "桌 01"
|
||||
*/
|
||||
@Column(name = "name", nullable = false, length = 32)
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 本房间最大玩家数
|
||||
*/
|
||||
@Column(name = "max_players", nullable = false)
|
||||
private Integer maxPlayers;
|
||||
|
||||
/**
|
||||
* 选定的玩法模式
|
||||
*/
|
||||
@Column(name = "mode", nullable = false, length = 32)
|
||||
private String mode;
|
||||
|
||||
/**
|
||||
* 底注金额
|
||||
*/
|
||||
@Column(name = "stakes", nullable = false)
|
||||
private Integer stakes;
|
||||
|
||||
/**
|
||||
* 房间密码(明文),null 表示公开房间
|
||||
*/
|
||||
@Column(name = "password", length = 64)
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 房间状态:waiting / playing / finished
|
||||
*/
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 房主 account_id
|
||||
*/
|
||||
@Column(name = "creator_id", nullable = false, length = 64)
|
||||
private String creatorId;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.entity;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.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 = "room_player")
|
||||
public class RoomPlayerEntity extends UUIDBaseEntity {
|
||||
|
||||
/**
|
||||
* 关联的房间 ID
|
||||
*/
|
||||
@Column(name = "room_id", nullable = false, length = 64)
|
||||
private String roomId;
|
||||
|
||||
/**
|
||||
* 关联的账号 ID
|
||||
*/
|
||||
@Column(name = "user_id", nullable = false, length = 64)
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 座位号(1 ~ maxPlayers)
|
||||
*/
|
||||
@Column(name = "seat_number", nullable = false)
|
||||
private Integer seatNumber;
|
||||
|
||||
/**
|
||||
* 是否已准备
|
||||
*/
|
||||
@Column(name = "ready_status", nullable = false)
|
||||
private Boolean readyStatus;
|
||||
|
||||
/**
|
||||
* 加入时间
|
||||
*/
|
||||
@Column(name = "join_time", nullable = false)
|
||||
private LocalDateTime joinTime;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.webgame.webgamebackend.entities.base;
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.webgame.webgamebackend.entities;
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.entity;
|
||||
|
||||
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.UUIDBaseEntity;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
@@ -68,4 +68,16 @@ public class UserEntity extends UUIDBaseEntity {
|
||||
*/
|
||||
@Column(name = "birthday")
|
||||
private LocalDate birthday;
|
||||
|
||||
/**
|
||||
* 金币余额,默认 1000
|
||||
*/
|
||||
@Column(name = "balance", nullable = false)
|
||||
private Long balance = 1000L;
|
||||
|
||||
/**
|
||||
* 在线状态:ONLINE / AWAY / DND / INVISIBLE,默认 ONLINE
|
||||
*/
|
||||
@Column(name = "status", length = 16, nullable = false)
|
||||
private String status = "ONLINE";
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.webgame.webgamebackend.repository;
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.repository;
|
||||
|
||||
import com.webgame.webgamebackend.entities.AccountEntity;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.AccountEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.repository;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameConfigEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 游戏配置 Repository
|
||||
*/
|
||||
public interface GameConfigRepository extends JpaRepository<GameConfigEntity, String> {
|
||||
|
||||
/**
|
||||
* 根据游戏标识查询
|
||||
*
|
||||
* @param gameId 游戏标识
|
||||
* @return 游戏配置实体
|
||||
*/
|
||||
Optional<GameConfigEntity> findByGameId(String gameId);
|
||||
|
||||
/**
|
||||
* 查询所有启用的游戏,按排序权重升序
|
||||
*
|
||||
* @return 启用的游戏列表
|
||||
*/
|
||||
List<GameConfigEntity> findByEnabledTrueOrderBySortOrderAsc();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.repository;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.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.infrastructure.persistence.repository;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.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);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.repository;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.GameRoomEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 游戏房间 Repository
|
||||
*/
|
||||
public interface GameRoomRepository extends JpaRepository<GameRoomEntity, String> {
|
||||
|
||||
/**
|
||||
* 根据游戏 ID 和状态查询房间列表
|
||||
*
|
||||
* @param gameId 游戏标识
|
||||
* @param status 房间状态
|
||||
* @return 房间列表
|
||||
*/
|
||||
List<GameRoomEntity> findByGameIdAndStatus(String gameId, String status);
|
||||
|
||||
/**
|
||||
* 根据游戏 ID 查询所有房间(不限状态)
|
||||
*
|
||||
* @param gameId 游戏标识
|
||||
* @return 房间列表
|
||||
*/
|
||||
List<GameRoomEntity> findByGameId(String gameId);
|
||||
|
||||
/**
|
||||
* 查询指定游戏下最大的桌号
|
||||
*
|
||||
* @param gameId 游戏标识
|
||||
* @return 最大桌号,可能为 null
|
||||
*/
|
||||
@Query("SELECT MAX(r.roomNumber) FROM GameRoomEntity r WHERE r.gameId = :gameId")
|
||||
Integer findMaxRoomNumberByGameId(@Param("gameId") String gameId);
|
||||
|
||||
/**
|
||||
* 根据 ID 查询房间(带悲观写锁,防止并发问题)
|
||||
*
|
||||
* @param id 房间 ID
|
||||
* @return 房间实体
|
||||
*/
|
||||
@Query("SELECT r FROM GameRoomEntity r WHERE r.id = :id")
|
||||
Optional<GameRoomEntity> findByIdForUpdate(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* 根据状态和更新时间查询房间(用于超时清理)
|
||||
*
|
||||
* @param status 房间状态
|
||||
* @param updateTime 更新时间上限
|
||||
* @return 符合条件的房间列表
|
||||
*/
|
||||
List<GameRoomEntity> findByStatusAndUpdateTimeBefore(String status, LocalDateTime updateTime);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.repository;
|
||||
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.RoomPlayerEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 房间玩家关联 Repository
|
||||
*/
|
||||
public interface RoomPlayerRepository extends JpaRepository<RoomPlayerEntity, String> {
|
||||
|
||||
/**
|
||||
* 查询房间内所有玩家
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @return 玩家列表
|
||||
*/
|
||||
List<RoomPlayerEntity> findByRoomId(String roomId);
|
||||
|
||||
/**
|
||||
* 查询指定房间和用户的关联记录
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param userId 用户 ID
|
||||
* @return 关联记录
|
||||
*/
|
||||
Optional<RoomPlayerEntity> findByRoomIdAndUserId(String roomId, String userId);
|
||||
|
||||
/**
|
||||
* 统计房间当前玩家人数
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @return 玩家人数
|
||||
*/
|
||||
int countByRoomId(String roomId);
|
||||
|
||||
/**
|
||||
* 删除指定房间和用户的关联
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @param userId 用户 ID
|
||||
*/
|
||||
void deleteByRoomIdAndUserId(String roomId, String userId);
|
||||
|
||||
/**
|
||||
* 判断用户是否已在某房间中
|
||||
*
|
||||
* @param userId 用户 ID
|
||||
* @return 是否存在关联
|
||||
*/
|
||||
boolean existsByUserId(String userId);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.webgame.webgamebackend.repository;
|
||||
package com.webgame.webgamebackend.infrastructure.persistence.repository;
|
||||
|
||||
import com.webgame.webgamebackend.entities.UserEntity;
|
||||
import com.webgame.webgamebackend.infrastructure.persistence.entity.UserEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@@ -14,4 +15,9 @@ public interface UserRepository extends JpaRepository<UserEntity, String> {
|
||||
* 根据账号 ID 查询用户
|
||||
*/
|
||||
Optional<UserEntity> findByAccountId(String accountId);
|
||||
|
||||
/**
|
||||
* 根据账号 ID 列表批量查询用户
|
||||
*/
|
||||
List<UserEntity> findByAccountIdIn(List<String> accountIds);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.webgame.webgamebackend.service;
|
||||
|
||||
import com.webgame.webgamebackend.common.dto.account.LoginRequest;
|
||||
import com.webgame.webgamebackend.common.dto.account.LoginResponse;
|
||||
import com.webgame.webgamebackend.common.dto.account.RegisterRequest;
|
||||
|
||||
/**
|
||||
* 账号服务接口
|
||||
*/
|
||||
public interface AccountService {
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*
|
||||
* @param request 登录请求
|
||||
* @return 登录结果(含 token)
|
||||
*/
|
||||
LoginResponse login(LoginRequest request);
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*
|
||||
* @param request 注册请求
|
||||
* @return 注册结果(含双 token,注册后自动登录)
|
||||
*/
|
||||
LoginResponse register(RegisterRequest request);
|
||||
|
||||
/**
|
||||
* 刷新令牌
|
||||
*
|
||||
* @param refreshToken 刷新令牌
|
||||
* @return 新的双 token(accessToken + refreshToken)
|
||||
*/
|
||||
LoginResponse refresh(String refreshToken);
|
||||
|
||||
/**
|
||||
* 登出,使 access token 和 refresh token 失效
|
||||
*
|
||||
* @param refreshToken 刷新令牌
|
||||
*/
|
||||
void logout(String refreshToken);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.webgame.webgamebackend.service;
|
||||
|
||||
/**
|
||||
* 刷新令牌服务接口,管理 refresh token 的生命周期
|
||||
*/
|
||||
public interface RefreshTokenService {
|
||||
|
||||
/**
|
||||
* 为用户创建新的刷新令牌,存入 Redis,TTL 30 天
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return 生成的 refresh token 字符串
|
||||
*/
|
||||
String create(String userId);
|
||||
|
||||
/**
|
||||
* 校验刷新令牌是否有效
|
||||
*
|
||||
* @param token 刷新令牌
|
||||
* @return 对应的用户ID
|
||||
* @throws com.webgame.webgamebackend.common.exception.BusinessException 令牌无效时抛出
|
||||
*/
|
||||
String validate(String token);
|
||||
|
||||
/**
|
||||
* 轮换刷新令牌:删除旧 token,生成新 token(防重放)
|
||||
*
|
||||
* @param oldToken 旧的刷新令牌
|
||||
* @return 新的刷新令牌
|
||||
* @throws com.webgame.webgamebackend.common.exception.BusinessException 旧令牌无效时抛出
|
||||
*/
|
||||
String rotate(String oldToken);
|
||||
|
||||
/**
|
||||
* 撤销刷新令牌(登出时调用)
|
||||
*
|
||||
* @param token 要撤销的刷新令牌
|
||||
*/
|
||||
void revoke(String token);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.webgame.webgamebackend.service;
|
||||
|
||||
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
|
||||
|
||||
/**
|
||||
* 用户服务接口
|
||||
*/
|
||||
public interface UserService {
|
||||
|
||||
/**
|
||||
* 获取当前登录用户的资料
|
||||
*
|
||||
* @return 用户信息
|
||||
*/
|
||||
UserInfoResponse getCurrentUserInfo();
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.webgame.webgamebackend.service.impl;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
|
||||
import com.webgame.webgamebackend.common.exception.BusinessException;
|
||||
import com.webgame.webgamebackend.common.exception.ErrorCode;
|
||||
import com.webgame.webgamebackend.entities.UserEntity;
|
||||
import com.webgame.webgamebackend.repository.UserRepository;
|
||||
import com.webgame.webgamebackend.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 用户服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Override
|
||||
public UserInfoResponse getCurrentUserInfo() {
|
||||
String accountId = StpUtil.getLoginIdAsString();
|
||||
log.info("[用户服务] 获取当前登录用户信息, accountId: {}", accountId);
|
||||
UserEntity user = userRepository.findByAccountId(accountId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
||||
return UserInfoResponse.from(user);
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,19 @@ spring:
|
||||
# 连接池中的最小空闲连接
|
||||
min-idle: 0
|
||||
|
||||
# SQL 初始化(data.sql)
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
|
||||
# JPA相关配置
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
show-sql: false # 是否输出sql
|
||||
open-in-view: false
|
||||
# JPA 初始化延迟到 data.sql 执行之后,避免表未创建
|
||||
defer-datasource-initialization: true
|
||||
|
||||
# Servlet相关配置
|
||||
servlet:
|
||||
@@ -67,6 +74,15 @@ logging:
|
||||
# #如果是true,则用的是https而不是http,默认值是true
|
||||
# secure: false
|
||||
|
||||
############## 游戏配置 ##############
|
||||
game:
|
||||
# 断线重连超时时间(秒)
|
||||
disconnect-timeout-seconds: 60
|
||||
# 聊天消息最大长度
|
||||
chat-max-length: 500
|
||||
# 五子棋棋盘大小
|
||||
board-size: 15
|
||||
|
||||
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
|
||||
sa-token:
|
||||
# token 名称(同时也是 cookie 名称)
|
||||
|
||||
53
src/main/resources/data.sql
Normal file
53
src/main/resources/data.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- 游戏配置初始化数据
|
||||
-- 使用 INSERT IGNORE 保证幂等,重复执行不会报错
|
||||
|
||||
INSERT IGNORE INTO game_config (id, game_id, icon, name, description, gameplay, max_players, modes, stakes_options, sort_order, enabled, create_time, update_time)
|
||||
VALUES
|
||||
(
|
||||
REPLACE(UUID(), '-', ''),
|
||||
'snake',
|
||||
'🐍',
|
||||
'贪吃蛇',
|
||||
'经典贪吃蛇竞技,在狭小空间内争夺生存权',
|
||||
'方向键控制蛇的移动,吃到食物变长。撞墙或其他蛇的身体即死亡,最后存活的玩家获胜。',
|
||||
4,
|
||||
'["经典模式","道具模式","加速模式"]',
|
||||
'[100,200,300,400,500]',
|
||||
0, 1, NOW(), NOW()
|
||||
),
|
||||
(
|
||||
REPLACE(UUID(), '-', ''),
|
||||
'tetris',
|
||||
'🧊',
|
||||
'俄罗斯方块',
|
||||
'经典下落消除,与对手一较高下',
|
||||
'方向键移动和旋转方块,填满横排即可消除。同时向对手发送干扰行,坚持到最后的玩家获胜。',
|
||||
2,
|
||||
'["1v1对战","竞速模式"]',
|
||||
'[150,200,300,400,500]',
|
||||
1, 1, NOW(), NOW()
|
||||
),
|
||||
(
|
||||
REPLACE(UUID(), '-', ''),
|
||||
'gomoku',
|
||||
'⚫',
|
||||
'五子棋',
|
||||
'黑白对弈,先连成五子者胜',
|
||||
'鼠标点击落子,黑白双方轮流在棋盘上放置棋子。先在横、竖、斜任意方向连成五子的一方获胜。',
|
||||
2,
|
||||
'["无禁手","有禁手"]',
|
||||
'[100,200,300,400,500]',
|
||||
2, 1, NOW(), NOW()
|
||||
),
|
||||
(
|
||||
REPLACE(UUID(), '-', ''),
|
||||
'guess-number',
|
||||
'🔢',
|
||||
'猜数字',
|
||||
'逻辑推理对战,猜中对方的秘密数字',
|
||||
'每轮输入猜测的数字组合,系统会提示位置和数字正确的数量。率先猜中对手数字组合的玩家获胜。',
|
||||
4,
|
||||
'["经典模式","限时模式"]',
|
||||
'[100,150,200,300,500]',
|
||||
3, 1, NOW(), NOW()
|
||||
);
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
BIN
uploads/avatars/642f38fb-f82e-40fd-b4f3-9f8a8fb530ba.jpg
Normal file
BIN
uploads/avatars/642f38fb-f82e-40fd-b4f3-9f8a8fb530ba.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
BIN
uploads/avatars/729bf910-6ca8-43a5-b7d7-5ebecf4db810.jpg
Normal file
BIN
uploads/avatars/729bf910-6ca8-43a5-b7d7-5ebecf4db810.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
BIN
uploads/avatars/9301aa11-cbee-4656-abf9-1d60c41011c6.jpg
Normal file
BIN
uploads/avatars/9301aa11-cbee-4656-abf9-1d60c41011c6.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Reference in New Issue
Block a user