简介:面向数据库课程设计与Java桌面开发入门者的宾馆管理系统项目,将SQL Server数据库设计与Swing图形界面整合为可运行的酒店业务案例,适合需要完成课设、课程作业或自学JDBC与桌面开发的学生参考。压缩包共40个文件、约2.02MB,含SQL建库脚本与mdf、ldf数据库文件、Java源码及编译后的class、可运行jar包、工程配置和使用说明文档,覆盖从建库、连接数据库到界面操作的完整链路。已有379人学习,在同类课设资料中具备一定参考价值。读者可了解宾馆管理场景下的表结构设计、房间与入住信息管理等模块划分,直接运行程序观察业务流程,并对照源码理解Swing界面布局、事件处理与数据库访问的配合方式;说明文档与脚本便于还原环境,整体结构适合作为课设搭建、答辩演示与功能扩展的起点。
1. 深夜交接班时最容易出问题的是账,不是房态
凌晨两点,前台交接班。上一班的人说 0803 已经退房,系统里也确实显示房间空闲、账单已结,可押金那笔钱怎么都对不上:客人交的是 500,账单里扣的是 300,剩下 200 去了哪里,谁也说不清。房间状态是肉眼可见的,账目却像黑匣子,这类问题在手工台账和小型单机软件里反复出现。
使用 SQL Server + Swing 实现的宾馆管理系统,要解决的就是这件事:把房态、客单、账目三条线固定在同一个数据库事务里,任何一步没落库,整笔操作就回滚。这份数据库课设方案覆盖从 E-R 设计、建表建索引、存储过程,到 Swing 三窗口界面与 DAO 分层的完整链路,适合正在做数据库课程设计、Swing 界面实训,或者想把「宾馆管理」这类经典题目做得能跑通、能演示、也能答辩的人。
2. 需求拆解与技术选型:三条主线和一套不折腾的组合
2.1 把「宾馆管理系统」拆成三条主线
刚拿到题目的人容易按界面拆功能:入住界面、退房界面、查询界面、房间管理界面。按界面拆出来的系统,写着写着就会变成一堆互不相干的窗体,每个窗体自己拼 SQL,最后房态表和入住表对不上。我一般按数据本身的流向拆,拆完再映射到界面。
第一条线是房态线。房间只有空闲、在住、待打扫、维修四种状态,所有操作都是在这四个状态之间迁移:入住把空闲变在住,退房把在住变待打扫,保洁完成后变回空闲。状态的每一次跃迁都必须留下记录,否则出了问题无从追溯。
第二条线是客单线。一位客人从预订或直接到店开始,产生一条入住单;住店期间可能换房、续住、加床;离店时形成一张结算单。入住单和结算单是一对一的关系,换房记录挂在入住单下面,形成一对多。
第三条线是账目线。押金是一笔收入,房费是一笔支出(从客人角度),额外消费(迷你吧、洗衣、赔偿)是附加项。结算金额 = 房费 + 消费 − 押金抵扣部分。这条线最怕两件事:金额用float存,以及结算和房态更新不在同一个事务里。
三条主线交叉的地方就是业务核心:入住时同时写入住单 + 改房态 + 收押金;退房时同时写结算单 + 改房态 + 结清账款。搞清这个交叉点,后面所有的表和代码都是它的展开。
2.2 为什么是 SQL Server + Swing,而不是换一套技术栈
课程设计题目已经限定了技术栈,但选型理由要讲得出来,答辩时一定会被问「为什么不用 MySQL」「为什么不用 JavaFX 或 Web」。合理的回答不是「老师要求」,而是这套组合在特定约束下确实合适。
SQL Server 的优势在约束和事务的表达力上。它支持筛选索引,可以做到「同一个房间在住记录只能有一条」这种业务红线由数据库强制;它的DECIMAL类型对金额是精确的;T-SQL 的存储过程写结算逻辑比在 Java 里拼字符串清楚得多;SSMS 的图形化执行计划对排查慢查询很友好,课设里数据量小看不出差别,但讲解索引为什么生效时很直观。
Swing 的优势是没有中间层。一个JFrame加一个JTable,数据从ResultSet到界面只隔一层 DAO,学生能完整看到「点击按钮 → 执行 SQL → 界面刷新」的全过程。换成 Web 或 JavaFX,要么多一层 HTTP 和前端框架,要么多一层 FXML 绑定,课设周期内很难把业务做深。Swing 唯一的门槛是布局管理器(BorderLayout、GridBagLayout的组合),但这是可以靠模板解决的,不涉及业务理解。
需要提醒的一点是:Swing 不等于把 SQL 写进按钮的监听器里。很多课设就是这么翻车的,代码能跑,但一改需求就推倒重来。这套方案里,界面层只负责收集参数和展示结果,SQL 全部收在 DAO 层,这一层隔开是后面所有可维护性的前提。
2.3 角色与功能边界
系统按三个角色划分权限:前台服务员、收银员、管理员。前台负责入住、换房、续住、查询;收银员负责结算和发票记录;管理员负责房间、房型、价格和用户管理。收银员不能改房态,前台不能改价格,这是审计的基本要求,在代码里靠「登录时缓存角色 + 菜单项可见性 + DAO 层再校验一次」三道关实现。
| 功能模块 | 前台 | 收银员 | 管理员 | 涉及数据表 |
|---|---|---|---|---|
| 入住登记 | 可操作 | 只读 | 可操作 | CheckIn、Room、Guest |
| 换房/续住 | 可操作 | 只读 | 可操作 | CheckIn、RoomChange、Room |
| 退房结算 | 只读 | 可操作 | 可操作 | CheckIn、Bill、ChargeItem |
| 房态维护 | 可操作 | 无权限 | 可操作 | Room |
| 房型与价格 | 只读 | 只读 | 可操作 | RoomType |
| 用户与日志 | 无权限 | 无权限 | 可操作 | SysUser、OperLog |
功能边界写清楚之后,界面数量也就定下来了:登录窗、主窗(含房态图、菜单)、入住窗、退房结算窗、房型管理窗、报表窗。六个窗体,其中后四个是主窗内的JInternalFrame或模态JDialog,不需要各自独立成JFrame,否则任务栏会挤成一排。
3. 数据库设计:九张表撑起房态、客单与账目
3.1 从 E-R 图落到表:先定主键再定字段
E-R 图画完到建表之间有个常见的断层:图上是实体和联系,建表要定主键、外键、可空性和默认值。我的习惯是先把每张表的主键确定下来,再回头补业务字段,因为主键决定了后面关联查询怎么写。
这个系统里主键分两类。一类是业务主键,比如房间号RoomNo,天然唯一、长度固定(四位,前两位楼层后两位序号),直接拿来当主键比自增 ID 更好用,前台报一个「0803」就能定位。另一类是代理主键,比如入住单号、结算单号,用IDENTITY自增,再额外给一个对外展示的OrderNo(形如CI20240512001),保证对账时不暴露自增序列。
外键关系上要克制。客人表和入住单之间加外键是必要的;操作日志表就不要加外键到用户表,否则删用户会连带失败,日志的意义就是「用户没了记录还在」。这类取舍在答辩时能讲出理由,比一味追求「所有关联都建外键」更显功底。
九张表的划分是这样的:
| 表名 | 作用 | 主要字段 | 预估记录量 |
|---|---|---|---|
| RoomType | 房型与基准价 | TypeId、TypeName、BasePrice、BedCount | 5~10 |
| Room | 房间实体与当前房态 | RoomNo、TypeId、FloorNo、RoomStatus | 50~200 |
| Guest | 客人档案 | GuestId、IdCard、Name、Phone | 数百 |
| CheckIn | 入住单 | CheckInId、OrderNo、RoomNo、GuestId、Status | 数千 |
| RoomChange | 换房记录 | ChangeId、CheckInId、OldRoom、NewRoom | 数百 |
| ChargeItem | 住店消费明细 | ItemId、CheckInId、ItemName、Amount | 数千 |
| Bill | 结算单 | BillId、CheckInId、RoomFee、TotalFee、PayTime | 数千 |
| SysUser | 系统用户与角色 | UserId、LoginName、PwdHash、Role | 5~20 |
| OperLog | 操作日志 | LogId、UserId、Action、LogTime | 上万 |
3.2 关键表结构与字段说明
先看房型表和客房表。把价格放在房型而不是房间上,是因为同一房型的所有房间价格一致,改价时只改一行;房间表只管物理属性和当前状态。
-- 房型表:价格策略集中在这里,调价只改一行 CREATE TABLE RoomType ( TypeId INT IDENTITY(1,1) PRIMARY KEY, TypeName NVARCHAR(20) NOT NULL, -- 单人间 / 标准双床 / 大床房 / 套房 BasePrice DECIMAL(10,2) NOT NULL, -- 门市价,金额一律用 DECIMAL,禁止 FLOAT BedCount TINYINT NOT NULL DEFAULT 1, Remark NVARCHAR(100) NULL, CONSTRAINT CK_RoomType_Price CHECK (BasePrice >= 0) ); -- 客房表:RoomStatus 是全局房态的唯一真相来源 CREATE TABLE Room ( RoomNo CHAR(4) PRIMARY KEY, -- 楼层+序号,如 '0803' TypeId INT NOT NULL, FloorNo TINYINT NOT NULL, RoomStatus TINYINT NOT NULL DEFAULT 0, -- 0空闲 1在住 2待打扫 3维修 CONSTRAINT FK_Room_Type FOREIGN KEY (TypeId) REFERENCES RoomType(TypeId), CONSTRAINT CK_Room_Status CHECK (RoomStatus IN (0,1,2,3)) );入住单是整条客单线的核心,它把房间和客人绑在一起,并用Status标记生命周期。这里有一个设计细节值得说:PlanOutTime(预计离店时间)在入住时就要算好并落库,而不是退房时用「当前时间 − 入住时间」现算。原因很实际——客人在住期间改过续住,如果只存入住时间,退房时算出来的天数就不对了,续住记录等于白做。
CREATE TABLE CheckIn ( CheckInId BIGINT IDENTITY(1,1) PRIMARY KEY, OrderNo VARCHAR(20) NOT NULL UNIQUE, -- 对外单号 CI20240512001 RoomNo CHAR(4) NOT NULL, GuestId INT NOT NULL, CheckInTime DATETIME NOT NULL, PlanOutTime DATETIME NOT NULL, -- 入住时就落库,续住时更新 RealOutTime DATETIME NULL, Deposit DECIMAL(10,2) NOT NULL DEFAULT 0, -- 押金 Status TINYINT NOT NULL DEFAULT 1, -- 1在住 2已退房 3已取消 Remark NVARCHAR(200) NULL, CONSTRAINT FK_CheckIn_Room FOREIGN KEY (RoomNo) REFERENCES Room(RoomNo), CONSTRAINT FK_CheckIn_Guest FOREIGN KEY (GuestId) REFERENCES Guest(GuestId), CONSTRAINT CK_CheckIn_Status CHECK (Status IN (1,2,3)) );字段名和类型看着朴素,但每个都能在答辩时解释一句:OrderNo唯一约束防止重复提交;Deposit默认 0 保证不收押金的场景也能落库;RealOutTime可空是因为在住期间它确实没有值,用NULL表达「未发生」比用1900-01-01这种默认值干净得多。
3.3 约束、索引与触发器:让数据库兜住业务红线
课程设计里最容易被忽略、也最能体现设计水平的部分,是把业务规则下推到数据库。应用层代码可以绕过,数据库约束绕不过去。
第一条红线:同一个房间同时只能有一个在住客人。窗口层的「先查再插」有并发漏洞,两个前台同时给 0803 办入住,两边都查到空闲,都能插进去。SQL Server 的筛选索引可以直接封死这条路:
-- 只对 Status = 1(在住)的行建唯一索引,从数据库层面拒绝重复入住 CREATE UNIQUE INDEX UX_CheckIn_Living ON CheckIn(RoomNo) WHERE Status = 1;WHERE Status = 1这个筛选条件意味着已退房的记录不参与唯一性约束,同一个房间历史上可以有无数条入住记录,但同时只能有一条在住。索引建好之后,应用层插入第二条时直接抛 2601 错误,捕获后提示「房间已被占用」,比任何前置检查都可靠。
第二条红线:金额不能为负,退房时间不能早于入住时间。这类检查用CHECK约束写:
ALTER TABLE Bill WITH CHECK ADD CONSTRAINT CK_Bill_Total CHECK (TotalFee >= 0); ALTER TABLE CheckIn WITH CHECK ADD CONSTRAINT CK_CheckIn_Time CHECK (RealOutTime IS NULL OR RealOutTime >= CheckInTime);索引方面,课设数据量小,索引的收益看不出来,但该建的还是要建,讲解时才有内容。查询频次最高的三个场景是「按房号查在住」「按客人姓名/证件号查历史」「按日期范围统计营收」,对应的索引是:
CREATE INDEX IX_CheckIn_Room_Status ON CheckIn(RoomNo, Status); CREATE INDEX IX_Guest_Name ON Guest(Name); CREATE INDEX IX_CheckIn_Time ON CheckIn(CheckInTime) INCLUDE (Status, RoomNo);INCLUDE子句的作用是把Status、RoomNo带到索引页里,统计查询只扫索引不回表。这个点在答辩时提到执行计划里的Index Seek与Key Lookup差别,会比背概念有说服力。
3.4 测试数据与初始化脚本
演示效果好不好,一半取决于测试数据。空表点进去什么都看不到,答辩现场很难讲。我的做法是写一个可重复执行的初始化脚本,先清后插,保证每次演示环境一致。
-- 按外键依赖倒序清理,避免删除失败 DELETE FROM Bill; DELETE FROM ChargeItem; DELETE FROM RoomChange; DELETE FROM CheckIn; DELETE FROM Guest; DELETE FROM Room; DELETE FROM RoomType; DBCC CHECKIDENT ('RoomType', RESEED, 0); INSERT INTO RoomType(TypeName, BasePrice, BedCount) VALUES (N'单人间', 168.00, 1), (N'标准双床', 228.00, 2), (N'大床房', 258.00, 1), (N'套房', 458.00, 2); -- 用递归 CTE 批量生成 8 层 × 10 间 = 80 间房,避免手写 80 条 INSERT WITH N AS (SELECT 1 AS i UNION ALL SELECT i + 1 FROM N WHERE i < 10), F AS (SELECT 1 AS f UNION ALL SELECT f + 1 FROM F WHERE f < 8) INSERT INTO Room(RoomNo, TypeId, FloorNo, RoomStatus) SELECT RIGHT('0' + CAST(f AS VARCHAR(2)), 2) + RIGHT('0' + CAST(i AS VARCHAR(2)), 2), CASE WHEN i <= 4 THEN 2 WHEN i <= 8 THEN 3 ELSE 4 END, f, 0 FROM F CROSS JOIN N OPTION (MAXRECURSION 100);递归 CTE 生成房号这一手在课设里很实用:改一个数字就能把 80 间房变成 200 间,演示时房态图铺满屏幕的效果比手动插 10 条数据强得多。OPTION (MAXRECURSION 100)是必须带的,SQL Server 默认递归上限 100,超过会报错。
4. 业务层与 Swing 界面实现:入住、退房与账务的代码闭环
4.1 分层骨架:UI / Service / DAO / DB 四层
四层的职责要划清楚,否则写着写着就塌回「按钮里拼 SQL」。UI 层(ui包)只做三件事:收集输入、调用 Service、把结果刷到组件上;Service 层(service包)负责业务规则、事务边界、异常翻译;DAO 层(dao包)只负责 SQL 和ResultSet到对象的映射,不含判断逻辑;DB 层是一个工具类,管连接和事务模板。
判断分层有没有做对,有个简单标准:把 UI 层整个删掉,Service 和 DAO 还能被一个main方法跑通,说明业务逻辑没有漏到界面里。这个标准在答辩演示时很好用——现场用控制台跑一遍入住流程,比点界面更能证明逻辑是独立的。
4.2 JDBC 连接与事务模板
连接管理的两个坑:一是每个方法自己getConnection,忘记close,几十次操作之后连接池耗尽;二是把AutoCommit保持默认的true,导致「改房态成功、写账单失败」,房态回不去。
try-with-resources解决第一个,事务模板解决第二个。下面这个工具类把「开事务、执行、提交、异常回滚、关连接」固化成一个方法,业务代码只关心中间那段。
public final class Db { // encrypt=true 是驱动默认要求;本地开发用 trustServerCertificate 免证书 private static final String URL = "jdbc:sqlserver://localhost:1433;databaseName=HotelDb" + ";encrypt=true;trustServerCertificate=true"; private static final String USER = "sa"; private static final String PWD = "Your@Password1"; public interface Work<T> { T run(Connection conn) throws Exception; } /** 统一事务模板:正常提交,异常回滚,连接一定关闭 */ public static <T> T inTx(Work<T> work) { Connection conn = null; try { conn = DriverManager.getConnection(URL, USER, PWD); conn.setAutoCommit(false); // 关键:手动控制提交时机 conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); T result = work.run(conn); conn.commit(); return result; } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignore) { } } throw new BizException("操作失败:" + rootMessage(e), e); } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignore) { } } } } }参数说明:TRANSACTION_READ_COMMITTED是 SQL Server 的默认隔离级别,能防脏读,但防不住「两个事务同时读到空闲房态」的幻读问题,所以房态抢占必须在 SQL 里用带条件的UPDATE解决,不能靠隔离级别。rootMessage是一个小工具方法,沿着getCause()找最内层异常信息,因为驱动抛出的原始错误(比如 2601 唯一索引冲突)藏在SQLServerException里面,直接打印外层信息看不到真正原因。
4.3 入住与退房的业务闭环
入住这一步要在一个事务里完成四件事:抢占房态、写客人档案(老客人则复用)、写入住单、写押金消费记录。抢占房态用带条件的更新,这是整个系统里最关键的一行 SQL。
public String checkIn(String roomNo, Guest guest, int days, BigDecimal deposit) { return Db.inTx(conn -> { // 1) 抢占房态:条件是 RoomStatus = 0,影响行数为 0 说明房已被别人开走 try (PreparedStatement ps = conn.prepareStatement( "UPDATE Room SET RoomStatus = 1 WHERE RoomNo = ? AND RoomStatus = 0")) { ps.setString(1, roomNo); if (ps.executeUpdate() == 0) { throw new BizException("房间 " + roomNo + " 已被占用,请刷新房态图后重试"); } } // 2) 客人档案:按证件号去重,存在则复用 GuestId int guestId = upsertGuest(conn, guest); // 3) 查房型基准价,算出预计离店时间和预收房费 BigDecimal price = queryBasePrice(conn, roomNo); LocalDateTime in = LocalDateTime.now(); LocalDateTime planOut = in.plusDays(days); // 4) 写入住单 String orderNo = "CI" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(in); long checkInId = insertCheckIn(conn, orderNo, roomNo, guestId, in, planOut, deposit); // 5) 押金入账,挂到入住单下,退房结算时统一冲抵 insertCharge(conn, checkInId, "押金", deposit.negate()); insertCharge(conn, checkInId, "预收房费", price.multiply(BigDecimal.valueOf(days))); return orderNo; }); }逻辑说明:第 1 步是并发防线,UPDATE ... WHERE RoomStatus = 0在 SQL Server 里会对目标行加排他锁,两个事务同时执行只有一方影响行数为 1,另一方拿到 0 直接抛业务异常。第 2 步的upsertGuest先按IdCard查,查到就更新手机号返回已有 ID,查不到再插入,避免同一位客人反复入住产生大量重复档案。第 4、5 步的金额一律用BigDecimal,deposit.negate()表示押金在账目上是「挂在客人名下的一笔可退款项」,这个符号约定要在设计文档里写清楚,否则后期对账时会怀疑人生。
退房结转用存储过程更合适,因为涉及多表更新且需要输出结算金额。T-SQL 的OUTPUT参数配合XACT_ABORT ON把回滚逻辑交给引擎,比在 Java 里一层层判断干净。
CREATE PROCEDURE usp_CheckOut @CheckInId BIGINT, @PayAmount DECIMAL(10,2) OUTPUT -- 输出:客人还需补付的金额(负数表示应退) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; -- 任一语句出错整笔回滚,不用手写 TRY/CATCH 逐层判断 BEGIN TRAN; DECLARE @room CHAR(4), @planOut DATETIME, @deposit DECIMAL(10,2); SELECT @room = RoomNo, @planOut = PlanOutTime, @deposit = Deposit FROM CheckIn WHERE CheckInId = @CheckInId AND Status = 1; IF @room IS NULL BEGIN ROLLBACK TRAN; RAISERROR(N'入住单不存在或已退房', 16, 1); RETURN; END -- 房费按实际离店时间结算,超过预计离店 2 小时按半天加收 DECLARE @realOut DATETIME = GETDATE(); DECLARE @days INT = DATEDIFF(DAY, (SELECT CheckInTime FROM CheckIn WHERE CheckInId=@CheckInId), @realOut); IF @days = 0 SET @days = 1; DECLARE @roomFee DECIMAL(10,2) = @days * (SELECT rt.BasePrice FROM Room r JOIN RoomType rt ON r.TypeId=rt.TypeId WHERE r.RoomNo=@room); -- 消费明细合计(押金记为负,天然参与冲抵) DECLARE @extra DECIMAL(10,2) = ISNULL((SELECT SUM(Amount) FROM ChargeItem WHERE CheckInId=@CheckInId), 0); SET @PayAmount = @roomFee + @extra; -- 押金已作为负数计入 @extra INSERT INTO Bill(CheckInId, RoomFee, ExtraFee, TotalFee, PayTime) VALUES(@CheckInId, @roomFee, @extra, @PayAmount, @realOut); UPDATE CheckIn SET Status = 2, RealOutTime = @realOut WHERE CheckInId = @CheckInId; UPDATE Room SET RoomStatus = 2 WHERE RoomNo = @room; -- 退房后进入待打扫 COMMIT TRAN; END参数说明:@CheckInId是入住单主键;@PayAmount是输出参数,正数表示客人还要补钱,负数表示酒店要退钱,界面据此决定显示「收款」还是「退款」按钮。存储过程里先校验Status = 1,避免重复退房;SET XACT_ABORT ON保证任何一步报错都整笔回滚,房态不会停在「已退房但没写结算单」的中间态。退房后房态置为 2(待打扫)而不是 0(空闲),是为了让保洁环节有明确的待办,这也是真实宾馆的流程。
4.4 Swing 界面:房态图与 JTable 的数据绑定
界面层最容易写成一坨ActionListener嵌套。可行的做法是:主窗用BorderLayout,中间放房态图(JPanel+GridLayout按钮矩阵),右侧放待办列表(JTable),底部放状态栏。房态图用按钮而不是自绘,是因为按钮自带点击事件和禁用态,改颜色只需setBackground。
数据绑定走AbstractTableModel,不要直接给JTable塞Vector。自定义 Model 的好处是列名、数据类型、是否可编辑都能控制,刷新时调fireTableDataChanged()即可。
public class RoomTableModel extends AbstractTableModel { private final String[] COLS = {"房号", "房型", "楼层", "状态", "在住客人"}; private List<RoomRow> rows = new ArrayList<>(); @Override public int getRowCount() { return rows.size(); } @Override public int getColumnCount() { return COLS.length; } @Override public String getColumnName(int c) { return COLS[c]; } @Override public boolean isCellEditable(int r, int c) { return false; } // 只读,改数据走业务按钮 @Override public Object getValueAt(int r, int c) { RoomRow row = rows.get(r); switch (c) { case 0: return row.roomNo; case 1: return row.typeName; case 2: return row.floorNo; case 3: return row.statusText(); // 0空闲 1在住 2待打扫 3维修 case 4: return row.guestName == null ? "" : row.guestName; default: return ""; } } /** 整体替换数据源并通知视图重绘,避免逐行 add 造成的闪烁 */ public void reload(List<RoomRow> data) { this.rows = data; fireTableDataChanged(); } }逻辑说明:isCellEditable返回false是刻意的——房态不能靠双击单元格直接改,必须走「入住/退房/维修」按钮触发事务,否则界面能改、数据库没改,就成了两套真相。reload用整体替换而不是逐行增删,是因为房态刷新是「全量重查」的场景,逐行操作会触发多次重绘,80 个房间的房态图会有肉眼可见的卡顿。房态图按钮的颜色映射建议抽成一个静态方法statusColor(int),界面和表格共用一套配色,避免房态图上显示绿色、表格里显示「维修」这种不一致。
数据刷新的时机也要定规矩:入住、退房、换房、维修登记这四个动作成功后必须调一次reload,其他操作不刷。定时刷新(比如javax.swing.Timer每 30 秒查一次)在多前台场景下有用,但会掩盖事务问题,课设阶段建议先不加,等业务跑通了再作为扩展点提。
5. 避坑与排查:五处最容易翻车的地方
5.1 连接与资源类问题
现象:系统跑十几分钟后界面卡死,点任何按钮都没反应,重启才好。
原因:某个 DAO 方法里Connection、PreparedStatement、ResultSet没有关闭,或者close写在try块末尾而不是finally,一旦中间抛异常就漏掉。SQL Server 默认连接数有限,漏几十个之后新连接全部超时。
解决:所有 JDBC 资源统一用try-with-resources,Connection由Db.inTx统一管理,DAO 方法只接收Connection参数、不自己创建。排查时在 SSMS 里执行SELECT * FROM sys.dm_exec_sessions WHERE is_user_process = 1,看会话数是否只增不减,一眼就能确认是不是泄漏。
5.2 房态与并发类问题
现象:两个人同时给 0803 办入住,两笔都成功了,房态图上显示两位客人;或者退房时提示成功,但房态还是「在住」。
原因:前者是「先SELECT查房态、再UPDATE」的两步操作,中间有窗口期;后者是写结算单和改房态不在同一个事务里,或者AutoCommit忘了关。
解决:房态抢占必须用「带条件的单条UPDATE+ 判断影响行数」,配合 3.3 节的筛选索引双保险。跨表操作一律包进事务,AutoCommit(false)加显式commit/rollback。验收时专门测一遍:开两个客户端同时点入住,正确的结果是一个成功、一个提示「房间已被占用」。
5.3 金额、时间与字符集类问题
现象:结算金额出现299.99999999这种数字;中文客人姓名存进去变成问号;跨月统计时少了一天。
原因:金额用了float/double;数据库排序规则不是中文,或者 JDBC URL 没带字符集参数;时间用字符串比较,'2024-5-1'和'2024-05-01'排序不同。
解决:金额从建表到 Java 全链路用DECIMAL(10,2)和BigDecimal,BigDecimal运算必须传RoundingMode,divide不传会抛ArithmeticException;字符字段统一NVARCHAR,SQL Server 驱动 12 以后默认 UTF-8,但旧驱动需要在 URL 里加sendStringParametersAsUnicode=true;时间字段一律DATETIME类型,统计时用DATEDIFF(DAY, ...)而不是字符串截取。
5.4 界面与数据绑定类问题
现象:表格里删了一行,数据库里还在;或者修改房型价格后,房态图还是旧价格。
原因:JTable的TableModel只改了内存里的List,没有同步 DAO;或者多个界面各自缓存了一份数据,改了一处不知道通知别处。
解决:界面不持有业务数据的「副本」,每次操作成功后重新查一次并reload。这条规则看起来低效,但对课设规模的数据量完全够用,而且彻底消除了「界面与数据库不一致」这一整类问题。等真要优化性能,再考虑事件总线或观察者模式,不要一开始就上。
6. 验收与答辩:怎么证明这套系统是「对的」
验收这件事,很多人把它当成「把功能点一遍」。真正能拿分的做法是准备一份可复现的验收用例表,每条用例写清楚前置条件、操作步骤、预期结果和实际结果,现场按表执行。这样做的好处是:演示不会因为紧张漏掉功能,评委也能看到你有验证意识,而不是「我觉得它能跑」。
| 编号 | 用例 | 前置条件 | 操作 | 预期结果 |
|---|---|---|---|---|
| TC-01 | 正常入住 | 0803 房态为空闲 | 输入客人信息、住 2 天、押金 500 | 房态变在住,生成 CI 单号,账目出现押金 −500 和预收房费 |
| TC-02 | 重复入住拦截 | 0803 已在住 | 再次对 0803 办入住 | 提示「房间已被占用」,数据库无新增在住记录 |
| TC-03 | 退房结算 | 0803 在住 2 晚,押金 500 | 执行退房 | 生成 Bill 记录,房态变待打扫,应退/应补金额正确 |
| TC-04 | 换房 | 0803 在住 | 换到 0805 | RoomChange 新增一条,0803 变空闲,0805 变在住 |
| TC-05 | 事务回滚 | 退房过程中 Bill 表被故意设为只读 | 执行退房 | 报错,房态仍为在住,无半截数据 |
| TC-06 | 权限隔离 | 以收银员登录 | 尝试点「房型管理」 | 菜单不可见,直接调 DAO 也被拒绝 |
| TC-07 | 金额精度 | 房费 228.00,住 3 晚,消费 12.50 | 结算 | 合计 696.50,无浮点尾数 |
TC-05 是最值得重点演示的一条,也是很多课设的薄弱环节。演示方法很简单:在 SSMS 里对Bill表执行DENY INSERT TO sa,再去界面点退房,系统应该报错并且房态保持不变。这一条能过,说明事务边界是真的做对了,不是「碰巧能跑」。
答辩提问高频集中在三处:为什么这么设计表、索引有没有用上、并发怎么处理。前两个靠第 3 章的内容就能答;第三个问题如果只答「加了事务」,通常会被追问「两个事务同时读到空闲怎么办」,这时候把 4.3 节那句带条件的UPDATE拿出来讲,配合 3.3 的筛选索引,逻辑就闭合了。反过来,如果只答「我在 Java 里判断了一下房态」,基本就到此为止了。
还有一个实操技巧:提前准备好一份「坏数据」脚本。答辩现场如果评委想看异常处理,直接跑脚本往CheckIn表插一条Status = 1但房间号不存在的脏数据,再看界面刷新时会不会崩。系统在这种情况下应该捕获异常并提示「数据异常,请联系管理员」,而不是抛一堆红色堆栈。这个细节做不做,观感差别很大。
血泪经验是:演示环境一定要和开发环境隔离,并且提前一天把整套流程从头跑三遍。我见过太多次现场翻车——数据库附加失败、SQL Server 服务没启动、连接字符串里还留着上一位同学改的 IP。从那以后我每次交课程设计,都会在演示前用一份全新的数据库备份还原一遍,从登录到退房走完整流程,确认无误再上讲台。这套系统的完整脚本、建表语句、测试数据和界面代码都在,按第 3 章到第 4 章的顺序跑一遍就能复现,希望帮到你。
本文还有配套的精品资源,点击获取