🪟 第18章 Visual C++与SQL Server实现酒店管理系统
📄 一句话概括

开房 = 插住单 + 改房态,两件必须一起成;只做一件,同一间房能被开两次。 退房 = 插账单 + 住单改已退 + 房态改空闲,三件也要一起成,少改房态房间永远空不出来。 金额要「存下来」不要「每次算」:房价会变,今天的账单明年必须还是那个数。

📝 本章和前面几章的关系

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 秒实验

  1. 把开房那段的两条语句拆开(去掉 BEGIN/COMMIT),然后故意让第二条失败。 住单插进去了、房态没改——再跑自查 SQL,返回 1。
  2. 给 tb_checkin 加一个部分唯一索引(WHERE state = '在住'),再试着给同一间房插第二条在住单。 会被拦住——这就是「库层兜底」。
  3. 想一想:客人「换房」你会怎么实现? 在事务里做三件事:旧单标已退、新单插入、两个房态互换——跟退房+开房一样,只是不结账。

常见误区

  1. 「账单一律现算,不存金额」。 房价一变,历史账单全变。 金额必须快照(价格、汇率、折扣都是这一类)。
  2. 「改完界面上的数字就行,不用重查」。 一旦界面和库不一致,你就分不清谁对。 改完一定要重查。
  3. 「并发问题以后再说」。 两个前台同时开同一间房,一天就能发生好几次(尤其忙的时候)。 事务 + 库层约束,现在就得做。
自测三问(点开对答案) 1. 开房为什么必须用事务? —— **因为要同时做两件事:插住单、改房态。** **只成功一件,房态和住单就对不上——同一间房能被开两次。** 2. 为什么账单要存「金额」而不是每次重算? —— **因为房价会变,历史账单必须定格。** 一年后重算会把 576 算成 656,**跟当年的单据对不上。** 3. 「房态自查 SQL」查的是什么? —— **查「房态是已住、却没有在住记录」的房**——那是事务没做全留下的脏数据。 **反方向也要查:有在住记录但房态是空闲。**

下一篇 18.8 收口这一章:四层结构各放什么、三个项目换了什么没换什么, 以及一套「冒烟测试」怎么在改动之后三十秒内确认系统没坏。

相关笔记