☰
基于SpringBoot酒店客房管理系统:房态流转与订单闭环实战
2026/10/8 2:21:25 网站建设 项目流程

简介:这是一套基于SpringBoot与Vue前后端分离架构的酒店客房管理系统完整源码,面向计算机专业学生、Java全栈初学者及需要课程设计或毕业设计参考的开发者。后端采用SpringBoot、MybatisPlus、MySQL、Redis与Shiro权限中间件,提供RESTful风格接口;前端基于Vue搭配Vuex、Vue Router、Axios、Apex图表与Antd UI组件,功能覆盖管理员公告、客房与房间管理、酒店商品与部门职位管理、美食推荐、收藏留言、订单预约与评价、采购记录及员工用户管理等十余个模块。压缩包共625个文件,约4.28MB,其中195个Java文件承载后端业务逻辑,169个XML对应MyBatis映射与配置,147个Vue文件构成前端页面,另有JS、图片、FTL模板及数据库脚本等辅助资源,目录结构清晰。目前已有147人学习下载,适合作为全栈项目实战、二次开发与功能扩展的参考底稿。

1. 基于SpringBoot酒店客房管理系统:从入住登记到退房结算,一套能跑通的最小闭环

酒店前台最怕的不是满房,而是凌晨两点客人到店,系统里显示“待打扫”却查不到房态变更记录。基于SpringBoot酒店客房管理系统要解决的就是这类问题:把房态、订单、入住人、账单四条线拧成一股绳,让前台点一下鼠标就能完成从排房到退房的全流程。它适合中小型酒店、民宿、公寓式酒店的技术负责人,也适合想拿一个真实业务练手SpringBoot的开发者。这个系统不追求大而全,核心是客房状态机、订单生命周期、账单结算三件事。常见做法是用SpringBoot做单体后端,MySQL存业务数据,Redis缓存房态,前端用Vue或Thymeleaf都行。下面按“先跑通再优化”的思路,把选型、建表、接口、避坑一次讲透。

2. 技术选型与工程骨架:为什么用SpringBoot而不是别的

2.1 单体架构在这个场景下反而更稳

酒店客房管理系统的业务边界非常清晰:客房、订单、入住人、账单、用户权限。没有高并发秒杀,没有海量日志分析,日订单量在几百到几千级别。这种场景下,微服务带来的分布式事务、链路追踪、服务发现全是负担。我一般会直接上SpringBoot单体,配合模块化包结构,后期真需要拆分再拆。

SpringBoot版本选择上,别追最新。JDK 17 + SpringBoot 3.2.x 是当前比较稳的组合,如果团队还在JDK 8,用SpringBoot 2.7.x也完全够用。热词里提到的“springboot版本太高”是真实痛点:SpringBoot 3.x要求JDK 17起步,且Jakarta EE替换了javax,老项目升级时MyBatis、Druid等依赖都要跟着换。新项目直接上3.x没问题,老系统迁移要评估依赖兼容性。

工程结构建议按业务分包,而不是按技术分层。常见做法是:

src/main/java/com/hotel/ ├── room/ # 客房模块 │ ├── controller/ │ ├── service/ │ ├── mapper/ │ └── entity/ ├── order/ # 订单模块 ├── bill/ # 账单模块 ├── guest/ # 入住人模块 └── common/ # 公共配置、工具、异常

这样每个模块内部自洽,改客房逻辑不用在多个层之间跳。热词里的“springboot项目结构”和“springboot web项目结构目录”说的就是这件事,按业务分包比按controller/service/mapper分包更适合业务系统。

2.2 依赖清单与最小pom配置

下面是一个能跑起来的最小依赖集,直接抄:

<!-- pom.xml 关键依赖 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> </parent> <dependencies> <!-- Web层,提供REST接口 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus,简化单表CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.6</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Redis,缓存房态 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- Lombok,减少样板代码 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

逻辑说明:Web层负责暴露接口,MyBatis-Plus处理数据库读写,Redis用来缓存房态避免每次查库,Validation做入参校验。参数说明:MyBatis-Plus版本要跟SpringBoot 3.x匹配,用mybatis-plus-spring-boot3-starter而不是老的mybatis-plus-boot-starter,否则启动报错。Redis如果暂时不想引入,可以先注释掉,用数据库直接查,后期再加。

配置文件application.yml最小配置:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hotel_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver data: redis: host: localhost port: 6379 timeout: 3000ms mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

这里map-underscore-to-camel-case让数据库的room_number自动映射到Java的roomNumber,省掉手动写ResultMap。逻辑删除字段deleted配合MyBatis-Plus的@TableLogic注解,删除时只改标记不物理删除,账单和订单数据留痕,后期对账有据可查。

3. 客房状态机与数据库设计:房态流转是系统的命根子

3.1 房态到底有几种,怎么流转

酒店客房管理系统最核心的不是CRUD,是房态流转。房态设计错了,后面订单、入住、退房全乱。我见过有人只用“空闲/占用”两个状态,结果客人退房后房间需要打扫,系统里直接显示“空闲”,前台又把脏房排给了新客人,客人进房发现没打扫,投诉到店长那里。

正确的房态至少六种:空闲(VC)、已预订(RB)、已入住(OC)、待打扫(VD)、打扫中(CL)、维修中(MT)。流转规则如下:

当前状态触发动作目标状态说明
空闲创建订单已预订锁定房间,防止超售
已预订客人到店入住已入住登记入住人信息
已入住客人退房待打扫触发账单结算
待打扫保洁开始打扫打扫中保洁人员操作
打扫中打扫完成空闲房间可再次售卖
任意状态报修维修中维修完成后回到空闲

这个状态机用枚举实现,不要用魔法数字:

public enum RoomStatus { VACANT("VC", "空闲"), RESERVED("RB", "已预订"), OCCUPIED("OC", "已入住"), VACANT_DIRTY("VD", "待打扫"), CLEANING("CL", "打扫中"), MAINTENANCE("MT", "维修中"); private final String code; private final String desc; RoomStatus(String code, String desc) { this.code = code; this.desc = desc; } // 校验状态流转是否合法 public static boolean canTransfer(RoomStatus from, RoomStatus to) { return switch (from) { case VACANT -> to == RESERVED || to == MAINTENANCE; case RESERVED -> to == OCCUPIED || to == VACANT; case OCCUPIED -> to == VACANT_DIRTY; case VACANT_DIRTY -> to == CLEANING; case CLEANING -> to == VACANT; case MAINTENANCE -> to == VACANT; }; } // getter省略 }

逻辑说明:canTransfer方法把流转规则收在一处,任何房态变更前先调这个方法,不合法直接抛业务异常。参数说明:from是当前状态,to是目标状态,返回布尔值。这样前台误操作时系统会拦住,而不是让脏房被重复售卖。

3.2 核心表结构与索引设计

客房表、订单表、入住人表、账单表四张核心表。客房表:

CREATE TABLE room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_number VARCHAR(16) NOT NULL COMMENT '房间号', room_type VARCHAR(32) NOT NULL COMMENT '房型', floor INT NOT NULL COMMENT '楼层', price DECIMAL(10,2) NOT NULL COMMENT '标准价', status VARCHAR(8) NOT NULL DEFAULT 'VC' COMMENT '房态', deleted TINYINT DEFAULT 0, UNIQUE KEY uk_room_number (room_number), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

订单表关键字段:订单号、房间ID、入住人ID、入住日期、离店日期、订单状态、总金额。索引要建在room_id、check_in_date、status上,因为前台最常查的是“今天哪些房间有预订”“某房间未来几天是否空闲”。

CREATE TABLE hotel_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '订单号', room_id BIGINT NOT NULL, guest_id BIGINT NOT NULL, check_in_date DATE NOT NULL, check_out_date DATE NOT NULL, status VARCHAR(16) NOT NULL COMMENT 'BOOKED/CHECKED_IN/CHECKED_OUT/CANCELLED', total_amount DECIMAL(10,2), deleted TINYINT DEFAULT 0, UNIQUE KEY uk_order_no (order_no), KEY idx_room_date (room_id, check_in_date, check_out_date), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

提示:日期区间查询用check_in_date < ? AND check_out_date > ?判断重叠,不要用BETWEEN,否则边界日期会漏判。

3.3 房态缓存与Redis键设计

房态查询频率极高,每次前台刷新页面都要查所有房间状态。直接查库在房间数超过200时会有明显延迟。用Redis缓存房态,键设计为hotel:room:status:{roomId},值为状态码,过期时间设30分钟。房态变更时先更新数据库,再删除缓存,下次查询时重建。

@Service public class RoomStatusService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private RoomMapper roomMapper; private static final String KEY_PREFIX = "hotel:room:status:"; public String getStatus(Long roomId) { String key = KEY_PREFIX + roomId; String cached = redisTemplate.opsForValue().get(key); if (cached != null) { return cached; } // 缓存未命中,查库并回填 Room room = roomMapper.selectById(roomId); if (room != null) { redisTemplate.opsForValue().set(key, room.getStatus(), 30, TimeUnit.MINUTES); return room.getStatus(); } return null; } public void updateStatus(Long roomId, String newStatus) { // 先更新数据库 Room room = new Room(); room.setId(roomId); room.setStatus(newStatus); roomMapper.updateById(room); // 再删除缓存,下次查询重建 redisTemplate.delete(KEY_PREFIX + roomId); } }

逻辑说明:读时先查缓存,未命中查库回填;写时先改库再删缓存,避免脏数据。参数说明:过期时间30分钟是经验值,太长会导致房态不一致,太短缓存命中率低。如果Redis不可用,降级为直接查库,在getStatus里加try-catch即可。

4. 订单与入住退房接口:把业务闭环写成代码

4.1 创建订单与防超售

创建订单时最容易翻车的是超售:两个前台同时给同一房间下单,都查到空闲,都写入成功。解决办法是在数据库层加唯一约束或乐观锁。常见做法是用room_id + check_in_date做唯一索引,但同一房间不同日期可以重复预订,所以唯一索引要建在room_id + check_in_date + status上,且只对有效订单生效。MySQL不支持条件唯一索引,可以用Redis分布式锁或数据库行锁。

我一般用SELECT ... FOR UPDATE锁住房行,在事务内完成“查房态-写订单-改房态”三步:

@Service public class OrderService { @Autowired private RoomMapper roomMapper; @Autowired private OrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public String createOrder(Long roomId, Long guestId, Date checkIn, Date checkOut) { // 行锁锁住房记录,防止并发超售 Room room = roomMapper.selectByIdForUpdate(roomId); if (room == null || !"VC".equals(room.getStatus())) { throw new BizException("房间不可预订"); } // 检查日期区间是否与已有订单重叠 int overlap = orderMapper.countOverlap(roomId, checkIn, checkOut); if (overlap > 0) { throw new BizException("该时段已被预订"); } // 创建订单 HotelOrder order = new HotelOrder(); order.setOrderNo(generateOrderNo()); order.setRoomId(roomId); order.setGuestId(guestId); order.setCheckInDate(checkIn); order.setCheckOutDate(checkOut); order.setStatus("BOOKED"); orderMapper.insert(order); // 更新房态为已预订 room.setStatus("RB"); roomMapper.updateById(room); return order.getOrderNo(); } }

逻辑说明:selectByIdForUpdate对应SQL是SELECT * FROM room WHERE id = ? FOR UPDATE,在事务内锁住该行,其他事务等待。参数说明:checkIn和checkOut是日期,重叠判断SQL为SELECT COUNT(*) FROM hotel_order WHERE room_id = ? AND status IN ('BOOKED','CHECKED_IN') AND check_in_date < #{checkOut} AND check_out_date > #{checkIn}。注意FOR UPDATE必须在事务内才生效,@Transactional不能少。

4.2 入住登记与退房结算

入住登记做三件事:校验订单状态为BOOKED、写入住人信息、把房态改为OC。退房结算做四件事:计算实际住店天数、生成账单、把房态改为VD、订单状态改为CHECKED_OUT。

账单计算逻辑:

public BigDecimal calculateBill(HotelOrder order, Date actualCheckOut) { // 实际住店天数,不足一天按一天算 long nights = ChronoUnit.DAYS.between( order.getCheckInDate().toInstant().atZone(ZoneId.systemDefault()).toLocalDate(), actualCheckOut.toInstant().atZone(ZoneId.systemDefault()).toLocalDate() ); if (nights <= 0) { nights = 1; } Room room = roomMapper.selectById(order.getRoomId()); BigDecimal roomFee = room.getPrice().multiply(BigDecimal.valueOf(nights)); // 这里可以叠加其他消费,如迷你吧、洗衣费 return roomFee; }

逻辑说明:ChronoUnit.DAYS.between计算两个日期之间的天数,不足一天按一天算。参数说明:actualCheckOut是实际退房时间,可能早于或晚于预订离店日期。如果客人提前退房,按实际天数算;如果延迟退房,超过中午12点加收半天房费,这个规则写在calculateBill里即可。

退房接口:

@Transactional(rollbackFor = Exception.class) public void checkOut(String orderNo) { HotelOrder order = orderMapper.selectByOrderNo(orderNo); if (order == null || !"CHECKED_IN".equals(order.getStatus())) { throw new BizException("订单状态不允许退房"); } // 生成账单 Bill bill = new Bill(); bill.setOrderNo(orderNo); bill.setAmount(calculateBill(order, new Date())); bill.setStatus("UNPAID"); billMapper.insert(bill); // 更新订单状态 order.setStatus("CHECKED_OUT"); orderMapper.updateById(order); // 房态改为待打扫 Room room = new Room(); room.setId(order.getRoomId()); room.setStatus("VD"); roomMapper.updateById(room); // 删除房态缓存 redisTemplate.delete("hotel:room:status:" + order.getRoomId()); }

逻辑说明:退房是一个事务,账单、订单、房态三者要么全成功要么全回滚。参数说明:orderNo是订单号,bill.setStatus("UNPAID")表示账单未支付,前台收款后再改为PAID。房态改为VD后,保洁人员在自己的界面能看到待打扫房间。

4.3 定时任务:自动处理超时未入住订单

热词里提到“springboot定时任务”,这个场景确实用得上。客人预订后未按时到店,订单一直挂在BOOKED状态,房间被锁死无法售卖。用SpringBoot的@Scheduled每天凌晨2点扫描超时订单:

@Component public class OrderTimeoutTask { @Autowired private OrderMapper orderMapper; // 每天凌晨2点执行 @Scheduled(cron = "0 0 2 * * ?") public void cancelTimeoutOrders() { // 预订入住日期已过且状态仍为BOOKED的订单 List<HotelOrder> timeoutOrders = orderMapper.selectTimeoutOrders(); for (HotelOrder order : timeoutOrders) { order.setStatus("CANCELLED"); orderMapper.updateById(order); // 释放房间,房态改回VC Room room = new Room(); room.setId(order.getRoomId()); room.setStatus("VC"); roomMapper.updateById(room); } } }

逻辑说明:@Scheduled的cron表达式0 0 2 * * ?表示每天2:00执行。参数说明:selectTimeoutOrders的SQL条件是status = 'BOOKED' AND check_in_date < CURDATE()。注意定时任务默认单线程,如果任务执行时间长会影响其他任务,可以在配置类里自定义TaskScheduler线程池。

5. 避坑与排查:这五个坑我踩过,你别再踩

5.1 房态缓存与数据库不一致

现象:前台看到房间空闲,点进去却提示已被预订。原因:更新数据库后删除缓存失败,或者缓存过期时间太长,旧数据一直没被替换。解决:写操作先改库再删缓存,删缓存失败时记录日志并重试;缓存过期时间不要超过30分钟;关键操作(如下单)直接查库不走缓存。

5.2 日期区间重叠判断漏掉边界

现象:客人预订1月1日到1月3日,另一个客人预订1月3日到1月5日,系统允许了,但1月3日当天两个订单都有效。原因:重叠判断用了BETWEEN,把退房日期也算作占用。解决:用check_in_date < #{checkOut} AND check_out_date > #{checkIn},退房当天不算占用,新客人可以当天入住。

5.3 SpringBoot 3.x升级后MyBatis报错

现象:启动时报java.lang.ClassNotFoundException: javax.servlet.http.HttpServletRequest。原因:SpringBoot 3.x用Jakarta EE替换了Java EE,包名从javax.*变成jakarta.*,老版本MyBatis-Plus不兼容。解决:换用mybatis-plus-spring-boot3-starter,同时检查Druid、Swagger等依赖是否有Jakarta兼容版本。

5.4 定时任务在集群环境下重复执行

现象:部署两个节点后,凌晨2点两个节点同时执行取消超时订单,同一订单被取消两次。原因:@Scheduled在每个节点独立执行,没有分布式协调。解决:用Redis分布式锁,执行前抢锁SETNX hotel:task:orderTimeout 1,抢到才执行,执行完删除;或者用Quartz集群模式。

5.5 账单金额用double计算出现精度丢失

现象:房费199.99元,住3晚,账单显示599.9700000000001。原因:用double或float做金额计算。解决:金额字段用DECIMAL(10,2),Java里用BigDecimal,且用BigDecimal.valueOf()而不是new BigDecimal(double)。乘法用multiply,加法用add,比较用compareTo。

6. 进阶技巧:用状态机引擎把房态流转管起来

前面用枚举和canTransfer方法管房态,房间少的时候够用。如果酒店有几百间房、房态流转规则经常变,硬编码的switch会越来越难维护。我后来把房态流转抽成配置,用Spring StateMachine或轻量的状态机引擎驱动。

以Spring StateMachine为例,定义状态和事件:

@Configuration @EnableStateMachine public class RoomStateMachineConfig extends StateMachineConfigurerAdapter<String, String> { @Override public void configure(StateMachineStateConfigurer<String, String> states) throws Exception { states.withStates() .initial("VC") .state("RB") .state("OC") .state("VD") .state("CL") .state("MT"); } @Override public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception { transitions .withExternal().source("VC").target("RB").event("BOOK") .and() .withExternal().source("RB").target("OC").event("CHECK_IN") .and() .withExternal().source("OC").target("VD").event("CHECK_OUT") .and() .withExternal().source("VD").target("CL").event("START_CLEAN") .and() .withExternal().source("CL").target("VC").event("FINISH_CLEAN"); } }

逻辑说明:状态机把流转规则从业务代码里抽出来,改规则只改配置。参数说明:source是当前状态,target是目标状态,event是触发事件。业务代码里注入StateMachine,发送事件即可:

@Autowired private StateMachine<String, String> stateMachine; public void bookRoom(Long roomId) { stateMachine.startReactively(); stateMachine.sendEvent("BOOK"); // 更新数据库房态 }

验证状态机是否按预期流转,写单元测试:

@Test public void testRoomStateFlow() { StateMachine<String, String> sm = new StateMachineFactory<String, String>() .getStateMachine(); // 初始状态应为VC assertEquals("VC", sm.getState().getId()); // 发送BOOK事件后应变为RB sm.sendEvent("BOOK"); assertEquals("RB", sm.getState().getId()); // 从RB直接发CHECK_OUT应被拒绝 assertFalse(sm.sendEvent("CHECK_OUT")); }

这个测试验证了合法流转和非法流转两种情况。我一般会把所有状态组合都跑一遍,确保没有遗漏。

最后说一个我自己的习惯:每次改房态相关代码,先在测试环境用脚本模拟100个并发下单,看有没有超售;再模拟退房后立即下单,看脏房会不会被排出去。这两个场景跑通,系统基本就稳了。希望帮到你。

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

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

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

立即咨询