打牌记账

  日常   76分钟   114浏览   0评论

版本变更记录

v1.1.0 (2026-02-27)

新增功能

  1. 房间结算功能

    • 房主可结算并关闭房间
    • 结算结果展示所有用户盈亏情况
    • WebSocket广播通知所有在线用户
  2. "我加入的房间"功能增强

    • 展示所有加入过的房间(包括已结算和未结算)
    • 房间状态标识(已结算/未结算)
    • 已结算房间可查看历史结算结果
    • 未结算房间可直接进入
  3. 房间信息展示

    • 显示房间创建时间(YYYY-MM-DD HH:MM:SS格式)
    • 显示房主用户名

技术变更

  1. 数据库: room表添加owner_id字段和索引
  2. API: 新增结算相关接口(settle、settleResult、batchCheck)
  3. DTO: 新增RoomStatusDTO、SettleRequest、BatchCheckRequest
  4. 前端: localStorage实现房间列表本地持久化

v1.0.0 (初始版本)

核心功能

  • 房间创建与加入
  • 用户管理与在线状态
  • 实时记账与余额计算
  • WebSocket实时同步
  • 历史记录查询

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 - 原生技术、无需构建、快速迭代
构建工具 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        # 批量查询请求
│       │   ├── 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\                         # 服务实现
│       └── 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();
    }
}

实现思路:

  1. 使用UUID生成32位随机字符串
  2. 移除连字符后取前8位大写字母数字组合
  3. 理论上支持 36^8 ≈ 2.8万亿 种组合,冲突概率极低
  4. 使用数据库唯一索引确保绝对唯一
  5. 第一个加入房间的用户自动成为房主(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 结算流程

  1. 房主点击结算按钮 → 弹出二次确认对话框
  2. 确认结算 → 后端验证房主身份并关闭房间
  3. 广播房间关闭消息 → 所有在线用户收到通知
  4. 展示结算结果 → 显示所有用户的最终盈亏情况
// 前端处理房间关闭
function handleRoomClosed() {
    isRoomSettled = true;
    showToast('房间已结算');
    showSettleResultForAll(); // 显示结算页面
}

3.5 实时通信模块

3.4.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连接管理

问题: 用户断线后需要重连,并保持数据同步。

解决方案:

  1. 心跳机制: 每30秒发送PING/PONG保持连接
  2. 断线重连: 客户端自动重连,服务器恢复用户状态
  3. 历史数据同步: 重连后自动推送历史记录
// 客户端心跳
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');
    }
}

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';

// 保存房间到列表
function saveRoom(roomCode, roomName) {
    const rooms = getMyRooms();
    if (!rooms.some(r => r.roomCode === roomCode)) {
        rooms.unshift({
            roomCode: roomCode,
            roomName: roomName,
            joinTime: new Date().toISOString()
        });
        localStorage.setItem(MY_ROOMS_KEY, JSON.stringify(rooms));
    }
}

// 获取房间列表(包含已结算和未结算)
async function showMyRooms() {
    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) {
        const roomStatuses = result.data;
        // 渲染所有有效房间(不过滤已结算)
        const validRooms = roomStatuses.filter(status => status.exists);
        renderRoomList(validRooms);
    }
}

// 渲染房间列表(区分已结算和未结算)
function renderRoomList(rooms) {
    return rooms.map(status => {
        const isSettled = status.settled;
        const statusClass = isSettled ? 'settled' : 'active';
        const statusText = isSettled ? '已结算' : '未结算';
        const buttonClass = isSettled ? 'btn-view' : 'btn-enter';
        const buttonText = isSettled ? '查看' : '加入';
        const buttonAction = isSettled 
            ? `viewSettleResult('${status.roomCode}')` 
            : `enterRoom('${status.roomCode}')`;

        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>
                <div class="room-details">
                    <span>房主: ${status.ownerName}</span>
                    <span>创建时间: ${status.createTime}</span>
                    ${!isSettled ? `<span>在线: ${status.currentUsers}/${status.maxUsers}人</span>` : ''}
                </div>
                <button class="${buttonClass}" onclick="${buttonAction}">${buttonText}</button>
            </div>
        `;
    }).join('');
}

// 查看已结算房间结果
async function viewSettleResult(roomCode) {
    const response = await fetch(`/api/room/settleResult/${roomCode}`);
    const result = await response.json();

    if (result.code === 200) {
        showSettleResultModal(result.data);
    }
}

功能特点:

  1. 状态标识: 清晰显示"已结算"或"未结算"状态
  2. 差异化操作: 未结算房间显示"加入"按钮,已结算房间显示"查看"按钮
  3. 结算结果查看: 点击"查看"可查看历史结算结果,包括所有用户盈亏统计
  4. 本地持久化: 使用localStorage保存房间列表,最多保存20个房间

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. 项目成果与总结

7.1 功能清单

功能模块 功能点 状态
房间管理 创建房间 ✅ 完成
加入房间 ✅ 完成
房间人数限制(8人) ✅ 完成
房主自动分配 ✅ 完成
房间结算关闭 ✅ 完成
用户管理 昵称设置 ✅ 完成
在线状态显示 ✅ 完成
余额实时显示 ✅ 完成
房主标识显示 ✅ 完成
记账功能 用户间记账 ✅ 完成
记账记录生成 ✅ 完成
历史记录查看 ✅ 完成
实时同步 WebSocket通信 ✅ 完成
断线重连 ✅ 完成
多用户同步 ✅ 完成
结算通知广播 ✅ 完成
数据持久化 MySQL存储 ✅ 完成
房间数据保存 ✅ 完成
记账记录保存 ✅ 完成
房间列表 "我加入的房间"功能 ✅ 完成
房间状态批量查询 ✅ 完成
已结算房间可查看结果 ✅ 完成
房间信息 创建时间显示 ✅ 完成
房主信息显示 ✅ 完成
前端适配 移动端适配 ✅ 完成
响应式布局 ✅ 完成
结算页面展示 ✅ 完成

7.2 技术亮点

  1. 并发安全: 使用数据库乐观锁实现无锁并发控制
  2. 实时通信: WebSocket实现低延迟数据同步
  3. 数据一致性: 事务保证记账操作原子性
  4. 降级兼容: 多种方案确保跨浏览器兼容
  5. 简约设计: 清晰的信息架构和视觉层次

7.3 性能指标

指标 数值 说明
页面加载时间 < 2s 首屏加载
WebSocket延迟 < 100ms 局域网环境
数据库查询 < 50ms 简单查询
并发支持 100+ 同时在线房间数

7.4 已知限制

  1. 本地存储依赖: "我加入的房间"功能依赖浏览器localStorage,清除浏览器数据会丢失列表
  2. 房主唯一性: 当前仅支持一个房主,房主离线后无法转让房主身份
  3. 房间持久化: 房间数据长期保留,需要定期清理已结算的过期房间
  4. 并发限制: 单服务器部署,并发能力受限于服务器性能
  5. 结算结果查看: 已结算房间的历史结果需要房主结算后才能查看,结算前无法预览

7.5 后续优化方向

  1. 安全性增强: 添加用户认证、防SQL注入、XSS防护
  2. 性能优化: 引入Redis缓存、数据库连接池优化
  3. 功能扩展: 支持更多游戏类型、添加结算统计功能
  4. 移动端: 开发原生App或小程序版本
  5. 国际化: 支持多语言界面
  6. 房主转让: 支持房主将权限转让给其他用户
  7. 房间回收: 自动清理长期未使用的已结算房间

附录

A. 启动命令

# 开发环境启动
mvn spring-boot:run

# 生产环境打包
mvn clean package -DskipTests

# 生产环境运行
java -jar target/card-accounting-1.0.0.jar

B. 配置文件

# application.yml 关键配置
server:
  port: 8086

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: 123456

C. API接口列表

房间管理接口

接口 方法 说明
/api/room/create POST 创建房间
/api/room/info/{roomCode} GET 获取房间信息(包含房主名称、创建时间)
/api/room/check/{roomCode} GET 检查房间是否存在
/api/room/join POST 加入房间
/api/room/settle/{roomCode} POST 结算并关闭房间(仅房主)
/api/room/settleResult/{roomCode} GET 获取房间结算结果(包含用户盈亏明细)
/api/room/isOwner/{roomCode} GET 检查用户是否为房主
/api/room/batchCheck POST 批量查询房间状态(用于"我加入的房间")
/api/room/accounting POST 记账操作

实时通信接口

接口 方法 说明
/ws/room/{roomCode} WebSocket 实时通信

WebSocket消息类型:

  • JOIN - 用户加入房间
  • ACCOUNTING - 记账操作
  • ROOM_CLOSED - 房间结算关闭
  • PING/PONG - 心跳检测
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论