拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SSM酒店客房管理系统实战:从数据库设计到并发控制

SSM酒店客房管理系统实战:从数据库设计到并发控制

马上要交课程设计了,选题还没定,或者定了但不知道从哪下手?如果你正在Java方向找管理系统类课题,酒店客房管理系统其实被很多人低估了。别把它想象成普通的学生信息增删改查——真实的酒店业务里,房间状态要不停流转,客人住店时间会互相重叠,房费结算也分好几种规则。把这些业务规则用SSM(Spring + SpringMVC + MyBatis)框架落到代码里,一个学期的Java知识基本就串起来了。

这篇文章不打算写成课程设计说明书,而是以“java_ssm62酒店客房管理系统”为例,讲清楚这类系统从数据库设计、后端分层、并发控制到答辩回答的完整思路。文中所有代码和方案都来自我实际做过、调试过的项目,适合正在做Java课程设计的学生,也适合想用SSM把CRUD项目做出层次感的开发者。不管你是刚开始学Java基础、还没把框架集成跑通,还是已经被数据一致性、并发这些概念绕晕,这篇文章都能给你一套能直接照做的方案。

1. 为什么选酒店客房管理做课程设计:业务模型比CRUD值钱

1.1 看似简单的管理系统,藏着完整的状态机

我见过太多人做“学生管理系统”或“图书管理系统”,最后的成果就是四个操作:加一条、删一条、改一条、按关键字查一条。不能说没用,但面试官听完基本记不住任何亮点。酒店客房管理不一样,它的核心业务天然带着状态变化:一间房从空闲变成已预订,客人到店后从已预订变成已入住,退房结账后又回到空闲,中间可能还穿插维修、清洁等状态。

这其实就是一个小型状态机。你不需要专门学状态机理论,但把这个业务实现一遍,你会自然理解“状态流转需要约束、需要校验、需要事务保护”。比如你不可能让一间已入住的房间被另一个客户直接预订,也不可能让一间维修中的房间分配给前台。这些规则写进代码,项目就有了“业务逻辑”,而不是单纯的“数据操作”。

另外,这个课题还能锻炼面向对象建模能力。房型、房间、客户、预订单、入住单、退房记录,每张表对应一个Java实体类,实体之间的关系对应外键或业务关联字段。用Java面向对象编程的思路去设计这些类,后面写Service和Controller会顺手很多。

1.2 技术路线:为什么用SSM而不是直接手写JSP+Servlet

有些课程设计还在用JSP文件里直接写Java脚本的方式,页面里塞一堆<%和<%=,请求处理靠Servlet硬编码。这种写法能运行,但代码全揉在一起,改一个功能要翻好几个文件,答辩时也很难说清楚分层思想。

SSM的价值在于三个层次各管一摊:

  • Spring负责对象管理(IoC)和事务管理(AOP),Service、Mapper这些对象由容器创建和注入,你不用到处new。
  • SpringMVC负责Web层,处理请求映射、参数绑定、返回视图或JSON数据。
  • MyBatis负责数据库访问,把SQL写在XML映射文件里,和Java代码分离。

如果你以后要学Spring Boot,SSM正好是它的底层框架。学SSM期间你手动配置过数据源、事务管理器、MyBatis扫描这些,再去理解Spring Boot的自动配置就轻松很多。所以课程设计选SSM不是走弯路,是给后面铺路。

1.3 功能清单:哪些必须有,哪些是加分项

做这类系统最忌讳一上来就打开IDE写代码。先定功能范围,我建议按这个优先级来:

功能模块优先级说明
管理员登录/权限必须有至少做简单登录认证,区分管理员和前台
房型管理必须有房型名称、价格、床型、面积、最大入住人数
房间管理必须有房间号、楼层、所属房型、房间状态、清洁状态
客房预订必须有客户信息 + 房型 + 入住/离店日期 + 间数
入住登记必须有到店后把预订或直接散客分配合适房间
退房结账必须有根据价格、会员折扣、超时情况计算最终费用
订单查询必须有按日期/手机号/状态查订单列表
统计报表建议做月度入住率、营收统计,用SQL分组实现
会员等级加分项不同会员折扣不同,配合策略模式计算房费
钟点房计费加分项按小时计费,和全天入住区分开
房间清洁状态加分项退房后待打扫,打扫完再变为可售

核心闭环是“房型→房间→预订→入住→退房→统计”,先把这条线跑通,再考虑加分项。我当年就是先写预订,后来发现退房还要算钱、算完还要改房态,被迫回头改表结构,前前后后改了三轮,所以强烈建议前期把状态关联设计放第一位。

2. 数据库建模:房态、客户、订单三块表怎么关联

2.1 五张核心表,两张扩展表

酒店客房管理系统的数据库设计,我最终定下来是这么几张表:

  • t_admin:管理员账号表,字段有 id、username、password、real_name、role、create_time。
  • t_room_type:房型表,字段有 id、type_name、base_price、bed_num、area、max_people、remark。
  • t_room:房间表,字段有 id、room_no、floor、room_type_id、room_status、clean_status、version、create_time。
  • t_customer:客户表,字段有 id、name、id_card、phone、member_level、discount_rate。
  • t_reserve:预订表,字段有 id、reserve_no、customer_id、room_type_id、room_num、in_date、out_date、status、create_time。
  • t_checkin:入住登记表,字段有 id、reserve_id、customer_id、room_id、in_date、expect_out_date、deposit、status。
  • t_checkout:退房记录表,字段有 id、checkin_id、out_date、total_amount、actual_out_date。

可选扩展表还有 t_member(会员表)、t_fee_rule(费率规则表)。课程设计阶段建议至少把前五张表和退房记录表建好,会员和费率规则作为加分项。

这里要注意一个重要关系:预订表存的是“房型 + 间数”,不直接指定具体房间号。因为客人预订时酒店还没决定具体给哪一间,只是锁定额外房间数量。入住登记时才把具体房间号 t_checkin.room_id 定下来。这一点答辩常被追问,如果你把 t_reserve 里直接存 room_id,反而暴露了业务理解不够的问题。

2.2 房态字段:别用魔法数字,用枚举

t_room 表里的 room_status 字段,我建议用 tinyint 存 0、1、2、3,分别表示空闲、已预订、已入住、维修。但代码里不要到处写数字,推荐定义一个枚举类:

public enum RoomStatus { FREE(0, "空闲"), RESERVED(1, "已预订"), CHECKED_IN(2, "已入住"), MAINTENANCE(3, "维修"); private final int code; private final String desc; RoomStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }

用枚举的好处是,代码里写RoomStatus.FREE.getCode()比直接写0可读性强很多,也不会出现“0到底是空闲还是维修”这种迷惑。数据库里存数字没问题,那是为了查询和索引效率,但Java代码层面必须语义化。这也是“说人话”在代码里的体现。

另外一个容易踩坑的点:清洁状态clean_status一定要和房间状态分开。之前有同学把“已打扫”和“空闲”合成一个字段,结果前台想分配房间时,系统无法区分“空闲但还没打扫”和“空闲且已打扫”,最后只能把所有房间都当成可售,卫生问题完全没法管理。两个维度就是两个字段:房间状态管能不能住,清洁状态管能不能卖。

2.3 外键和索引:课程设计阶段我建议加

很多生产项目强调性能,会故意去掉外键,由应用层保证数据一致性。但课程设计阶段,我建议保留外键。理由很实际:答辩时你能对着表结构清楚说明“t_room.room_type_id 关联到 t_room_type.id,是一对多关系”,而且 MySQL 的 InnoDB 在删除父表记录时,如果存在子表引用会报错,相当于数据库层面多了道保护,防止你误删已经关联的房间类型。

索引方面,至少给这些字段建索引:

  • t_room.room_no:唯一索引,房间号不能重复。
  • t_reserve.customer_id 和 t_reserve.status:组合索引或单列索引,因为查询订单常按客户和时间过滤。
  • t_checkin.room_id:查询某房间历史入住记录会用到。
  • t_reserve.in_date / out_date:查“某个时间段内哪些房型被占用”是核心查询。

2.4 初始化SQL片段

这里给一段建核心表的SQL,你可以直接改库名使用。字段类型我按 MySQL 5.7/8.0 写:

CREATE DATABASE IF NOT EXISTS hotel_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE hotel_db; CREATE TABLE t_room_type ( id INT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(50) NOT NULL COMMENT '房型名称', base_price DECIMAL(10,2) NOT NULL COMMENT '基础价格', bed_num INT DEFAULT 1 COMMENT '床数', area DECIMAL(6,1) DEFAULT 0 COMMENT '面积平米', max_people INT DEFAULT 2 COMMENT '最大入住人数', remark VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='房型表'; CREATE TABLE t_room ( id INT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(10) NOT NULL UNIQUE COMMENT '房间号', floor INT NOT NULL COMMENT '楼层', room_type_id INT NOT NULL COMMENT '房型id', room_status TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1已预订 2已入住 3维修', clean_status TINYINT NOT NULL DEFAULT 1 COMMENT '0待打扫 1已打扫', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_room_type FOREIGN KEY (room_type_id) REFERENCES t_room_type(id) ) ENGINE=InnoDB COMMENT='房间表'; CREATE TABLE t_customer ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, id_card VARCHAR(18) NOT NULL COMMENT '身份证号', phone VARCHAR(20), member_level TINYINT DEFAULT 0 COMMENT '0普通 1银卡 2金卡', discount_rate DECIMAL(4,2) DEFAULT 1.00 COMMENT '折扣率,0.85表示85折', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='客户表'; CREATE TABLE t_reserve ( id INT PRIMARY KEY AUTO_INCREMENT, reserve_no VARCHAR(30) NOT NULL UNIQUE COMMENT '预订单号', customer_id INT NOT NULL, room_type_id INT NOT NULL, room_num INT NOT NULL DEFAULT 1 COMMENT '预订间数', in_date DATE NOT NULL COMMENT '入住日期', out_date DATE NOT NULL COMMENT '离店日期', status TINYINT NOT NULL DEFAULT 0 COMMENT '0已预订 1已入住 2已取消 3已离店', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_reserve_customer FOREIGN KEY (customer_id) REFERENCES t_customer(id), CONSTRAINT fk_reserve_room_type FOREIGN KEY (room_type_id) REFERENCES t_room_type(id) ) ENGINE=InnoDB COMMENT='预订表';

注意 DECIMAL 字段,尤其是价格,千万别用 DOUBLE。后面第6节会专门讲为什么,简单说就是二进制浮点数算钱会产生精度错误,课程设计里这算一个很值钱的细节。

3. SSM三层调用链:一份预订请求从前端到数据库的完整旅程

3.1 Controller层:先做参数收口和格式统一

以“用户提交预订”为例,前端表单提交客户姓名、手机号、身份证、房型ID、入住日期、离店日期、间数。Controller 层要做的事有三件:接收参数、校验参数、调用Service。

日期格式是这里最常见的坑。前端传过来的入住日期是字符串"2025-06-01",Java 里不要用java.util.Date去接,更不要用SimpleDateFormat到处解析,直接用LocalDate加@DateTimeFormat注解:

@Controller @RequestMapping("/reserve") public class ReserveController { @Autowired private ReserveService reserveService; @PostMapping("/add") @ResponseBody public Result add(ReserveAddDTO dto) { // DTO里已经用 @DateTimeFormat(pattern = "yyyy-MM-dd") 接收 LocalDate if (dto.getOutDate().isBefore(dto.getInDate())) { return Result.error("离店日期必须晚于入住日期"); } reserveService.createReserve(dto); return Result.success(); } }

参数校验在课程设计阶段至少做到:非空判断、离店日期大于入住日期、间数大于0。用 Hibernate Validator 的@NotNull、@Future注解会更正规,但手写判断也够用,关键是别跳过。

Controller 里尽量不要出现 SQL、不要直接操作数据库对象,它只做“消息翻译”:把HTTP参数转成DTO,把Service返回结果包装成前端要的JSON。这个习惯坚持住,项目结构会清爽很多。

3.2 Service层:业务规则必须在这里,事务边界也在这里

这是SSM分层里最核心的一环。预订业务在Service层至少要做这几步:

  1. 根据 roomTypeId 查房型,确认房型存在。
  2. 根据入住、离店日期查询该房型在时间段内的可售房间数量。
  3. 如果可用数量大于等于预订间数,插入 t_reserve 记录。
  4. 把对应房型下空闲状态的房间标记为已预订,或者至少锁定额外数量。
  5. 所有步骤要么全成功,要么全失败。

第5点靠的就是事务。在实现类方法上加@Transactional:

@Service public class ReserveServiceImpl implements ReserveService { @Autowired private ReserveMapper reserveMapper; @Autowired private RoomMapper roomMapper; @Override @Transactional(rollbackFor = Exception.class) public void createReserve(ReserveAddDTO dto) { // 1. 查询房型 // 2. 查询可售房间数,不足就抛业务异常 // 3. 插入预订记录 // 4. 更新房间状态 } }

这里有个容易被忽视的技术细节:@Transactional默认只对 RuntimeException 和 Error 回滚。如果你的业务异常是自定义的BizException extends Exception,事务不会回滚,数据就处于“预订记录存在但房间状态没改”的中间状态。所以要么让自定义异常继承 RuntimeException,要么在注解里显式写rollbackFor = Exception.class。我建议两种都了解,答辩能说清楚区别,面试也很爱问。

事务放在Service实现类上,不要放在Controller上,也不建议只放在Mapper接口上。Mapper层的每个方法都是独立SQL,如果只给Mapper加事务,一个业务跨多个Mapper方法时就管不住了。放Controller上则事务范围太宽,而且SpringMVC的Controller默认是单例,业务边界不清晰。放在Service实现类上是最合适的粒度。

3.3 Mapper层与XML:动态SQL解决多条件组合查询

MyBatis的Mapper接口写法大家都熟,但XML里动态SQL才是真正值钱的地方。比如订单查询页面,用户可能按“手机号”“日期范围”“状态”“房型”任意组合筛选,如果每个条件都写一个SQL方法,组合会爆炸,正确做法是动态拼接:

<select id="selectReserveList" resultType="com.hotel.entity.Reserve"> SELECT id, reserve_no, customer_id, room_type_id, room_num, in_date, out_date, status, create_time FROM t_reserve <where> <if test="phone != null and phone != ''"> AND customer_id IN (SELECT id FROM t_customer WHERE phone = #{phone}) </if> <if test="status != null"> AND status = #{status} </if> <if test="roomTypeId != null"> AND room_type_id = #{roomTypeId} </if> <if test="startDate != null"> AND out_date &gt;= #{startDate} </if> <if test="endDate != null"> AND in_date &lt;= #{endDate} </if> </where> ORDER BY create_time DESC </select>

<where>标签会自动去掉第一个多余的 AND,<if>判断条件是否拼进来。这样一套逻辑搞定所有组合查询,不用写十几个方法。

实体映射方面有个高频坑:如果表字段是room_type_id,实体属性是roomTypeId,MyBatis默认不会自动转驼峰。必须在配置文件里开启:

<setting name="mapUnderscoreToCamelCase" value="true"/>

不开启的话,查出来的 roomTypeId 全是 null,页面一片空白,排查半天找不到原因。这类问题我在第7节会再展开讲。

4. 房间预订与入住的时间冲突判断:容易被忽视的边界条件

4.1 重叠区间判断的标准写法

做酒店客房管理系统,最大的业务陷阱是“同一间房在同一个时间段能不能被两个人预订”。很多人第一反应是写“新的入住日期不能在已有预订的开始和结束之间”,但这个条件不完整。

正确判断两个时间段[a, b)和[c, d)是否重叠,数学条件是:

a < d 且 c < b

翻译成业务语言:新预订的入住日期要早于已有预订的离店日期,且已有预订的入住日期要早于新预订的离店日期。SQL 里查某个房型在指定时间段内被占用的数量,核心片段是这样:

SELECT COUNT(*) FROM t_reserve WHERE room_type_id = #{roomTypeId} AND status IN (0, 1) AND in_date < #{outDate} AND out_date > #{inDate}

拿实际例子验证一下:已有预订是6月1日到6月3日。新预订6月3日到6月5日,in_date < outDate即6月1日<6月5日成立,out_date > inDate即6月3日>6月3日不成立(等值不算重叠),所以不冲突,可以订。这符合酒店惯例:6月3日中午退房,新房客6月3日中午后入住,完全没问题。

但如果你写成out_date >= inDate,就会把6月3日入住的客人拒之门外,其实6月3日只是和退房客人在同一天交接,并不冲突。很多课程设计在这里写错,入住率和客流都会出问题。

4.2 跨天、钟点房、续住的边界

日期字段比较只是第一层,实际业务还有几类场景必须单独处理:

  • 跨天住宿:客人晚上22点到店,订的是当天入住次天离店。预订记录里的 in_date 和 out_date 都是日期,没问题,但退房截止时间通常是每天中午12点。如果只按日期算费用,容易把“住一晚”和“住两天”算错。
  • 钟点房:有些客房按小时卖,入住时间是“2025-06-01 14:00”,离店时间是“2025-06-01 18:00”,这两个时间在同一天但区间只有4小时。如果用 DATE 类型存,系统会认为这单占用了一整天,导致很多本可以接的钟点房被锁死。解决方法是给预订表加一个is_hourly标志,钟点房单走一套时间逻辑,用 DATETIME 类型。
  • 续住:客人到店后想多住一天,不能直接改 out_date,要先检查该房间后续时间段是否已有别的预订。这个检查和预订时的区间判断完全一样,只是操作对象从“房型房间数量”变成“具体某一间房”。

4.3 我踩过的坑:不要用字符串比日期

有一版功能,我图省事,直接用前端传的字符串"2025-06-01"和数据库层的字符串字段比较,当月测试一切正常,结果跨月就出了诡异bug。原因是'2025-07-01'和'2025-06-15'做字符串比较时,字符 '6' 大于 '7' 吗?不,'6' 比 '7' 小,所以'2025-06-15' < '2025-07-01'按字典序反而成立,看起来“6月15日在7月1日之前”是对的,但换成'2025-10-01'和'2025-09-15'时,字符串比较'09'和'10'又出问题了。日期这类时间语义,永远要转成 LocalDate 或专门的日期类型再比较,别信字符串的字典序。

4.4 状态机流转约束

房间状态的流转不是随便改的,得按业务规则来。我总结成下面这条线:

  • 空闲 -> 已预订:客人提交预订成功。
  • 已预订 -> 已入住:客人到店办理入住。
  • 已入住 -> 空闲:客人退房结账完成。
  • 空闲/已预订 -> 维修:管理员报修房间。
  • 维修 -> 空闲:维修完成恢复可售。
  • 已入住 -> 维修:一般不允许,客人还没走不能抢修,只能等退房。

这些约束放在Service层统一封装,比如RoomStateService.checkIn(roomId)、checkOut(roomId)。犯错的人通常是直接写一个roomMapper.updateRoomStatus(roomId, status)到处传值,结果页面里一个按钮就能把已入住的房间改成维修,业务彻底乱掉。正确做法是每个状态变更都是一个独立的业务方法,方法里先做状态校验,再执行更新。

5. 并发与数据一致性:课程设计里也躲不开的两个关键词

5.1 典型场景:两个人同时订了同一个房间

很多课程设计做完,看起来功能都正常,但一开两个浏览器窗口同时提交预订,就会出现超卖——一个只剩2间标准间,两个客户同时下单各订1间,系统可能两张单都成功,但实际上只有1间可售了。这不是Java代码逻辑写得对不对的问题,而是并发时序问题:两个请求同时查库存,查到的都是“还有2间”,然后各自写各自的预订记录,谁也没拦住谁。

面试官问“你怎么保证数据一致性”,指的就是这类场景。不管是单机项目还是微服务,只要多个线程同时读写共享数据,就必须考虑并发控制。

5.2 第一道防线:事务约束

事务能保证“先查后写”的原子性,让多个SQL操作要么全部成功、要么全部回滚。比如预订单插入和房间状态更新放在同一个事务里,就不会出现“订单建了但房间状态没改”的情况。但事务本身解决不了“两个请求同时查到库存2间”的问题,因为两个查询在时间上完全交错,必须配合锁机制。

5.3 乐观锁:version字段的完整写法

课程设计阶段最优雅、也最容易讲清楚的方案是乐观锁。给 t_room 表加一个version字段,每次更新房间状态时带上版本号:

UPDATE t_room SET room_status = 1, version = version + 1 WHERE id = #{roomId} AND version = #{oldVersion}

Java 侧的逻辑是这样:

public boolean tryLockRoom(Integer roomId) { Room room = roomMapper.selectRoomForUpdate(roomId); int oldVersion = room.getVersion(); int rows = roomMapper.updateStatusWithVersion(roomId, RoomStatus.RESERVED.getCode(), oldVersion); return rows == 1; }

如果rows == 0,说明在执行 UPDATE 之前,这条记录的 version 已经被别的线程改掉了,也就是房间被别人抢了。这时程序要提示用户“该房间刚刚被预订,请重新选择”,而不是继续往下走。

乐观锁的适用前提是“冲突不频繁”,读了数据的人能接受偶尔更新失败重试。酒店预订显然符合这个特征,而且实现简单,不用长时间占用数据库行锁,性能也好。答辩被问到并发控制时,能把这个原理讲清楚,已经比大多数课程设计项目高一个档次。

5.4 悲观锁:SELECT FOR UPDATE 什么时候用

和乐观锁对应的是悲观锁,查询时直接把行锁住,其他事务必须等待:

SELECT * FROM t_room WHERE id = #{roomId} FOR UPDATE;

在事务里执行这条语句后,其他线程对同一行的 UPDATE 或 SELECT FOR UPDATE 都会阻塞,直到当前事务提交或回滚。悲观锁写起来更直白,逻辑上最安全,但缺点是并发性能差,行锁等待时间一长容易拖垮数据库。

我的建议是:课程设计优先做乐观锁,把version字段和更新失败重试的逻辑写完整,面试时再补一句“如果并发量高到乐观锁重试频繁,才会考虑悲观锁或队列化处理”,这样既落地了方案,又能体现你懂取舍。说到锁,如果你后续去看 JUC 源码,会发现 ReentrantLock、Semaphore、CountDownLatch 这些工具底层都依赖 AQS(AbstractQueuedSynchronizer)。课程设计阶段不会直接用 AQS,但理解它之后再看锁的设计会通透很多。

6. 计费模块设计:用策略模式把房费算明白

6.1 需求先拆解:普通会员钟点房,三种计费规则

如果只做一个“单价乘天数”,酒店管理系统就少了一个很大的亮点。真实的计费至少要覆盖三种情况:

  • 普通入住:基础价格 × 住宿天数。
  • 会员入住:基础价格 × 天数 × 会员折扣。
  • 钟点房:按小时累计,通常是首小时一口价,超时部分按每小时价格累加。

再加上退房超时的场景:标准离店时间是中午12点,如果客人下午14点才退房,有些酒店会加收半天房费。这些规则叠在一起,如果用一堆 if-else 写在退房方法里,代码会越来越难维护。这里很适合用设计模式里的策略模式。

6.2 策略接口与三个实现类

先定义一个策略接口:

public interface FeeStrategy { BigDecimal calculate(FeeContext context); }

FeeContext 里放好计算所需的数据:基础价格、入住天数、钟点时长、会员折扣、超时小时数等。然后三个实现类:

public class NormalFeeStrategy implements FeeStrategy { @Override public BigDecimal calculate(FeeContext context) { return context.getBasePrice() .multiply(BigDecimal.valueOf(context.getDays())); } } public class MemberFeeStrategy implements FeeStrategy { @Override public BigDecimal calculate(FeeContext context) { return context.getBasePrice() .multiply(BigDecimal.valueOf(context.getDays())) .multiply(context.getDiscountRate()); } } public class HourlyFeeStrategy implements FeeStrategy { @Override public BigDecimal calculate(FeeContext context) { BigDecimal firstHourPrice = context.getBasePrice(); BigDecimal extraHourPrice = context.getBasePrice() .multiply(new BigDecimal("0.5")); long hours = context.getHourlyDuration(); if (hours <= 1) { return firstHourPrice; } return firstHourPrice.add(extraHourPrice.multiply(BigDecimal.valueOf(hours - 1))); } }

再写一个FeeContextFactory或FeeStrategyFactory,根据客户类型和订单类型返回对应策略。这样退房结账的代码几乎没有 if-else,将来新增“团队协议价”“节假日价”,只需要加实现类,不改核心逻辑。面试问“设计模式你怎么用的”,这就是一个真实的、能说透使用场景的例子,比背概念强。

6.3 费率数据放数据库,不要写死在代码里

策略类只负责“怎么算”,具体单价、折扣率应该从数据库查出来。房型表里存 base_price,客户表里存 discount_rate,钟点房加价规则可以放费率规则表 t_fee_rule。这样酒店调整价格时,改数据库就行,不用重新编译部署。答辩时这个细节非常加分,它体现的是可维护性思维。

我在实际项目里见过有人把价格直接写在 Java 常量类里,后来改价只能改代码重新打包,这显然不符合真实酒店需求。放数据库一开始多写一次查询,长远省很多事。

6.4 BigDecimal 精度坑

金额计算必须用 BigDecimal,这是铁律。两个注意点:

第一,创建 BigDecimal 不要用构造函数传 double,new BigDecimal(0.1)得到的不是精确的0.1,而是0.1000000000000000055511151231257827021181583404541015625。正确写法是new BigDecimal("0.1")或者用BigDecimal.valueOf(0.1)。

第二,除法时要指定精度和舍入模式,否则除不尽会抛 ArithmeticException。比如算日均费用:

BigDecimal daily = totalAmount.divide( BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP );

用 double 算钱,0.1加0.2都可能不等于0.3,更不用说累加房费、折扣、押金。这一节虽然基础,但你在答辩现场说出“金额用 BigDecimal,不能使用浮点数”,考官基本会点头。

7. 前端联动与调试经验:让表单不吵架,让列表不白屏

7.1 前端选型:JSP+Bootstrap 还是前后端分离

课程设计阶段我推荐用 JSP + Bootstrap。理由很直接:业务量不大,JSP 服务端渲染能顺带把数据显示完,Bootstrap 样式可以让页面不至于太难看,而且服务器端渲染避免了前后端分离带来的跨域配置、接口联调这些额外成本。你不需要证明自己会微服务,你要证明的是业务闭环完整。

如果已经会 Vue,也可以用 Vue + axios,后端返回 JSON。但一定要注意:SpringMVC 返回对象时要加@ResponseBody或@RestController,不然 Spring 会把返回值当视图名解析,页面就白屏了。

7.2 核心联动场景:选房型、选日期、查可订

前端最典型的交互是“预订表单联动”。我的页面布局是:房型下拉框、入住日期、离店日期、间数,四个字段变化后,后台实时返回可订数量并显示价格。

实现逻辑不复杂,用原生 JS 或 jQuery 发一个 ajax 请求:

function checkAvailable() { var roomTypeId = $("#roomTypeId").val(); var inDate = $("#inDate").val(); var outDate = $("#outDate").val(); if (!roomTypeId || !inDate || !outDate) return; if (outDate <= inDate) { $("#tip").text("离店日期必须晚于入住日期"); return; } $.get("/reserve/available", { roomTypeId: roomTypeId, inDate: inDate, outDate: outDate }, function (data) { if (data.code === 0) { $("#tip").text("可订" + data.data.available + "间,价格" + data.data.totalPrice); } else { $("#tip").text(data.msg); } }); }

后端对应接口要重新走一遍“区间重叠判断”,前端提示只能作为体验优化,后端校验才是业务保障。如果列表需要过滤、排序,Java 8 的 Stream 和 Lambda 很常用,比如按创建时间排序:

List<Reserve> sorted = list.stream() .sorted(Comparator.comparing(Reserve::getCreateTime).reversed()) .collect(Collectors.toList());

能写 SQL 时就写 SQL,少在内存里做过滤,数据量一大性能差别很明显。面试被问到排序算法时,冒泡排序、快排这些基础要会手写,但实际业务排序优先交给数据库。

7.3 列表空白或乱码:三个方向排查

页面数据不对,是最消耗耐心的事。我的排查链路固定为三条:

第一,先看浏览器 Network 面板,请求返回状态码是 200 还是 500。500 就去翻 Tomcat 日志,优先看最底层的 Caused by,不要盯着前面一长串堆栈发愣。

第二,确认接口返回的是 JSON 还是视图名。如果 Controller 方法没加@ResponseBody,返回一个对象时 Spring 会尝试找同名 JSP 页面,找不到就报 404 或白屏。这个错误太常见了。

第三,检查数据库连接串。MySQL 8 之前版本时区问题会导致日期字段返回少 8 小时,连接串要带上:

jdbc:mysql://localhost:3306/hotel_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

编码问题则是统一入口:JSP 页面顶部pageEncoding="UTF-8",响应头ContentType="text/html;charset=UTF-8",数据库连接串characterEncoding=utf8,三处一致,中文十有八九就不会乱码。

7.4 日志与调试:用对工具能少熬一个通宵

很多课程设计代码里全是System.out.println,控制台一滚屏啥都看不清。建议接一个 slf4j + logback 的组合,代码里用占位符输出,没有字符串拼接损耗。拼接大量日志时,用 StringBuilder 而不是一堆字符串+号,这是 Java 基础里经常被问到的点,实际开发也真是这么用的:

Logger log = LoggerFactory.getLogger(ReserveServiceImpl.class); log.info("创建预订 | 客户id:{} | 房型id:{} | 日期:{}~{}", dto.getCustomerId(), dto.getRoomTypeId(), dto.getInDate(), dto.getOutDate());

再把 MyBatis 的 SQL 日志级别调到 DEBUG,每个 SQL 的执行参数就都能看到。排查“cond 查出来是 null”这类问题,看 SQL 日志比盲改代码快得多。

8. 答辩复盘:考官追问最多的八类问题

8.1 高频问题清单与回答方向

做了几十个类似项目梳理后,答辩现场被问到的高频问题基本是这八类,提前准备能稳很多:

  1. 表之间是什么关系?房型对房间是一对多,客户对预订单是一对多,预订到入住再对退房是逐级关联。讲的时候先画概念关系,别埋头念字段。
  2. 为什么要加外键?课程设计阶段外键能保证数据完整性,防止删除房型时留下悬空房间。同时提一句生产环境可能为了性能和分库分表去掉外键,由应用层保证一致性。
  3. 房间状态是谁维护的?Service 层通过独立方法维护,Controller 不直接改房间状态字段。要展示 checkIn、checkOut 这些方法里的状态校验逻辑。
  4. 同一房间重复预订怎么防?讲乐观锁 version 机制,再说 SELECT FOR UPDATE 作为备选方案。
  5. 事务加在哪层,为什么?Service 实现类方法上,因为一个业务方法往往跨多个 Mapper 操作,事务粒度要覆盖完整业务单元。注意补充自定义异常要继承 RuntimeException,或者显式配置 rollbackFor。
  6. 如果真上线,哪里要改?数据库肯定要加索引、去掉外键约束、连接池配置、密码加密存储;日志接入完整监控;房间状态变更增加操作记录表(操作日志、审计字段)。这些是很好的加分答案。
  7. 房费怎么算?普通、会员、钟点房用策略模式,费率配置在数据库里,金额用 BigDecimal 计算。
  8. 报表怎么统计?按月统计入住率用 SQL 分组和条件聚合,比如按年月分组统计已入住房晚数除以总房晚数。给一个简单示例题思路,不用现场完全写出来。

8.2 不会答的问题,比硬编答案更安全

答辩不是要你满分,项目真实度比完美度重要。遇到不清楚的问题,最稳妥的回答方式是:“这块我当时做了简化处理,没有考虑到 XX。如果现在重新设计,我会往 XX 方向改进。” 这个回答模板听起来像反思,不像编造。最怕的是现场开始胡编一个自己都没跑通过的方案,考官多追问两句直接露馅,那比承认不足严重得多。

8.3 我个人的课程设计心得

这个项目做下来,我最深的体会是:数据库设计花的时间,决定了后面编码是不是顺畅。我第一版直接对着页面写表,结果做到退房结账时发现缺少会员折扣字段,又回去改表、改实体、改Mapper,前后返工了很久。后来学乖了,先把业务状态流转、计费规则、并发场景都列出来,再去定表结构,一切顺多了。

你可能觉得课程设计只是个作业,能跑就行。但如果你肯把上面这些细节都做进去,这个项目完全可以写进简历,面试时作为“能讲清楚业务、能应对并发追问、用了设计模式”的项目案例。SSM 这个技术栈看起来不算最新,但底子打得越扎实,后面学 Spring Boot、微服务的时候就越不吃力。酒店客房管理系统这个题目,不算新鲜,但你能把它做深一层,它就能比别人多值很多分。

返回列表