From 4267445256c243c516040ffe3fa79ddb7246b967 Mon Sep 17 00:00:00 2001
From: Azure <983547216@qq.com>
Date: Mon, 22 Jun 2026 10:09:40 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B8=B8=E6=88=8F=E6=88=BF=E9=97=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../common/constants/UserConstants.java | 67 +++
.../common/dto/game/CreateRoomRequest.java | 34 ++
.../common/dto/game/CreateRoomResponse.java | 6 +
.../common/dto/game/GameConfigDto.java | 12 +
.../common/dto/game/GameItemResponse.java | 37 ++
.../common/dto/game/GameListResponse.java | 8 +
.../common/dto/game/JoinRoomRequest.java | 15 +
.../common/dto/game/KickPlayerRequest.java | 14 +
.../common/dto/game/LeaveRoomRequest.java | 11 +
.../common/dto/game/ReadyRequest.java | 11 +
.../common/dto/game/RoomItemResponse.java | 59 +++
.../common/dto/game/RoomListResponse.java | 8 +
.../common/dto/game/RoomPlayerResponse.java | 9 +
.../common/dto/game/StartGameRequest.java | 11 +
.../dto/user/UpdateUserInfoRequest.java | 17 +
.../common/exception/ErrorCode.java | 13 +
.../controller/GameController.java | 127 ++++++
.../controller/UserController.java | 19 +-
.../entities/GameConfigEntity.java | 87 ++++
.../entities/GameRoomEntity.java | 81 ++++
.../entities/RoomPlayerEntity.java | 58 +++
.../webgamebackend/entities/UserEntity.java | 6 +
.../repository/GameConfigRepository.java | 28 ++
.../repository/GameRoomRepository.java | 50 +++
.../repository/RoomPlayerRepository.java | 54 +++
.../repository/UserRepository.java | 6 +
.../webgamebackend/service/GameService.java | 77 ++++
.../webgamebackend/service/UserService.java | 10 +
.../service/impl/AccountServiceImpl.java | 7 +-
.../service/impl/GameServiceImpl.java | 392 ++++++++++++++++++
.../service/impl/UserServiceImpl.java | 33 ++
src/main/resources/application.yml | 7 +
src/main/resources/data.sql | 53 +++
33 files changed, 1422 insertions(+), 5 deletions(-)
create mode 100644 src/main/java/com/webgame/webgamebackend/common/constants/UserConstants.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomRequest.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomResponse.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/GameConfigDto.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/GameItemResponse.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/GameListResponse.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/JoinRoomRequest.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/KickPlayerRequest.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/LeaveRoomRequest.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/ReadyRequest.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/RoomItemResponse.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/RoomListResponse.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/RoomPlayerResponse.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/game/StartGameRequest.java
create mode 100644 src/main/java/com/webgame/webgamebackend/common/dto/user/UpdateUserInfoRequest.java
create mode 100644 src/main/java/com/webgame/webgamebackend/controller/GameController.java
create mode 100644 src/main/java/com/webgame/webgamebackend/entities/GameConfigEntity.java
create mode 100644 src/main/java/com/webgame/webgamebackend/entities/GameRoomEntity.java
create mode 100644 src/main/java/com/webgame/webgamebackend/entities/RoomPlayerEntity.java
create mode 100644 src/main/java/com/webgame/webgamebackend/repository/GameConfigRepository.java
create mode 100644 src/main/java/com/webgame/webgamebackend/repository/GameRoomRepository.java
create mode 100644 src/main/java/com/webgame/webgamebackend/repository/RoomPlayerRepository.java
create mode 100644 src/main/java/com/webgame/webgamebackend/service/GameService.java
create mode 100644 src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java
create mode 100644 src/main/resources/data.sql
diff --git a/src/main/java/com/webgame/webgamebackend/common/constants/UserConstants.java b/src/main/java/com/webgame/webgamebackend/common/constants/UserConstants.java
new file mode 100644
index 0000000..7897f1f
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/constants/UserConstants.java
@@ -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 = """
+
+ """.formatted(bgColor, escapeXml(initial));
+
+ String base64 = Base64.getEncoder().encodeToString(svg.getBytes(StandardCharsets.UTF_8));
+ return "data:image/svg+xml;base64," + base64;
+ }
+
+ /**
+ * XML 特殊字符转义(防止文字中特殊字符破坏 SVG 结构)
+ */
+ private static String escapeXml(String text) {
+ return text
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'");
+ }
+}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomRequest.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomRequest.java
new file mode 100644
index 0000000..701fd70
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomRequest.java
@@ -0,0 +1,34 @@
+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
+) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomResponse.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomResponse.java
new file mode 100644
index 0000000..04e19d1
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/CreateRoomResponse.java
@@ -0,0 +1,6 @@
+package com.webgame.webgamebackend.common.dto.game;
+
+/**
+ * 创建房间响应
+ */
+public record CreateRoomResponse(RoomItemResponse room) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/GameConfigDto.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/GameConfigDto.java
new file mode 100644
index 0000000..1dcbef5
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/GameConfigDto.java
@@ -0,0 +1,12 @@
+package com.webgame.webgamebackend.common.dto.game;
+
+import java.util.List;
+
+/**
+ * 游戏配置 DTO,对应前端 GameConfig
+ */
+public record GameConfigDto(
+ int maxPlayers,
+ List modes,
+ List stakesOptions
+) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/GameItemResponse.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/GameItemResponse.java
new file mode 100644
index 0000000..930959f
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/GameItemResponse.java
@@ -0,0 +1,37 @@
+package com.webgame.webgamebackend.common.dto.game;
+
+import com.alibaba.fastjson2.JSON;
+import com.webgame.webgamebackend.entities.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 modes = JSON.parseArray(entity.getModes(), String.class);
+ List 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)
+ );
+ }
+}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/GameListResponse.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/GameListResponse.java
new file mode 100644
index 0000000..7a7b36c
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/GameListResponse.java
@@ -0,0 +1,8 @@
+package com.webgame.webgamebackend.common.dto.game;
+
+import java.util.List;
+
+/**
+ * 游戏列表响应
+ */
+public record GameListResponse(List list) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/JoinRoomRequest.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/JoinRoomRequest.java
new file mode 100644
index 0000000..60df26c
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/JoinRoomRequest.java
@@ -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
+) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/KickPlayerRequest.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/KickPlayerRequest.java
new file mode 100644
index 0000000..924a025
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/KickPlayerRequest.java
@@ -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
+) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/LeaveRoomRequest.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/LeaveRoomRequest.java
new file mode 100644
index 0000000..c1c157a
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/LeaveRoomRequest.java
@@ -0,0 +1,11 @@
+package com.webgame.webgamebackend.common.dto.game;
+
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * 离开房间请求
+ */
+public record LeaveRoomRequest(
+ @NotBlank(message = "房间ID不能为空")
+ String roomId
+) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/ReadyRequest.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/ReadyRequest.java
new file mode 100644
index 0000000..7c95840
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/ReadyRequest.java
@@ -0,0 +1,11 @@
+package com.webgame.webgamebackend.common.dto.game;
+
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * 准备/取消准备请求
+ */
+public record ReadyRequest(
+ @NotBlank(message = "房间ID不能为空")
+ String roomId
+) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomItemResponse.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomItemResponse.java
new file mode 100644
index 0000000..9129181
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomItemResponse.java
@@ -0,0 +1,59 @@
+package com.webgame.webgamebackend.common.dto.game;
+
+import com.webgame.webgamebackend.entities.GameRoomEntity;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 房间信息响应,对应前端 RoomItem
+ */
+public record RoomItemResponse(
+ String id,
+ String name,
+ String gameIcon,
+ String gameId,
+ int maxPlayers,
+ List players,
+ String status,
+ String statusText,
+ int stakes,
+ String mode,
+ String creatorName
+) {
+ /** 状态 → 展示文本映射 */
+ private static final Map 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 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
+ );
+ }
+}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomListResponse.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomListResponse.java
new file mode 100644
index 0000000..ab652a2
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomListResponse.java
@@ -0,0 +1,8 @@
+package com.webgame.webgamebackend.common.dto.game;
+
+import java.util.List;
+
+/**
+ * 房间列表响应
+ */
+public record RoomListResponse(List list) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomPlayerResponse.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomPlayerResponse.java
new file mode 100644
index 0000000..31b5b0d
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/RoomPlayerResponse.java
@@ -0,0 +1,9 @@
+package com.webgame.webgamebackend.common.dto.game;
+
+/**
+ * 房间内玩家信息,对应前端 RoomPlayer
+ */
+public record RoomPlayerResponse(
+ String avatar,
+ String nickname
+) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/game/StartGameRequest.java b/src/main/java/com/webgame/webgamebackend/common/dto/game/StartGameRequest.java
new file mode 100644
index 0000000..7328db1
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/game/StartGameRequest.java
@@ -0,0 +1,11 @@
+package com.webgame.webgamebackend.common.dto.game;
+
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * 开始游戏请求
+ */
+public record StartGameRequest(
+ @NotBlank(message = "房间ID不能为空")
+ String roomId
+) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/dto/user/UpdateUserInfoRequest.java b/src/main/java/com/webgame/webgamebackend/common/dto/user/UpdateUserInfoRequest.java
new file mode 100644
index 0000000..f779b08
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/common/dto/user/UpdateUserInfoRequest.java
@@ -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
+) {}
diff --git a/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java b/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java
index 43a895a..8f899f4 100644
--- a/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java
+++ b/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java
@@ -21,6 +21,19 @@ public enum ErrorCode {
// ========== 用户模块 ==========
USER_NOT_FOUND(2001, "用户不存在"),
+
+ // ========== 游戏模块 ==========
+ GAME_NOT_FOUND(3001, "游戏不存在或已下架"),
+ ROOM_NOT_FOUND(3002, "房间不存在"),
+ ROOM_FULL(3003, "房间已满"),
+ ROOM_PASSWORD_ERROR(3004, "房间密码错误"),
+ ROOM_ALREADY_STARTED(3005, "游戏已开始,无法加入"),
+ NOT_ROOM_OWNER(3006, "非房主无权操作"),
+ PLAYER_NOT_IN_ROOM(3007, "玩家不在该房间"),
+ NOT_ALL_READY(3008, "有玩家尚未准备"),
+ PLAYER_COUNT_INSUFFICIENT(3009, "人数不足,无法开始"),
+ BALANCE_INSUFFICIENT(3010, "余额不足"),
+ ALREADY_IN_ROOM(3011, "已在房间中"),
;
private final int code;
diff --git a/src/main/java/com/webgame/webgamebackend/controller/GameController.java b/src/main/java/com/webgame/webgamebackend/controller/GameController.java
new file mode 100644
index 0000000..5613eb8
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/controller/GameController.java
@@ -0,0 +1,127 @@
+package com.webgame.webgamebackend.controller;
+
+import cn.dev33.satoken.annotation.SaIgnore;
+import cn.dev33.satoken.stp.StpUtil;
+import com.webgame.webgamebackend.common.dto.ApiResponse;
+import com.webgame.webgamebackend.common.dto.game.*;
+import com.webgame.webgamebackend.service.GameService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 游戏控制器
+ *
+ * 管理游戏列表查询、房间的创建/加入/离开/踢人/准备/开始等操作。
+ */
+@Validated
+@RestController
+@RequestMapping("/game")
+@RequiredArgsConstructor
+public class GameController {
+
+ private final GameService gameService;
+
+ /**
+ * 获取游戏列表
+ *
+ * 无需登录即可调用。
+ */
+ @SaIgnore
+ @GetMapping("/list")
+ public ApiResponse list() {
+ GameListResponse result = gameService.getGameList();
+ return ApiResponse.ok(result);
+ }
+
+ /**
+ * 获取房间列表
+ *
+ * 支持按状态筛选。无需登录即可调用。
+ *
+ * @param gameId 游戏 ID(必填)
+ * @param status 状态筛选(可选)
+ */
+ @SaIgnore
+ @GetMapping("/room/list")
+ public ApiResponse roomList(
+ @RequestParam String gameId,
+ @RequestParam(required = false) String status) {
+ RoomListResponse result = gameService.getRoomList(gameId, status);
+ return ApiResponse.ok(result);
+ }
+
+ /**
+ * 创建房间
+ *
+ * 需登录。房主自动加入房间并设为已准备。
+ */
+ @PostMapping("/room/create")
+ public ApiResponse createRoom(@Valid @RequestBody CreateRoomRequest request) {
+ String userId = StpUtil.getLoginIdAsString();
+ CreateRoomResponse result = gameService.createRoom(request, userId);
+ return ApiResponse.ok(result);
+ }
+
+ /**
+ * 加入房间
+ *
+ * 需登录。公开房间无需传密码。
+ */
+ @PostMapping("/room/join")
+ public ApiResponse joinRoom(@Valid @RequestBody JoinRoomRequest request) {
+ String userId = StpUtil.getLoginIdAsString();
+ RoomItemResponse result = gameService.joinRoom(request, userId);
+ return ApiResponse.ok(result);
+ }
+
+ /**
+ * 离开房间
+ *
+ * 需登录。房主离开时顺位转让。无人时房间自动结束。
+ */
+ @PostMapping("/room/leave")
+ public ApiResponse leaveRoom(@Valid @RequestBody LeaveRoomRequest request) {
+ String userId = StpUtil.getLoginIdAsString();
+ gameService.leaveRoom(request.roomId(), userId);
+ return ApiResponse.ok(null);
+ }
+
+ /**
+ * 踢出玩家
+ *
+ * 需登录。仅房主可操作。
+ */
+ @PostMapping("/room/kick")
+ public ApiResponse kickPlayer(@Valid @RequestBody KickPlayerRequest request) {
+ String ownerId = StpUtil.getLoginIdAsString();
+ gameService.kickPlayer(request.roomId(), ownerId, request.userId());
+ return ApiResponse.ok(null);
+ }
+
+ /**
+ * 切换准备状态
+ *
+ * 需登录。在等待中的房间里切换准备/取消准备。
+ */
+ @PostMapping("/room/ready")
+ public ApiResponse toggleReady(@Valid @RequestBody ReadyRequest request) {
+ String userId = StpUtil.getLoginIdAsString();
+ boolean ready = gameService.toggleReady(request.roomId(), userId);
+ return ApiResponse.ok(ready);
+ }
+
+ /**
+ * 开始游戏
+ *
+ * 需登录。仅房主可操作。需全部玩家准备且人数 >= 2。
+ * 开始后扣除所有玩家的底注。
+ */
+ @PostMapping("/room/start")
+ public ApiResponse startGame(@Valid @RequestBody StartGameRequest request) {
+ String ownerId = StpUtil.getLoginIdAsString();
+ gameService.startGame(request.roomId(), ownerId);
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/src/main/java/com/webgame/webgamebackend/controller/UserController.java b/src/main/java/com/webgame/webgamebackend/controller/UserController.java
index a2d5963..e6fabeb 100644
--- a/src/main/java/com/webgame/webgamebackend/controller/UserController.java
+++ b/src/main/java/com/webgame/webgamebackend/controller/UserController.java
@@ -1,13 +1,14 @@
package com.webgame.webgamebackend.controller;
+import cn.dev33.satoken.stp.StpUtil;
import com.webgame.webgamebackend.common.dto.ApiResponse;
+import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
import com.webgame.webgamebackend.service.UserService;
+import jakarta.validation.Valid;
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;
+import org.springframework.web.bind.annotation.*;
/**
* 用户控制器
@@ -28,4 +29,16 @@ public class UserController {
UserInfoResponse result = userService.getCurrentUserInfo();
return ApiResponse.ok(result);
}
+
+ /**
+ * 更新当前登录用户信息
+ *
+ * 所有字段可选,传什么更新什么。
+ */
+ @PutMapping("/info")
+ public ApiResponse updateInfo(@Valid @RequestBody UpdateUserInfoRequest request) {
+ String accountId = StpUtil.getLoginIdAsString();
+ UserInfoResponse result = userService.updateUserInfo(accountId, request);
+ return ApiResponse.ok(result);
+ }
}
diff --git a/src/main/java/com/webgame/webgamebackend/entities/GameConfigEntity.java b/src/main/java/com/webgame/webgamebackend/entities/GameConfigEntity.java
new file mode 100644
index 0000000..0e703b5
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/entities/GameConfigEntity.java
@@ -0,0 +1,87 @@
+package com.webgame.webgamebackend.entities;
+
+import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+/**
+ * 游戏配置实体
+ *
+ * 存储每款游戏的元信息(图标、名称、玩法介绍)和可选配置(模式、底注档位)。
+ * 采用独立表而非硬编码,支持运营后台动态上下架游戏。
+ */
+@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;
+}
diff --git a/src/main/java/com/webgame/webgamebackend/entities/GameRoomEntity.java b/src/main/java/com/webgame/webgamebackend/entities/GameRoomEntity.java
new file mode 100644
index 0000000..4d9a444
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/entities/GameRoomEntity.java
@@ -0,0 +1,81 @@
+package com.webgame.webgamebackend.entities;
+
+import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+/**
+ * 游戏房间(桌台)实体
+ *
+ * 每个房间属于某款游戏,包含桌号、人数上限、玩法模式、底注等信息。
+ * 房间有三种状态: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;
+
+ /**
+ * 房间密码(bcrypt 哈希),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;
+}
diff --git a/src/main/java/com/webgame/webgamebackend/entities/RoomPlayerEntity.java b/src/main/java/com/webgame/webgamebackend/entities/RoomPlayerEntity.java
new file mode 100644
index 0000000..def9c54
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/entities/RoomPlayerEntity.java
@@ -0,0 +1,58 @@
+package com.webgame.webgamebackend.entities;
+
+import com.webgame.webgamebackend.entities.base.UUIDBaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
+import java.time.LocalDateTime;
+
+/**
+ * 房间玩家关联实体
+ *
+ * 记录玩家与房间的关联关系、座位号和准备状态。
+ */
+@Entity
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Accessors(chain = true)
+@Table(name = "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;
+}
diff --git a/src/main/java/com/webgame/webgamebackend/entities/UserEntity.java b/src/main/java/com/webgame/webgamebackend/entities/UserEntity.java
index 0a4a52b..6314d8b 100644
--- a/src/main/java/com/webgame/webgamebackend/entities/UserEntity.java
+++ b/src/main/java/com/webgame/webgamebackend/entities/UserEntity.java
@@ -68,4 +68,10 @@ public class UserEntity extends UUIDBaseEntity {
*/
@Column(name = "birthday")
private LocalDate birthday;
+
+ /**
+ * 金币余额,默认 1000
+ */
+ @Column(name = "balance", nullable = false)
+ private Long balance = 1000L;
}
diff --git a/src/main/java/com/webgame/webgamebackend/repository/GameConfigRepository.java b/src/main/java/com/webgame/webgamebackend/repository/GameConfigRepository.java
new file mode 100644
index 0000000..1e8c52d
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/repository/GameConfigRepository.java
@@ -0,0 +1,28 @@
+package com.webgame.webgamebackend.repository;
+
+import com.webgame.webgamebackend.entities.GameConfigEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 游戏配置 Repository
+ */
+public interface GameConfigRepository extends JpaRepository {
+
+ /**
+ * 根据游戏标识查询
+ *
+ * @param gameId 游戏标识
+ * @return 游戏配置实体
+ */
+ Optional findByGameId(String gameId);
+
+ /**
+ * 查询所有启用的游戏,按排序权重升序
+ *
+ * @return 启用的游戏列表
+ */
+ List findByEnabledTrueOrderBySortOrderAsc();
+}
diff --git a/src/main/java/com/webgame/webgamebackend/repository/GameRoomRepository.java b/src/main/java/com/webgame/webgamebackend/repository/GameRoomRepository.java
new file mode 100644
index 0000000..333751e
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/repository/GameRoomRepository.java
@@ -0,0 +1,50 @@
+package com.webgame.webgamebackend.repository;
+
+import com.webgame.webgamebackend.entities.GameRoomEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 游戏房间 Repository
+ */
+public interface GameRoomRepository extends JpaRepository {
+
+ /**
+ * 根据游戏 ID 和状态查询房间列表
+ *
+ * @param gameId 游戏标识
+ * @param status 房间状态
+ * @return 房间列表
+ */
+ List findByGameIdAndStatus(String gameId, String status);
+
+ /**
+ * 根据游戏 ID 查询所有房间(不限状态)
+ *
+ * @param gameId 游戏标识
+ * @return 房间列表
+ */
+ List 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 findByIdForUpdate(@Param("id") String id);
+}
diff --git a/src/main/java/com/webgame/webgamebackend/repository/RoomPlayerRepository.java b/src/main/java/com/webgame/webgamebackend/repository/RoomPlayerRepository.java
new file mode 100644
index 0000000..084f3b1
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/repository/RoomPlayerRepository.java
@@ -0,0 +1,54 @@
+package com.webgame.webgamebackend.repository;
+
+import com.webgame.webgamebackend.entities.RoomPlayerEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 房间玩家关联 Repository
+ */
+public interface RoomPlayerRepository extends JpaRepository {
+
+ /**
+ * 查询房间内所有玩家
+ *
+ * @param roomId 房间 ID
+ * @return 玩家列表
+ */
+ List findByRoomId(String roomId);
+
+ /**
+ * 查询指定房间和用户的关联记录
+ *
+ * @param roomId 房间 ID
+ * @param userId 用户 ID
+ * @return 关联记录
+ */
+ Optional 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);
+}
diff --git a/src/main/java/com/webgame/webgamebackend/repository/UserRepository.java b/src/main/java/com/webgame/webgamebackend/repository/UserRepository.java
index 0b4e588..6c459ab 100644
--- a/src/main/java/com/webgame/webgamebackend/repository/UserRepository.java
+++ b/src/main/java/com/webgame/webgamebackend/repository/UserRepository.java
@@ -3,6 +3,7 @@ package com.webgame.webgamebackend.repository;
import com.webgame.webgamebackend.entities.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.List;
import java.util.Optional;
/**
@@ -14,4 +15,9 @@ public interface UserRepository extends JpaRepository {
* 根据账号 ID 查询用户
*/
Optional findByAccountId(String accountId);
+
+ /**
+ * 根据账号 ID 列表批量查询用户
+ */
+ List findByAccountIdIn(List accountIds);
}
diff --git a/src/main/java/com/webgame/webgamebackend/service/GameService.java b/src/main/java/com/webgame/webgamebackend/service/GameService.java
new file mode 100644
index 0000000..0bdcee4
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/service/GameService.java
@@ -0,0 +1,77 @@
+package com.webgame.webgamebackend.service;
+
+import com.webgame.webgamebackend.common.dto.game.*;
+
+/**
+ * 游戏服务接口
+ */
+public interface GameService {
+
+ /**
+ * 获取游戏列表
+ *
+ * @return 所有启用的游戏信息
+ */
+ GameListResponse getGameList();
+
+ /**
+ * 获取指定游戏的房间列表
+ *
+ * @param gameId 游戏标识
+ * @param status 状态筛选(可选),不传返回所有未结束的房间
+ * @return 房间列表
+ */
+ RoomListResponse getRoomList(String gameId, String status);
+
+ /**
+ * 创建房间
+ *
+ * @param request 创建房间请求
+ * @param creatorId 房主 account_id
+ * @return 新建的房间信息
+ */
+ CreateRoomResponse createRoom(CreateRoomRequest request, String creatorId);
+
+ /**
+ * 加入房间
+ *
+ * @param request 加入房间请求
+ * @param userId 用户 account_id
+ * @return 房间信息
+ */
+ RoomItemResponse joinRoom(JoinRoomRequest request, String userId);
+
+ /**
+ * 离开房间
+ *
+ * @param roomId 房间 ID
+ * @param userId 用户 account_id
+ */
+ void leaveRoom(String roomId, String userId);
+
+ /**
+ * 房主踢出玩家
+ *
+ * @param roomId 房间 ID
+ * @param ownerId 房主 account_id
+ * @param targetUserId 目标用户 account_id
+ */
+ void kickPlayer(String roomId, String ownerId, String targetUserId);
+
+ /**
+ * 切换准备状态
+ *
+ * @param roomId 房间 ID
+ * @param userId 用户 account_id
+ * @return 切换后的准备状态
+ */
+ boolean toggleReady(String roomId, String userId);
+
+ /**
+ * 开始游戏
+ *
+ * @param roomId 房间 ID
+ * @param ownerId 房主 account_id
+ */
+ void startGame(String roomId, String ownerId);
+}
diff --git a/src/main/java/com/webgame/webgamebackend/service/UserService.java b/src/main/java/com/webgame/webgamebackend/service/UserService.java
index 32a8df5..71d9e43 100644
--- a/src/main/java/com/webgame/webgamebackend/service/UserService.java
+++ b/src/main/java/com/webgame/webgamebackend/service/UserService.java
@@ -1,5 +1,6 @@
package com.webgame.webgamebackend.service;
+import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
/**
@@ -13,4 +14,13 @@ public interface UserService {
* @return 用户信息
*/
UserInfoResponse getCurrentUserInfo();
+
+ /**
+ * 更新当前登录用户的资料
+ *
+ * @param accountId 账号 ID
+ * @param request 更新请求(所有字段可选)
+ * @return 更新后的用户信息
+ */
+ UserInfoResponse updateUserInfo(String accountId, UpdateUserInfoRequest request);
}
diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java
index b1cacdb..d15ed2b 100644
--- a/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java
+++ b/src/main/java/com/webgame/webgamebackend/service/impl/AccountServiceImpl.java
@@ -1,6 +1,7 @@
package com.webgame.webgamebackend.service.impl;
import cn.dev33.satoken.stp.StpUtil;
+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;
@@ -64,10 +65,12 @@ public class AccountServiceImpl implements AccountService {
// 创建用户资料(默认值)
UserEntity user = new UserEntity();
user.setAccount(account);
- user.setNickName("玩家" + generateRandomCode());
+ String nickName = "玩家" + generateRandomCode();
+ user.setNickName(nickName);
user.setSex(0);
user.setSignature("");
- user.setAvatar("");
+ // 根据昵称生成默认 DiceBear 头像
+ user.setAvatar(UserConstants.generateDefaultAvatar(nickName));
userRepository.save(user);
log.info("用户注册成功: username={}", request.username());
diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java
new file mode 100644
index 0000000..37f0f18
--- /dev/null
+++ b/src/main/java/com/webgame/webgamebackend/service/impl/GameServiceImpl.java
@@ -0,0 +1,392 @@
+package com.webgame.webgamebackend.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.webgame.webgamebackend.common.dto.game.*;
+import com.webgame.webgamebackend.common.exception.BusinessException;
+import com.webgame.webgamebackend.common.exception.ErrorCode;
+import com.webgame.webgamebackend.entities.*;
+import com.webgame.webgamebackend.repository.*;
+import com.webgame.webgamebackend.service.GameService;
+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.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 游戏服务实现
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GameServiceImpl implements GameService {
+
+ private final GameConfigRepository gameConfigRepository;
+ private final GameRoomRepository gameRoomRepository;
+ private final RoomPlayerRepository roomPlayerRepository;
+ private final UserRepository userRepository;
+ private final BCryptPasswordEncoder passwordEncoder;
+
+ @Override
+ public GameListResponse getGameList() {
+ List configs = gameConfigRepository.findByEnabledTrueOrderBySortOrderAsc();
+ List list = configs.stream()
+ .map(GameItemResponse::from)
+ .toList();
+ log.info("[游戏服务] 获取游戏列表, 共 {} 款", list.size());
+ return new GameListResponse(list);
+ }
+
+ @Override
+ 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 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 list = new ArrayList<>();
+ for (GameRoomEntity room : rooms) {
+ List players = buildRoomPlayers(room.getId());
+ String creatorName = getNickname(room.getCreatorId());
+ list.add(RoomItemResponse.from(room, players, gameConfig.getIcon(), creatorName));
+ }
+
+ log.info("[游戏服务] 获取房间列表, gameId={}, status={}, 共 {} 个", gameId, status, list.size());
+ return new RoomListResponse(list);
+ }
+
+ @Override
+ @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 modes = JSON.parseArray(gameConfig.getModes(), String.class);
+ if (!modes.contains(request.mode())) {
+ throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的模式: " + request.mode());
+ }
+
+ // 校验 stakes 在可选列表中
+ List 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(passwordEncoder.encode(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);
+
+ // 组装响应
+ List players = buildRoomPlayers(room.getId());
+ String creatorName = getNickname(creatorId);
+ RoomItemResponse roomResponse = RoomItemResponse.from(room, players, gameConfig.getIcon(), creatorName);
+
+ log.info("[游戏服务] 创建房间成功, roomId={}, gameId={}, creatorId={}", room.getId(), request.gameId(), creatorId);
+ return new CreateRoomResponse(roomResponse);
+ }
+
+ @Override
+ @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 (!passwordEncoder.matches(request.password(), room.getPassword())) {
+ 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 existingPlayers = roomPlayerRepository.findByRoomId(room.getId());
+ int nextSeat = 1;
+ if (!existingPlayers.isEmpty()) {
+ int maxSeat = existingPlayers.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);
+
+ // 组装响应
+ GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId())
+ .orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND));
+ List players = buildRoomPlayers(room.getId());
+ String creatorName = getNickname(room.getCreatorId());
+
+ log.info("[游戏服务] 玩家加入房间, roomId={}, userId={}", room.getId(), userId);
+ return RoomItemResponse.from(room, players, gameConfig.getIcon(), creatorName);
+ }
+
+ @Override
+ @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));
+
+ roomPlayerRepository.delete(player);
+
+ List remaining = roomPlayerRepository.findByRoomId(roomId);
+
+ if (remaining.isEmpty()) {
+ // 无人了,房间标记为结束
+ room.setStatus("finished");
+ gameRoomRepository.save(room);
+ log.info("[游戏服务] 房间无人,标记为结束, roomId={}", roomId);
+ } else if (userId.equals(room.getCreatorId())) {
+ // 房主离开,顺位转让给下一个玩家
+ RoomPlayerEntity nextOwner = remaining.get(0);
+ room.setCreatorId(nextOwner.getUserId());
+ // 新房主自动设为已准备
+ nextOwner.setReadyStatus(true);
+ roomPlayerRepository.save(nextOwner);
+ gameRoomRepository.save(room);
+ log.info("[游戏服务] 房主离开,转让给 userId={}, roomId={}", nextOwner.getUserId(), roomId);
+ }
+
+ log.info("[游戏服务] 玩家离开房间, roomId={}, userId={}", roomId, userId);
+ }
+
+ @Override
+ @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));
+
+ roomPlayerRepository.delete(target);
+ log.info("[游戏服务] 房主踢出玩家, roomId={}, targetUserId={}", roomId, targetUserId);
+ }
+
+ @Override
+ @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={}", newStatus ? "" : "取消", roomId, userId);
+ return newStatus;
+ }
+
+ @Override
+ @Transactional
+ public void startGame(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);
+ }
+
+ // 校验房间状态
+ if (!"waiting".equals(room.getStatus())) {
+ throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED);
+ }
+
+ // 校验玩家人数 >= 2
+ List players = roomPlayerRepository.findByRoomId(roomId);
+ if (players.size() < 2) {
+ throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT);
+ }
+
+ // 校验所有玩家已准备(房主除外,房主默认准备)
+ for (RoomPlayerEntity player : players) {
+ if (!player.getReadyStatus() && !player.getUserId().equals(room.getCreatorId())) {
+ throw new BusinessException(ErrorCode.NOT_ALL_READY);
+ }
+ }
+
+ // 扣除房主底注
+ UserEntity ownerUser = userRepository.findByAccountId(ownerId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
+ if (ownerUser.getBalance() < room.getStakes()) {
+ throw new BusinessException(ErrorCode.BALANCE_INSUFFICIENT);
+ }
+ ownerUser.setBalance(ownerUser.getBalance() - room.getStakes());
+ userRepository.save(ownerUser);
+
+ // 扣除其他玩家底注
+ for (RoomPlayerEntity player : players) {
+ if (!player.getUserId().equals(ownerId)) {
+ UserEntity user = userRepository.findByAccountId(player.getUserId())
+ .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
+ if (user.getBalance() < room.getStakes()) {
+ throw new BusinessException(ErrorCode.BALANCE_INSUFFICIENT,
+ "玩家 " + user.getNickName() + " 余额不足");
+ }
+ user.setBalance(user.getBalance() - room.getStakes());
+ userRepository.save(user);
+ }
+ }
+
+ // 更新房间状态为进行中
+ room.setStatus("playing");
+ gameRoomRepository.save(room);
+
+ log.info("[游戏服务] 游戏开始, roomId={}, 参与人数={}, 底注={}", roomId, players.size(), room.getStakes());
+ }
+
+ // ==================== 私有辅助方法 ====================
+
+ /**
+ * 组装房间内的玩家信息列表
+ *
+ * @param roomId 房间 ID
+ * @return 玩家响应列表(含头像和昵称)
+ */
+ private List buildRoomPlayers(String roomId) {
+ List roomPlayers = roomPlayerRepository.findByRoomId(roomId);
+ if (roomPlayers.isEmpty()) {
+ return List.of();
+ }
+
+ // 批量查询玩家用户信息
+ List userIds = roomPlayers.stream()
+ .map(RoomPlayerEntity::getUserId)
+ .toList();
+ List users = userRepository.findByAccountIdIn(userIds);
+ Map userMap = users.stream()
+ .collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
+
+ return roomPlayers.stream()
+ .map(rp -> {
+ UserEntity user = userMap.get(rp.getUserId());
+ String avatar = (user != null) ? user.getAvatar() : "";
+ String nickname = (user != null) ? user.getNickName() : "未知玩家";
+ return new RoomPlayerResponse(avatar, nickname);
+ })
+ .toList();
+ }
+
+ /**
+ * 获取用户昵称
+ *
+ * @param accountId 账号 ID
+ * @return 昵称,查不到则返回 "未知玩家"
+ */
+ private String getNickname(String accountId) {
+ return userRepository.findByAccountId(accountId)
+ .map(UserEntity::getNickName)
+ .orElse("未知玩家");
+ }
+}
diff --git a/src/main/java/com/webgame/webgamebackend/service/impl/UserServiceImpl.java b/src/main/java/com/webgame/webgamebackend/service/impl/UserServiceImpl.java
index 56ff7fe..026b397 100644
--- a/src/main/java/com/webgame/webgamebackend/service/impl/UserServiceImpl.java
+++ b/src/main/java/com/webgame/webgamebackend/service/impl/UserServiceImpl.java
@@ -1,6 +1,7 @@
package com.webgame.webgamebackend.service.impl;
import cn.dev33.satoken.stp.StpUtil;
+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;
@@ -10,6 +11,7 @@ import com.webgame.webgamebackend.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
/**
* 用户服务实现
@@ -29,4 +31,35 @@ public class UserServiceImpl implements UserService {
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
return UserInfoResponse.from(user);
}
+
+ @Override
+ @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);
+ }
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index a29dc92..ffa3a05 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -33,12 +33,19 @@ spring:
# 连接池中的最小空闲连接
min-idle: 0
+ # SQL 初始化(data.sql)
+ sql:
+ init:
+ mode: always
+
# JPA相关配置
jpa:
hibernate:
ddl-auto: update
show-sql: false # 是否输出sql
open-in-view: false
+ # JPA 初始化延迟到 data.sql 执行之后,避免表未创建
+ defer-datasource-initialization: true
# Servlet相关配置
servlet:
diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql
new file mode 100644
index 0000000..940fb52
--- /dev/null
+++ b/src/main/resources/data.sql
@@ -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()
+);