Compare commits

...

46 Commits

Author SHA1 Message Date
3eeb5bbd51 feat: 重构 2026-07-07 09:58:42 +08:00
a5fe28e536 feat: 重构游戏房间架构 2026-06-30 11:54:21 +08:00
0d61038e67 feat: 重构游戏房间架构 2026-06-30 11:27:14 +08:00
c1134f48e0 feat: 重构游戏房间架构 2026-06-30 10:32:29 +08:00
ff284e2841 feat: 重构游戏房间架构 2026-06-30 10:20:26 +08:00
d92f7bb581 feat: sse 2026-06-30 08:52:23 +08:00
aa9a0732d6 feat: sse 2026-06-24 12:42:34 +08:00
ab2a2dbf7f feat: 解耦认证逻辑 2026-06-24 12:17:09 +08:00
5d9f5b8ab7 feat: HTTP状态码 + 业务码 2026-06-24 12:00:17 +08:00
df6c7f6b10 feat: HTTP状态码 + 业务码 2026-06-24 11:14:42 +08:00
61ff5d46e6 fix: sse修改 2026-06-24 10:40:28 +08:00
3580ad934e fix: 暂存 2026-06-24 09:50:01 +08:00
842e5c29f9 fix: 暂存 2026-06-24 08:30:22 +08:00
c5e9a149f4 fix: 暂存 2026-06-23 14:44:07 +08:00
2a311ca25f fix: 暂存 2026-06-23 14:32:19 +08:00
9be461539c fix: 修改房间逻辑 2026-06-23 13:49:15 +08:00
47e586e7f5 feat: satoken修改 2026-06-23 12:53:01 +08:00
15bf6d0531 fix: 代码审查问题修复 — rebuildBoard 参数修正 + 玩家座位号排序 2026-06-23 11:28:27 +08:00
f85e23d545 feat: 添加五子棋对局逻辑服务 2026-06-23 11:07:31 +08:00
6e4eb6120a feat: 添加房间详情 API 2026-06-23 11:05:17 +08:00
5209df46d1 feat: CreateRoomRequest 和 RoomItemResponse 新增 allowSpectate 字段 2026-06-23 11:03:17 +08:00
e72fbff6e6 feat: 添加走棋记录和对局记录 Repository 2026-06-23 11:01:40 +08:00
05520a2b1d feat: 添加走棋记录、对局记录实体,房间新增观战开关 2026-06-23 10:59:49 +08:00
be37d51930 feat: 添加 WebSocket Handler 与会话管理器 2026-06-23 10:55:06 +08:00
6ef11b8d5b feat: 添加 WebSocket 消息 DTO 2026-06-23 10:53:41 +08:00
d0ade3c469 feat: 添加 WebSocket 基础设施 2026-06-23 10:49:23 +08:00
d63ce3326b feat: satoken修改 2026-06-23 10:14:14 +08:00
4c3e3717eb feat: satoken修改 2026-06-22 14:48:14 +08:00
9a2dd66df4 feat: 添加 SSE 测试接口(在线人数查询 + 手动广播事件) 2026-06-22 14:09:31 +08:00
1e281ff8ca test: 添加 SSE 集成测试
验证 SseEventPublisher 的 Bean 注入、broadcast 和 sendTo 方法。
共 3 个测试用例全部通过。
2026-06-22 13:50:02 +08:00
bf09b7f342 feat: GameServiceImpl 集成 SSE 事件发布 2026-06-22 13:45:26 +08:00
399c5f5bd8 feat: 添加 SseController SSE 订阅端点 2026-06-22 13:43:25 +08:00
808dff53d2 feat: 添加 RedisSseListener 和 SseConfig Redis Pub/Sub 配置 2026-06-22 13:42:37 +08:00
42ae5ef1af feat: 添加 SseEventPublisher Redis Pub/Sub 事件发布器 2026-06-22 13:41:36 +08:00
0f240361bb feat: 添加 SseConnectionManager SSE 连接管理器 2026-06-22 13:40:56 +08:00
875f2bc073 feat: 添加 SseEventType 枚举和 SseEvent record 事件模型 2026-06-22 13:40:06 +08:00
2467a5097c feat: 个人信息修改 2026-06-22 12:56:46 +08:00
b7e8b02606 feat: 个人信息修改 2026-06-22 12:31:30 +08:00
6d90c21b44 feat: 游戏房间 2026-06-22 11:19:19 +08:00
4267445256 feat: 游戏房间 2026-06-22 10:09:40 +08:00
b512d50c78 feat: 登出接口 2026-06-17 10:16:39 +08:00
39d4c018e4 fix: 修改刷新token逻辑 2026-06-17 09:23:11 +08:00
b9ed1cd326 feat: token 2026-06-16 18:20:11 +08:00
5026c9b39c feat: 更新json 2026-06-16 17:08:49 +08:00
05ce76ad7a feat: 使用双token逻辑 2026-06-16 16:02:09 +08:00
fe1ffd73b2 feat: 提示词 2026-06-16 15:24:57 +08:00
102 changed files with 5929 additions and 767 deletions

576
docs/openapi.json Normal file
View File

@@ -0,0 +1,576 @@
{
"openapi": "3.0.3",
"info": {
"title": "WebGame 后端接口文档",
"version": "1.0.0",
"description": "WebGame 游戏后端 REST API 接口文档。认证采用双 Token 机制Access Token30 分钟有效,每次请求携带) + Refresh Token30 天有效,仅用于刷新 Access Token。"
},
"servers": [
{
"url": "http://localhost:8080",
"description": "本地开发环境"
}
],
"tags": [
{ "name": "账号", "description": "账号相关接口(登录、注册、刷新令牌,无需认证)" },
{ "name": "用户", "description": "用户相关接口(需 Access Token 认证)" }
],
"paths": {
"/account/login": {
"post": {
"tags": ["账号"],
"summary": "用户登录",
"description": "使用用户名和密码进行登录认证,成功返回 Access Token 和 Refresh Token。该接口无需登录即可调用。",
"operationId": "login",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/LoginRequest" },
"example": {
"username": "testuser",
"password": "123456"
}
}
}
},
"responses": {
"200": {
"description": "登录成功,返回双 Token",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponse" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/LoginResponse" }
}
}
]
},
"example": {
"code": 0,
"message": "success",
"data": {
"accessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" },
"500": { "$ref": "#/components/responses/InternalError" }
}
}
},
"/account/register": {
"post": {
"tags": ["账号"],
"summary": "用户注册",
"description": "注册新账号。用户名长度 3-32 位,密码长度 6-64 位。注册成功后自动登录并返回双 Token。该接口无需登录即可调用。",
"operationId": "register",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/RegisterRequest" },
"example": {
"username": "newuser",
"password": "abc123"
}
}
}
},
"responses": {
"200": {
"description": "注册成功,自动登录返回双 Token",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponse" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/LoginResponse" }
}
}
]
},
"example": {
"code": 0,
"message": "success",
"data": {
"accessToken": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" },
"500": { "$ref": "#/components/responses/InternalError" }
}
}
},
"/account/refresh": {
"post": {
"tags": ["账号"],
"summary": "刷新 Access Token",
"description": "当 Access Token 过期30 分钟)后,使用 Refresh Token 换取新的 Access Token。Refresh Token 保持不变,仅在满 30 天或重新登录时更新。该接口无需登录即可调用。",
"operationId": "refresh",
"security": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/RefreshTokenRequest" },
"example": {
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
}
},
"responses": {
"200": {
"description": "刷新成功,返回新的 Access Token 和原 Refresh Token",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponse" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/LoginResponse" }
}
}
]
},
"example": {
"code": 0,
"message": "success",
"data": {
"accessToken": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
"refreshToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" },
"500": { "$ref": "#/components/responses/InternalError" }
}
}
},
"/user/info": {
"get": {
"tags": ["用户"],
"summary": "获取当前登录用户信息",
"description": "根据请求头中的 saTokenAccess Token获取当前登录用户的详细信息包括昵称、头像、个性签名、年龄、性别、生日。需登录认证。",
"operationId": "getUserInfo",
"security": [{ "saToken": [] }],
"responses": {
"200": {
"description": "查询成功,返回用户信息",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/ApiResponse" },
{
"type": "object",
"properties": {
"data": { "$ref": "#/components/schemas/UserInfoResponse" }
}
}
]
},
"example": {
"code": 0,
"message": "success",
"data": {
"nickName": "玩家001",
"avatar": "https://cdn.example.com/avatars/default.png",
"signature": "这个人很懒,什么都没写",
"age": 25,
"sex": 1,
"birthday": "2001-01-01"
}
}
}
}
},
"401": { "$ref": "#/components/responses/Unauthorized" },
"500": { "$ref": "#/components/responses/InternalError" }
}
}
}
},
"components": {
"securitySchemes": {
"saToken": {
"type": "apiKey",
"in": "header",
"name": "saToken",
"description": "Access Token登录/注册/刷新接口返回的 accessToken 值)。有效期 30 分钟,过期后使用 /account/refresh 接口获取新 Token。"
}
},
"schemas": {
"ApiResponse": {
"type": "object",
"description": "统一 API 响应信封。所有接口均使用此格式返回。",
"required": ["code", "message", "data"],
"properties": {
"code": {
"type": "integer",
"format": "int32",
"description": "业务状态码。0 表示成功,非 0 表示失败。详细错误码见 ErrorCode 枚举。"
},
"message": {
"type": "string",
"description": "响应消息。成功时固定为 \"success\",失败时为具体错误描述。"
},
"data": {
"nullable": true,
"description": "响应数据负载。成功时携带对应的业务数据对象,失败时为 null。"
}
}
},
"LoginRequest": {
"type": "object",
"description": "登录请求体",
"required": ["username", "password"],
"properties": {
"username": {
"type": "string",
"description": "用户名,不能为空",
"minLength": 1,
"example": "testuser"
},
"password": {
"type": "string",
"format": "password",
"description": "密码,不能为空",
"minLength": 1,
"example": "123456"
}
}
},
"RegisterRequest": {
"type": "object",
"description": "注册请求体",
"required": ["username", "password"],
"properties": {
"username": {
"type": "string",
"description": "用户名,长度限制 3-32 位,不能为空",
"minLength": 3,
"maxLength": 32,
"example": "newuser"
},
"password": {
"type": "string",
"format": "password",
"description": "密码,长度限制 6-64 位,不能为空",
"minLength": 6,
"maxLength": 64,
"example": "abc123"
}
}
},
"RefreshTokenRequest": {
"type": "object",
"description": "刷新令牌请求体",
"required": ["refreshToken"],
"properties": {
"refreshToken": {
"type": "string",
"description": "刷新令牌,登录/注册时返回的 refreshToken 值,不能为空",
"minLength": 1,
"example": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
},
"LoginResponse": {
"type": "object",
"description": "登录、注册或刷新令牌成功时返回的认证令牌对",
"required": ["accessToken", "refreshToken"],
"properties": {
"accessToken": {
"type": "string",
"description": "Access TokenUUID 格式),有效期 30 分钟。后续请求需在 Header 中通过 saToken 字段携带此值。过期后使用 /account/refresh 接口刷新。",
"example": "12345678-1234-1234-1234-123456789abc"
},
"refreshToken": {
"type": "string",
"description": "Refresh Token64 位十六进制随机字符串),有效期 30 天。仅用于 /account/refresh 接口换取新的 Access Token。刷新时不变重新登录时替换。",
"example": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
}
},
"UserInfoResponse": {
"type": "object",
"description": "当前登录用户的详细信息",
"properties": {
"nickName": {
"type": "string",
"description": "用户昵称",
"maxLength": 16,
"example": "玩家001"
},
"avatar": {
"type": "string",
"description": "头像图片 URL 地址,可能为 null",
"maxLength": 512,
"nullable": true,
"example": "https://cdn.example.com/avatars/default.png"
},
"signature": {
"type": "string",
"description": "个性签名,可能为 null",
"maxLength": 64,
"nullable": true,
"example": "这个人很懒,什么都没写"
},
"age": {
"type": "integer",
"format": "int32",
"description": "年龄,可能为 null",
"nullable": true,
"example": 25
},
"sex": {
"type": "integer",
"format": "int32",
"description": "性别。1=男2=女,可能为 null",
"nullable": true,
"enum": [1, 2],
"x-enum-descriptions": {
"1": "男",
"2": "女"
},
"example": 1
},
"birthday": {
"type": "string",
"format": "date",
"description": "生日ISO 8601 日期格式),可能为 null",
"nullable": true,
"example": "2001-01-01"
}
}
},
"ErrorResponse": {
"type": "object",
"description": "错误响应体模板。code 为具体错误码message 为错误描述data 恒为 null。",
"required": ["code", "message", "data"],
"properties": {
"code": {
"type": "integer",
"format": "int32",
"description": "错误码,详见 ErrorCode 枚举表"
},
"message": {
"type": "string",
"description": "人类可读的错误描述信息"
},
"data": {
"type": "null",
"description": "错误时恒为 null"
}
}
}
},
"responses": {
"BadRequest": {
"description": "请求参数校验失败或业务逻辑错误HTTP 200 但 code ≠ 0",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"examples": {
"validation": {
"summary": "参数校验失败",
"value": {
"code": 400,
"message": "username 用户名不能为空; password 密码长度6-64位",
"data": null
}
},
"usernameExists": {
"summary": "用户名已存在",
"value": {
"code": 1001,
"message": "用户名已存在",
"data": null
}
},
"badCredentials": {
"summary": "用户名或密码错误",
"value": {
"code": 1002,
"message": "用户名或密码错误",
"data": null
}
},
"refreshTokenInvalid": {
"summary": "刷新令牌无效或已过期",
"value": {
"code": 1003,
"message": "刷新令牌无效或已过期",
"data": null
}
},
"refreshTokenMissing": {
"summary": "缺少刷新令牌",
"value": {
"code": 1004,
"message": "缺少刷新令牌",
"data": null
}
},
"userNotFound": {
"summary": "用户不存在",
"value": {
"code": 2001,
"message": "用户不存在",
"data": null
}
}
}
}
}
},
"Unauthorized": {
"description": "未登录或 Access Token 无效/过期HTTP 200 但 code=401",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"example": {
"code": 401,
"message": "请先登录",
"data": null
}
}
}
},
"Forbidden": {
"description": "无权限或无角色访问HTTP 200 但 code=403",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"example": {
"code": 403,
"message": "无权限",
"data": null
}
}
}
},
"NotFound": {
"description": "请求的资源不存在",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"example": {
"code": 404,
"message": "资源不存在",
"data": null
}
}
}
},
"MethodNotAllowed": {
"description": "请求方法不支持(如 GET 访问 POST 接口)",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"example": {
"code": 405,
"message": "请求方法不支持",
"data": null
}
}
}
},
"InternalError": {
"description": "服务器内部错误HTTP 200 但 code=5000",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" },
"example": {
"code": 5000,
"message": "服务器内部错误",
"data": null
}
}
}
}
},
"x-enums": {
"ErrorCode": {
"description": "业务错误码枚举com.webgame.webgamebackend.common.exception.ErrorCode",
"values": {
"SUCCESS": {
"code": 0,
"message": "success",
"description": "操作成功"
},
"BAD_REQUEST": {
"code": 400,
"message": "参数校验失败",
"description": "请求参数不符合校验规则"
},
"USERNAME_EXISTS": {
"code": 1001,
"message": "用户名已存在",
"description": "注册时用户名已被占用"
},
"BAD_CREDENTIALS": {
"code": 1002,
"message": "用户名或密码错误",
"description": "登录时用户名或密码不匹配"
},
"REFRESH_TOKEN_INVALID": {
"code": 1003,
"message": "刷新令牌无效或已过期",
"description": "Refresh Token 不存在或已过期30 天),需重新登录"
},
"REFRESH_TOKEN_MISSING": {
"code": 1004,
"message": "缺少刷新令牌",
"description": "请求体中的 refreshToken 字段为空"
},
"USER_NOT_FOUND": {
"code": 2001,
"message": "用户不存在",
"description": "查询的用户记录不存在"
},
"INTERNAL_ERROR": {
"code": 5000,
"message": "服务器内部错误",
"description": "未预期的服务器异常"
}
}
},
"Sex": {
"description": "性别枚举UserEntity.sex 字段)",
"values": {
"MALE": {
"code": 1,
"label": "男"
},
"FEMALE": {
"code": 2,
"label": "女"
}
}
}
}
}
}

8
docs/prompt.txt Normal file
View File

@@ -0,0 +1,8 @@
--- api接口文档提示词 ---
你现在是后端开发,梳理当前项目全部业务接口,输出标准 OpenAPI 3.0 完整JSON文档要求
1. 包含全部GET/POST/PUT/DELETE接口
2. 区分路径参数、查询参数、Body请求体
3. 所有字段标注类型、是否必填、中文注释;
4. 包含成功返回体、500/401/400错误返回结构
5. 枚举值完整列出;
只输出JSON文本不要多余解释。

View File

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

View File

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

View File

@@ -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);
}
}
}

View File

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

View File

@@ -0,0 +1,142 @@
package com.webgame.webgamebackend.adapter.sse;
import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.event.SseEventPublisher;
import com.webgame.webgamebackend.common.event.SseEventType;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
/**
* SSE 订阅端点
*
* 提供 GET /sse/subscribe 端点建立持久化 SSE 连接。
* 心跳由 SseConnectionManager 统一批量管理,此处不再维护 per-connection 定时器。
*/
@Slf4j
@RestController
@RequestMapping("/sse")
@RequiredArgsConstructor
public class SseController {
/** SseEmitter 超时时间30 分钟),超时后由 SseConnectionManager 的 onTimeout 回调清理 */
private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L;
private final SseConnectionManager connectionManager;
private final AuthenticationProvider authProvider;
private final SseEventPublisher eventPublisher;
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId,
HttpServletResponse response) throws IOException {
// SSE 路径已排除在 AuthInterceptor 之外,此处手动校验登录态
String token = authProvider.getCurrentToken();
if (token == null || token.isBlank()) {
log.warn("[SSE] 缺少认证 token");
return writeAuthError(response, HttpServletResponse.SC_UNAUTHORIZED, "缺少认证信息,请先登录");
}
String userId = authProvider.getUserIdByToken(token);
if (userId == null) {
log.warn("[SSE] token 无效或已过期");
return writeAuthError(response, HttpServletResponse.SC_UNAUTHORIZED, "登录已过期,请重新登录");
}
// 设置认证上下文,后续 getCurrentUserId() 可用
authProvider.setContextToken(token, -1);
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
String connectionId = connectionManager.register(userId, emitter);
// 发送初始连接确认
try {
emitter.send(SseEmitter.event()
.name("connected")
.data("{\"message\":\"连接成功\"}"));
} catch (IOException | IllegalStateException e) {
log.warn("[SSE] 发送 connected 事件失败, userId={}, connectionId={}",
userId, connectionId, e);
connectionManager.remove(userId, connectionId);
}
if (lastEventId != null && !lastEventId.isBlank()) {
log.debug("[SSE] 客户端重连, Last-Event-ID={}, userId={}, connectionId={}",
lastEventId, userId, connectionId);
}
return emitter;
}
/**
* 测试用消息发布接口
*
* 支持广播(不传 userId和定向推送传 userId两种模式。
* 可传入任意 JSON 数据作为消息体,事件类型固定为 CUSTOM。
*
* 请求体示例:
* <pre>
* // 广播
* { "data": { "msg": "hello" } }
*
* // 定向推送
* { "userId": "123", "data": { "msg": "hello" } }
* </pre>
*
* @param body 请求体,包含可选的 userId 和必填的 data
* @return 统一 API 响应
*/
@PostMapping("/publish")
public ApiResponse<Map<String, Object>> publish(@RequestBody Map<String, Object> body) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) body.get("data");
if (data == null || data.isEmpty()) {
return ApiResponse.fail(400, "data 字段不能为空");
}
String userId = body.get("userId") != null ? body.get("userId").toString() : null;
if (userId != null && !userId.isBlank()) {
eventPublisher.sendTo(userId, SseEventType.CUSTOM, data);
log.info("[SSE] 测试定向推送, userId={}, data={}", userId, data);
} else {
eventPublisher.broadcast(SseEventType.CUSTOM, data);
log.info("[SSE] 测试广播推送, data={}", data);
}
return ApiResponse.ok(Map.of(
"mode", userId != null && !userId.isBlank() ? "targeted" : "broadcast",
"userId", userId != null ? userId : "all",
"data", data
));
}
/**
* 向 SSE 客户端返回认证错误
*
* 直接写入 HttpServletResponse 返回 401 + SSE error 事件,
* 前端 SseClient.onopen 检测到 response.status === 401 后触发 onUnauthorized
* 自动刷新 token 并重连。
*
* @return null通知 Spring MVC 响应已处理完毕,无需 MessageConverter
*/
private SseEmitter writeAuthError(HttpServletResponse response, int status, String message) throws IOException {
response.setStatus(status);
response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
response.getWriter().write("event: error\ndata: {\"code\":" + status + ",\"message\":\"" + message + "\"}\n\n");
response.getWriter().flush();
return null;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}
}

View File

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

View File

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

View File

@@ -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";
}

View File

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

View File

@@ -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";
}

View File

@@ -1,11 +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.ApiResponse;
import com.webgame.webgamebackend.common.dto.account.LoginRequest; import com.webgame.webgamebackend.common.dto.account.LoginRequest;
import com.webgame.webgamebackend.common.dto.account.LoginResponse; 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.common.dto.account.RegisterRequest;
import com.webgame.webgamebackend.service.AccountService; import com.webgame.webgamebackend.domain.account.AccountService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -28,7 +29,6 @@ public class AccountController {
/** /**
* 登录 * 登录
*/ */
@SaIgnore
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) { public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
LoginResponse result = accountService.login(request); LoginResponse result = accountService.login(request);
@@ -38,10 +38,27 @@ public class AccountController {
/** /**
* 注册 * 注册
*/ */
@SaIgnore
@PostMapping("/register") @PostMapping("/register")
public ApiResponse<LoginResponse> register(@Valid @RequestBody RegisterRequest request) { public ApiResponse<LoginResponse> register(@Valid @RequestBody RegisterRequest request) {
LoginResponse result = accountService.register(request); LoginResponse result = accountService.register(request);
return ApiResponse.ok(result); return ApiResponse.ok(result);
} }
/**
* 刷新令牌 refresh token 换取新的 access token refresh token
*/
@PostMapping("/refresh")
public ApiResponse<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
LoginResponse result = accountService.refresh(request.refreshToken());
return ApiResponse.ok(result);
}
/**
* 登出使 access token refresh token 失效
*/
@PostMapping("/logout")
public ApiResponse<Void> logout(@Valid @RequestBody LogoutRequest request) {
accountService.logout(request.refreshToken());
return ApiResponse.ok(null);
}
} }

View File

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

View File

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

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,27 +0,0 @@
package com.webgame.webgamebackend.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import java.time.Duration;
@Configuration
public class RedisConfigurer {
/**
* 创建并配置 Redis 消息监听容器
* 用于实现 Redis 的发布/订阅功能,支持监听特定频道的消息
*
* @param redisConnectionFactory Redis 连接工厂
* @return 配置好的 RedisMessageListenerContainer 实例
*/
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
return container;
}
}

View File

@@ -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();
};
}
}

View File

@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;");
}
}

View File

@@ -11,7 +11,7 @@ public record ApiResponse<T>(int code, String message, T data) {
* 成功响应 * 成功响应
*/ */
public static <T> ApiResponse<T> ok(T data) { public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(0, "success", data); return new ApiResponse<>(0, "成功", data);
} }
/** /**

View File

@@ -1,7 +1,7 @@
package com.webgame.webgamebackend.common.dto.account; package com.webgame.webgamebackend.common.dto.account;
/** /**
* 登录/注册响应 * 登录/注册/刷新响应,包含 accessToken 和 refreshToken
*/ */
public record LoginResponse(String token) { public record LoginResponse(String accessToken, String refreshToken) {
} }

View File

@@ -0,0 +1,15 @@
package com.webgame.webgamebackend.common.dto.account;
import com.webgame.webgamebackend.common.dto.base.BaseVo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* 登出请求
*/
public record LogoutRequest(
@NotNull(message = "刷新令牌不能为null")
@NotBlank(message = "刷新令牌不能为空")
String refreshToken
) implements BaseVo {
}

View File

@@ -0,0 +1,15 @@
package com.webgame.webgamebackend.common.dto.account;
import com.webgame.webgamebackend.common.dto.base.BaseVo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* 刷新令牌请求
*/
public record RefreshTokenRequest(
@NotNull(message = "刷新令牌不能为null")
@NotBlank(message = "刷新令牌不能为空")
String refreshToken
) implements BaseVo {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,6 @@
package com.webgame.webgamebackend.common.dto.game;
/**
* 创建房间响应
*/
public record CreateRoomResponse(RoomItemResponse room) {}

View File

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

View File

@@ -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
) {}

View File

@@ -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)
);
}
}

View File

@@ -0,0 +1,8 @@
package com.webgame.webgamebackend.common.dto.game;
import java.util.List;
/**
* 游戏列表响应
*/
public record GameListResponse(List<GameItemResponse> list) {}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

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

View File

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

View File

@@ -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,
/** 当前轮到哪方落子(黑方/白方的 userIdnull 表示对局未开始 */
String currentTurn,
/** 走棋记录(对局中才有) */
List<MoveItem> moves
) {
/**
* 带座位号和准备状态的玩家信息
*/
public record RoomPlayerDetail(
String userId,
String avatar,
String nickname,
int seatNumber,
boolean ready
) {}
/**
* 走棋记录项
*/
public record MoveItem(
int moveIndex,
String playerId,
int row,
int col
) {}
}

View File

@@ -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
);
}
}

View File

@@ -0,0 +1,8 @@
package com.webgame.webgamebackend.common.dto.game;
import java.util.List;
/**
* 房间列表响应
*/
public record RoomListResponse(List<RoomItemResponse> list) {}

View File

@@ -0,0 +1,9 @@
package com.webgame.webgamebackend.common.dto.game;
/**
* 房间内玩家信息,对应前端 RoomPlayer
*/
public record RoomPlayerResponse(
String avatar,
String nickname
) {}

View File

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

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.common.dto.user; 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; import java.time.LocalDate;
@@ -8,24 +8,28 @@ import java.time.LocalDate;
* 用户信息响应 * 用户信息响应
*/ */
public record UserInfoResponse( public record UserInfoResponse(
String userId,
String nickName, String nickName,
String avatar, String avatar,
String signature, String signature,
Integer age, Integer age,
Integer sex, Integer sex,
LocalDate birthday LocalDate birthday,
String status
) { ) {
/** /**
* 从实体转换 * 从实体转换
*/ */
public static UserInfoResponse from(UserEntity user) { public static UserInfoResponse from(UserEntity user) {
return new UserInfoResponse( return new UserInfoResponse(
user.getAccount().getId(),
user.getNickName(), user.getNickName(),
user.getAvatar(), user.getAvatar(),
user.getSignature(), user.getSignature(),
user.getAge(), user.getAge(),
user.getSex(), user.getSex(),
user.getBirthday() user.getBirthday(),
user.getStatus()
); );
} }
} }

View File

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

View File

@@ -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 目标账号 IDnull 表示广播
* @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);
}
}

View File

@@ -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, "事件推送失败,请重试");
}
}
}

View File

@@ -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
}

View File

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

View File

@@ -3,29 +3,56 @@ package com.webgame.webgamebackend.common.exception;
import lombok.Getter; import lombok.Getter;
/** /**
* 业务错误码枚举,统一管理所有错误码默认消息 * 业务错误码枚举,统一管理所有错误码默认消息和对应的 HTTP 状态码
* <p>
* HTTP 状态码表达错误大类协议层code 表达具体业务错误(应用层),
* 两者分工协作形成双层错误表达模式。
*/ */
@Getter @Getter
public enum ErrorCode { public enum ErrorCode {
// ========== 通用 ========== // ========== 通用 ==========
SUCCESS(0, "success"), SUCCESS(0, "成功", 200),
BAD_REQUEST(400, "参数校验失败"), BAD_REQUEST(400, "参数校验失败", 400),
INTERNAL_ERROR(5000, "服务器内部错误"), INTERNAL_ERROR(5000, "服务器内部错误", 500),
// ========== 账号模块 ========== // ========== 账号模块 ==========
USERNAME_EXISTS(1001, "用户名已存在"), USERNAME_EXISTS(1001, "用户名已存在", 409),
BAD_CREDENTIALS(1002, "用户名或密码错误"), 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 int code;
private final String defaultMessage; private final String defaultMessage;
private final int httpStatus;
ErrorCode(int code, String defaultMessage) { ErrorCode(int code, String defaultMessage, int httpStatus) {
this.code = code; this.code = code;
this.defaultMessage = defaultMessage; this.defaultMessage = defaultMessage;
this.httpStatus = httpStatus;
} }
} }

View File

@@ -1,127 +1,170 @@
package com.webgame.webgamebackend.common.handler; package com.webgame.webgamebackend.common.handler;
import cn.dev33.satoken.exception.NotLoginException; import com.alibaba.fastjson2.JSON;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import com.webgame.webgamebackend.common.dto.ApiResponse; import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.exception.BusinessException; import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode; import com.webgame.webgamebackend.common.exception.ErrorCode;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; 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.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; 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.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.resource.NoResourceFoundException; 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 @Slf4j
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { 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) @ExceptionHandler(BusinessException.class)
public ApiResponse<Void> handleBusinessException(BusinessException ex) { public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage()); log.warn("业务异常: code={}, httpStatus={}, message={}", ex.getCode(), ex.getHttpStatus(), ex.getMessage());
return ApiResponse.fail(ex.getCode(), ex.getMessage()); return ResponseEntity
.status(ex.getHttpStatus())
.body(ApiResponse.fail(ex.getCode(), ex.getMessage()));
} }
// ==================== 参数校验异常 ==================== // ==================== 参数校验异常 → HTTP 400 ====================
/**
* 处理 @Valid 参数校验失败,合并所有字段的错误消息
*/
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleValidation(MethodArgumentNotValidException ex) { public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream() String message = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + " " + e.getDefaultMessage()) .map(error -> error.getField() + " " + error.getDefaultMessage())
.reduce((a, b) -> a + "; " + b) .reduce((a, b) -> a + "; " + b)
.orElse(ErrorCode.BAD_REQUEST.getDefaultMessage()); .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) @ExceptionHandler(HttpMessageNotReadableException.class)
public ApiResponse<Void> handleMessageNotReadable(HttpMessageNotReadableException ex) { public ResponseEntity<ApiResponse<Void>> handleMessageNotReadable(HttpMessageNotReadableException ex) {
log.warn("请求体解析失败: {}", ex.getMessage()); log.warn("请求体解析失败: {}", ex.getMessage());
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式错误"); return ResponseEntity
.badRequest()
.body(ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式无效"));
} }
/**
* 路径参数类型不匹配
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class) @ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ApiResponse<Void> handleTypeMismatch(MethodArgumentTypeMismatchException ex) { public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
log.warn("参数类型不匹配: {}", ex.getMessage()); log.warn("参数类型不匹配: {}", ex.getMessage());
return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型错误"); return ResponseEntity
.badRequest()
.body(ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型无效"));
} }
/** @ExceptionHandler(MaxUploadSizeExceededException.class)
* 资源不存在404 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) @ExceptionHandler(NoResourceFoundException.class)
public ApiResponse<Void> handleNoResourceFound(NoResourceFoundException ex) { public void handleNoResourceFound(NoResourceFoundException ex, HttpServletResponse response) throws IOException {
log.warn("资源不存在: {}", ex.getMessage()); log.warn("资源未找到: {}", ex.getMessage());
return ApiResponse.fail(404, "资源不存在"); 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) private static void writeJsonError(HttpServletResponse response, HttpStatus status,
public ApiResponse<Void> handleException(Exception ex) { int code, String message) throws IOException {
log.error("服务器内部错误", ex); response.setStatus(status.value());
return ApiResponse.fail(ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getDefaultMessage()); response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(JSON.toJSONString(ApiResponse.fail(code, message)));
} }
} }

View File

@@ -1,294 +0,0 @@
package com.webgame.webgamebackend.common.utils;
import com.alibaba.fastjson2.JSON;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* Redis缓存工具类
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisCacheUtil {
/**
* Redis缓存前缀
*/
private final static String CACHE_KEY_PREFIX = "xzg:";
/**
* Redis操作类
*/
private final RedisTemplate redisTemplate;
/**
* 将值加入缓存
*
* @param k 键
* @param v 值
*
* @return 是否成功
*/
public Boolean cacheValue(String k, Object v) {
return cacheValue(k.startsWith(":") ? k.substring(1) : k, v, -1);
}
/**
* 将值加入缓存,并添加过期时间。
*
* @param k 键
* @param v 值
* @param expireTime 过期时间(毫秒)
*
* @return 是否成功
*/
public Boolean cacheValue(String k, Object v, long expireTime) {
return cacheValue(k.startsWith(":") ? k.substring(1) : k, v, expireTime, TimeUnit.MILLISECONDS);
}
/**
* 将值加入缓存,并添加过期时间。
*
* @param k 键
* @param v 值
* @param expireTime 过期时间
* @param expireUnit 过期时间单位
*
* @return 是否成功
*/
public Boolean cacheValue(String k, Object v, long expireTime, TimeUnit expireUnit) {
if (k.startsWith(":")) k = k.substring(1);
var key = CACHE_KEY_PREFIX + k;
var json = "";
try {
json = JSON.toJSONString(v);
} catch (Exception e) {
log.error("写入缓存失败, 键: {}, 原因: {}", key, e.getMessage(), e);
return false;
}
try {
var valueOps = redisTemplate.opsForValue();
valueOps.set(key, json);
if (expireTime > 0) redisTemplate.expire(key, expireTime, expireUnit);
return true;
} catch (Exception e) {
log.error("写入缓存失败, 键: {}, 值: {}, 原因: {}", key, json, e.getMessage(), e);
}
return false;
}
/**
* 将值加入缓存,并添加过期时间
*
* @param prefix 键组前缀
* @param k 键
* @param v 值
* @param expireTime 过期时间(毫秒)
*
* @return 是否成功
*/
public Boolean cacheValue(String prefix, String k, Object v, long expireTime) {
if (!prefix.endsWith(":")) prefix = prefix + ":";
if (k.startsWith(":")) k = k.substring(1);
return cacheValue(prefix + k, v, expireTime);
}
/**
* 将值加入缓存,并添加过期时间。
*
* @param prefix 键组前缀
* @param k 键
* @param v 值
* @param expireTime 过期时间(毫秒)
*
* @return 是否成功
*/
public Boolean cacheValue(String prefix, Long k, Object v, long expireTime) {
if (prefix.startsWith(":")) prefix = prefix.substring(1);
if (!prefix.endsWith(":")) prefix = prefix + ":";
return cacheValue(prefix + k, v, expireTime);
}
/**
* 查询缓存
*
* @param k 键
* @param cls 序列化类型
* @param expireTime 查询后更新过期时间(毫秒)
* @param <T> 序列化类型
*
* @return 查询结果
*/
public <T> T getCache(String k, Class<T> cls, long expireTime) {
return getCache(k.startsWith(":") ? k.substring(1) : k, cls, expireTime, TimeUnit.MILLISECONDS);
}
/**
* 查询缓存
*
* @param k 键
* @param cls 序列化类型
* @param expireTime 查询后更新过期时间
* @param expireUnit 时间单位
* @param <T> 序列化类型
*
* @return 查询结果
*/
public <T> T getCache(String k, Class<T> cls, long expireTime, TimeUnit expireUnit) {
if (k.startsWith(":")) k = k.substring(1);
var key = CACHE_KEY_PREFIX + k;
try {
// 由于 ValueOperations 的 getAndExpire 查询命令 GETEX
// 在 Redis 6.2 以下版本不支持, 由于可能服务器环境使用 WindowsServer系统
// 安装的Redis版本小于6.2版本,所以这里使用 get + expire。
var valueOps = redisTemplate.opsForValue();
// 查询缓存
var json = valueOps.get(key);
if (json == null) return null;
// 更新过期时间
redisTemplate.expire(key, expireTime, expireUnit);
// 返回结果
return JSON.to(cls, json);
} catch (Exception e) {
log.error("查询缓存失败, 键: {}", key, e);
}
return null;
}
/**
* 查询缓存
*
* @param prefix 键组前缀
* @param k 键
* @param cls 序列化类型
* @param expireTime 查询后更新过期时间(毫秒)
* @param <T> 序列化类型
*
* @return 查询结果
*/
public <T> T getCache(String prefix, String k, Class<T> cls, long expireTime) {
if (prefix.startsWith(":")) prefix = prefix.substring(1);
if (!prefix.endsWith(":")) prefix = prefix + ":";
if (k.startsWith(":")) k = k.substring(1);
return getCache(prefix + k, cls, expireTime, TimeUnit.MILLISECONDS);
}
/**
* 查询缓存
*
* @param prefix 键组前缀
* @param k 键
* @param cls 序列化类型
* @param expireTime 查询后更新过期时间(毫秒)
* @param <T> 序列化类型
*
* @return 查询结果
*/
public <T> T getCache(String prefix, Long k, Class<T> cls, long expireTime) {
if (prefix.startsWith(":")) prefix = prefix.substring(1);
if (!prefix.endsWith(":")) prefix = prefix + ":";
return getCache(prefix + k, cls, expireTime, TimeUnit.MILLISECONDS);
}
/**
* 查询缓存
*
* @param k 键
* @param cls 序列化类型
* @param <T> 序列化类型
*
* @return 查询结果
*/
public <T> T getCache(String k, Class<T> cls) {
return getCache(k.startsWith(":") ? k.substring(1) : k, cls, false);
}
/**
* 查询缓存
*
* @param k 键
* @param cls 序列化类型
* @param remove 查询后是否移除
* @param <T> 序列化类型
*
* @return 查询结果
*/
public <T> T getCache(String k, Class<T> cls, Boolean remove) {
if (k.startsWith(":")) k = k.substring(1);
var key = CACHE_KEY_PREFIX + k;
try {
var valueOps = redisTemplate.opsForValue();
var json = remove ? valueOps.getAndDelete(key) : valueOps.get(key);
if (json == null) return null;
return JSON.to(cls, json);
} catch (Exception e) {
log.error("查询缓存失败, 键: {}", key, e);
}
return null;
}
/**
* 移除缓存
*
* @param prefix 键组前缀
* @param k 键
*
* @return 是否成功
*/
public Boolean removeCache(String prefix, Long k) {
if (prefix.startsWith(":")) prefix = prefix.substring(1);
if (!prefix.endsWith(":")) prefix = prefix + ":";
return removeCache(prefix + k);
}
/**
* 移除缓存
*
* @param k 键
*
* @return 是否成功
*/
public Boolean removeCache(String k) {
if (k.startsWith(":")) k = k.substring(1);
var key = CACHE_KEY_PREFIX + k;
try {
if (key.endsWith(":*")) {
var keys = redisTemplate.keys(key);
if (!keys.isEmpty())
redisTemplate.delete(keys);
return true;
} else return redisTemplate.delete(key);
} catch (Exception e) {
log.error("移除缓存失败, 键: {}", key, e);
}
return false;
}
/**
* 移除缓存
*
* @param prefix 键组前缀
* @param k 键
*
* @return 是否成功
*/
public Boolean removeCache(String prefix, String k) {
if (prefix.startsWith(":")) prefix = prefix.substring(1);
if (!prefix.endsWith(":")) prefix = prefix + ":";
if (k.startsWith(":")) k = k.substring(1);
return removeCache(prefix + k);
}
}

View File

@@ -1,90 +0,0 @@
package com.webgame.webgamebackend.common.utils;
import com.alibaba.fastjson2.JSON;
import io.micrometer.common.util.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.stereotype.Component;
import java.util.function.Consumer;
/**
* Redis消息队列工具类
* @author xzg
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisMqUtil {
private final RedisTemplate<String, String> redisTemplate;
private final RedisMessageListenerContainer redisMessageListenerContainer;
public void pub(String channel, Object message) {
try {
if (StringUtils.isBlank(channel)) {
log.warn("[REDIS消息队列] 发布信息失败: 通道名称为 null");
return;
}
if (message == null) {
log.warn("[REDIS消息队列] 发布信息至通道({})失败: 信息体为 null", channel);
return;
}
// 信息体转JSON
var json = "";
try {
json = JSON.toJSONString(message);
} catch (Exception e) {
log.warn("[REDIS消息队列] 发布信息至通道({})异常: 信息实体无法正确的序列化为JSON字符串, {}", channel, e.getMessage(), e);
return;
}
redisTemplate.convertAndSend(channel, json);
} catch (Exception e) {
log.warn("[REDIS消息队列] 发布信息至通道({})异常: {}", channel, e.getMessage(), e);
}
}
public <T> void sub(String channel, Class<T> clazz, Consumer<T> callback) {
if (StringUtils.isBlank(channel)) {
log.warn("[REDIS消息队列] 订阅通道失败, 通道名称不可为空");
return;
}
if (callback == null) {
log.warn("[REDIS消息队列] 订阅通道({})失败, 回调方法不可为空", channel);
return;
}
try {
var adapter = new MessageListenerAdapter((MessageListener) (message, pattern) -> {
var body = message.getBody();
var channelName = new String(message.getChannel());
T dto;
try {
dto = JSON.parseObject(new String(body), clazz);
} catch (Exception e) {
log.error("[REDIS消息队列] 消费通道({})信息实体类型转换处理异常: {}, 信息: {}", channelName, e.getMessage(), body, e);
return;
}
try {
callback.accept(dto);
} catch (Exception e) {
log.error("[REDIS消息队列] 消费通道({})信息回调处理异常: {}, 信息: {}", channelName, e.getMessage(), body, e);
}
});
redisMessageListenerContainer.addMessageListener(adapter, new PatternTopic(channel));
} catch (Exception e) {
log.error("[REDIS消息队列] 订阅通道({})异常: {}", channel, e.getMessage(), e);
return;
}
log.info("[REDIS消息队列] 订阅通道({})成功", channel);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,106 @@
package com.webgame.webgamebackend.domain.account;
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.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;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.ThreadLocalRandom;
/**
* 账号服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService;
private final AuthenticationProvider authProvider;
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);
}
// 登录,生成 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);
}
@Transactional
public LoginResponse register(RegisterRequest request) {
// 检查用户名是否已存在
if (accountRepository.existsByUsername(request.username())) {
throw new BusinessException(ErrorCode.USERNAME_EXISTS);
}
// 创建账号
AccountEntity account = new AccountEntity();
account.setUsername(request.username());
account.setPassword(passwordEncoder.encode(request.password()));
accountRepository.save(account);
// 创建用户资料(默认值)
UserEntity user = new UserEntity();
user.setAccount(account);
String nickName = "玩家" + generateRandomCode();
user.setNickName(nickName);
user.setSex(0);
user.setSignature("");
// 根据昵称生成默认 DiceBear 头像
user.setAvatar(UserConstants.generateDefaultAvatar(nickName));
userRepository.save(user);
log.info("用户注册成功: username={}", request.username());
// 注册后自动登录,生成 access token 和 refresh token
String accessToken = authProvider.login(account.getId());
String refreshToken = refreshTokenService.create(account.getId());
return new LoginResponse(accessToken, refreshToken);
}
public LoginResponse refresh(String refreshToken) {
// 校验 refresh token有效则用它签发新的 access token
String userId = refreshTokenService.validate(refreshToken);
String accessToken = authProvider.login(userId);
log.info("令牌刷新成功: userId={}", userId);
// refresh token 保持不变,只返回新的 access token
return new LoginResponse(accessToken, refreshToken);
}
public void logout(String refreshToken) {
// 获取当前登录用户ID用于日志
String loginId = authProvider.getCurrentUserId();
// 登出,使 access token 失效
authProvider.logout();
// 撤销 refresh token使其在 Redis 中删除
refreshTokenService.revoke(refreshToken);
log.info("用户已登出: userId={}", loginId);
}
/**
* 生成6位随机数字用于默认昵称
*/
private String generateRandomCode() {
return String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,158 @@
package com.webgame.webgamebackend.domain.token;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
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;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.concurrent.TimeUnit;
/**
* 刷新令牌服务实现,基于 Redis 存储
*
* <p>数据结构:</p>
* <ul>
* <li>xzg:refresh:{token} → userId正向映射用于校验</li>
* <li>xzg:refresh:user:{userId} → token反向映射用于登录时清除旧 token</li>
* </ul>
* <p>TTL: 30 天,由 Redis 自动过期</p>
* <p>一个用户同一时间只保留一个有效的 refresh token新登录会使旧 token 失效</p>
* <p>直接使用 StringRedisTemplate 存储纯字符串,避免 JSON 序列化导致的引号问题</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
/** Redis key 前缀 */
private static final String KEY_PREFIX = "xzg:refresh:";
/** 用户→token 反向映射 key 前缀 */
private static final String USER_KEY_PREFIX = "xzg:refresh:user:";
/** 刷新令牌有效期30 天 */
private static final long EXPIRE_DAYS = 30;
/** 安全随机数生成器 */
private static final SecureRandom RANDOM = new SecureRandom();
private final StringRedisTemplate stringRedisTemplate;
public String create(String userId) {
if (userId == null || userId.isBlank()) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "用户ID不能为空");
}
// 先清除该用户之前的 refresh token确保一个用户只有一个有效 token
String oldToken = get(USER_KEY_PREFIX + userId);
if (oldToken != null) {
delete(KEY_PREFIX + oldToken);
log.debug("已清除旧的刷新令牌: userId={}", userId);
}
// 创建新 token
String token = generateToken();
log.debug("刷新令牌已生成: token={}", token);
set(KEY_PREFIX + token, userId);
// 维护反向映射
set(USER_KEY_PREFIX + userId, token);
log.debug("刷新令牌已创建: userId={}", userId);
return token;
}
public String validate(String token) {
if (token == null || token.isBlank()) {
throw new BusinessException(ErrorCode.REFRESH_TOKEN_MISSING);
}
String userId = get(KEY_PREFIX + token);
if (userId == null) {
throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
}
return userId;
}
public String rotate(String oldToken) {
String userId = validate(oldToken);
// 先删反向映射,再删正向映射:
// 若反向映射删除失败,正向映射仍存在,调用方可重试;
// 若反向已删除而正向删除失败,正向映射仍存在,重试仍可通过。
delete(USER_KEY_PREFIX + userId);
delete(KEY_PREFIX + oldToken);
log.debug("旧刷新令牌已删除: userId={}", userId);
return create(userId);
}
public void revoke(String token) {
if (token == null || token.isBlank()) {
return;
}
try {
String userId = get(KEY_PREFIX + token);
if (userId != null) {
delete(USER_KEY_PREFIX + userId);
}
delete(KEY_PREFIX + token);
log.debug("刷新令牌已撤销");
} catch (BusinessException e) {
// 登出时 Redis 不可用,记录日志后放行,不影响用户登出
log.warn("撤销刷新令牌失败Redis 不可用),跳过清理: token={}", token, e);
}
}
/**
* 写入字符串到 Redis带过期时间
*/
private void set(String key, String value) {
try {
stringRedisTemplate.opsForValue().set(key, value, EXPIRE_DAYS, TimeUnit.DAYS);
} catch (Exception e) {
log.error("刷新令牌写入 Redis 失败: key={}", key, e);
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "刷新令牌操作失败");
}
}
/**
* 从 Redis 读取字符串(读取时续期)
*
* @return 值key 不存在时返回 null
* @throws BusinessException Redis 操作失败时抛出
*/
private String get(String key) {
try {
String value = stringRedisTemplate.opsForValue().get(key);
if (value != null) {
stringRedisTemplate.expire(key, EXPIRE_DAYS, TimeUnit.DAYS);
}
return value;
} catch (Exception e) {
log.error("查询缓存失败: key={}", key, e);
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "刷新令牌操作失败");
}
}
/**
* 从 Redis 删除 key
*
* @throws BusinessException Redis 操作失败时抛出
*/
private void delete(String key) {
try {
stringRedisTemplate.delete(key);
} catch (Exception e) {
log.error("删除缓存失败: key={}", key, e);
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "刷新令牌操作失败");
}
}
/**
* 生成 64 位十六进制随机字符串作为刷新令牌
*/
private String generateToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
}

View File

@@ -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);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
package com.webgame.webgamebackend.infrastructure.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 配置类:序列化方式 + 消息监听容器
*/
@Configuration
public class RedisConfigurer {
/**
* 配置 RedisTemplate使用 String 序列化器替代 Java 原生序列化
* <p>
* 不配置此项的话Spring Boot 默认使用 JdkSerializationRedisSerializer
* 存入 Redis 的数据会带有 Java 序列化魔数前缀(\xAC\xED\x00\x05
* 导致人类不可读且无法被其他语言客户端读取。
*/
@Bean
@Primary
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setValueSerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(stringSerializer);
template.afterPropertiesSet();
return template;
}
/**
* 创建并配置 Redis 消息监听容器
* 用于实现 Redis 的发布/订阅功能,支持监听特定频道的消息
*
* @param redisConnectionFactory Redis 连接工厂
* @return 配置好的 RedisMessageListenerContainer 实例
*/
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
return container;
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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("*");
}
}

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.entities; package com.webgame.webgamebackend.infrastructure.persistence.entity;
import com.webgame.webgamebackend.entities.base.UUIDBaseEntity; import com.webgame.webgamebackend.infrastructure.persistence.entity.UUIDBaseEntity;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
@@ -68,4 +68,16 @@ public class UserEntity extends UUIDBaseEntity {
*/ */
@Column(name = "birthday") @Column(name = "birthday")
private LocalDate 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";
} }

View File

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

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -14,4 +15,9 @@ public interface UserRepository extends JpaRepository<UserEntity, String> {
* 根据账号 ID 查询用户 * 根据账号 ID 查询用户
*/ */
Optional<UserEntity> findByAccountId(String accountId); Optional<UserEntity> findByAccountId(String accountId);
/**
* 根据账号 ID 列表批量查询用户
*/
List<UserEntity> findByAccountIdIn(List<String> accountIds);
} }

View File

@@ -1,27 +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);
}

View File

@@ -1,16 +0,0 @@
package com.webgame.webgamebackend.service;
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
/**
* 用户服务接口
*/
public interface UserService {
/**
* 获取当前登录用户的资料
*
* @return 用户信息
*/
UserInfoResponse getCurrentUserInfo();
}

View File

@@ -1,82 +0,0 @@
package com.webgame.webgamebackend.service.impl;
import cn.dev33.satoken.stp.StpUtil;
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.ThreadLocalRandom;
/**
* 账号服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AccountServiceImpl implements AccountService {
private final AccountRepository accountRepository;
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
@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 登录
StpUtil.login(account.getId());
String token = StpUtil.getTokenValue();
log.info("用户登录成功: username={}", request.username());
return new LoginResponse(token);
}
@Override
@Transactional
public LoginResponse register(RegisterRequest request) {
// 检查用户名是否已存在
if (accountRepository.existsByUsername(request.username())) {
throw new BusinessException(ErrorCode.USERNAME_EXISTS);
}
// 创建账号
AccountEntity account = new AccountEntity();
account.setUsername(request.username());
account.setPassword(passwordEncoder.encode(request.password()));
accountRepository.save(account);
// 创建用户资料(默认值)
UserEntity user = new UserEntity();
user.setAccount(account);
user.setNickName("玩家" + generateRandomCode());
user.setSex(0);
user.setSignature("");
user.setAvatar("");
userRepository.save(user);
log.info("用户注册成功: username={}", request.username());
// 注册后自动登录
StpUtil.login(account.getId());
String token = StpUtil.getTokenValue();
return new LoginResponse(token);
}
/**
* 生成6位随机数字用于默认昵称
*/
private String generateRandomCode() {
return String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000));
}
}

View File

@@ -1,31 +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();
UserEntity user = userRepository.findByAccountId(accountId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
return UserInfoResponse.from(user);
}
}

View File

@@ -33,12 +33,19 @@ spring:
# 连接池中的最小空闲连接 # 连接池中的最小空闲连接
min-idle: 0 min-idle: 0
# SQL 初始化data.sql
sql:
init:
mode: always
# JPA相关配置 # JPA相关配置
jpa: jpa:
hibernate: hibernate:
ddl-auto: update ddl-auto: update
show-sql: false # 是否输出sql show-sql: false # 是否输出sql
open-in-view: false open-in-view: false
# JPA 初始化延迟到 data.sql 执行之后,避免表未创建
defer-datasource-initialization: true
# Servlet相关配置 # Servlet相关配置
servlet: servlet:
@@ -67,12 +74,21 @@ logging:
# #如果是true则用的是https而不是http,默认值是true # #如果是true则用的是https而不是http,默认值是true
# secure: false # secure: false
############## 游戏配置 ##############
game:
# 断线重连超时时间(秒)
disconnect-timeout-seconds: 60
# 聊天消息最大长度
chat-max-length: 500
# 五子棋棋盘大小
board-size: 15
############## Sa-Token 配置 (文档: https://sa-token.cc) ############## ############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token: sa-token:
# token 名称(同时也是 cookie 名称) # token 名称(同时也是 cookie 名称)
token-name: saToken token-name: saToken
# token 有效期(单位:秒) 默认30天-1 代表永久有效 # token 有效期(单位:秒)access token 30分钟refresh token 由应用层管理
timeout: 2592000 timeout: 1800
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结 # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1 active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录) # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)

View 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()
);

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Some files were not shown because too many files have changed in this diff Show More