简介:采用Java Swing和MySQL开发的图形化酒店管理系统,是一套功能完整的课设级项目,适合作为Java课程设计或毕业设计参考。系统区分管理员与普通用户登录,涵盖客房管理、用户管理、订单管理、客房服务、历史记录和管理员权限等模块;综合运用多对多查询、JFreeChart统计图表、MySQL触发器自动保存退房记录、CSV导出等功能,并模仿QQ登录根据账号显示不同头像,退出时可最小化到托盘,内容完整、交互细节丰富,还涉及房态查看、房间设备配图等实用细节。源码包共415个文件,解压后约33.24MB,以75个Java源文件、138个class字节码、173张png图片、11个jar依赖库以及SQL脚本、XML配置、MD文档为主,分类清晰,便于按模块阅读核心代码与界面资源。已有122人学习下载,适合练习Swing界面布局、MySQL多表查询、触发器应用及文件导出,订单多对多展示、用户头像联动、历史记录自动迁移等具体实现可直接用于扩展自己的项目,整体代码结构规范,注释清晰,适合二次开发。
1. 基于 Java Swing + MySQL 的图形化酒店管理系统,核心是先理顺表与状态
酒店管理系统在课程设计和接单项目里出现频率很高,但大多数变体都能追溯到同一个核心矛盾:Swing 的事件模型不是为数据库查询设计的,而 MySQL 的事务边界又必须在业务层显式控制。hotel-management 这类项目如果只把每个按钮绑定一条 SQL,开发很快,但一旦遇到“取消房间后又要换房”“前台交接时盘点应收”这些场景,代码就会变成一团 if。这套系统要做的,表面上是把房间、客户、订单填进界面上,实际是把状态流转和结算流水对齐。下面从数据库建模开始,按 Java Swing + MySQL 的实际开发顺序,把图形化酒店管理系统的代码骨架、参数设置和排错路径完整过一遍。适合已有 Java 基础、正在做课程设计,或者想自己维护一套桌面前台管理系统的开发人员。拿到源码工程时,文档里最关键的不是类图,而是初始化 SQL 与连接串,这两项写明白了,项目换机器也能直接跑。
2. 酒店管理系统的 MySQL 表结构设计与 JDBC 连接层
2.1 房间表、客户表、预订表如何划分
hotel-management 最常用的做法是拆成四类:房间、客户、预订/入住单、消费流水。许多课程设计会把消费流水并进订单一列,看似省事,但结算时一旦出现加床、点餐、赔偿物品,就要额外再造表。推荐的简化表结构如下:
CREATE TABLE rooms ( room_id INT AUTO_INCREMENT PRIMARY KEY, room_no VARCHAR(10) NOT NULL, room_type VARCHAR(30) NOT NULL, price DECIMAL(10,2) NOT NULL, status VARCHAR(20) DEFAULT 'AVAILABLE' ); CREATE TABLE guests ( guest_id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, phone VARCHAR(20), id_card VARCHAR(30) ); CREATE TABLE reservations ( reservation_id INT AUTO_INCREMENT PRIMARY KEY, guest_id INT NOT NULL, room_id INT NOT NULL, check_in_date DATE, check_out_date DATE, total_amount DECIMAL(10,2), pay_status VARCHAR(20) DEFAULT 'UNPAID', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_res_guest FOREIGN KEY (guest_id) REFERENCES guests(guest_id), CONSTRAINT fk_res_room FOREIGN KEY (room_id) REFERENCES rooms(room_id) );这里有几个设计判断值得说清楚。price 直接放在 rooms 表里,原因是这个系统按“房间类型”定价,换房时按新房间价格重算;如果要做周末调价或淡旺季价格,应该抽一张 room_price 表。status 字段推荐用大写英文状态值,比如 AVAILABLE、OCCUPIED,不要直接存中文,运行时再用枚举或字典映射成中文,否则不同机器的字符集差异很容易让查询条件失效。
外键在桌面管理系统里经常被省略,但 reservations 表里的 guest_id、room_id 一旦连回主表,就能避免删除客户或房间时留下孤儿订单。MySQL 的 InnoDB 引擎默认启用外键约束,MyISAM 不支持,建库时确认引擎是 InnoDB。日期字段尽量用 DATE 而不是 VARCHAR,否则后面 DATEDIFF、DATE_FORMAT 这类报表统计全部要 CAST,反而多出错。
2.2 JDBC 连接 MySQL 的推荐连接串
数据库建好以后,Java 程序与 MySQL 的通道就是 JDBC。常见做法是把连接参数集中到一个 DbUtil 类,后续 DAO 层所有类都从这里拿连接。连接串通常这样写:
public class DbUtil { private static final String URL = "jdbc:mysql://localhost:3306/hotel_db" + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }连接串里的参数按顺序讲:useSSL=false 是本地开发和课程设计里最常见的参数,能跳过 SSL 握手时的证书提示,降低连接延迟;serverTimezone=Asia/Shanghai 解决 MySQL Connector/J 8.x 报出的时区异常;characterEncoding=utf8 与建库字符集保持一致,避免存中文变成问号。如果装的是 MySQL 8.0,驱动类名从 com.mysql.jdbc.Driver 换成了 com.mysql.cj.jdbc.Driver,最好把驱动 jar 升级到 8.0 以上,否则容易遇到通信链路异常。
把这些参数做成一张表,日常直接照抄:
| 参数 | 取值 | 作用 |
|---|---|---|
| useSSL | false | 跳过本地 SSL 握手,降低连接耗时 |
| serverTimezone | Asia/Shanghai | 统一 JVM 与 MySQL 时区,避免 CST 歧义 |
| characterEncoding | utf8 | 连接层字符集,必须与库表字符集一致 |
| rewriteBatchedStatements | true | 批量 executeBatch 时拼接 SQL,提高插入性能 |
| connectTimeout | 3000 | 连接超时毫秒数,桌面端让报错更快弹出 |
如果装的是 MySQL 免安装版,初始化 data 目录时默认没有指定字符集,登录后先用SHOW VARIABLES LIKE 'character_set_server'; 查看服务端字符集,再在 my.ini 中设置 character-set-server=utf8mb4。用 Navicat 或 MySQL Workbench 建库时,同样注意选择 utf8mb4 和 utf8mb4_unicode_ci。utf8mb4 比 utf8 多出的部分用来存 emoji 和其他扩展字符,酒店客户姓名、备注字段里经常出现这类内容。
2.3 不要在 Swing 事件线程里反复开关连接
刚开始写 hotel-management 时,最常见的写法是每个按钮的 actionPerformed 里都 new 一个 Connection,用完整关。这在数据量小的演示系统里能跑,但连续点击查询就会看到窗口卡顿。卡顿的根因不是 JDBC 慢,而是 Swing 的事件分发线程被数据库等待占用,整个界面没法重绘。
我一般会把数据库操作拆到独立 DAO,并在 UI 层用 SwingWorker 异步加载。更大的桌面系统也不直接用 DriverManager,而是用 HikariCP 连接池,替换方法放在最后一章。这里先记住一个原则:Connection 没必要每次新建,但也不能做成全局静态共享;同一个连接同一时刻只能被一个线程执行 SQL,多窗口同时操作时就交给连接池来协调。另外一个容易误用的点:PreparedStatement 应该作为局部变量,用后即关。如果把 Statement 放在成员变量里,多次执行时连接状态混乱,而且无法安全关闭。每个操作都要在最外层 finally 里关闭 ResultSet、PreparedStatement、Connection,或者直接用 try-with-resources。
3. 从预订到退房:hotel-management 的房间状态机与事务控制
3.1 用枚举和状态转换表约束房态
酒店房间的状态不能靠肉眼判断。一个标准流程中,房间在 AVAILABLE(可售)、RESERVED(预留)、OCCUPIED(在住)、CLEANING(待清洁)之间流转。如果直接用 String 赋值,代码里就会到处出现if (status.equals("在住")),拼错一个字查询结果就变成空。相对稳妥的方案是定义枚举:
public enum RoomStatus { AVAILABLE("可售"), RESERVED("已预定"), OCCUPIED("已入住"), CLEANING("清洁中"); private final String desc; RoomStatus(String desc) { this.desc = desc; } public String getDesc() { return desc; } }枚举类既界定了合法状态集合,又承担了中文显示。界面上直接调用 getDesc() 显示,数据库里通过 name() 存入大写字符串。合法的房态跳转,建议直接用下面这张表定义业务规则:
| 动作 | 前置状态 | 后置状态 | 额外条件 |
|---|---|---|---|
| 预订 | AVAILABLE | RESERVED | 客户信息完整,可收押金 |
| 入住 | RESERVED | OCCUPIED | 已核对身份证,房费已收或挂账 |
| 换房 | OCCUPIED | OCCUPIED | 新房间必须 AVAILABLE,旧房间转 CLEANING |
| 退房 | OCCUPIED | CLEANING | 结清消费,退还押金 |
| 清洁完成 | CLEANING | AVAILABLE | 保洁确认 |
把状态转移写成一张表,好处是后续加“保洁员批量清洁”功能时,只改枚举的转换关系,不影响预订和结算逻辑。
3.2 预订房间的乐观锁与事务提交
直接执行 INSERT INTO reservations 再 UPDATE rooms 的问题在于:如果第二步失败,订房记录留下了,但房间状态没变化,前台会看到一张预定单挂在已经可售的房间上。二者必须同生共死。事务写法如下:
public void createReservation(Reservation r) throws SQLException { Connection conn = DbUtil.getConnection(); try { conn.setAutoCommit(false); String lockSql = "UPDATE rooms SET status='RESERVED' " + "WHERE room_id=? AND status='AVAILABLE'"; try (PreparedStatement ps = conn.prepareStatement(lockSql)) { ps.setInt(1, r.getRoomId()); if (ps.executeUpdate() == 0) { throw new SQLException("房间已被其他订单占用,请重新选择"); } } String insertSql = "INSERT INTO reservations " + "(guest_id, room_id, check_in_date, check_out_date, total_amount) " + "VALUES (?, ?, ?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setInt(1, r.getGuestId()); ps.setInt(2, r.getRoomId()); ps.setDate(3, new java.sql.Date(r.getCheckInDate().getTime())); ps.setDate(4, new java.sql.Date(r.getCheckOutDate().getTime())); ps.setBigDecimal(5, r.getTotalAmount()); ps.executeUpdate(); } conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); } }代码里的关键在UPDATE ... WHERE room_id=? AND status='AVAILABLE'。这相当于把“房间是否可用”的检查下沉到数据库行锁上,同一时刻多个窗口同时点预订,只有一个 UPDATE 能影响一行,另一个 executeUpdate 返回 0 后直接抛异常。这种写法没有引入额外中间件,课程设计、单机部署和多人同时操作同一个 MySQL 的场景都够用。
提示:不要先用 SELECT 查房态,再 UPDATE,因为两个连接之间有间隙,并发时依然会超卖。
try-with-resources 会帮我们关闭两个 PreparedStatement,最后的 finally 只负责恢复自动提交和关掉 Connection,事务回滚后连接可以复用。有人担心conn.setAutoCommit(true)放在 finally 里会不会覆盖回滚状态,实际上 rollback 已经执行完,这里只是把连接状态恢复成默认,对业务无影响。
3.3 退房时计算房费与更新状态
退房的动作包含两部分:算出应收金额,然后修改订单支付状态和房间状态。金额最好不要拆成多段 Java 代码逐项求和,直接用 SQL 计算可以减少代码量:
SELECT r.check_in_date, r.check_out_date, DATEDIFF(r.check_out_date, r.check_in_date) AS days, rm.price AS room_price FROM reservations r JOIN rooms rm ON r.room_id = rm.room_id WHERE r.reservation_id = ?;如果客户超期未退房,而系统又把 check_out_date 设置为预计离店日,DATEDIFF 会算出负数或偏小值。更稳妥的做法是用LEAST(CURRENT_DATE, r.check_out_date)把日期封顶:
SELECT DATEDIFF(LEAST(CURRENT_DATE, r.check_out_date), r.check_in_date) AS days, rm.price AS room_price FROM reservations r JOIN rooms rm ON r.room_id = rm.room_id WHERE r.reservation_id = ?;退房记录一般不做 DELETE,因为酒店管理系统要保留历史流水。很多新手直接把记录删除,最后报表和查账都缺数据。正确做法是把 pay_status 更新为 PAID,房间状态再按流程更新到 CLEANING。换房时也只插入一条换房流水,原房间转清洁,新房间从 AVAILABLE 转 OCCUPIED,这样账目才能追溯。
4. 图形界面实现:JTable 实时房态、字段校验与月度报表
4.1 用 AbstractTableModel 让 JTable 与房态联动
Swing 中显示房间数据最直接的方式是 JTable,但把二维数组塞进 DefaultTableModel 的坏处是新增列、改类型都要重新构建数组。为酒店管理系统的房间列表定制 TableModel 是更好的做法:
public class RoomTableModel extends AbstractTableModel { private final String[] columns = {"房号", "类型", "价格", "状态"}; private List<Room> rooms = new ArrayList<>(); public void setRooms(List<Room> rooms) { this.rooms = rooms; fireTableDataChanged(); } @Override public int getRowCount() { return rooms.size(); } @Override public int getColumnCount() { return columns.length; } @Override public Object getValueAt(int rowIndex, int columnIndex) { Room room = rooms.get(rowIndex); switch (columnIndex) { case 0: return room.getRoomNo(); case 1: return room.getRoomType(); case 2: return "¥" + room.getPrice(); case 3: return RoomStatus.valueOf(room.getStatus()).getDesc(); default: return ""; } } }把状态转中文的职责放在 getValueAt,是因为表格数据只用于显示,不需要把中文写回数据库。刷新时调用 model.setRooms(dao.findAll()),JTable 会自动触发重绘。前台搜索时,只要在下拉框、文本框的监听器里重新传条件执行同一个方法即可,不需要为每种筛选单独写 Model。
4.2 SwingWorker 加载数据,避免界面冻住
如果查询数据时按钮“按不动”,说明数据库查询执行在了 EDT 上。正确做法是用 SwingWorker 在后台线程查数据库:
public void refreshRoomList() { new SwingWorker<List<Room>, Void>() { @Override protected List<Room> doInBackground() { return roomDao.findAll(); } @Override protected void done() { try { model.setRooms(get()); } catch (Exception e) { JOptionPane.showMessageDialog(frame, "加载房态失败:" + e.getMessage()); } } }.execute(); }SwingWorker 的 done 方法会在事件分发线程回调,因此可以安全地刷新 JTable。普通 Thread 不能直接更新 UI,Swing 组件不是线程安全的。对于 hotel-management 这类小型系统,SwingWorker 覆盖查询和报表生成已经足够;连接池优化解决的是每次 new Connection 的开销,和这个异步刷新是两件独立的事。
4.3 表单字段校验规则与日期校验代码
前台办理入住时最容易出错的字段是手机号和日期。在提交按钮的监听器里先做校验:
private boolean validateCheckInForm() { if (txtGuestName.getText() == null || txtGuestName.getText().trim().isEmpty()) { JOptionPane.showMessageDialog(this, "姓名不能为空"); txtGuestName.requestFocus(); return false; } if (!txtPhone.getText().trim().matches("^1[3-9]\\d{9}$")) { JOptionPane.showMessageDialog(this, "手机号格式不正确"); txtPhone.requestFocus(); return false; } java.util.Date in = dateCheckIn.getDate(); java.util.Date out = dateCheckOut.getDate(); if (in == null || out == null || out.before(in)) { JOptionPane.showMessageDialog(this, "离店日期不能早于入住日期"); return false; } return true; }校验顺序很重要:先查空值,再查格式,最后查业务日期。如果一条 if 把条件用 && 连起来,用户不知道光标该放在哪。手机号正则只针对大陆手机号;如果系统面向游客,可以放宽为 6 至 20 位字母数字,数据库层仍然用 VARCHAR(30) 存储,不要把所有校验责任交给正则。
4.4 月度营收报表的 SQL 与展示
报表是 Java 基础面试里常问的聚合查询场景。假设前台的结账动作只把 pay_status 更新为 PAID,统计月度收入时使用:
SELECT DATE_FORMAT(check_in_date, '%Y-%m') AS month, COUNT(*) AS order_count, SUM(total_amount) AS revenue FROM reservations WHERE pay_status = 'PAID' GROUP BY month ORDER BY month DESC;这个查询有两个细节:DATE_FORMAT 的格式符大小写有讲究,%Y 是四位年份,%m 是两位月份,写成小写 %y 就变成两位年份;WHERE 里必须过滤 pay_status,否则会把未收款订单算进收入。把查询结果填入 JTable 时,同样用自定义 TableModel,列改成“月份 / 订单数 / 收入”。数据量上来以后,给 reservations 表加一个(pay_status, check_in_date)联合索引,这个查询几乎不会有压力。
5. 部署与维护:hotel-management 的乱码排查、mysql 备份与连接池替换
5.1 中文乱码的排查顺序
hotel-management 换到新机器上运行,最容易出现的是中文变问号。通常按三层排查:库表字符集、连接 URL 字符集、JVM 的 file.encoding。先执行SHOW CREATE DATABASE hotel_db;,如果结果显示 latin1,就执行ALTER DATABASE hotel_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;再重新导入数据。表本身是 latin1 的,需要ALTER TABLE guests CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;。连接 URL 按第二章写完整。JVM 层面,IDE 里给运行配置加上-Dfile.encoding=UTF-8;打包成 jar 后用java -Dfile.encoding=UTF-8 -jar hotel-management.jar启动。这三个点按顺序检查,基本能定位十有八九的乱码。
5.2 常用 mysql 备份命令与 HikariCP 连接池替换
单机系统的备份用 mysqldump 就够,命令如下:
mysqldump -uroot -p --single-transaction --default-character-set=utf8mb4 hotel_db > hotel_backup.sql--single-transaction 只适用于 InnoDB,它不锁表,能在前台操作不中断的情况下导出一致性快照;MyISAM 表需要换成 --lock-all-tables。还原时先创建同名数据库,再执行mysql -uroot -p hotel_db < hotel_backup.sql。Windows 上可以把命令写成 mysql_backup.bat,交给计划任务定时执行,文件名里带日期,方便保留历史版本。
连接使用久了之后,如果每次操作都新建连接,并发高时会明显变慢。把 DbUtil 改成 HikariCP 连接池版本:
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/hotel_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"); config.setUsername("root"); config.setPassword("root"); config.setMaximumPoolSize(5); config.setMinimumIdle(1); config.setConnectionTimeout(3000); HikariDataSource ds = new HikariDataSource(config);桌面管理系统的 maximumPoolSize 设成 5 就够,因为 SwingWorker 与事件线程并发的数据库操作很少超过两三个;minimumIdle 设为 1,避免空闲时也占用完整连接;connectionTimeout 三秒,拿不到连接时快速报错,不让前台一直转圈。替换后,原来调用DbUtil.getConnection()的地方不需要改。最后给一条收尾技巧:所有 DAO 方法统一用 try-with-resources 处理 Connection、PreparedStatement、ResultSet 三个对象,发生异常时连接会自动归还到连接池,酒店管理系统长期维护起来省心很多。
本文还有配套的精品资源,点击获取