开房 = 插住单 + 改房态,两件必须一起成;只做一件,同一间房能被开两次。 退房 = 插账单 + 住单改已退 + 房态改空闲,三件也要一起成,少改房态房间永远空不出来。 金额要「存下来」不要「每次算」:房价会变,今天的账单明年必须还是那个数。
18.6 把界面骨架搭好了,这一节填进去三块肉:客房信息、入住退房、账单查询。
教材 18.7 分三小节(客房信息管理、入住与退房登记、账单查询),
外加一小节「业务模块的公共骨架」——最后那个才是重点:
三个模块的形状是一样的「查—改—提交」,只是表不同。
这一段的事务是真跑的(BEGIN/COMMIT/ROLLBACK),
房态一致性自查 SQL 也是真跑——它是这个项目里最值得带走的一条查询。
一、公共骨架:三个模块长得一样
| 步骤 | 做什么 | 易错点 |
|---|---|---|
| 1. 查询 | 按条件查出列表 | 忘了限制条数,一屏拉出几万行 |
| 2. 选中一行 | 把该行数据装进实体类 | 用「行号」当标识,排序一变就错行 |
| 3. 修改 | 在事务里改表 | 漏了事务,两处改动只成功一半 |
| 4. 刷新 | 重新查一次,不要自己拼结果 | 界面显示对、库里是错的 |
第 4 步是很多人的习惯性错误: 改完之后手动把界面上的数字改一下(省一次查询)—— 结果一旦库里的值和界面不一致,你就再也分不清谁对。 正确做法永远是「改完重查」。 多一次查询的代价,比「界面骗人」小得多。 第 2 步那个坑也很常见: 用表格的「第几行」定位记录, 用户一排序、一筛选,「第 3 行」就换人了。 正确做法是「把主键藏在行里」,选中时取主键。
二、开房:两件事必须一起成
| rno | state | days |
|---|---|---|
| 101 | 已住 | 2 |
开房要做两件事:插一张住单 + 把房态改成「已住」。
只做前一件,房态还是「空闲」,下一单还能开同一间房。
不开事务的结果:住单插进去了、房态没改——于是同一间房能被开两次,两个客人住一间。
这条检查(「状态不是空闲就拒绝」)还必须在事务里做。
否则两个前台同时查、同时看到「空闲」、同时插入——这就是「并发下的超卖」,
SQL Server 里靠锁或 UPDLOCK 提示解决。
更狠的做法是在表上兜底: 给 tb_checkin 加一个「同一间房只能有一条在住记录」的唯一约束。
应用层检查 + 库层约束,两道都上——因为「代码会漏」是常态。
三、退房结账:房费怎么算
| 项目 | 算法 | 金额 |
|---|---|---|
| 房费 | 2 天 × 288.00 | 576.00 |
| 其他消费 | 手工录入(餐费、洗衣) | 58.00 |
| 合计 | 房费 + 其他 | 634.00 |
结账也是三件事:插账单、住单改「已退」、房态改「空闲」。
三件必须一起成——少改房态,房间就永远空不出来。
金额一定要「存下来」而不是每次算。 因为房价会变、
今天算出来的账单,明年再看必须还是那个数。
所以 tb_bill 存的是「当时的房费」,不是「引用房价去算」。
这个原则有个名字: 「快照化」。
凡是「事后会变、但当时必须定格」的东西都要快照——价格、汇率、折扣率、收货地址,全是这一类。
不这么做,年底对账会和当年的单据对不上。
四、账单查询:三张表连起来
| gname | rno | room_fee | other | total |
|---|---|---|---|---|
| 张伟 | 101 | 576.0 | 58.0 | 634.0 |
账单表里不存姓名和房号——它只存 ci_id,其余靠连表拿。
这样客人改名、房间重编号,账单自动跟着变。
注意这里的矛盾: 金额要「快照」,但姓名房号要「连表」。
为什么? 因为金额是「交易发生时的值」,姓名是「这个人的标识」——
前者必须定格,后者应该跟着最新数据走。
这个区分很重要: 判断标准是「这个值变了,历史单据该怎么显示?
」「金额变了历史也要变」显然不对(所以要快照);
「改个名字,历史记录跟着改」是对的(所以不冗余)。
五、一致性自查:一条 SQL 抓脏数据
SELECT r.rno, r.state FROM tb_room r
WHERE r.state = '已住'
AND NOT EXISTS (SELECT 1 FROM tb_checkin ci
WHERE ci.room_id = r.id AND ci.state = '在住')
这条查出 0 条,说明现在的数据是一致的。 它抓的是「事务没做全」留下的脏数据——平时不显眼,一到月底对账就全冒出来。 反过来也要查一遍: 「有在住记录、 房态却是空闲」——两个方向都查,才能覆盖「漏改」和「改反」两种错。 建议每天跑一次,而且把它写进「运维手册」。 它的价值在于「早发现」——等业务方发现问题时,已经错了半个月了。
六、动手 Demo:开房、退房、账单,外加一条自查 SQL
这一段真跑三个业务动作:开房(事务里改两处)、退房结账(事务里改三处)、账单查询(三表连接), 最后跑那条房态自查 SQL。 注意 ③ 那段金额是算出来的:2 天 × 288 + 58 其他 = 634—— 而 634 是被「存进账单表」的,不是每次重算。
# ① 开房:两件事一起成
c.execute(u"BEGIN")
c.execute(u"INSERT INTO tb_checkin (room_id, guest_id, in_time, days, state)"
u" VALUES (1, 1, '2026-05-01 14:00', 2, '在住')")
c.execute(u"UPDATE tb_room SET state = '已住' WHERE id = 1")
c.execute(u"COMMIT")
# ② 退房结账:三件事一起成
room_fee = days * price # 2 * 288 = 576
total = room_fee + other # 576 + 58 = 634
c.execute(u"BEGIN")
c.execute(u"INSERT INTO tb_bill (ci_id, room_fee, other, total, paid)"
u" VALUES (?,?,?,?,1)", (ci_id, room_fee, other, total))
c.execute(u"UPDATE tb_checkin SET state = '已退', out_time = ? WHERE id = ?",
(u"2026-05-03 11:30", ci_id))
c.execute(u"UPDATE tb_room SET state = '空闲' WHERE id = 1")
c.execute(u"COMMIT")
# ③ 自查:房态已住、却没有在住记录
print(len(db.query(u"SELECT r.rno FROM tb_room r WHERE r.state = '已住'"
u" AND NOT EXISTS (SELECT 1 FROM tb_checkin ci"
u" WHERE ci.room_id = r.id AND ci.state = '在住')")[1]))
实跑输出:
--------------------------------------------------------------------
业务模块:开房、退房、账单
--------------------------------------------------------------------
① 开房:两件事必须一起成
--------------------------------------------------------------------
开房要做两件事:插一张住单 + 把房态改成「已住」。
只做前一件,房态还是「空闲」,下一单还能开同一间房。
开房成功:住单 1 张、房 101 变「已住」。
| rno | state | days |
|---|---|---:|
| 101 | 已住 | 2 |
为什么要事务? 因为两条语句中间可能失败(比如第二条被约束挡住)。
不开事务的结果:住单插进去了、房态没改——于是同一间房能被开两次,两个客人住一间。
② 再开一次同一间房:约束会挡住吗
--------------------------------------------------------------------
现在 101 已经是「已住」。如果代码先检查房态再插入:
房 101 当前状态:已住
业务判断:状态不是「空闲」,拒绝开房。
这条检查必须在事务里做,否则两个前台同时查、同时看到「空闲」、同时插入——这就是「并发下的超卖」。
SQL Server 里靠锁或 UPDLOCK 提示解决。
更狠的做法是在表上兜底:
给 tb_checkin 加一个「同一间房只能有一条在住记录」的唯一约束(部分索引)。
应用层检查 + 库层约束,两道都上。
③ 退房结账:房费怎么算
--------------------------------------------------------------------
| 项目 | 算法 | 金额 |
|---|---|---:|
| 房费 | 2 天 × 288.00 | 576.00 |
| 其他消费 | 手工录入(餐费、洗衣) | 58.00 |
| 合计 | 房费 + 其他 | 634.00 |
结账也是三件事:插账单、住单改「已退」、房态改「空闲」。
三件必须一起成——少改房态,房间就永远空不出来。
金额一定要「存下来」而不是每次算。 因为房价会变、今天算出来的账单,明年再看必须还是那个数。
所以 tb_bill 存的是「当时的房费」,不是「引用房价去算」。
④ 账单查询:三张表连起来
--------------------------------------------------------------------
| gname | rno | room_fee | other | total |
|---|---|---:|---:|---:|
| 张伟 | 101 | 576.0 | 58.0 | 634.0 |
账单表里不存姓名和房号——它只存 ci_id,其余靠连表拿。这样客人改名、房间重编号,账单自动跟着变。
⑤ 一致性自查:找出「已住但没有住单」的房
--------------------------------------------------------------------
查出 0 条「房态已住、却没有在住记录」的房
这条自查 SQL 建议每天跑一次。
它抓的是「事务没做全」留下的脏数据——平时不显眼,一到月底对账就全冒出来。
反过来也要查一遍: 「有在住记录、房态却是空闲」——两个方向都查,
才能覆盖「漏改」和「改反」两种错。
开房那段只有四行,但少任何一行都出事: 少 BEGIN 就没有事务;
少 UPDATE 房态就永远空不出来(或者永远占着);少 COMMIT 就全白干。
退房那段是「三件事」,比开房更危险。
账单插了、住单没改 → 这笔单永远「在住」,房态永远「已住」;
住单改了、账单没插 → 钱收了但账上没有。两种都会在月底炸出来。
金额 634 是「存进去的」,不是「查的时候算的」。
这条区别在演示里看不出来,但一年后就显出来了:
房价从 288 涨到 328 之后,老账单必须还是 576,不能被重新算成 656。
最后那条自查返回 0,说明这一串操作是对的。
如果你手动把某个 UPDATE 注释掉、只留 INSERT,再跑一次自查——
它就会返回 1,把「房态已住但没有住单」的房抓出来。这就是「断言」的用法。
30 秒实验
- 把开房那段的两条语句拆开(去掉
BEGIN/COMMIT),然后故意让第二条失败。 住单插进去了、房态没改——再跑自查 SQL,返回 1。 - 给
tb_checkin加一个部分唯一索引(WHERE state = '在住'),再试着给同一间房插第二条在住单。 会被拦住——这就是「库层兜底」。 - 想一想:客人「换房」你会怎么实现? 在事务里做三件事:旧单标已退、新单插入、两个房态互换——跟退房+开房一样,只是不结账。
常见误区
- 「账单一律现算,不存金额」。 房价一变,历史账单全变。 金额必须快照(价格、汇率、折扣都是这一类)。
- 「改完界面上的数字就行,不用重查」。 一旦界面和库不一致,你就分不清谁对。 改完一定要重查。
- 「并发问题以后再说」。 两个前台同时开同一间房,一天就能发生好几次(尤其忙的时候)。 事务 + 库层约束,现在就得做。
自测三问(点开对答案)
1. 开房为什么必须用事务? —— **因为要同时做两件事:插住单、改房态。** **只成功一件,房态和住单就对不上——同一间房能被开两次。** 2. 为什么账单要存「金额」而不是每次重算? —— **因为房价会变,历史账单必须定格。** 一年后重算会把 576 算成 656,**跟当年的单据对不上。** 3. 「房态自查 SQL」查的是什么? —— **查「房态是已住、却没有在住记录」的房**——那是事务没做全留下的脏数据。 **反方向也要查:有在住记录但房态是空闲。**下一篇 18.8 收口这一章:四层结构各放什么、三个项目换了什么没换什么, 以及一套「冒烟测试」怎么在改动之后三十秒内确认系统没坏。
- ☐ (本篇完,下一篇 18.8 本章总结)