1.1 项目背景
你好呀,我是小邹。
在日常娱乐活动中,打牌是一种常见的社交方式。然而,多人打牌时的记账问题一直困扰着玩家:传统的纸笔记账容易出错、难以实时同步、且不方便查看历史记录。虽然当前市面上也有这类小程序,但是满天飞的广告使人体验极差。所以,我开发了这款打牌记账应用,旨在提供一个简单、实时、多人协作的数字化记账解决方案。
1.2 项目目标
- 实时同步: 房间内所有用户的记账操作实时同步
- 多人协作: 支持最多8人同时在线记账
- 数据持久化: 记账记录永久保存,支持历史查询
- 跨平台访问: 基于Web技术,支持各种设备访问
- 简洁易用: 界面直观,操作简便
1.3 技术栈选型
| 层级 | 技术选型 | 版本 | 选型理由 |
|---|---|---|---|
| 后端框架 | Spring Boot | 2.7.14 | 快速开发、生态丰富、生产级稳定 |
| 数据访问 | MyBatis-Plus | 3.5.3.1 | 简化CRUD、支持逻辑删除、性能优秀 |
| 数据库 | MySQL | 8.0 | 关系型数据存储、事务支持、高可靠性 |
| 实时通信 | WebSocket | - | 全双工通信、低延迟、实时推送 |
| 前端技术 | HTML5 + CSS3 + JavaScript | - | 原生技术、无需构建、快速迭代 |
| 模板引擎 | Thymeleaf | - | 服务端模板渲染、Spring原生支持 |
| JSON处理 | Fastjson2 | 2.0.40 | 高性能JSON序列化/反序列化 |
| 密码加密 | BCrypt | 5.8.0 | Spring Security Crypto,不可逆加密 |
| 构建工具 | Maven | - | 依赖管理、项目构建标准化 |
2. 技术架构设计
2.1 系统架构图
┌─────────────────────────────────────────────────────────────┐
│ 客户端层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 浏览器 │ │ 手机浏览器 │ │ 微信浏览器 │ │
│ │ (Chrome) │ │ (Safari) │ │ (WebView) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
└─────────┼────────────────┼────────────────┼────────────────┘
│ │ │
└────────────────┴────────────────┘
│
HTTP / WebSocket
│
┌──────────────────────────┼──────────────────────────────────┐
│ 应用服务层 │
│ ┌───────────────────────┴──────────────────────────────┐ │
│ │ Spring Boot Application │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Controller │ │ Service │ │ Mapper │ │ │
│ │ │ (REST) │ │ (Business) │ │ (Data) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ WebSocket │ │ MyBatis-Plus│ │ │
│ │ │ Handler │ │ (ORM) │ │ │
│ │ └─────────────┘ └─────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ JDBC
│
┌──────────────────────────┼──────────────────────────────────┐
│ 数据存储层 │
│ ┌─────┴─────┐ │
│ │ MySQL │ │
│ │ (8.0) │ │
│ └───────────┘ │
└─────────────────────────────────────────────────────────────┘
2.2 项目结构
d:\Code\puke\
├── pom.xml # Maven 依赖配置
├── PROJECT_TECHNICAL_DOCUMENTATION.md # 技术文档
├── src\
│ └── main\
│ ├── java\com\puke\
│ │ ├── CardAccountingApplication.java # 启动类
│ │ ├── config\
│ │ │ ├── MyMetaObjectHandler.java # 自动填充处理器
│ │ │ ├── WebSocketConfig.java # WebSocket配置
│ │ │ └── RoomWebSocketHandler.java # WebSocket处理器
│ │ ├── controller\
│ │ │ ├── PageController.java # 页面路由
│ │ │ └── RoomController.java # API接口
│ │ ├── dto\
│ │ │ ├── Result.java # 统一响应
│ │ │ ├── RoomDTO.java # 房间DTO
│ │ │ ├── UserDTO.java # 用户DTO
│ │ │ ├── RecordDTO.java # 记录DTO
│ │ │ ├── RoomStatusDTO.java # 房间状态DTO
│ │ │ ├── JoinRoomRequest.java # 加入请求
│ │ │ ├── AccountingRequest.java # 记账请求
│ │ │ ├── SettleRequest.java # 结算请求
│ │ │ ├── BatchCheckRequest.java # 批量查询请求
│ │ │ └── UpdateUsernameRequest.java # 修改昵称请求
│ │ ├── entity\
│ │ │ ├── Room.java # 房间实体
│ │ │ ├── User.java # 用户实体
│ │ │ └── Record.java # 记录实体
│ │ ├── mapper\
│ │ │ ├── RoomMapper.java # 房间Mapper
│ │ │ ├── UserMapper.java # 用户Mapper
│ │ │ └── RecordMapper.java # 记录Mapper
│ │ ├── service\
│ │ │ ├── RoomService.java # 房间服务接口
│ │ │ ├── UserService.java # 用户服务接口
│ │ │ ├── RecordService.java # 记录服务接口
│ │ │ └── impl\ # 服务实现
│ │ └── util\
│ │ ├── HtmlUtils.java # XSS防护工具类
│ │ ├── PasswordEncoder.java # BCrypt密码加密工具
│ │ └── PasswordMigrationUtil.java # 密码迁移工具类
│ └── resources\
│ ├── application.yml # 应用配置
│ └── templates\
│ ├── index.html # 首页
│ └── room.html # 房间页
3. 核心功能实现
3.1 房间管理模块
3.1.1 房间创建与唯一标识生成
房间采用8位随机字符串作为唯一标识,使用UUID生成确保唯一性。第一个加入房间的用户自动成为房主:
@Service
public class RoomServiceImpl extends ServiceImpl<RoomMapper, Room> implements RoomService {
@Override
@Transactional
public Room createRoom() {
Room room = new Room();
// 生成8位随机房间码
String roomCode = generateRoomCode();
room.setRoomCode(roomCode);
room.setRoomName("打牌房间");
room.setMaxUsers(8);
room.setCurrentUsers(0);
room.setStatus(1);
room.setOwnerId(null); // 房主在第一个用户加入时设置
this.save(room);
log.info("创建房间成功,房间码:{},房间ID:{}", roomCode, room.getId());
return room;
}
/**
* 生成8位随机房间码
*/
private String generateRoomCode() {
return UUID.randomUUID().toString()
.replaceAll("-", "")
.substring(0, 8)
.toUpperCase();
}
}
实现思路:
- 使用UUID生成32位随机字符串
- 移除连字符后取前8位大写字母数字组合
- 理论上支持 36^8 ≈ 2.8万亿 种组合,冲突概率极低
- 使用数据库唯一索引确保绝对唯一
- 第一个加入房间的用户自动成为房主(ownerId字段)
3.1.2 房间人数控制
通过数据库乐观锁实现并发安全的房间人数控制:
@Mapper
public interface RoomMapper extends BaseMapper<Room> {
/**
* 增加房间当前用户数量
* 使用条件判断确保不超过最大人数
*/
@Update("UPDATE room SET current_users = current_users + 1 " +
"WHERE id = #{roomId} AND current_users < max_users")
int incrementUserCount(@Param("roomId") Long roomId);
/**
* 减少房间当前用户数量
*/
@Update("UPDATE room SET current_users = current_users - 1 " +
"WHERE id = #{roomId} AND current_users > 0")
int decrementUserCount(@Param("roomId") Long roomId);
}
3.2 用户管理模块
3.2.1 用户加入房间流程
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Override
@Transactional
public User joinRoom(Long roomId, String username, String sessionId) {
// 1. 检查房间是否已满
if (roomService.isRoomFull(roomId)) {
throw new RuntimeException("房间已满,最多支持8名用户");
}
// 2. 检查用户名是否已存在(同房间内)
if (isUsernameExists(roomId, username)) {
throw new RuntimeException("用户名已存在,请更换用户名");
}
// 3. 创建用户
User user = new User();
user.setRoomId(roomId);
user.setUsername(username);
user.setBalance(0); // 初始余额为0
user.setSessionId(sessionId);
user.setOnlineStatus(1);
this.save(user);
// 4. 增加房间用户数量
roomService.incrementUserCount(roomId);
// 5. 如果是第一个用户,设置为房主
Room room = roomService.getById(roomId);
if (room != null && room.getOwnerId() == null) {
room.setOwnerId(user.getId());
roomService.updateById(room);
log.info("设置房主,用户ID:{},房间ID:{}", user.getId(), roomId);
}
return user;
}
}
关键设计:
- 使用事务确保数据一致性
- 前置校验避免无效操作
- 余额单位使用"分"避免浮点精度问题
- 第一个加入房间的用户自动成为房主
3.2.2 在线状态管理
@Override
public void updateOnlineStatus(Long userId, Integer status) {
baseMapper.updateOnlineStatus(userId, status);
}
@Override
public void updateSessionId(Long userId, String sessionId) {
baseMapper.updateSessionId(userId, sessionId);
}
3.3 记账功能模块
3.3.1 记账事务处理
记账操作涉及两个用户的余额更新,必须使用事务保证数据一致性:
@Override
@Transactional
public boolean updateBalance(Long fromUserId, Long toUserId, Integer amount) {
// 付款方减少金额(余额为负表示应付)
int result1 = baseMapper.updateBalance(fromUserId, -amount);
// 收款方增加金额(余额为正表示应收)
int result2 = baseMapper.updateBalance(toUserId, amount);
return result1 > 0 && result2 > 0;
}
@Update("UPDATE user SET balance = balance + #{amount} WHERE id = #{userId}")
int updateBalance(@Param("userId") Long userId, @Param("amount") Integer amount);
3.3.2 记账记录生成
@Override
@Transactional
public Record createRecord(Long roomId, Long fromUserId, String fromUsername,
Long toUserId, String toUsername, Integer amount) {
Record record = new Record();
record.setRoomId(roomId);
record.setFromUserId(fromUserId);
record.setFromUsername(fromUsername);
record.setToUserId(toUserId);
record.setToUsername(toUsername);
record.setAmount(amount); // 单位:分
this.save(record);
log.info("创建记账记录成功,房间ID:{},付款人:{},收款人:{},金额:{}",
roomId, fromUsername, toUsername, amount);
return record;
}
3.4 房间结算模块
3.4.1 结算权限控制
只有房主可以执行结算操作,通过数据库验证房主身份:
@Override
@Transactional
public boolean settleAndCloseRoom(Long roomId, Long ownerId) {
Room room = this.getById(roomId);
if (room == null) {
throw new RuntimeException("房间不存在");
}
// 验证房主身份
if (!ownerId.equals(room.getOwnerId())) {
throw new RuntimeException("只有房主可以结算房间");
}
// 检查房间是否已关闭
if (room.getStatus() == 0) {
throw new RuntimeException("房间已关闭");
}
// 关闭房间
room.setStatus(0);
return this.updateById(room);
}
3.4.2 结算流程
- 房主点击结算按钮 → 弹出二次确认对话框
- 确认结算 → 后端验证房主身份并关闭房间
- 广播房间关闭消息 → 所有在线用户收到通知
- 展示结算结果 → 显示所有用户的最终盈亏情况
// 前端处理房间关闭
function handleRoomClosed() {
isRoomSettled = true;
showToast('房间已结算');
showSettleResultForAll(); // 显示结算页面
}
3.5 用户昵称修改模块
3.5.1 昵称修改流程
用户点击自己的头像或用户名可修改昵称,修改后实时同步到所有相关数据:
@Override
@Transactional
public User updateUsername(Long userId, String newUsername) {
User user = this.getById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 检查新昵称是否已存在(在当前房间内)
if (!user.getUsername().equals(newUsername) &&
isUsernameExists(user.getRoomId(), newUsername)) {
throw new RuntimeException("用户名已存在,请更换用户名");
}
user.setUsername(newUsername);
this.updateById(user);
// 同步更新记账记录中的昵称
recordMapper.updateFromUsername(userId, newUsername);
recordMapper.updateToUsername(userId, newUsername);
return user;
}
3.5.2 前端实时更新
// 提交修改昵称
async function submitUpdateUsername() {
const response = await fetch(`${API_BASE}/api/room/updateUsername`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: currentUser.id,
newUsername: newUsername
})
});
if (result.code === 200) {
// 立即更新本地显示
updateCurrentUserDisplay();
// 广播给其他用户
ws.send(JSON.stringify({
type: 'UPDATE_USERNAME',
userId: currentUser.id,
username: newUsername
}));
}
}
// 处理其他用户的昵称变更
async function handleUsernameUpdate(data) {
// 更新用户列表
const userIndex = lastUsersData.findIndex(u => u.id === data.userId);
if (userIndex !== -1) {
lastUsersData[userIndex].username = data.username;
renderUsers(lastUsersData);
// 如果是房主,更新右上角显示
if (lastUsersData[userIndex].isOwner === 1) {
document.getElementById('roomOwner').textContent = data.username;
}
// 重新加载记账记录
await reloadRecords();
}
}
3.6 实时通信模块
3.6.1 WebSocket配置
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final RoomWebSocketHandler roomWebSocketHandler;
public WebSocketConfig(RoomWebSocketHandler roomWebSocketHandler) {
this.roomWebSocketHandler = roomWebSocketHandler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(roomWebSocketHandler, "/ws/room/{roomCode}")
.setAllowedOrigins("*"); // 允许跨域
}
}
3.4.2 WebSocket消息处理器
@Component
public class RoomWebSocketHandler extends TextWebSocketHandler {
// 存储房间内的所有会话
private static final Map<String, Map<String, WebSocketSession>> roomSessions
= new ConcurrentHashMap<>();
// 存储会话对应的用户信息
private static final Map<String, Long> sessionUserMap = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String roomCode = getRoomCodeFromSession(session);
String sessionId = session.getId();
// 将会话加入房间
roomSessions.computeIfAbsent(roomCode, k -> new ConcurrentHashMap<>())
.put(sessionId, session);
// 广播用户列表更新
broadcastUserList(roomCode);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message)
throws Exception {
String payload = message.getPayload();
String roomCode = getRoomCodeFromSession(session);
JSONObject json = JSON.parseObject(payload);
String type = json.getString("type");
switch (type) {
case "JOIN":
handleJoin(session, json, roomCode);
break;
case "ACCOUNTING":
handleAccounting(session, json, roomCode);
break;
case "PING":
handlePing(session);
break;
}
}
/**
* 广播消息到房间
*/
private void broadcastToRoom(String roomCode, String message) {
Map<String, WebSocketSession> sessions = roomSessions.get(roomCode);
if (sessions == null) return;
TextMessage textMessage = new TextMessage(message);
sessions.values().forEach(session -> {
try {
if (session.isOpen()) {
session.sendMessage(textMessage);
}
} catch (IOException e) {
log.error("发送消息失败", e);
}
});
}
}
3.4.3 记账消息处理
private void handleAccounting(WebSocketSession session, JSONObject json, String roomCode)
throws IOException {
Long fromUserId = json.getLong("fromUserId");
Long toUserId = json.getLong("toUserId");
Integer amount = json.getInteger("amount");
User fromUser = userService.getById(fromUserId);
User toUser = userService.getById(toUserId);
// 更新用户余额
boolean success = userService.updateBalance(fromUserId, toUserId, amount);
if (!success) {
sendErrorMessage(session, "记账失败");
return;
}
// 创建记账记录
Record record = recordService.createRecord(
fromUser.getRoomId(),
fromUserId, fromUser.getUsername(),
toUserId, toUser.getUsername(),
amount
);
// 构建记录DTO
RecordDTO recordDTO = convertToRecordDTO(record);
// 广播记账消息到房间内所有用户
JSONObject broadcast = new JSONObject();
broadcast.put("type", "NEW_RECORD");
broadcast.put("data", recordDTO);
broadcastToRoom(roomCode, broadcast.toJSONString());
// 广播用户列表更新(余额变化)
broadcastUserList(roomCode);
}
4. 关键技术难点及解决方案
4.1 并发控制 - 房间人数限制
问题: 多个用户同时加入房间时,可能出现超出人数限制的情况。
解决方案: 使用数据库条件更新实现乐观锁
UPDATE room
SET current_users = current_users + 1
WHERE id = #{roomId} AND current_users < max_users
原理:
- 只有当前人数小于最大人数时,更新才会成功
- 返回影响行数为0表示更新失败(房间已满)
- 无需显式加锁,利用数据库原子性保证并发安全
4.2 数据一致性 - 记账事务
问题: 记账涉及两个用户余额更新,必须保证原子性。
解决方案: Spring声明式事务 + 数据库事务
@Transactional
public boolean updateBalance(Long fromUserId, Long toUserId, Integer amount) {
int result1 = baseMapper.updateBalance(fromUserId, -amount);
int result2 = baseMapper.updateBalance(toUserId, amount);
if (result1 == 0 || result2 == 0) {
throw new RuntimeException("余额更新失败");
}
return true;
}
回滚策略: 任一更新失败抛出异常,Spring自动回滚事务。
4.3 实时同步 - WebSocket连接管理
问题: 用户断线后需要重连,并保持数据同步。
解决方案:
- 心跳机制: 每30秒发送PING/PONG保持连接
- 断线重连: 客户端自动重连,服务器恢复用户状态
- 历史数据同步: 重连后自动推送历史记录
// 客户端心跳
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'PING' }));
}
}, 30000);
// 断线重连
ws.onclose = function() {
if (!reconnectInterval) {
reconnectInterval = setInterval(() => {
initWebSocket();
}, 3000);
}
};
4.4 跨浏览器复制功能
问题: Clipboard API在不安全环境(HTTP)或旧浏览器中不可用。
解决方案: 降级方案兼容处理
async function copyRoomCode() {
const currentUrl = window.location.href;
try {
// 优先使用现代 Clipboard API
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(currentUrl);
showToast('链接已复制');
return;
}
// 降级方案:使用 execCommand
const textArea = document.createElement('textarea');
textArea.value = currentUrl;
textArea.style.cssText = 'position:fixed;left:-9999px;';
document.body.appendChild(textArea);
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
showToast(successful ? '链接已复制' : '复制失败',
successful ? 'success' : 'error');
} catch (err) {
showToast('复制失败,请手动复制', 'error');
}
}
4.5 XSS安全防护
问题: 用户输入可能包含恶意脚本,存在XSS攻击风险。
解决方案: 后端HtmlUtils工具类对用户输入进行转义处理
@Component
public class HtmlUtils {
/**
* 对HTML特殊字符进行转义,防止XSS攻击
*/
public static String escapeHtml(String input) {
if (input == null || input.isEmpty()) {
return input;
}
return input.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll("\"", """)
.replaceAll("'", "'")
.replaceAll("/", "/");
}
}
使用场景:
- 用户昵称修改时进行HTML转义
- 房间名称展示时进行转义
- 记账记录中的用户名展示时进行转义
4.6 房间密码安全 - BCrypt加密实现
问题: 房间密码需要安全存储,防止数据库泄露导致密码暴露。
解决方案: 采用BCrypt算法进行不可逆加密存储
4.6.1 BCrypt加密工具类实现
@Component
public class PasswordEncoder {
/**
* BCrypt加密器
* strength: 10 (默认),范围4-31,数值越大计算越慢越安全
* 10表示2^10=1024轮迭代
*/
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
/**
* 加密密码
*/
public String encode(String rawPassword) {
if (rawPassword == null || rawPassword.isEmpty()) {
return null;
}
return encoder.encode(rawPassword);
}
/**
* 验证密码
*/
public boolean matches(String rawPassword, String encodedPassword) {
if (rawPassword == null || rawPassword.isEmpty()) {
return false;
}
if (encodedPassword == null || encodedPassword.isEmpty()) {
return false;
}
return encoder.matches(rawPassword, encodedPassword);
}
/**
* 判断密码是否已经加密
* BCrypt加密后的密码格式:$2a$10$...
*/
public boolean isEncoded(String password) {
if (password == null || password.isEmpty()) {
return false;
}
return password.startsWith("$2a$") ||
password.startsWith("$2b$") ||
password.startsWith("$2y$");
}
}
4.6.2 房间创建时加密密码
@Override
@Transactional
public Room createRoom(String password) {
Room room = new Room();
// ... 其他初始化代码
// 设置房间密码(如果提供)- 使用BCrypt加密
if (password != null && !password.isEmpty()) {
String encodedPassword = passwordEncoder.encode(password);
room.setRoomPassword(encodedPassword);
log.debug("房间密码已加密,原始长度:{},加密后长度:{}",
password.length(), encodedPassword.length());
}
this.save(room);
return room;
}
4.6.3 密码验证(兼容加密和明文)
// 验证房间密码(支持加密密码和明文密码兼容验证)
if (room.getRoomPassword() != null && !room.getRoomPassword().isEmpty()) {
if (request.getPassword() == null || request.getPassword().isEmpty()) {
return Result.error("该房间需要密码,请输入密码");
}
// 检查密码是否已经加密
if (passwordEncoder.isEncoded(room.getRoomPassword())) {
// 已加密密码,使用BCrypt验证
if (!passwordEncoder.matches(request.getPassword(), room.getRoomPassword())) {
return Result.error("密码错误,请重新输入");
}
} else {
// 未加密密码(兼容旧数据),使用明文比较
if (!room.getRoomPassword().equals(request.getPassword())) {
return Result.error("密码错误,请重新输入");
}
}
}
4.6.4 数据迁移方案
@Component
@Order(1) // 确保在应用启动早期执行
public class PasswordMigrationUtil implements ApplicationRunner {
@Autowired
private RoomMapper roomMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public void run(ApplicationArguments args) {
migratePasswords();
}
/**
* 执行密码迁移
* 将所有明文密码转换为BCrypt加密格式
*/
public void migratePasswords() {
// 查询所有有密码的房间
List<Room> roomsWithPassword = roomMapper.selectRoomsWithPassword();
for (Room room : roomsWithPassword) {
String password = room.getRoomPassword();
// 检查密码是否已经加密
if (passwordEncoder.isEncoded(password)) {
continue; // 已加密,跳过
}
// 明文密码,需要加密
String encodedPassword = passwordEncoder.encode(password);
room.setRoomPassword(encodedPassword);
roomMapper.updateById(room);
}
}
}
4.6.5 BCrypt技术特点
| 特性 | 说明 |
|---|---|
| 自动盐值 | 每次加密生成不同的随机盐值,相同密码加密结果不同 |
| 工作因子 | 可配置迭代次数(默认10,即1024轮),数值越大越安全 |
| 内置版本 | 密码格式包含版本标识($2a$),便于后续升级 |
| 不可逆性 | 无法从密文反推出原始密码 |
| 抗彩虹表 | 随机盐值有效防止彩虹表攻击 |
加密示例对比:
原始密码: 123456
第一次加密: $2a$10$mp0Vq.D6izkkBuNY3PaMmeIyWeEwlCcjzw/u2eBbQO8BPekTSpLNm
第二次加密: $2a$10$zZWJIba2eoKMDUxxgusCt.p6ob0gcrk1s2FCKxrcRzkCyu7Xjv8JC
注: 相同密码两次加密结果不同,但都能验证通过。
5. 数据库设计
5.1 实体关系图
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ room │ │ user │ │ record │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ PK id │◄──────┤ FK room_id │ │ FK room_id │
│ room_code│ │ PK id │ │ FK from_user│
│ room_name│ │ username │ │ FK to_user │
│ max_users│ │ balance │ │ amount │
│ current_ │ │ session_ │ │ record_ │
│ status │ │ online_ │ │ │
│ create_ │ │ join_time│ │ │
│ update_ │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
5.2 表结构定义
room 表
CREATE TABLE room (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
owner_id BIGINT COMMENT '房主用户ID',
room_code VARCHAR(32) NOT NULL UNIQUE COMMENT '房间唯一标识码',
room_name VARCHAR(100) DEFAULT '打牌房间' COMMENT '房间名称',
max_users INT DEFAULT 8 COMMENT '最大用户数量',
current_users INT DEFAULT 0 COMMENT '当前用户数量',
status TINYINT DEFAULT 1 COMMENT '房间状态:0-关闭,1-开启',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除标志',
INDEX idx_room_code (room_code),
INDEX idx_owner_id (owner_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房间表';
user 表
CREATE TABLE user (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
room_id BIGINT NOT NULL COMMENT '所属房间ID',
username VARCHAR(50) NOT NULL COMMENT '用户名称',
balance INT DEFAULT 0 COMMENT '用户余额(单位:分)',
session_id VARCHAR(100) COMMENT 'WebSocket会话ID',
online_status TINYINT DEFAULT 1 COMMENT '在线状态',
join_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
last_active_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后活跃时间',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除标志',
INDEX idx_room_id (room_id),
INDEX idx_session_id (session_id),
FOREIGN KEY (room_id) REFERENCES room(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
record 表
CREATE TABLE record (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
room_id BIGINT NOT NULL COMMENT '所属房间ID',
from_user_id BIGINT NOT NULL COMMENT '付款用户ID',
from_username VARCHAR(50) NOT NULL COMMENT '付款用户名称',
to_user_id BIGINT NOT NULL COMMENT '收款用户ID',
to_username VARCHAR(50) NOT NULL COMMENT '收款用户名称',
amount INT NOT NULL COMMENT '记账金额(单位:分)',
record_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '记账时间',
deleted TINYINT DEFAULT 0 COMMENT '逻辑删除标志',
INDEX idx_room_id (room_id),
INDEX idx_record_time (record_time),
FOREIGN KEY (room_id) REFERENCES room(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='记账记录表';
5.3 索引设计策略
| 表名 | 索引字段 | 索引类型 | 说明 |
|---|---|---|---|
| room | room_code | 唯一索引 | 房间码唯一标识 |
| room | owner_id | 普通索引 | 按房主查询房间 |
| user | room_id | 普通索引 | 按房间查询用户 |
| user | session_id | 普通索引 | WebSocket会话关联 |
| record | room_id | 普通索引 | 按房间查询记录 |
| record | record_time | 普通索引 | 按时间排序 |
6. 前端设计与实现
6.1 响应式布局设计
采用移动优先的响应式设计策略:
/* 基础样式 - 移动端 */
.container {
max-width: 480px;
margin: 0 auto;
padding: 48px 24px;
}
.users-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
/* 大屏手机适配 */
@media (min-width: 400px) {
.users-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* 小屏手机适配 */
@media (max-width: 360px) {
.container {
padding: 32px 20px;
}
.users-grid {
gap: 8px;
}
}
6.2 简约风格设计系统
色彩规范
:root {
--primary: #2563EB; /* 主色:蓝色 */
--primary-dark: #1D4ED8; /* 主色深 */
--text-primary: #1F2937; /* 主要文字 */
--text-secondary: #6B7280; /* 次要文字 */
--bg-primary: #FFFFFF; /* 主背景 */
--bg-secondary: #F9FAFB; /* 次背景 */
--border: #E5E7EB; /* 边框 */
--success: #10B981; /* 成功 */
--danger: #EF4444; /* 危险 */
}
组件规范
按钮组件:
.btn {
height: 52px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:active {
transform: scale(0.98);
}
卡片组件:
.section {
background: var(--bg-primary);
border-radius: 16px;
padding: 20px;
margin-bottom: 12px;
}
6.3 前端状态管理
使用原生JavaScript实现简单状态管理:
// 全局状态
const state = {
roomCode: '',
currentUser: null,
selectedTargetUser: null,
ws: null,
lastUsersData: [], // 保存用户数据用于结算展示
isRoomSettled: false // 房间是否已结算
};
// WebSocket消息处理
function handleWebSocketMessage(message) {
switch (message.type) {
case 'USER_LIST':
renderUsers(message.data);
break;
case 'NEW_RECORD':
addNewRecord(message.data);
showToast('记账成功');
break;
case 'ROOM_CLOSED':
handleRoomClosed(); // 处理房间结算
break;
// ...
}
}
// 本地存储持久化
localStorage.setItem(`room_${roomCode}_user`, JSON.stringify(currentUser));
6.4 "我加入的房间"功能
使用localStorage实现本地房间列表管理,支持展示所有加入过的房间(包括已结算和未结算),并提供分类筛选功能:
const MY_ROOMS_KEY = 'my_joined_rooms';
// 当前筛选状态
let currentRoomFilter = 'unsettled';
let cachedRoomStatuses = [];
// 切换房间筛选
function switchRoomFilter(filter) {
currentRoomFilter = filter;
// 更新按钮状态
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelector(`[data-filter="${filter}"]`).classList.add('active');
// 重新渲染列表
renderFilteredRooms();
}
// 渲染筛选后的房间列表
function renderFilteredRooms() {
const listContainer = document.getElementById('roomsList');
// 根据筛选条件过滤房间
const filteredRooms = cachedRoomStatuses.filter(status => {
if (currentRoomFilter === 'settled') {
return status.settled;
} else {
return !status.settled;
}
});
if (filteredRooms.length === 0) {
const emptyText = currentRoomFilter === 'settled'
? '暂无已结算房间'
: '暂无未结算房间';
listContainer.innerHTML = `
<div class="empty-rooms">
<div class="empty-icon">🏠</div>
<p>${emptyText}</p>
</div>
`;
return;
}
// 渲染房间列表
listContainer.innerHTML = filteredRooms.map(status => {
const isSettled = status.settled;
const statusClass = isSettled ? 'settled' : 'active';
const statusText = isSettled ? '已结算' : '未结算';
const buttonClass = isSettled ? 'btn-view' : 'btn-enter';
const buttonText = isSettled ? '查看' : '加入';
return `
<div class="room-item">
<div class="room-status ${statusClass}">${statusText}</div>
<div class="room-name">${status.ownerName}的房间</div>
<div class="room-code">${status.roomCode}</div>
<button class="${buttonClass}"
onclick="${isSettled
? `viewSettleResult('${status.roomCode}')`
: `enterRoom('${status.roomCode}')`}">
${buttonText}
</button>
</div>
`;
}).join('');
}
// 获取房间列表
async function showMyRooms() {
// 重置筛选状态为未结算
currentRoomFilter = 'unsettled';
document.querySelector('[data-filter="unsettled"]').classList.add('active');
const rooms = getMyRooms();
const response = await fetch('/api/room/batchCheck', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roomCodes: rooms.map(r => r.roomCode) })
});
const result = await response.json();
if (result.code === 200) {
// 缓存房间数据
cachedRoomStatuses = result.data.filter(status => status.exists);
// 渲染筛选后的列表
renderFilteredRooms();
}
}
// 查看已结算房间结果
async function viewSettleResult(roomCode) {
const response = await fetch(`/api/room/settleResult/${roomCode}`);
const result = await response.json();
if (result.code === 200) {
showSettleResultModal(result.data);
}
}
筛选按钮样式:
.filter-tabs {
display: flex;
gap: 8px;
padding: 0 20px 16px;
border-bottom: 1px solid var(--border);
}
.filter-tab {
flex: 1;
height: 40px;
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
transition: all 0.2s;
}
.filter-tab.active {
background: var(--primary);
color: white;
}
功能特点:
- 分类筛选: 支持"已结算"和"未结算"两种筛选状态
- 默认状态: 打开弹窗默认显示"未结算"房间
- 状态标识: 清晰显示房间结算状态
- 差异化操作: 未结算房间显示"加入"按钮,已结算房间显示"查看"按钮
- 结算结果查看: 点击"查看"可查看历史结算结果
- 本地持久化: 使用localStorage保存房间列表
6.5 房间信息展示
在房间界面显示创建时间和房主信息:
// 加载房间信息
async function loadRoomInfo() {
const response = await fetch(`/api/room/info/${roomCode}`);
const result = await response.json();
if (result.code === 200) {
const roomData = result.data;
// 格式化创建时间
const date = new Date(roomData.createTime);
const formattedTime = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`;
document.getElementById('roomCreateTime').textContent = formattedTime;
// 显示房主
document.getElementById('roomOwner').textContent = roomData.ownerName || '未知';
}
}
7. API设计规范
7.1 RESTful API设计原则
所有API遵循RESTful设计规范,使用统一的响应格式:
@Data
public class Result<T> {
private Integer code; // 状态码:200成功,其他为错误码
private String message; // 提示信息
private T data; // 响应数据
private Long timestamp; // 时间戳
}
7.2 核心API接口列表
房间管理API
| 接口 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 创建房间 | POST | /api/room/create | 创建新房间,可选设置密码 |
| 获取房间信息 | GET | /api/room/info/{roomCode} | 获取房间详细信息 |
| 加入房间 | POST | /api/room/join | 用户加入房间 |
| 验证密码 | POST | /api/room/verifyPassword | 验证房间密码 |
| 批量检查房间 | POST | /api/room/batchCheck | 批量查询房间状态 |
| 结算房间 | POST | /api/room/settle | 房主结算房间 |
| 获取结算结果 | GET | /api/room/settleResult/{roomCode} | 获取房间结算结果 |
用户管理API
| 接口 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 修改昵称 | POST | /api/room/updateUsername | 修改用户昵称 |
| 检查用户名 | GET | /api/room/checkUsername | 检查用户名是否已存在 |
记账API
| 接口 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 记账 | POST | /api/room/accounting | 用户间记账 |
| 获取记录 | GET | /api/room/records/{roomCode} | 获取房间记账记录 |
7.3 API请求/响应示例
创建房间
请求:
{
"username": "张三",
"password": "123456"
}
响应:
{
"code": 200,
"message": "success",
"data": {
"id": 1,
"roomCode": "ABCD1234",
"roomName": "打牌房间",
"ownerId": 1,
"maxUsers": 8,
"currentUsers": 1,
"status": 1,
"createTime": "2026-03-03T10:30:00"
},
"timestamp": 1709433600000
}
批量检查房间状态
请求:
{
"roomCodes": ["ABCD1234", "EFGH5678"]
}
响应:
{
"code": 200,
"message": "success",
"data": [
{
"roomCode": "ABCD1234",
"exists": true,
"settled": false,
"ownerName": "张三"
},
{
"roomCode": "EFGH5678",
"exists": false,
"settled": true,
"ownerName": null
}
],
"timestamp": 1709433600000
}
7.4 错误码定义
| 错误码 | 说明 | 场景 |
|---|---|---|
| 200 | 成功 | 操作成功完成 |
| 400 | 请求参数错误 | 参数缺失或格式不正确 |
| 401 | 未授权 | 需要密码但未提供 |
| 403 | 禁止访问 | 非房主尝试结算 |
| 404 | 资源不存在 | 房间或用户不存在 |
| 409 | 资源冲突 | 用户名已存在 |
| 500 | 服务器内部错误 | 系统异常 |
7.5 WebSocket消息协议
消息格式
{
"type": "消息类型",
"data": {消息数据},
"timestamp": 1709433600000
}
消息类型说明
| 类型 | 方向 | 说明 |
|---|---|---|
| JOIN | C→S | 用户加入房间 |
| USER_LIST | S→C | 用户列表更新 |
| ACCOUNTING | C→S | 记账请求 |
| NEW_RECORD | S→C | 新记账记录 |
| UPDATE_USERNAME | C→S | 修改昵称 |
| USERNAME_UPDATED | S→C | 昵称更新通知 |
| ROOM_CLOSED | S→C | 房间结算通知 |
| PING/PONG | C↔S | 心跳检测 |
8. 性能优化策略
8.1 数据库优化
连接池配置
spring:
datasource:
hikari:
minimum-idle: 5 # 最小空闲连接
maximum-pool-size: 20 # 最大连接数
idle-timeout: 300000 # 空闲连接超时时间(5分钟)
max-lifetime: 1200000 # 连接最大生命周期(20分钟)
connection-timeout: 20000 # 连接超时时间(20秒)
索引优化
-- 房间码唯一索引(已存在)
CREATE UNIQUE INDEX idx_room_code ON room(room_code);
-- 用户房间ID索引(加速按房间查询用户)
CREATE INDEX idx_user_room_id ON user(room_id);
-- 记录时间索引(加速按时间排序查询)
CREATE INDEX idx_record_time ON record(record_time);
-- 复合索引(房间ID+时间)
CREATE INDEX idx_record_room_time ON record(room_id, record_time);
查询优化
- 使用MyBatis-Plus的条件构造器避免SQL注入
- 批量查询使用IN语句减少数据库往返
- 避免SELECT *,只查询需要的字段
8.2 前端性能优化
资源加载优化
<!-- 内联关键CSS -->
<style>
/* 首屏关键样式 */
.container { max-width: 480px; margin: 0 auto; }
</style>
<!-- 异步加载非关键CSS -->
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
JavaScript优化
// 防抖处理频繁操作
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
// 使用requestAnimationFrame优化动画
function animate() {
requestAnimationFrame(() => {
// 执行动画
});
}
本地存储优化
// 限制存储数量,避免localStorage溢出
const MAX_ROOMS = 20;
function saveRoom(roomCode, roomName) {
let rooms = JSON.parse(localStorage.getItem('my_joined_rooms') || '[]');
// 去重并限制数量
rooms = rooms.filter(r => r.roomCode !== roomCode);
rooms.unshift({ roomCode, roomName, joinTime: new Date().toISOString() });
if (rooms.length > MAX_ROOMS) {
rooms = rooms.slice(0, MAX_ROOMS);
}
localStorage.setItem('my_joined_rooms', JSON.stringify(rooms));
}
8.3 WebSocket优化
心跳机制
// 每30秒发送心跳,检测连接状态
const HEARTBEAT_INTERVAL = 30000;
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'PING' }));
}
}, HEARTBEAT_INTERVAL);
断线重连策略
// 指数退避重连策略
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
const BASE_RECONNECT_INTERVAL = 3000;
function reconnect() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
showToast('连接失败,请刷新页面重试', 'error');
return;
}
const delay = BASE_RECONNECT_INTERVAL * Math.pow(2, reconnectAttempts);
setTimeout(() => {
reconnectAttempts++;
initWebSocket();
}, delay);
}
8.4 缓存策略
浏览器缓存
// 静态资源缓存控制
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
}
}
应用层缓存
// 房间信息缓存(可考虑引入Redis)
@Cacheable(value = "room", key = "#roomCode")
public Room getByRoomCode(String roomCode) {
return roomMapper.selectByRoomCode(roomCode);
}
8.5 监控与日志
性能监控
// API执行时间监控
@Aspect
@Component
public class PerformanceAspect {
@Around("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
long duration = System.currentTimeMillis() - start;
if (duration > 1000) {
log.warn("慢查询: {} 耗时 {}ms", joinPoint.getSignature(), duration);
}
return result;
}
}
错误日志
// 全局异常处理
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e);
return Result.error("系统繁忙,请稍后重试");
}
}
9. 项目成果与总结
9.1 功能清单
| 功能模块 | 功能点 | 状态 |
|---|---|---|
| 房间管理 | 创建房间 | ✅ 完成 |
| 加入房间 | ✅ 完成 | |
| 房间人数限制(8人) | ✅ 完成 | |
| 房主自动分配 | ✅ 完成 | |
| 房间结算关闭 | ✅ 完成 | |
| 用户管理 | 昵称设置 | ✅ 完成 |
| 在线状态显示 | ✅ 完成 | |
| 余额实时显示 | ✅ 完成 | |
| 房主标识显示 | ✅ 完成 | |
| 记账功能 | 用户间记账 | ✅ 完成 |
| 记账记录生成 | ✅ 完成 | |
| 历史记录查看 | ✅ 完成 | |
| 实时同步 | WebSocket通信 | ✅ 完成 |
| 断线重连 | ✅ 完成 | |
| 多用户同步 | ✅ 完成 | |
| 结算通知广播 | ✅ 完成 | |
| 数据持久化 | MySQL存储 | ✅ 完成 |
| 房间数据保存 | ✅ 完成 | |
| 记账记录保存 | ✅ 完成 | |
| 房间列表 | "我加入的房间"功能 | ✅ 完成 |
| 房间状态批量查询 | ✅ 完成 | |
| 已结算房间可查看结果 | ✅ 完成 | |
| 房间分类筛选(已结算/未结算) | ✅ 完成 | |
| 用户管理 | 用户昵称修改 | ✅ 完成 |
| 昵称修改实时同步 | ✅ 完成 | |
| 记账记录昵称同步更新 | ✅ 完成 | |
| 创建房间时设置房主昵称 | ✅ 完成 | |
| 房间信息 | 创建时间显示 | ✅ 完成 |
| 房主信息显示 | ✅ 完成 | |
| 前端适配 | 移动端适配 | ✅ 完成 |
| 响应式布局 | ✅ 完成 | |
| 结算页面展示 | ✅ 完成 | |
| 创建房间弹窗样式 | ✅ 完成 | |
| 邀请功能 | 二维码邀请好友 | ✅ 完成 |
| 房间链接复制 | ✅ 完成 | |
| 加号卡片展示 | ✅ 完成 | |
| 房间安全 | 房间密码保护 | ✅ 完成 |
| 密码验证接口 | ✅ 完成 | |
| 房主免密进入 | ✅ 完成 | |
| UI设计 | 现代UI色彩方案 | ✅ 完成 |
| 扑克牌Logo设计 | ✅ 完成 | |
| 用户使用手册链接 | ✅ 完成 | |
| 技术文档链接 | ✅ 完成 |
9.2 技术亮点
- 并发安全: 使用数据库乐观锁实现无锁并发控制
- 实时通信: WebSocket实现低延迟数据同步
- 数据一致性: 事务保证记账操作原子性
- 降级兼容: 多种方案确保跨浏览器兼容
- 简约设计: 清晰的信息架构和视觉层次
- 密码安全: BCrypt加密存储房间密码
- XSS防护: 用户输入HTML转义处理
9.3 性能指标
| 指标 | 数值 | 说明 |
|---|---|---|
| 页面加载时间 | < 2s | 首屏加载 |
| WebSocket延迟 | < 100ms | 局域网环境 |
| 数据库查询 | < 50ms | 简单查询 |
| 并发支持 | 100+ | 同时在线房间数 |
| API响应时间 | < 300ms | 95%请求 |
9.4 已知限制
- 本地存储依赖: "我加入的房间"功能依赖浏览器localStorage,清除浏览器数据会丢失列表
- 房主唯一性: 当前仅支持一个房主,房主离线后无法转让房主身份
- 房间持久化: 房间数据长期保留,需要定期清理已结算的过期房间
- 并发限制: 单服务器部署,并发能力受限于服务器性能
- 结算结果查看: 已结算房间的历史结果需要房主结算后才能查看,结算前无法预览
9.5 后续优化方向
- 安全性增强: 添加用户认证、防SQL注入、XSS防护
- 性能优化: 引入Redis缓存、数据库连接池优化
- 功能扩展: 支持更多游戏类型、添加结算统计功能
- 移动端: 开发原生App或小程序版本
- 国际化: 支持多语言界面
- 房主转让: 支持房主将权限转让给其他用户
- 房间回收: 自动清理长期未使用的已结算房间
附录
A. 启动命令
# 开发环境启动
mvn spring-boot:run
# 生产环境打包
mvn clean package -DskipTests
# 生产环境运行
java -jar target/card-accounting-1.0.0.jar
B. 配置文件
# application.yml 关键配置
server:
port: 8085
spring:
datasource:
url: jdbc:mysql://localhost:3306/card_accounting?useSSL=true&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: your_password
hikari:
minimum-idle: 5
maximum-pool-size: 20
idle-timeout: 300000
max-lifetime: 1200000
connection-timeout: 20000
jpa:
hibernate:
ddl-auto: update
show-sql: false
thymeleaf:
cache: false
prefix: classpath:/templates/
suffix: .html
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
C. 数据库配置说明
创建数据库:
CREATE DATABASE card_accounting DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
导入表结构:
mysql -u root -p card_accounting < card_accounting.sql
D. 安全配置建议
- 数据库密码:生产环境应使用环境变量或配置中心管理数据库密码,避免明文存储
- 跨域限制:生产环境应配置具体的允许域名,而非使用
setAllowedOrigins("*") - HTTPS部署:生产环境建议使用HTTPS部署,确保数据传输安全
- 输入验证:添加XSS防护和输入参数校验
- 日志脱敏:生产环境日志配置应避免输出敏感信息
E. API接口列表
房间管理接口
| 接口 | 方法 | 说明 | 请求参数 | 返回数据 |
|---|---|---|---|---|
| /api/room/create | POST | 创建房间(支持设置密码) | username, roomPassword(可选) | roomCode, userId |
| /api/room/info/{roomCode} | GET | 获取房间信息 | - | roomCode, ownerName, createTime, hasPassword |
| /api/room/check/{roomCode} | GET | 检查房间是否存在 | - | exists, status |
| /api/room/join | POST | 加入房间 | roomCode, username, password(可选) | userId, isOwner |
| /api/room/verifyPassword | POST | 验证房间密码 | roomCode, password | valid |
| /api/room/settle/{roomCode} | POST | 结算并关闭房间 | userId | success |
| /api/room/settleResult/{roomCode} | GET | 获取房间结算结果 | - | users[], settleTime |
| /api/room/isOwner/{roomCode} | GET | 检查用户是否为房主 | userId | isOwner |
| /api/room/batchCheck | POST | 批量查询房间状态 | roomCodes[] | roomStatuses[] |
| /api/room/accounting | POST | 记账操作 | fromUserId, toUserId, amount | record |
| /api/room/updateUsername | POST | 修改用户昵称 | userId, newUsername | user |
页面路由接口
| 接口 | 方法 | 说明 |
|---|---|---|
| / | GET | 首页(创建/加入房间) |
| /room/{roomCode} | GET | 房间页面 |
实时通信接口
| 接口 | 方法 | 说明 |
|---|---|---|
| /ws/room/{roomCode} | WebSocket | 实时通信 |
WebSocket消息类型
| 消息类型 | 方向 | 说明 | 数据结构 |
|---|---|---|---|
| JOIN | 客户端→服务端 | 用户加入房间 | {type, roomCode, username, userId} |
| ACCOUNTING | 客户端→服务端 | 记账操作 | {type, fromUserId, toUserId, amount} |
| UPDATE_USERNAME | 客户端→服务端 | 修改昵称 | {type, userId, newUsername} |
| USER_LIST | 服务端→客户端 | 用户列表更新 | {type, users[]} |
| NEW_RECORD | 服务端→客户端 | 新记账记录 | {type, record} |
| ROOM_CLOSED | 服务端→客户端 | 房间已结算 | {type} |
| UPDATE_USERNAME | 服务端→客户端 | 昵称更新通知 | {type, userId, username} |
| PING | 客户端→服务端 | 心跳检测 | {type} |
| PONG | 服务端→客户端 | 心跳响应 | {type} |
响应数据结构
统一响应格式 (Result.java)
{
"code": 200,
"message": "success",
"data": {}
}
错误码定义
| code | message | 说明 |
|---|---|---|
| 200 | success | 请求成功 |
| 400 | bad request | 请求参数错误 |
| 404 | not found | 资源不存在 |
| 500 | server error | 服务器内部错误 |
版本信息
| 版本 | 日期 | 更新内容 |
|---|---|---|
| v1.4.1 | 2026-03-02 | 房间密码BCrypt加密、XSS防护增强、安全升级 |
| v1.4.0 | 2026-02-28 | 新增房间密码功能 |
| v1.3.0 | 2026-02-28 | 新增"我加入的房间"功能 |
| v1.2.0 | 2026-02-28 | 新增昵称修改功能 |
| v1.1.0 | 2026-02-28 | 实时同步优化 |
| v1.0.0 | 2026-02-28 | 初始版本发布 |
- 当前版本:v1.4.1
- 最后更新:2026-03-02

评论区 0