☰
客房管理数据库课设避坑:事务、状态机与唯一索引兜底
2026/11/4 3:31:47 网站建设 项目流程

简介:这份酒店管理系统(客房管理)课程设计材料面向高校数据库课程设计学习者,围绕客房预订、入住、退房、房间状态更新与账单查询等业务流程,提供可参考的关系型数据库建模与Java实现方案。压缩包共30个文件,以18个class编译文件、10个java源码和2张jpg图片为主,源码覆盖客房、客户、订单、入住登记、退房结算及查询等模块,class文件便于直接运行对照,图片可用于了解界面或流程;整体约404KB,体量轻便,适合课程设计复现与二次修改。已有1877人学习,说明其在同类课程设计中具有一定参考价值。读者可从中梳理实体关系、表结构设计、外键约束与增删改查实现思路,并借鉴预订、入住、退房等场景的业务逻辑组织方式,用于完成客房管理模块的设计、编码与测试。

1. 客房管理不是 CRUD:数据库课程设计里最容易翻车的三个一致性点

前阵子帮人看一个酒店管理系统的课设,演示到“入住”时,同一间房被开了两张在住单,操作的同学还说自己点之前查过是空房。这类翻车在数据库课程设计的客房管理模块里很常见:房态当一个字段随手改,订单当一张表随手插,最后报表、日志、房态三套数据各说各话。客房管理真正要解决的不是增删改查,而是“同一房间同一时段只能有一个在住订单”“退房必须同时关订单和改房态”“换房不能把历史记录改丢”这三个一致性点。下面按表结构、事务、查询、避坑、自证顺序拆开讲,适合正在做酒店管理系统课设、又不想在答辩时被追问到哑口的人。

2. 从入住单到房态:把客房管理拆成五张核心表和一套状态机

2.1 先定状态机:房态不是只有“空闲/占用”

很多课设把房态做成空闲/占用两个值,做到退房就卡住:客人走了,房间到底能不能直接卖?如果直接改成空闲,清洁没做,下一位客人进去看到的是没收拾的房间;如果改成占用,报表上又显示有人住。常见做法是把房态拆成五个值:VC空净可售、VD脏房待清洁、OC在住、OOO维修锁房、RS预订保留。这五个值不是拍脑袋来的,它们对应客房部、前台、工程部三个岗位对同一间房的不同视角。

状态迁移要写死,不能允许前台从VD直接开到OC。我一般会把迁移规则画成一张表,答辩时直接投出来,比口头解释管用。

当前状态事件目标状态触发方
VC办理入住OC前台
OC办理退房VD前台
VD清洁完成VC客房部
VC报修锁房OOO工程部
OOO维修完成VD工程部
VC预订保留RS预订渠道
RS取消预订VC前台
RS到店入住OC前台

这张表里最容易被忽略的是RS → OC:预订保留的房间在客人到店前不能算在住,但也不能被别的客人开走。状态机落到数据库里,room.current_status只是快照,stay_order才是事实,room_state_log负责回答“这间房什么时候从 VC 变成 OC 的”。为什么不全用订单表聚合出房态?房态看板是高频查询,每次聚合订单表在课设数据量下看不出差别,但逻辑上会越来越绕;保留快照是用一点冗余换查询清晰,代价是必须用事务和日志保证快照不飘。

提示:状态值用英文枚举,不要用中文“空闲”“在住”直接存。后面写查询、做索引、对接前端时,英文枚举少很多编码麻烦。

2.2 五张核心表:room_type、room、guest、stay_order、room_state_log

表不用多,五张够把客房管理的主线跑通。room_type存房型,room存物理房间,guest存客人,stay_order存入住单,room_state_log存房态变更流水。字段设计上,金额一律用DECIMAL(10,2),不要用FLOAT,房费算出来 399.999 再四舍五入,报表对不上时很难查。时间用DATETIME,课设里不需要考虑跨时区,但要注意checkout_plan_at是计划退房时间,checkout_at是实际退房时间,两个字段不要合并。

表关键字段约束要点
room_typeroom_type_id, type_name, bed_count, base_price, max_occupancy主键自增,base_price 非负
roomroom_id, room_no, room_type_id, floor, current_statusroom_no 唯一,room_type_id 外键
guestguest_id, guest_name, id_no, phoneid_no 唯一,避免同一客人重复建档
stay_orderorder_id, room_id, guest_id, checkin_at, checkout_plan_at, checkout_at, status, active_room_id, rate, depositactive_room_id 唯一,room_id 外键
room_state_loglog_id, room_id, from_status, to_status, order_id, changed_at, reasonroom_id 外键,按 room_id + changed_at 建索引

stay_order.active_room_id是这张设计里最关键的一个字段:当订单状态是IN_HOUSE时它等于room_id,其他状态为NULL。MySQL 的唯一索引允许多个NULL,所以同一房间可以有很多历史订单,但最多只能有一条在住订单。这个约束把“重复入住”从应用层判断变成了数据库层兜底,后面避坑章会展开。

guest.id_no加唯一索引看起来理所当然,但课设里经常有人用手机号做唯一键,结果一家人用同一个手机号订两间房就冲突。身份证号唯一更稳,手机号只做普通索引。room.room_no同理,房号是物理标识,不能重复。

2.3 建表 SQL 与约束:把“同一房间不能重叠入住”落到数据库层

下面这段 SQL 按 MySQL 8.0 写,生成列和唯一索引配合使用。如果环境更老,把active_room_id换成普通列,在事务里显式维护,逻辑一样,只是少了一层数据库兜底。

CREATE TABLE room_type ( room_type_id INT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(32) NOT NULL, bed_count TINYINT NOT NULL, base_price DECIMAL(10,2) NOT NULL, max_occupancy TINYINT NOT NULL, CONSTRAINT chk_price CHECK (base_price >= 0) ) ENGINE=InnoDB; CREATE TABLE room ( room_id INT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(16) NOT NULL, room_type_id INT NOT NULL, floor SMALLINT NOT NULL, current_status ENUM('VC','VD','OC','OOO','RS') NOT NULL DEFAULT 'VC', UNIQUE KEY uk_room_no (room_no), KEY idx_floor_status (floor, current_status), CONSTRAINT fk_room_type FOREIGN KEY (room_type_id) REFERENCES room_type(room_type_id) ) ENGINE=InnoDB; CREATE TABLE guest ( guest_id INT PRIMARY KEY AUTO_INCREMENT, guest_name VARCHAR(32) NOT NULL, id_no VARCHAR(32) NOT NULL, phone VARCHAR(20), UNIQUE KEY uk_guest_id_no (id_no), KEY idx_guest_phone (phone) ) ENGINE=InnoDB; CREATE TABLE stay_order ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id INT NOT NULL, guest_id INT NOT NULL, checkin_at DATETIME NOT NULL, checkout_plan_at DATETIME NOT NULL, checkout_at DATETIME NULL, status ENUM('BOOKED','IN_HOUSE','CHECKED_OUT','CANCELLED') NOT NULL, active_room_id INT GENERATED ALWAYS AS ( CASE WHEN status = 'IN_HOUSE' THEN room_id ELSE NULL END ) STORED, rate DECIMAL(10,2) NOT NULL, deposit DECIMAL(10,2) NOT NULL DEFAULT 0.00, UNIQUE KEY uk_room_active (active_room_id), KEY idx_room_time (room_id, checkin_at, checkout_plan_at), KEY idx_status_plan (status, checkout_plan_at), CONSTRAINT fk_order_room FOREIGN KEY (room_id) REFERENCES room(room_id), CONSTRAINT fk_order_guest FOREIGN KEY (guest_id) REFERENCES guest(guest_id) ) ENGINE=InnoDB; CREATE TABLE room_state_log ( log_id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id INT NOT NULL, from_status VARCHAR(8) NOT NULL, to_status VARCHAR(8) NOT NULL, order_id BIGINT NULL, changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, reason VARCHAR(64) NOT NULL, KEY idx_room_time (room_id, changed_at), CONSTRAINT fk_log_room FOREIGN KEY (room_id) REFERENCES room(room_id) ) ENGINE=InnoDB;

uk_room_active是防重复入住的最后一道门:只要有人试图给同一房间插第二条IN_HOUSE订单,数据库直接报Duplicate entry。idx_room_time支撑“查某房间某段时间有没有预订”的范围查询,idx_status_plan支撑夜审找超时未退。room表上的idx_floor_status是为房态看板按楼层筛选准备的,单独在current_status上建索引意义不大,因为只有五个值,选择性太低,后面索引小节会细说。

为什么不用CHECK约束做时间重叠检查?因为CHECK只能看单行,管不了“同一房间两条订单时间交叉”。时间区间的排他约束在 MySQL 里没有原生支持,常见做法是事务里SELECT ... FOR UPDATE锁房间,再用查询判断交叉,最后用uk_room_active兜住“在住”这一种状态。预订状态的时间交叉要在应用层或存储过程里判断,课设里把这一条写进设计文档,答辩时能加不少分。

3. 用 SQL 跑通客房业务:入住、换房、退房、超时未退的最小闭环

3.1 入住登记:一条事务里写订单、改房态、记日志

入住不是一条INSERT就完事。顺序应该是:锁房间、校验房态、插订单、改房态、写日志、提交。锁房间用SELECT ... FOR UPDATE,作用是把这一行锁住,第二个前台再查同一间房时会等第一个事务提交,避免两个请求同时看到VC然后都去插订单。

START TRANSACTION; -- 1. 锁住房间行,防止并发入住 SELECT current_status INTO @room_status FROM room WHERE room_id = 1001 FOR UPDATE; -- 2. 只有空净可售才能入住 -- 应用层在这里判断 @room_status = 'VC',不满足就 ROLLBACK -- 3. 插入入住订单,active_room_id 由生成列自动维护 INSERT INTO stay_order (room_id, guest_id, checkin_at, checkout_plan_at, status, rate, deposit) VALUES (1001, 2001, NOW(), DATE_ADD(NOW(), INTERVAL 2 DAY), 'IN_HOUSE', 399.00, 500.00); SET @order_id = LAST_INSERT_ID(); -- 4. 改房态,条件里再带一次 VC,防止中间被别的会话改掉 UPDATE room SET current_status = 'OC' WHERE room_id = 1001 AND current_status = 'VC'; -- 5. 受影响行数必须是 1,否则回滚 -- 应用层检查 ROW_COUNT() = 1 -- 6. 记一条房态流水,方便对账和答辩演示 INSERT INTO room_state_log (room_id, from_status, to_status, order_id, reason) VALUES (1001, 'VC', 'OC', @order_id, 'CHECK_IN'); COMMIT;

这段事务里有两个细节容易写错。第一,UPDATE room的条件里又带了一次current_status='VC',这不是多余,而是防止在SELECT和UPDATE之间有人把房态改掉;虽然FOR UPDATE已经锁了行,但加上条件能让 SQL 自解释,也方便检查ROW_COUNT()。第二,LAST_INSERT_ID()在INSERT之后立刻取,不要等到UPDATE之后再取,虽然 MySQL 的UPDATE不会改变它,但代码顺序清楚一点,后面换房逻辑不容易混。

rate从room_type.base_price带出来,但允许前台覆盖,比如协议价、钟点房。deposit是押金,退房时多退少补。checkin_at用NOW(),checkout_plan_at由前台选,课设里不需要做复杂的房价日历,把这两个时间存准,后面算房费和夜审都靠它们。

3.2 换房与续住:为什么先锁房再改单

换房是课设里最容易写丢历史的一步。常见写法是直接UPDATE stay_order SET room_id = 新房 WHERE order_id = ?,看起来简单,但换房前的房间、换房时间、操作人全没了,答辩时问“这单换过几次房”就答不上来。我一般推荐“关旧单 + 开新单”:旧单状态改成CHECKED_OUT,checkout_at记换房时间,备注写“换房”;新单从换房时间开始,房态走一遍退房和入住。这样订单表里天然有换房历史,room_state_log也能对上。

START TRANSACTION; -- 1. 查出原订单,确认是在住状态 SELECT room_id, guest_id, rate INTO @old_room_id, @guest_id, @rate FROM stay_order WHERE order_id = 3001 AND status = 'IN_HOUSE' FOR UPDATE; -- 2. 按 room_id 升序锁两个房间,避免死锁 SELECT room_id FROM room WHERE room_id IN (@old_room_id, 1002) ORDER BY room_id FOR UPDATE; -- 3. 校验新房态为 VC,应用层判断 -- 4. 关旧单 UPDATE stay_order SET status = 'CHECKED_OUT', checkout_at = NOW() WHERE order_id = 3001 AND status = 'IN_HOUSE'; -- 5. 开新单,房态从换房时刻开始 INSERT INTO stay_order (room_id, guest_id, checkin_at, checkout_plan_at, status, rate, deposit) VALUES (1002, @guest_id, NOW(), DATE_ADD(NOW(), INTERVAL 1 DAY), 'IN_HOUSE', @rate, 0.00); SET @new_order_id = LAST_INSERT_ID(); -- 6. 原房转脏房,新房转在住 UPDATE room SET current_status = 'VD' WHERE room_id = @old_room_id; UPDATE room SET current_status = 'OC' WHERE room_id = 1002 AND current_status = 'VC'; -- 7. 两条日志,换房轨迹才完整 INSERT INTO room_state_log (room_id, from_status, to_status, order_id, reason) VALUES (@old_room_id, 'OC', 'VD', 3001, 'CHANGE_ROOM_OUT'); INSERT INTO room_state_log (room_id, from_status, to_status, order_id, reason) VALUES (1002, 'VC', 'OC', @new_order_id, 'CHANGE_ROOM_IN'); COMMIT;

锁两个房间时一定要按room_id排序。两个前台同时换房,一个从 A 换到 B,一个从 B 换到 A,如果不排序,一个先锁 A 再等 B,另一个先锁 B 再等 A,直接死锁。排序后两边都先锁较小的room_id,后锁较大的,死锁概率降到很低。这个细节在课设里写一句“按主键升序加锁”,答辩时是实打实的加分项。

续住比换房简单,只改checkout_plan_at,但也要先查这段时间房间有没有被预订。课设里可以简化成“续住只允许在原房间没有BOOKED订单的情况下操作”,用一条范围查询判断:

SELECT COUNT(*) INTO @conflict FROM stay_order WHERE room_id = 1001 AND status = 'BOOKED' AND checkin_at < @new_checkout_plan AND checkout_plan_at > @old_checkout_plan;

@conflict = 0才允许更新。这里没有用BETWEEN,因为时间区间交叉的判断是“新开始 < 旧结束 且 新结束 > 旧开始”,用BETWEEN会漏掉包含关系。

3.3 退房与夜审:用事件/定时任务处理超时未退

退房事务要同时关订单、改房态、写日志。顺序和入住相反:先锁订单,再锁房间,更新订单状态和实际退房时间,房间转VD,最后写日志。房费按实际入住天数算,课设里用TIMESTAMPDIFF(DAY, checkin_at, checkout_at)会遇到“住 3 小时算 0 天”的问题,常见做法是按夜算:DATEDIFF(checkout_at, checkin_at)再补一个最小 1 天。

START TRANSACTION; SELECT room_id, checkin_at INTO @room_id, @checkin_at FROM stay_order WHERE order_id = 3001 AND status = 'IN_HOUSE' FOR UPDATE; UPDATE stay_order SET status = 'CHECKED_OUT', checkout_at = NOW() WHERE order_id = 3001 AND status = 'IN_HOUSE'; UPDATE room SET current_status = 'VD' WHERE room_id = @room_id AND current_status = 'OC'; INSERT INTO room_state_log (room_id, from_status, to_status, order_id, reason) VALUES (@room_id, 'OC', 'VD', 3001, 'CHECK_OUT'); COMMIT;

夜审是酒店系统里一个很有代表性的定时任务:每天凌晨把“计划退房时间已过但还没退”的订单找出来,生成逾期清单,提醒前台。MySQL 可以用EVENT每天跑一次,前提是event_scheduler打开。课设环境如果没权限开事件,就写一个存储过程,演示时手动CALL,效果一样。

CREATE TABLE audit_overdue ( audit_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, room_id INT NOT NULL, overdue_hours INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; DELIMITER $$ CREATE PROCEDURE run_night_audit() BEGIN INSERT INTO audit_overdue (order_id, room_id, overdue_hours) SELECT order_id, room_id, TIMESTAMPDIFF(HOUR, checkout_plan_at, NOW()) FROM stay_order WHERE status = 'IN_HOUSE' AND checkout_plan_at < NOW() AND NOT EXISTS ( SELECT 1 FROM audit_overdue a WHERE a.order_id = stay_order.order_id ); END$$ DELIMITER ; CREATE EVENT ev_night_audit ON SCHEDULE EVERY 1 DAY STARTS CONCAT(CURDATE() + INTERVAL 1 DAY, ' 02:00:00') DO CALL run_night_audit();

NOT EXISTS是为了防止同一天重复跑夜审插重复记录。STARTS用明天凌晨两点,避免建事件时立刻触发一次。如果环境里EVENT不生效,先执行SHOW VARIABLES LIKE 'event_scheduler',看到OFF就让有权限的人打开,或者干脆用存储过程手动跑。课设演示时手动CALL run_night_audit()更可控,不会因为等凌晨而错过答辩。

4. 客房状态报表与常见查询:把“今天谁该退房”写成一个视图

4.1 三个高频查询:在住、待清洁、今日预抵

客房管理演示离不开三个查询:当前在住、待清洁房间、今日预抵。这三个查询写顺了,房态看板就完成了一半。在住查询要连订单、房间、客人三张表,注意只取IN_HOUSE,不要用room.current_status='OC'单独查,因为房态是快照,订单才是事实,两边对不上时以订单为准。

-- 当前在住 SELECT r.room_no, rt.type_name, g.guest_name, o.checkin_at, o.checkout_plan_at, o.rate FROM stay_order o JOIN room r ON r.room_id = o.room_id JOIN room_type rt ON rt.room_type_id = r.room_type_id JOIN guest g ON g.guest_id = o.guest_id WHERE o.status = 'IN_HOUSE' ORDER BY r.floor, r.room_no;

待清洁查询只看room表就够了,因为VD是物理房态,不依赖订单:

SELECT room_no, floor FROM room WHERE current_status = 'VD' ORDER BY floor, room_no;

今日预抵查BOOKED订单,时间条件不要写DATE(checkin_at) = CURDATE(),因为对字段做函数会让索引失效。数据量小的时候无所谓,但课设里养成范围查询的习惯,后面写报告也好看:

SELECT r.room_no, g.guest_name, o.checkin_at, o.checkout_plan_at FROM stay_order o JOIN room r ON r.room_id = o.room_id JOIN guest g ON g.guest_id = o.guest_id WHERE o.status = 'BOOKED' AND o.checkin_at >= CURDATE() AND o.checkin_at < CURDATE() + INTERVAL 1 DAY ORDER BY o.checkin_at;

4.2 用视图和窗口函数出“房态看板”

房态看板要把房间、房型、在住客人拼到一行,空房也要显示,所以stay_order要用LEFT JOIN,并且连接条件里带status='IN_HOUSE',否则历史订单会把空房也拼出多行。视图建好之后,前端直接查视图,不用每次写一长串JOIN。

CREATE VIEW v_room_board AS SELECT r.floor, r.room_no, rt.type_name, r.current_status, o.order_id, g.guest_name, o.checkout_plan_at FROM room r JOIN room_type rt ON rt.room_type_id = r.room_type_id LEFT JOIN stay_order o ON o.room_id = r.room_id AND o.status = 'IN_HOUSE' LEFT JOIN guest g ON g.guest_id = o.guest_id;

按楼层统计房态,一条GROUP BY就能出数字。这里用SUM(条件)而不是COUNT(CASE WHEN ...),写起来短,MySQL 里布尔表达式返回 1/0,求和就是计数:

SELECT floor, COUNT(*) AS total_rooms, SUM(current_status = 'VC') AS vc, SUM(current_status = 'OC') AS oc, SUM(current_status = 'VD') AS vd, SUM(current_status = 'OOO') AS ooo FROM room GROUP BY floor ORDER BY floor;

窗口函数在课设里是加分项,但别为了用而用。一个实际有用的场景是“每个房间最近一次状态变更”,用ROW_NUMBER()按房间分组、按时间倒序编号,取rn=1就是最新一条。这个查询可以用来对账:日志里的最新状态应该和room.current_status一致。

WITH latest_log AS ( SELECT room_id, to_status, changed_at, ROW_NUMBER() OVER (PARTITION BY room_id ORDER BY changed_at DESC) AS rn FROM room_state_log ) SELECT l.room_id, r.room_no, l.to_status AS log_status, r.current_status AS room_status, l.changed_at FROM latest_log l JOIN room r ON r.room_id = l.room_id WHERE l.rn = 1 AND l.to_status <> r.current_status;

这条查询返回空,说明房态快照和日志一致;返回非空,就说明某次事务只改了其中一边,顺着room_id去查日志和订单,很快能定位。

4.3 索引怎么加:别在状态字段上盲目建索引

课设里最常见的索引误区是给status、current_status这种低选择性字段单独建索引。current_status只有五个值,如果数据里 80% 是VC,查VC时优化器可能直接全表扫描,索引反而增加写入负担。真正有用的是复合索引:房态看板经常按楼层筛,建(floor, current_status);订单表按房间和时间范围查,建(room_id, checkin_at, checkout_plan_at);夜审按状态和计划退房时间查,建(status, checkout_plan_at)。

加索引之前先用EXPLAIN看一眼。比如查今日预抵:

EXPLAIN SELECT r.room_no, g.guest_name, o.checkin_at FROM stay_order o JOIN room r ON r.room_id = o.room_id JOIN guest g ON g.guest_id = o.guest_id WHERE o.status = 'BOOKED' AND o.checkin_at >= CURDATE() AND o.checkin_at < CURDATE() + INTERVAL 1 DAY;

看type是不是range或ref,看rows是不是明显小于总行数。如果stay_order上的idx_status_plan没被用上,检查是不是因为BOOKED占比太高,优化器觉得全表扫更快;数据量小的课设里这很常见,不用强行加FORCE INDEX,把EXPLAIN结果写进报告,说明“优化器选择全表扫描是因为数据量小、状态值分布倾斜”,反而显得懂行。

5. 避坑与排查:客房管理数据库课设最常翻车的五个地方

5.1 同一房间被重复入住:唯一索引不是万能药

现象:room表里一条OC,stay_order里同一房间两条IN_HOUSE,房态看板显示一条在住,订单报表显示两条。原因通常是应用层“先查后插”:两个请求同时查到VC,都认为可以入住,然后各自插入订单。唯一索引uk_room_active能兜住第二条插入,但很多人建了索引却没处理Duplicate entry异常,直接抛 500,前台以为没成功又点一次。

解决分两层:事务里SELECT ... FOR UPDATE锁房间,让第二个请求排队;同时捕获Duplicate entry,转成“该房间已被占用,请刷新房态”的提示。排查 SQL 很直接:

SELECT room_id, COUNT(*) AS in_house_count FROM stay_order WHERE status = 'IN_HOUSE' GROUP BY room_id HAVING COUNT(*) > 1;

返回空说明唯一索引生效;返回非空说明索引没建对,或者active_room_id的生成列逻辑写错了,比如把CHECKED_OUT也映射成了room_id。

5.2 退房后房态还是“在住”:事务边界写错

现象:订单已经是CHECKED_OUT,room.current_status还是OC,房态看板显示有人住,实际客人已经走了。原因有两种:一是订单更新和房态更新不在同一个事务里,第一步提交后第二步抛异常被吞掉;二是UPDATE room的条件写错,比如WHERE room_id = ? AND current_status = 'OC',但房态已经被别的逻辑改成VD,受影响行数为 0,代码没检查ROW_COUNT(),以为成功了。

解决:退房的三步必须在同一个事务;每次UPDATE后检查受影响行数,不是 1 就回滚;日志表必须写,出事能顺着order_id查。对账 SQL 把不一致的订单和房间拉出来:

SELECT o.order_id, o.room_id, o.status, r.current_status FROM stay_order o JOIN room r ON r.room_id = o.room_id WHERE o.status = 'CHECKED_OUT' AND r.current_status = 'OC';

5.3 并发换房把订单改丢:锁顺序和版本号

现象:两个前台同时给同一间房换房,后提交的订单把先提交的room_id覆盖了,客人实际住进了 A 房,系统显示 B 房。原因是没有按固定顺序锁房间,或者直接UPDATE stay_order SET room_id = ?不校验旧值。换房涉及两个房间,锁顺序不对还会死锁。

解决:按room_id升序SELECT ... FOR UPDATE;订单表加version字段做乐观锁,更新时带WHERE version = @old_version,受影响行数为 0 就提示“订单已被修改,请刷新”。课设里加一个version INT NOT NULL DEFAULT 0不复杂,答辩时能讲清楚乐观锁和悲观锁的取舍。换房用“关旧开新”而不是直接改room_id,也能天然避免覆盖问题,因为旧单关闭后新单是插入,不动历史行。

5.4 报表数字对不上:快照和事实表双写

现象:房态看板显示在住 10 间,订单表IN_HOUSE有 11 条;或者按楼层统计算出来 20 间,房间表总共 19 间。原因通常是room.current_status和stay_order.status双写不一致:某次操作只改了订单没改房态,或者只改房态没关订单。还有一种情况是测试时手工UPDATE了房态,没写日志,后面怎么查都查不出来。

解决:明确以stay_order为事实,room.current_status是快照;不允许手工改房态,所有变更走事务;每天夜审跑一次对账,把差异写进audit_overdue类似的表。两个方向的排查 SQL 都要会写:房态OC但没有在住订单,和在住订单但房态不是OC。

-- 方向一:房态说在住,订单说没有 SELECT r.room_id, r.room_no FROM room r WHERE r.current_status = 'OC' AND NOT EXISTS ( SELECT 1 FROM stay_order o WHERE o.room_id = r.room_id AND o.status = 'IN_HOUSE' ); -- 方向二:订单说在住,房态不是 OC SELECT o.order_id, o.room_id, r.current_status FROM stay_order o JOIN room r ON r.room_id = o.room_id WHERE o.status = 'IN_HOUSE' AND r.current_status <> 'OC';

5.5 删除房型报外键错:级联策略没设计

现象:演示“删除房型”时数据库报外键约束错误,比如Cannot delete or update a parent row。原因很简单,room表里有房间引用了这个房型,外键默认是RESTRICT。很多人第一反应是加ON DELETE CASCADE,结果一删房型,房间连带订单全没了,演示直接翻车。

解决:课程设计里保留RESTRICT,删除前先检查有没有房间引用,有就提示“该房型下还有房间,不能删除”。如果确实要停用房型,加一个is_active字段做逻辑删除,不要物理删。检查 SQL 先跑一遍:

SELECT rt.room_type_id, rt.type_name, COUNT(r.room_id) AS room_count FROM room_type rt LEFT JOIN room r ON r.room_type_id = rt.room_type_id GROUP BY rt.room_type_id, rt.type_name HAVING room_count > 0;

答辩时被问“为什么不用级联删除”,答“客房数据要保留历史订单,物理删除会破坏审计线索,所以用逻辑停用”比硬解释CASCADE更稳。

6. 把课设做成能演示的系统:用存储过程做夜审、用校验脚本自证正确

6.1 夜审存储过程:一条 SQL 找出超时未退

夜审的存储过程前面已经给过一版,核心是stay_order.status='IN_HOUSE' AND checkout_plan_at < NOW(),配合NOT EXISTS去重。演示时先插一条计划退房时间在昨天的订单,再CALL run_night_audit(),然后查audit_overdue能看到逾期小时数。这个过程比口头说“我会做定时任务”有说服力。注意EVENT依赖event_scheduler,课设环境不一定有权限,所以存储过程要能手动跑,事件只是锦上添花。

6.2 自证脚本:五个断言把数据一致性测出来

课设答辩最怕被问“你怎么证明数据是对的”。准备五个返回空结果集的断言,现场跑一遍,比任何解释都管用。第一个:每个OC房间必须恰有一条IN_HOUSE订单;第二个:每条IN_HOUSE订单的房间必须是OC;第三个:同一房间不能有两条IN_HOUSE;第四个:CHECKED_OUT订单必须有checkout_at;第五个:每个房间最新一条日志的状态必须等于room.current_status。

-- 断言1:OC 房间没有在住订单,应返回空 SELECT r.room_id FROM room r WHERE r.current_status = 'OC' AND NOT EXISTS (SELECT 1 FROM stay_order o WHERE o.room_id = r.room_id AND o.status = 'IN_HOUSE'); -- 断言2:在住订单的房间不是 OC,应返回空 SELECT o.order_id FROM stay_order o JOIN room r ON r.room_id = o.room_id WHERE o.status = 'IN_HOUSE' AND r.current_status <> 'OC'; -- 断言3:同一房间多条在住订单,应返回空 SELECT room_id FROM stay_order WHERE status = 'IN_HOUSE' GROUP BY room_id HAVING COUNT(*) > 1; -- 断言4:已退房但没有实际退房时间,应返回空 SELECT order_id FROM stay_order WHERE status = 'CHECKED_OUT' AND checkout_at IS NULL; -- 断言5:房态快照与最新日志不一致,应返回空 WITH latest_log AS ( SELECT room_id, to_status, ROW_NUMBER() OVER (PARTITION BY room_id ORDER BY changed_at DESC) AS rn FROM room_state_log ) SELECT r.room_id FROM room r JOIN latest_log l ON l.room_id = r.room_id AND l.rn = 1 WHERE l.to_status <> r.current_status;

这五条不需要写成自动化测试框架,放在一个.sql文件里,演示前逐条跑,截图放进报告。熟手看到这里会明白,这套断言本质上是在用数据库自检业务不变量,比单测更接近真实故障。

6.3 演示顺序与答辩时最容易被追问的两个点

演示顺序我一般建议:先投表结构和状态机,讲清五张表的分工;再跑一遍入住、换房、退房,每一步都展示订单表、房间表、日志表的变化;然后开两个会话跑并发入住,让第二个会话看到唯一索引报错或等待;最后跑五条断言和对账 SQL,收在“数据一致”上。整个流程十分钟以内,重点不是功能多,而是每一步都能解释为什么。

最容易被追问的两个点,提前准备好答案。第一个:“为什么房态不直接从订单表算,还要在room表存一份?”答:房态看板是高频查询,快照换性能;快照和订单的一致性靠事务、日志和每日对账保证,不是靠手工同步。第二个:“为什么不用触发器自动改房态?”答:触发器调试成本高,出错时像黑匣子;课设里显式事务能把每一步受影响行数打出来,答辩更可控。触发器可以作为兜底,但不能作为唯一手段。

我自己的习惯是每次改房态前先写日志、再改快照,最后跑一遍断言。这个顺序坚持下来,客房管理这块基本不会出大问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询