简介这份资源是面向高校计算机相关专业学生的数据库课程设计参考方案以SQL Server作为后台数据库、Java Swing构建桌面端界面实现了一套完整的宾馆管理系统。适合正在准备数据库课设、需要对照学习数据库设计与桌面应用整合的初学者或中级开发者可帮助理解从建库建表到界面交互的完整流程。压缩包共40个文件约2.02MB包含SQL脚本、mdf与ldf数据库文件、Java源码、编译后的class文件、可执行jar包以及使用说明文档覆盖数据库脚本、源代码与运行文件等主要类型便于直接运行或二次修改。目前已有378人学习下载。资源提供了从数据库设计到Swing界面实现的完整课设方案读者可参考其表结构设计、SQL语句编写与Java事件处理逻辑并借助说明文档快速部署运行适合作为课设起步或查漏补缺的实践素材。1. 宾馆管理系统为什么还在用 SQL Server Swing 这套老组合很多同行听到“宾馆管理系统”第一反应是上 Web、上微服务、上小程序但真到中小型宾馆、招待所、培训中心这类场景老板要的是一套能装在前台那台 Windows 机器上、双击就能跑、断网也能开房退房的东西。SQL Server Swing 这套组合看着老却刚好卡在这个需求上SQL Server 负责事务和并发Swing 负责桌面端交互部署时不用配 Nginx、不用管域名证书一个 jar 加一个数据库实例就能交付。这篇文章讲的就是怎么用这套组合把宾馆管理系统从零跑通包括库表怎么设计、Swing 界面怎么和 JDBC 对接、退房时并发怎么防、以及我踩过的几个血泪坑。适合手里有 Java 基础、想接一个能落地的桌面管理系统的开发者也适合正在做课程设计但不想只交个玩具的读者。2. 库表设计与 SQL Server 侧的关键约束2.1 五张核心表怎么切分职责宾馆管理系统的数据模型不复杂但切分方式直接决定后面代码好不好写。我一般会拆成五张表房间表、房型表、客人表、订单表、操作日志表。房型表存价格和床位信息房间表存物理房间号和当前状态订单表存入住到退房的完整生命周期客人表存证件信息操作日志表记录谁在什么时候改了哪条订单。这里有个容易翻车的地方很多人把“房间状态”直接塞进订单表靠查最新订单来判断房间是否空闲。这样做在并发开房时会出问题因为两个前台同时查“最新订单”可能都查到同一条已退房记录然后同时给同一个房间开单。正确做法是在房间表上放一个 status 字段用数据库行锁来保证状态变更的原子性。-- 房型表价格和床位信息 CREATE TABLE RoomType ( TypeID INT IDENTITY(1,1) PRIMARY KEY, TypeName NVARCHAR(32) NOT NULL, -- 如标准双床房 Price DECIMAL(10,2) NOT NULL, -- 每晚价格 BedCount INT NOT NULL DEFAULT 2, MaxGuests INT NOT NULL DEFAULT 2 ); -- 房间表物理房间status 是并发控制的核心 CREATE TABLE Room ( RoomID INT IDENTITY(1,1) PRIMARY KEY, RoomNo NVARCHAR(16) NOT NULL UNIQUE, -- 如8301 TypeID INT NOT NULL, FloorNo INT NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0空闲 1已入住 2维修 3预订 CONSTRAINT FK_Room_Type FOREIGN KEY (TypeID) REFERENCES RoomType(TypeID) ); -- 订单表一次入住对应一条退房时写离店时间 CREATE TABLE Booking ( BookingID INT IDENTITY(1,1) PRIMARY KEY, RoomID INT NOT NULL, GuestID INT NOT NULL, CheckInAt DATETIME NOT NULL DEFAULT GETDATE(), CheckOutAt DATETIME NULL, -- NULL 表示未退房 Deposit DECIMAL(10,2) NOT NULL DEFAULT 0, TotalFee DECIMAL(10,2) NULL, Status TINYINT NOT NULL DEFAULT 1, -- 1在住 2已退 3取消 CONSTRAINT FK_Booking_Room FOREIGN KEY (RoomID) REFERENCES Room(RoomID) );上面这段建表语句里Status字段用 TINYINT 而不是字符串是为了后面做条件更新时能用WHERE Status 0这种精确匹配避免字符串比较带来的隐式转换开销。CheckOutAt允许 NULL 是刻意设计这样“在住订单”的查询条件就是CheckOutAt IS NULL比额外维护一个状态位更不容易出错。2.2 用条件更新代替“先查后改”开房操作的本质是“把房间从空闲改成已入住同时插入一条订单”。如果写成先 SELECT 查状态、再 UPDATE两个线程之间就有窗口期。我一般直接用带条件的 UPDATE靠 SQL Server 的行锁保证只有一个事务能改成功。-- 开房只有当前状态为0空闲时才更新成功 UPDATE Room SET Status 1 WHERE RoomID roomId AND Status 0; -- 检查 ROWCOUNT如果为0说明房间已被别人占用 IF ROWCOUNT 0 BEGIN RAISERROR(房间已被占用请刷新后重试, 16, 1); RETURN; END INSERT INTO Booking (RoomID, GuestID, Deposit, Status) VALUES (roomId, guestId, deposit, 1);这段逻辑的关键在于WHERE RoomID roomId AND Status 0这个条件。SQL Server 在执行 UPDATE 时会对匹配的行加排他锁第二个事务进来时要么等待、要么在 READ COMMITTED 隔离级别下直接看到更新后的状态导致ROWCOUNT为 0。参数roomId和guestId由 Java 侧通过 PreparedStatement 传入不要用字符串拼接否则既有注入风险又会让执行计划无法复用。2.3 索引和连接池的两个必调参数订单表在数据量上来之后最常查的是“某房间当前在住订单”和“某时间段内的订单列表”。前者靠RoomID CheckOutAt组合索引后者靠CheckInAt单列索引。CREATE INDEX IX_Booking_Room_Active ON Booking (RoomID, CheckOutAt) INCLUDE (GuestID, Deposit); CREATE INDEX IX_Booking_CheckIn ON Booking (CheckInAt);INCLUDE里放 GuestID 和 Deposit 是为了让“查在住订单”这个查询走覆盖索引不用回表。连接池方面Swing 客户端一般并发不高HikariCP 的maximumPoolSize设 10 到 15 就够connectionTimeout设 3000 毫秒避免前台点按钮时卡太久。这里有个玄学现象如果maximumPoolSize设得比 SQL Server 的max worker threads还大反而会因为线程争抢导致响应变慢中小系统里 10 个连接足够支撑几十个前台。3. Swing 客户端与 JDBC 的对接方式3.1 用 TableModel 把 ResultSet 挡在界面之外Swing 里最容易写乱的地方是把 ResultSet 直接往 JTable 里塞。正确做法是自定义一个继承 AbstractTableModel 的类把查询结果转成 List界面只认这个 List。这样刷新数据、排序、过滤都在模型层做界面代码不会变成一锅粥。public class RoomTableModel extends AbstractTableModel { private final String[] columns {房间号, 房型, 楼层, 状态}; private ListRoomRow rows new ArrayList(); Override public int getRowCount() { return rows.size(); } Override public int getColumnCount() { return columns.length; } Override public String getColumnName(int col) { return columns[col]; } Override public Object getValueAt(int row, int col) { RoomRow r rows.get(row); switch (col) { case 0: return r.roomNo; case 1: return r.typeName; case 2: return r.floorNo; case 3: return r.statusText; // 已转成空闲/在住/维修 default: return ; } } // 刷新时整体替换避免逐行 fire 导致界面闪烁 public void reload(ListRoomRow newRows) { this.rows newRows; fireTableDataChanged(); } }RoomRow是一个纯 POJO字段和查询结果一一对应。reload方法里用fireTableDataChanged而不是逐行fireTableRowsInserted是因为房间数量通常几十到几百整体替换的开销可以接受而且能避免多次重绘带来的闪烁。状态字段在模型层就转成中文界面不需要再做映射。3.2 后台线程查库EDT 只负责画界面Swing 的单线程模型要求所有界面更新都在事件调度线程EDT上做但查库是阻塞操作放在 EDT 上会让界面卡死。我一般用 SwingWorker 把查询放到后台done 方法里再更新模型。private void loadRooms() { new SwingWorkerListRoomRow, Void() { Override protected ListRoomRow doInBackground() throws Exception { // 这里在后台线程执行可以放心查库 return roomDao.findAll(); } Override protected void done() { try { roomTableModel.reload(get()); } catch (Exception e) { JOptionPane.showMessageDialog(thisFrame, 加载房间失败 e.getMessage()); } } }.execute(); }doInBackground里调用的roomDao.findAll()内部从 HikariCP 拿连接、执行查询、关闭连接整个过程不碰任何 Swing 组件。done方法运行在 EDT 上get()拿到结果后更新模型触发重绘。这里要注意get()会抛出 ExecutionException必须捕获否则后台异常会被吞掉界面看起来像没反应。3.3 开房按钮的完整事务流程开房按钮点下去之后要做三件事校验输入、开启事务、执行条件更新加插入。任何一步失败都要回滚并提示。public boolean checkIn(int roomId, int guestId, BigDecimal deposit) throws SQLException { Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); // 关闭自动提交手动控制事务 // 第一步条件更新房间状态 try (PreparedStatement ps conn.prepareStatement( UPDATE Room SET Status 1 WHERE RoomID ? AND Status 0)) { ps.setInt(1, roomId); int updated ps.executeUpdate(); if (updated 0) { conn.rollback(); return false; // 房间已被占用 } } // 第二步插入订单 try (PreparedStatement ps conn.prepareStatement( INSERT INTO Booking (RoomID, GuestID, Deposit, Status) VALUES (?, ?, ?, 1))) { ps.setInt(1, roomId); ps.setInt(2, guestId); ps.setBigDecimal(3, deposit); ps.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) conn.setAutoCommit(true); if (conn ! null) conn.close(); // 归还到连接池 } }setAutoCommit(false)之后两条语句在同一个事务里要么都成功要么都回滚。conn.close()在 HikariCP 里不是真正关闭物理连接而是归还到池里所以不用担心频繁开关连接的开销。参数deposit用 BigDecimal 而不是 double是因为金额计算用浮点会出现 0.1 0.2 这类精度问题退房结算时对不上账。4. 退房结算与并发场景的避坑记录4.1 退房时金额算错的三类原因退房结算看起来简单实际是投诉重灾区。我遇到过三类算错的情况第一类是入住时间跨天但按自然日算比如晚上 11 点入住、第二天凌晨 1 点退房按自然日算只收一天但实际应该按 24 小时周期第二类是押金抵扣时用了 double 导致几分钱对不上第三类是两个前台同时给同一间房办退房重复退款。第一类的解法是在订单表里存CheckInAt的完整时间戳结算时用DATEDIFF(HOUR, CheckInAt, GETDATE())算小时数再按“不足 24 小时按一天、超过部分按小时加收”的规则折算。第二类统一用 BigDecimal 的setScale(2, RoundingMode.HALF_UP)。第三类靠退房时的条件更新防重。-- 退房只有当前是在住状态Status1且未退房时才更新 UPDATE Booking SET CheckOutAt GETDATE(), TotalFee totalFee, Status 2 WHERE BookingID bookingId AND Status 1 AND CheckOutAt IS NULL; IF ROWCOUNT 0 BEGIN RAISERROR(该订单已退房或不存在, 16, 1); RETURN; END UPDATE Room SET Status 0 WHERE RoomID roomId;WHERE Status 1 AND CheckOutAt IS NULL这个双重条件是后悔药即使两个前台同时点了退房只有一个事务能更新成功另一个ROWCOUNT为 0 直接报错不会重复退款。4.2 避坑五个真实踩过的坑现象一前台点开房没反应等几秒后弹出“房间已被占用”。原因是连接池connectionTimeout设得太长第一个事务持锁期间第二个请求一直在等。解决是把connectionTimeout降到 3000 毫秒并在界面上加一个“处理中”的禁用状态避免用户重复点击。现象二退房后房间状态没变回空闲房间列表里还是“在住”。原因是退房事务里先更新了订单再更新房间但更新房间时用的 RoomID 是从界面缓存里拿的旧值。解决是退房时从订单表反查 RoomID不要信界面传过来的值。现象三SQL Server 报“已超过锁请求超时”。原因是长事务里夹了网络调用或弹窗等待。解决是把所有需要用户确认的弹窗移到事务开始之前事务里只做数据库操作执行时间控制在几百毫秒内。现象四中文房型名在界面上显示成问号。原因是 JDBC URL 没指定字符集或者数据库排序规则不是中文兼容的。解决是连接串加characterEncodingUTF-8建库时排序规则选Chinese_PRC_CI_AS。现象五打包成 jar 后连不上数据库报“找不到驱动”。原因是 SQL Server 的 JDBC 驱动 jar 没打进 fat jar或者驱动类名写错。解决是用 Maven Shade 插件把mssql-jdbc打进去驱动类名用com.microsoft.sqlserver.jdbc.SQLServerDriver不要用老版本的com.microsoft.jdbc.sqlserver.SQLServerDriver。4.3 日志表不是可选项操作日志表在出问题时是唯一的黑匣子。我一般记录四个字段操作时间、操作员、操作类型、关联订单号。开房、退房、改价、取消预订这四类操作必须写日志而且日志插入要和业务操作在同一个事务里避免业务成功但日志丢失。CREATE TABLE OperationLog ( LogID BIGINT IDENTITY(1,1) PRIMARY KEY, OpTime DATETIME NOT NULL DEFAULT GETDATE(), Operator NVARCHAR(32) NOT NULL, OpType NVARCHAR(16) NOT NULL, -- CHECKIN/CHECKOUT/CANCEL BookingID INT NULL, Detail NVARCHAR(256) NULL );Operator字段存的是前台登录账号不要存“系统”这种模糊值否则对账时找不到人。Detail里可以放变更前后的关键值比如“押金 200 改为 300”方便追溯。5. 交付前的自检清单与一个结算技巧5.1 上线前必须跑通的六个场景交付前我会按这个清单逐条过一遍每条都要在真实数据库上跑不能只看代码场景操作预期结果并发开房两个客户端同时给同一房间开房一个成功一个提示已被占用并发退房两个客户端同时退同一订单一个成功一个提示已退房跨天结算23:00 入住次日 1:00 退房按规则算出正确金额断网重连拔网线后点查询再插回提示失败恢复后能正常查询中文显示房型名含中文和数字界面和数据库都无乱码日志追溯开房后查日志表有对应记录且操作员正确这张表里的并发场景必须用两个真实客户端测不要用单元测试模拟因为 Swing 的事件队列和连接池的交互只有在真实界面操作下才会暴露问题。5.2 一个结算技巧把计费规则做成可配置硬编码计费规则是后期改需求时的噩梦。我一般把规则抽成一张配置表结算时读配置再算。CREATE TABLE BillingRule ( RuleID INT IDENTITY(1,1) PRIMARY KEY, RuleName NVARCHAR(32) NOT NULL, FreeHours INT NOT NULL DEFAULT 0, -- 免费时长 DayHours INT NOT NULL DEFAULT 24, -- 一天按多少小时算 ExtraPerHour DECIMAL(10,2) NOT NULL DEFAULT 0, -- 超时每小时加收 Active BIT NOT NULL DEFAULT 1 );结算时先查 Active 为 1 的规则用DATEDIFF(HOUR, CheckInAt, CheckOutAt)得到总小时数减去 FreeHours再按 DayHours 折算天数和剩余小时。这样老板说“改成 6 小时算半天”时只需要改一行数据不用重新打包发版。public BigDecimal calcFee(int hours, BigDecimal dayPrice, BillingRule rule) { int billable Math.max(0, hours - rule.freeHours); int days billable / rule.dayHours; int extraHours billable % rule.dayHours; BigDecimal fee dayPrice.multiply(BigDecimal.valueOf(days)); fee fee.add(rule.extraPerHour.multiply(BigDecimal.valueOf(extraHours))); return fee.setScale(2, RoundingMode.HALF_UP); }Math.max(0, ...)是防止免费时长大于实际入住时长时出现负数。setScale(2, HALF_UP)保证金额永远是两位小数和数据库 DECIMAL(10,2) 对齐。这套写法我用了几年改计费规则时再也没加过班。希望帮到你。本文还有配套的精品资源点击获取