Compare commits

...

4 Commits

Author SHA1 Message Date
2467a5097c feat: 个人信息修改 2026-06-22 12:56:46 +08:00
b7e8b02606 feat: 个人信息修改 2026-06-22 12:31:30 +08:00
6d90c21b44 feat: 游戏房间 2026-06-22 11:19:19 +08:00
4267445256 feat: 游戏房间 2026-06-22 10:09:40 +08:00
38 changed files with 1675 additions and 8 deletions

View File

@@ -40,7 +40,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
// 允许指定域访问跨域资源 // 允许指定域访问跨域资源
.setHeader("Access-Control-Allow-Origin", "*")// 允许指定域访问跨域资源 .setHeader("Access-Control-Allow-Origin", "*")// 允许指定域访问跨域资源
// 允许所有请求方式 // 允许所有请求方式
.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE") .setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT, PATCH")
// 有效时间 // 有效时间
.setHeader("Access-Control-Max-Age", "3600") .setHeader("Access-Control-Max-Age", "3600")
// 允许的header参数 // 允许的header参数

View File

@@ -0,0 +1,32 @@
package com.webgame.webgamebackend.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 静态资源配置
*
* 将本地 uploads/ 目录映射为 /uploads/** URL用于头像等用户上传文件的访问。
* 使用绝对路径避免 Tomcat 临时工作目录导致的路径不一致问题。
*/
@Configuration
public class StaticResourceConfig implements WebMvcConfigurer {
/** 上传文件根目录(相对于项目工作目录) */
private static final String UPLOAD_DIR = "uploads/";
/**
* 注册静态资源处理器
*
* /uploads/avatars/xxx.jpg → file:<user.dir>/uploads/avatars/xxx.jpg
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 使用正斜杠替换反斜杠,确保 Windows 下 file: URL 格式正确
String userDir = System.getProperty("user.dir").replace("\\", "/");
String absoluteUploadPath = "file:" + userDir + "/" + UPLOAD_DIR;
registry.addResourceHandler("/uploads/**")
.addResourceLocations(absoluteUploadPath);
}
}

View File

@@ -0,0 +1,67 @@
package com.webgame.webgamebackend.common.constants;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* 用户相关常量
*/
public final class UserConstants {
private UserConstants() {
// 工具类禁止实例化
}
/**
* 头像背景色板14 种 Material Design 颜色)
*/
private static final String[] AVATAR_COLORS = {
"#E53935", "#D81B60", "#8E24AA", "#5E35B1",
"#3949AB", "#1E88E5", "#039BE5", "#00ACC1",
"#00897B", "#43A047", "#7CB342", "#C0CA33",
"#FB8C00", "#F4511E"
};
/**
* 默认昵称(当用户昵称为空时使用)
*/
private static final String DEFAULT_NICKNAME = "玩家";
/**
* 根据昵称生成默认头像 data URI本地 SVG无需外部 API
*
* @param nickName 用户昵称
* @return data:image/svg+xml;base64,... 格式的头像
*/
public static String generateDefaultAvatar(String nickName) {
String name = (nickName == null || nickName.isBlank()) ? DEFAULT_NICKNAME : nickName.trim();
// 取首字符作为头像文字
String initial = name.substring(0, 1);
// 根据昵称 hash 选颜色,同一昵称颜色不变
int colorIndex = Math.abs(name.hashCode()) % AVATAR_COLORS.length;
String bgColor = AVATAR_COLORS[colorIndex];
String svg = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="50" fill="%s"/>
<text x="50" y="62" text-anchor="middle" font-size="40"
fill="#FFFFFF" font-family="Arial, sans-serif">%s</text>
</svg>
""".formatted(bgColor, escapeXml(initial));
String base64 = Base64.getEncoder().encodeToString(svg.getBytes(StandardCharsets.UTF_8));
return "data:image/svg+xml;base64," + base64;
}
/**
* XML 特殊字符转义(防止文字中特殊字符破坏 SVG 结构)
*/
private static String escapeXml(String text) {
return text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;");
}
}

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
package com.webgame.webgamebackend.common.dto.game;
import java.util.List;
/**
* 游戏配置 DTO对应前端 GameConfig
*/
public record GameConfigDto(
int maxPlayers,
List<String> modes,
List<Integer> stakesOptions
) {}

View File

@@ -0,0 +1,37 @@
package com.webgame.webgamebackend.common.dto.game;
import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.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<String> modes = JSON.parseArray(entity.getModes(), String.class);
List<Integer> stakesOptions = JSON.parseArray(entity.getStakesOptions(), Integer.class);
return new GameItemResponse(
entity.getGameId(),
entity.getIcon(),
entity.getName(),
entity.getDescription(),
entity.getGameplay(),
new GameConfigDto(entity.getMaxPlayers(), modes, stakesOptions)
);
}
}

View File

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

View File

@@ -0,0 +1,15 @@
package com.webgame.webgamebackend.common.dto.game;
import jakarta.validation.constraints.NotBlank;
/**
* 加入房间请求
*/
public record JoinRoomRequest(
/** 房间 ID */
@NotBlank(message = "房间ID不能为空")
String roomId,
/** 房间密码(公开房间无需传) */
String password
) {}

View File

@@ -0,0 +1,14 @@
package com.webgame.webgamebackend.common.dto.game;
import jakarta.validation.constraints.NotBlank;
/**
* 踢出玩家请求
*/
public record KickPlayerRequest(
@NotBlank(message = "房间ID不能为空")
String roomId,
@NotBlank(message = "目标用户ID不能为空")
String userId
) {}

View File

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

View File

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

View File

@@ -0,0 +1,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<RoomPlayerResponse> players,
String status,
String statusText,
int stakes,
String mode,
String creatorName
) {
/** 状态 → 展示文本映射 */
private static final Map<String, String> STATUS_TEXT_MAP = Map.of(
"waiting", "等待中",
"playing", "进行中",
"finished", "已结束"
);
/**
* 从实体和玩家列表组装响应
*
* @param room 房间实体
* @param players 房间内玩家列表
* @param gameIcon 游戏图标
* @param creatorName 房主昵称
* @return 响应 DTO
*/
public static RoomItemResponse from(
GameRoomEntity room,
List<RoomPlayerResponse> players,
String gameIcon,
String creatorName) {
return new RoomItemResponse(
room.getId(),
room.getName(),
gameIcon,
room.getGameId(),
room.getMaxPlayers(),
players,
room.getStatus(),
STATUS_TEXT_MAP.getOrDefault(room.getStatus(), "未知"),
room.getStakes(),
room.getMode(),
creatorName
);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
package com.webgame.webgamebackend.common.dto.user;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* 更新在线状态请求
*/
public record UpdateStatusRequest(
@NotBlank(message = "状态不能为空")
@Pattern(
regexp = "ONLINE|AWAY|DND|INVISIBLE",
message = "状态值无效可选值ONLINE、AWAY、DND、INVISIBLE"
)
String status
) {}

View File

@@ -0,0 +1,17 @@
package com.webgame.webgamebackend.common.dto.user;
import java.time.LocalDate;
/**
* 更新用户信息请求
*
* 所有字段可选,传什么更新什么。
*/
public record UpdateUserInfoRequest(
String nickName,
String avatar,
String signature,
Integer age,
Integer sex,
LocalDate birthday
) {}

View File

@@ -8,24 +8,28 @@ import java.time.LocalDate;
* 用户信息响应 * 用户信息响应
*/ */
public record UserInfoResponse( public record UserInfoResponse(
String userId,
String nickName, String nickName,
String avatar, String avatar,
String signature, String signature,
Integer age, Integer age,
Integer sex, Integer sex,
LocalDate birthday LocalDate birthday,
String status
) { ) {
/** /**
* 从实体转换 * 从实体转换
*/ */
public static UserInfoResponse from(UserEntity user) { public static UserInfoResponse from(UserEntity user) {
return new UserInfoResponse( return new UserInfoResponse(
user.getId(),
user.getNickName(), user.getNickName(),
user.getAvatar(), user.getAvatar(),
user.getSignature(), user.getSignature(),
user.getAge(), user.getAge(),
user.getSex(), user.getSex(),
user.getBirthday() user.getBirthday(),
user.getStatus()
); );
} }
} }

View File

@@ -21,6 +21,22 @@ public enum ErrorCode {
// ========== 用户模块 ========== // ========== 用户模块 ==========
USER_NOT_FOUND(2001, "用户不存在"), USER_NOT_FOUND(2001, "用户不存在"),
AVATAR_UPLOAD_FAILED(2002, "头像上传失败"),
AVATAR_INVALID_TYPE(2003, "头像格式不支持,仅支持 JPG、PNG、WebP、GIF"),
AVATAR_TOO_LARGE(2004, "头像文件大小不能超过 2MB"),
// ========== 游戏模块 ==========
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; private final int code;

View File

@@ -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<GameListResponse> list() {
GameListResponse result = gameService.getGameList();
return ApiResponse.ok(result);
}
/**
* 获取房间列表
*
* 支持按状态筛选。无需登录即可调用。
*
* @param gameId 游戏 ID必填
* @param status 状态筛选(可选)
*/
@SaIgnore
@GetMapping("/room/list")
public ApiResponse<RoomListResponse> roomList(
@RequestParam String gameId,
@RequestParam(required = false) String status) {
RoomListResponse result = gameService.getRoomList(gameId, status);
return ApiResponse.ok(result);
}
/**
* 创建房间
*
* 需登录。房主自动加入房间并设为已准备。
*/
@PostMapping("/room/create")
public ApiResponse<CreateRoomResponse> createRoom(@Valid @RequestBody CreateRoomRequest request) {
String userId = StpUtil.getLoginIdAsString();
CreateRoomResponse result = gameService.createRoom(request, userId);
return ApiResponse.ok(result);
}
/**
* 加入房间
*
* 需登录。公开房间无需传密码。
*/
@PostMapping("/room/join")
public ApiResponse<RoomItemResponse> joinRoom(@Valid @RequestBody JoinRoomRequest request) {
String userId = StpUtil.getLoginIdAsString();
RoomItemResponse result = gameService.joinRoom(request, userId);
return ApiResponse.ok(result);
}
/**
* 离开房间
*
* 需登录。房主离开时顺位转让。无人时房间自动结束。
*/
@PostMapping("/room/leave")
public ApiResponse<Void> leaveRoom(@Valid @RequestBody LeaveRoomRequest request) {
String userId = StpUtil.getLoginIdAsString();
gameService.leaveRoom(request.roomId(), userId);
return ApiResponse.ok(null);
}
/**
* 踢出玩家
*
* 需登录。仅房主可操作。
*/
@PostMapping("/room/kick")
public ApiResponse<Void> 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<Boolean> 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<Void> startGame(@Valid @RequestBody StartGameRequest request) {
String ownerId = StpUtil.getLoginIdAsString();
gameService.startGame(request.roomId(), ownerId);
return ApiResponse.ok(null);
}
}

View File

@@ -1,13 +1,16 @@
package com.webgame.webgamebackend.controller; package com.webgame.webgamebackend.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.webgame.webgamebackend.common.dto.ApiResponse; import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest;
import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse; import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
import com.webgame.webgamebackend.service.UserService; import com.webgame.webgamebackend.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.bind.annotation.RestController;
/** /**
* 用户控制器 * 用户控制器
@@ -28,4 +31,53 @@ public class UserController {
UserInfoResponse result = userService.getCurrentUserInfo(); UserInfoResponse result = userService.getCurrentUserInfo();
return ApiResponse.ok(result); return ApiResponse.ok(result);
} }
/**
* 更新当前登录用户信息
*
* 所有字段可选,传什么更新什么。
*/
@PutMapping("/info")
public ApiResponse<UserInfoResponse> updateInfo(@Valid @RequestBody UpdateUserInfoRequest request) {
String accountId = StpUtil.getLoginIdAsString();
UserInfoResponse result = userService.updateUserInfo(accountId, request);
return ApiResponse.ok(result);
}
/**
* 更新当前登录用户在线状态
*
* 入参 status 可选值ONLINE、AWAY、DND、INVISIBLE
*/
@PatchMapping("/status")
public ApiResponse<Void> updateStatus(@Valid @RequestBody UpdateStatusRequest request) {
String accountId = StpUtil.getLoginIdAsString();
userService.updateStatus(accountId, request);
return ApiResponse.ok(null);
}
/**
* 上传用户头像
*
* 接收裁剪后的图片文件JPEG/PNG/WebP/GIF≤2MB保存后返回更新后的用户信息。
* 旧头像文件会被自动删除。
*/
@PostMapping("/avatar")
public ApiResponse<UserInfoResponse> uploadAvatar(@RequestParam("file") MultipartFile file) {
String accountId = StpUtil.getLoginIdAsString();
UserInfoResponse result = userService.uploadAvatar(accountId, file);
return ApiResponse.ok(result);
}
/**
* 删除用户头像
*
* 删除磁盘文件并将 DB 中 avatar 置空,前端会回退显示默认头像。
*/
@DeleteMapping("/avatar")
public ApiResponse<UserInfoResponse> deleteAvatar() {
String accountId = StpUtil.getLoginIdAsString();
UserInfoResponse result = userService.deleteAvatar(accountId);
return ApiResponse.ok(result);
}
} }

View File

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

View File

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

View File

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

View File

@@ -68,4 +68,16 @@ public class UserEntity extends UUIDBaseEntity {
*/ */
@Column(name = "birthday") @Column(name = "birthday")
private LocalDate birthday; private LocalDate birthday;
/**
* 金币余额,默认 1000
*/
@Column(name = "balance", nullable = false)
private Long balance = 1000L;
/**
* 在线状态ONLINE / AWAY / DND / INVISIBLE默认 ONLINE
*/
@Column(name = "status", length = 16, nullable = false)
private String status = "ONLINE";
} }

View File

@@ -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<GameConfigEntity, String> {
/**
* 根据游戏标识查询
*
* @param gameId 游戏标识
* @return 游戏配置实体
*/
Optional<GameConfigEntity> findByGameId(String gameId);
/**
* 查询所有启用的游戏,按排序权重升序
*
* @return 启用的游戏列表
*/
List<GameConfigEntity> findByEnabledTrueOrderBySortOrderAsc();
}

View File

@@ -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<GameRoomEntity, String> {
/**
* 根据游戏 ID 和状态查询房间列表
*
* @param gameId 游戏标识
* @param status 房间状态
* @return 房间列表
*/
List<GameRoomEntity> findByGameIdAndStatus(String gameId, String status);
/**
* 根据游戏 ID 查询所有房间(不限状态)
*
* @param gameId 游戏标识
* @return 房间列表
*/
List<GameRoomEntity> findByGameId(String gameId);
/**
* 查询指定游戏下最大的桌号
*
* @param gameId 游戏标识
* @return 最大桌号,可能为 null
*/
@Query("SELECT MAX(r.roomNumber) FROM GameRoomEntity r WHERE r.gameId = :gameId")
Integer findMaxRoomNumberByGameId(@Param("gameId") String gameId);
/**
* 根据 ID 查询房间(带悲观写锁,防止并发问题)
*
* @param id 房间 ID
* @return 房间实体
*/
@Query("SELECT r FROM GameRoomEntity r WHERE r.id = :id")
Optional<GameRoomEntity> findByIdForUpdate(@Param("id") String id);
}

View File

@@ -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<RoomPlayerEntity, String> {
/**
* 查询房间内所有玩家
*
* @param roomId 房间 ID
* @return 玩家列表
*/
List<RoomPlayerEntity> findByRoomId(String roomId);
/**
* 查询指定房间和用户的关联记录
*
* @param roomId 房间 ID
* @param userId 用户 ID
* @return 关联记录
*/
Optional<RoomPlayerEntity> findByRoomIdAndUserId(String roomId, String userId);
/**
* 统计房间当前玩家人数
*
* @param roomId 房间 ID
* @return 玩家人数
*/
int countByRoomId(String roomId);
/**
* 删除指定房间和用户的关联
*
* @param roomId 房间 ID
* @param userId 用户 ID
*/
void deleteByRoomIdAndUserId(String roomId, String userId);
/**
* 判断用户是否已在某房间中
*
* @param userId 用户 ID
* @return 是否存在关联
*/
boolean existsByUserId(String userId);
}

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
package com.webgame.webgamebackend.service; package com.webgame.webgamebackend.service;
import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest;
import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse; import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
import org.springframework.web.multipart.MultipartFile;
/** /**
* 用户服务接口 * 用户服务接口
@@ -13,4 +16,42 @@ public interface UserService {
* @return 用户信息 * @return 用户信息
*/ */
UserInfoResponse getCurrentUserInfo(); UserInfoResponse getCurrentUserInfo();
/**
* 更新当前登录用户的资料
*
* @param accountId 账号 ID
* @param request 更新请求(所有字段可选)
* @return 更新后的用户信息
*/
UserInfoResponse updateUserInfo(String accountId, UpdateUserInfoRequest request);
/**
* 更新当前登录用户的在线状态
*
* @param accountId 账号 ID
* @param request 状态更新请求
*/
void updateStatus(String accountId, UpdateStatusRequest request);
/**
* 上传并更新当前登录用户的头像
*
* 文件会被保存到 uploads/avatars/{accountId}.jpg并更新 DB 中的 avatar 字段。
*
* @param accountId 账号 ID
* @param file 头像文件(前端已裁剪为 200×200 JPEG
* @return 更新后的用户信息
*/
UserInfoResponse uploadAvatar(String accountId, MultipartFile file);
/**
* 删除当前登录用户的头像
*
* 删除磁盘文件,并将 DB 中 avatar 置为 null前端会回退显示默认头像
*
* @param accountId 账号 ID
* @return 更新后的用户信息
*/
UserInfoResponse deleteAvatar(String accountId);
} }

View File

@@ -1,6 +1,7 @@
package com.webgame.webgamebackend.service.impl; package com.webgame.webgamebackend.service.impl;
import cn.dev33.satoken.stp.StpUtil; 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.LoginRequest;
import com.webgame.webgamebackend.common.dto.account.LoginResponse; import com.webgame.webgamebackend.common.dto.account.LoginResponse;
import com.webgame.webgamebackend.common.dto.account.RegisterRequest; import com.webgame.webgamebackend.common.dto.account.RegisterRequest;
@@ -64,10 +65,12 @@ public class AccountServiceImpl implements AccountService {
// 创建用户资料(默认值) // 创建用户资料(默认值)
UserEntity user = new UserEntity(); UserEntity user = new UserEntity();
user.setAccount(account); user.setAccount(account);
user.setNickName("玩家" + generateRandomCode()); String nickName = "玩家" + generateRandomCode();
user.setNickName(nickName);
user.setSex(0); user.setSex(0);
user.setSignature(""); user.setSignature("");
user.setAvatar(""); // 根据昵称生成默认 DiceBear 头像
user.setAvatar(UserConstants.generateDefaultAvatar(nickName));
userRepository.save(user); userRepository.save(user);
log.info("用户注册成功: username={}", request.username()); log.info("用户注册成功: username={}", request.username());

View File

@@ -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<GameConfigEntity> configs = gameConfigRepository.findByEnabledTrueOrderBySortOrderAsc();
List<GameItemResponse> 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<GameRoomEntity> rooms;
if (status != null && !status.isBlank()) {
rooms = gameRoomRepository.findByGameIdAndStatus(gameId, status);
} else {
// 默认不返回已结束的房间
rooms = gameRoomRepository.findByGameId(gameId).stream()
.filter(r -> !"finished".equals(r.getStatus()))
.toList();
}
// 组装每个房间的玩家信息和房主昵称
List<RoomItemResponse> list = new ArrayList<>();
for (GameRoomEntity room : rooms) {
List<RoomPlayerResponse> 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<String> modes = JSON.parseArray(gameConfig.getModes(), String.class);
if (!modes.contains(request.mode())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的模式: " + request.mode());
}
// 校验 stakes 在可选列表中
List<Integer> stakesOptions = JSON.parseArray(gameConfig.getStakesOptions(), Integer.class);
if (!stakesOptions.contains(request.stakes())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的底注: " + request.stakes());
}
// 检查用户是否已在其他房间中
if (roomPlayerRepository.existsByUserId(creatorId)) {
throw new BusinessException(ErrorCode.ALREADY_IN_ROOM);
}
// 生成桌号
Integer maxRoomNumber = gameRoomRepository.findMaxRoomNumberByGameId(request.gameId());
int nextRoomNumber = (maxRoomNumber == null) ? 1 : maxRoomNumber + 1;
// 生成桌名
String roomName = (request.name() != null && !request.name().isBlank())
? request.name()
: "" + String.format("%02d", nextRoomNumber);
// 创建房间
GameRoomEntity room = new GameRoomEntity()
.setGameId(request.gameId())
.setRoomNumber(nextRoomNumber)
.setName(roomName)
.setMaxPlayers(request.maxPlayers())
.setMode(request.mode())
.setStakes(request.stakes())
.setStatus("waiting")
.setCreatorId(creatorId);
// 密码加密(如果提供了密码)
if (request.password() != null && !request.password().isBlank()) {
room.setPassword(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<RoomPlayerResponse> 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<RoomPlayerEntity> 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<RoomPlayerResponse> 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<RoomPlayerEntity> 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<RoomPlayerEntity> 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<RoomPlayerResponse> buildRoomPlayers(String roomId) {
List<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(roomId);
if (roomPlayers.isEmpty()) {
return List.of();
}
// 批量查询玩家用户信息
List<String> userIds = roomPlayers.stream()
.map(RoomPlayerEntity::getUserId)
.toList();
List<UserEntity> users = userRepository.findByAccountIdIn(userIds);
Map<String, UserEntity> userMap = users.stream()
.collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
return roomPlayers.stream()
.map(rp -> {
UserEntity user = userMap.get(rp.getUserId());
String avatar = (user != null) ? user.getAvatar() : "";
String nickname = (user != null) ? user.getNickName() : "未知玩家";
return new RoomPlayerResponse(avatar, nickname);
})
.toList();
}
/**
* 获取用户昵称
*
* @param accountId 账号 ID
* @return 昵称,查不到则返回 "未知玩家"
*/
private String getNickname(String accountId) {
return userRepository.findByAccountId(accountId)
.map(UserEntity::getNickName)
.orElse("未知玩家");
}
}

View File

@@ -1,6 +1,8 @@
package com.webgame.webgamebackend.service.impl; package com.webgame.webgamebackend.service.impl;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest;
import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse; import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
import com.webgame.webgamebackend.common.exception.BusinessException; import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode; import com.webgame.webgamebackend.common.exception.ErrorCode;
@@ -10,6 +12,14 @@ import com.webgame.webgamebackend.service.UserService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
/** /**
* 用户服务实现 * 用户服务实现
@@ -19,6 +29,24 @@ import org.springframework.stereotype.Service;
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserServiceImpl implements UserService { public class UserServiceImpl implements UserService {
/** 头像存储目录(相对于项目工作目录) */
private static final String AVATAR_DIR = "uploads/avatars";
/**
* 获取头像存储的绝对路径目录
*
* 使用 user.dir 构建绝对路径,避免 Tomcat 临时工作目录导致 FileNotFoundException。
*/
private Path getAvatarDir() {
return Paths.get(System.getProperty("user.dir"), AVATAR_DIR).toAbsolutePath();
}
/** 允许的头像文件类型 */
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"image/jpeg", "image/png", "image/webp", "image/gif"
);
/** 头像文件大小上限2MB */
private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024;
private final UserRepository userRepository; private final UserRepository userRepository;
@Override @Override
@@ -29,4 +57,128 @@ public class UserServiceImpl implements UserService {
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
return UserInfoResponse.from(user); 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);
}
@Override
@Transactional
public void updateStatus(String accountId, UpdateStatusRequest request) {
UserEntity user = userRepository.findByAccountId(accountId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
user.setStatus(request.status());
userRepository.save(user);
log.info("[用户服务] 更新在线状态, accountId={}, status={}", accountId, request.status());
}
@Override
@Transactional
public UserInfoResponse uploadAvatar(String accountId, MultipartFile file) {
// 校验文件类型
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
throw new BusinessException(ErrorCode.AVATAR_INVALID_TYPE);
}
// 校验文件大小
if (file.getSize() > MAX_AVATAR_SIZE) {
throw new BusinessException(ErrorCode.AVATAR_TOO_LARGE);
}
UserEntity user = userRepository.findByAccountId(accountId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
try {
// 确保目录存在(使用绝对路径,避免 Tomcat 临时目录问题)
Path avatarDir = getAvatarDir();
Files.createDirectories(avatarDir);
// 删除旧头像文件(如果存在且不是默认 SVG
deleteOldAvatarFile(user.getAvatar());
// 保存新头像uploads/avatars/{accountId}.jpg
String filename = accountId + ".jpg";
Path targetPath = avatarDir.resolve(filename);
file.transferTo(targetPath.toFile());
// 更新数据库
String avatarUrl = "/" + AVATAR_DIR + "/" + filename;
user.setAvatar(avatarUrl);
userRepository.save(user);
log.info("[用户服务] 头像上传成功, accountId={}, path={}", accountId, avatarUrl);
return UserInfoResponse.from(user);
} catch (IOException e) {
log.error("[用户服务] 头像上传失败, accountId={}", accountId, e);
throw new BusinessException(ErrorCode.AVATAR_UPLOAD_FAILED, "头像保存失败,请重试");
}
}
@Override
@Transactional
public UserInfoResponse deleteAvatar(String accountId) {
UserEntity user = userRepository.findByAccountId(accountId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
// 删除旧头像文件
deleteOldAvatarFile(user.getAvatar());
// 数据库置空(前端会回退显示默认头像)
user.setAvatar(null);
userRepository.save(user);
log.info("[用户服务] 头像已删除, accountId={}", accountId);
return UserInfoResponse.from(user);
}
/**
* 删除旧的用户头像文件
*
* 仅删除 uploads/avatars/ 下的文件,跳过默认 SVG 和非本地路径。
*
* @param avatarPath 头像路径,可能为 null
*/
private void deleteOldAvatarFile(String avatarPath) {
if (avatarPath == null || !avatarPath.startsWith("/" + AVATAR_DIR + "/")) {
return;
}
try {
// 从 URL 路径中提取文件名,使用绝对路径删除
String filename = avatarPath.substring(avatarPath.lastIndexOf('/') + 1);
Path oldFile = getAvatarDir().resolve(filename);
Files.deleteIfExists(oldFile);
log.debug("[用户服务] 旧头像已删除: {}", oldFile);
} catch (IOException e) {
log.warn("[用户服务] 旧头像删除失败: {}", avatarPath, e);
}
}
} }

View File

@@ -33,12 +33,19 @@ spring:
# 连接池中的最小空闲连接 # 连接池中的最小空闲连接
min-idle: 0 min-idle: 0
# SQL 初始化data.sql
sql:
init:
mode: always
# JPA相关配置 # JPA相关配置
jpa: jpa:
hibernate: hibernate:
ddl-auto: update ddl-auto: update
show-sql: false # 是否输出sql show-sql: false # 是否输出sql
open-in-view: false open-in-view: false
# JPA 初始化延迟到 data.sql 执行之后,避免表未创建
defer-datasource-initialization: true
# Servlet相关配置 # Servlet相关配置
servlet: servlet:

View File

@@ -0,0 +1,53 @@
-- 游戏配置初始化数据
-- 使用 INSERT IGNORE 保证幂等,重复执行不会报错
INSERT IGNORE INTO game_config (id, game_id, icon, name, description, gameplay, max_players, modes, stakes_options, sort_order, enabled, create_time, update_time)
VALUES
(
REPLACE(UUID(), '-', ''),
'snake',
'🐍',
'贪吃蛇',
'经典贪吃蛇竞技,在狭小空间内争夺生存权',
'方向键控制蛇的移动,吃到食物变长。撞墙或其他蛇的身体即死亡,最后存活的玩家获胜。',
4,
'["经典模式","道具模式","加速模式"]',
'[100,200,300,400,500]',
0, 1, NOW(), NOW()
),
(
REPLACE(UUID(), '-', ''),
'tetris',
'🧊',
'俄罗斯方块',
'经典下落消除,与对手一较高下',
'方向键移动和旋转方块,填满横排即可消除。同时向对手发送干扰行,坚持到最后的玩家获胜。',
2,
'["1v1对战","竞速模式"]',
'[150,200,300,400,500]',
1, 1, NOW(), NOW()
),
(
REPLACE(UUID(), '-', ''),
'gomoku',
'',
'五子棋',
'黑白对弈,先连成五子者胜',
'鼠标点击落子,黑白双方轮流在棋盘上放置棋子。先在横、竖、斜任意方向连成五子的一方获胜。',
2,
'["无禁手","有禁手"]',
'[100,200,300,400,500]',
2, 1, NOW(), NOW()
),
(
REPLACE(UUID(), '-', ''),
'guess-number',
'🔢',
'猜数字',
'逻辑推理对战,猜中对方的秘密数字',
'每轮输入猜测的数字组合,系统会提示位置和数字正确的数量。率先猜中对手数字组合的玩家获胜。',
4,
'["经典模式","限时模式"]',
'[100,150,200,300,500]',
3, 1, NOW(), NOW()
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB