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 = """ + + + %s + + """.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() +);