☰
Spring Boot酒店管理系统毕业设计:状态机建模与并发避坑实战
2026/10/2 3:52:52 网站建设 项目流程

简介:面向高校毕业设计学生和初学 Spring Boot 的开发者,该酒店管理系统项目包内含可直接运行的完整源代码与配套毕业论文,聚焦客户信息管理、房间管理、订单管理等典型业务模块,覆盖房间预订、客户信息录入与查询等常用操作。代码结构清晰、注释较丰富,模块边界明确,既便于阅读、维护和二次开发,也能支撑后续功能扩展与业务调整。压缩包为 RAR 格式,整体约 22MB,除源码外还包括论文文档;论文部分系统阐述项目背景、设计思路、技术选型、实现过程与测试结果,并结合系统各模块交代了核心功能的实现方式,对理解完整 Web 项目开发流程有直接帮助。资源已有 417 人浏览学习,适合需要快速掌握 Spring Boot 实战技能,或希望借鉴完整毕设项目与论文结构的同学。

1. 为什么说基于 Spring Boot 的酒店管理系统是最适合毕业设计的闭环业务

拿到“酒店管理系统”这个 Java 毕业设计题目的第一反应,很多同学都会想:这不过又是一套增删改查。真正动手做的人才会发现,这个题目最值钱的地方根本不在接口数量,而在房态与订单状态之间那套状态机。房间什么时候可订、什么时候不能释放,订单从创建到结算经历了哪些状态,这些规则一旦理清楚,论文的核心章节自然就立住了。基于 Spring Boot 开发,是因为它能让一个学生在一个学期内同时交付可用系统、源码和论文:生态成熟、资料多、面试时还能拿出来讲。这篇笔记写给正在做或正准备做这个题目的人,按“建模→开发→避坑→交付→答辩”的顺序走一遍落地过程。

2. 先建模再写代码:酒店管理系统的领域模型与数据库设计

2.1 房间与订单的状态机:业务规则先于代码

我接触过不少酒店管理类的毕设源码,最典型的问题不是代码写得烂,而是业务上根本没有“规则”。房间状态是一个字符串字段,订单状态是另一个字符串字段,两个字段互相之间没有任何约束。评审老师问一句“已退房的房间为什么不能马上被预订”,答案就卡壳了。

正确做法是在写接口之前,先把状态机画出来。房间里至少有五种状态:空闲、已预订、入住中、打扫中、维修停用。订单至少有五种状态:待支付、已支付、已入住、已退房、已取消。状态和状态之间的转换路径必须事先定死,比如“已预订”只能由“空闲”过来,不能从“入住中”跳过去。把这些状态定义成 Java 枚举,比到处写字符串常量省心得多:

public enum RoomStatus { AVAILABLE("空闲"), BOOKED("已预订"), OCCUPIED("入住中"), CLEANING("打扫中"), MAINTENANCE("维修停用"); private final String desc; RoomStatus(String desc) { this.desc = desc; } public String getDesc() { return desc; } }

同理,订单状态对应一个 OrderStatus 枚举,包含 CREATED、PAID、CHECKED_IN、CHECKED_OUT、CANCELLED 五类。为什么建议用枚举而不是直接在数据库里写字符串?因为 Java 里可以借助valueOf()做合法值约束,写接口时能提前感知状态是否写错;毕业论文里画状态图,也是把枚举的流转关系原样搬上去,图和代码对得上。

状态机还决定了 Service 层的方法划分。每个修改状态的动作都应该是一个独立 service 方法,比如checkIn()、checkOut()、cleanRoom(),不要在 Controller 里随手updateById。这样做的直接收益是:事务边界清晰,评审问“入住时到底改了几张表”时,你能一条一条列出来。

2.2 三张核心表怎么建:房、单、住客记录分离

数据库设计是酒店管理系统毕业论文的重头戏。我建议只保留三张核心业务表,其余统计表、权限表按需再补。三张表分别是房间表 tb_room、订单表 tb_order、入住记录表 tb_checkin。把入住记录单独拆出来,而不是在订单表上加两个时间字段,是因为续住、换房、提前退房这些场景都需要保留历史痕迹,订单状态变了,入住记录不能跟着丢。初始化 SQL 可以这样写:

CREATE TABLE tb_room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_number VARCHAR(10) NOT NULL, room_type VARCHAR(20) NOT NULL, floor INT NOT NULL DEFAULT 1, status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE', daily_price DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_room_number (room_number) ); CREATE TABLE tb_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, customer_name VARCHAR(50) NOT NULL, customer_phone VARCHAR(20), room_id BIGINT NOT NULL, check_in_date DATE NOT NULL, check_out_date DATE NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'CREATED', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_order_room_date (room_id, check_in_date, check_out_date) ); CREATE TABLE tb_checkin ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, room_id BIGINT NOT NULL, customer_name VARCHAR(50) NOT NULL, check_in_time DATETIME NOT NULL, check_out_time DATETIME NULL, actual_amount DECIMAL(10,2) NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_checkin_room (room_id) );

这里有几个参数值得说道:价格字段一律用DECIMAL(10,2),不用 double;日期只精确到天,所以 check_in_date、check_out_date 用 DATE 类型,DATETIME 留给真正的“时间点”;订单号加唯一索引,但不用自增主键当订单号,因为订单号要展示给客户,自增 id 会暴露酒店一天的订单量,这在论文的“安全性设计”里也能补一笔。

Java 侧对应的实体类不需要写复杂注解,普通 POJO 配合 MyBatis 的驼峰映射就够了。注意 status 字段在实体里建议用 String 存储,查出来之后在 Service 层用RoomStatus.valueOf(room.getStatus())转换,这样既不用写 TypeHandler,又能享受枚举带来的类型约束。

2.3 代码结构选型:按业务模块分包比按技术层分包好写论文

Spring Boot 项目的代码组织方式直接影响论文架构图。常见做法是按 controller、service、mapper 三层分包,但对毕设来说这种结构有个缺点:零散的类分布在三个包里,论文写“模块设计”时东拉西扯,评审不好核对。我更推荐按业务模块分包,目录结构长这样:

src/main/java/com/example/hotel/ ├── HotelApplication.java ├── common/ │ ├── Result.java │ ├── BizException.java │ └── GlobalExceptionHandler.java ├── config/ │ ├── CorsConfig.java │ └── MyBatisConfig.java ├── module/ │ ├── room/ │ │ ├── Room.java │ │ ├── RoomMapper.java │ │ ├── RoomService.java │ │ └── RoomController.java │ ├── order/ │ │ ├── Order.java │ │ ├── OrderMapper.java │ │ ├── OrderService.java │ │ └── OrderController.java │ ├── checkin/ │ └── report/ └── resources/ ├── application.yml ├── mapper/ │ ├── RoomMapper.xml │ └── OrderMapper.xml └── db/ └── init.sql

module 下面按业务再分 room、order、checkin、report,每个包内部仍然保持 controller-service-mapper 三层。这样论文里的“系统实现”章节可以直接照着 module 目录逐个模块写,每个小节对应一个包,做不到前后不一。另外 MyBatis 的 Mapper 接口与 XML 文件路径要一致,@MapperScan扫描的包和mapper-locations配置别写重了,这也是新人最容易踩的项目启动失败点。

3. 用 Spring Boot 把核心接口跑通:工程配置、事务边界与分页插件

3.1 最小可用工程:pom.xml 与 application.yml 的关键配置

酒店管理系统作为毕设,依赖不需要多,够用就行。我常用的组合是 Spring Boot 2.7 系 + MyBatis + PageHelper + Lombok,这套组合在 JDK 8 和 JDK 11 下都很稳。如果你的本机是 JDK 17,建议直接切到 Spring Boot 3.x,MyBatis 用 mybatis-spring-boot-starter 3.0 以上的版本,否则启动会报模块访问错误。pom.xml 核心依赖如下:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

数据库连接配置放在 application.yml 中,参数要区分开“本地连接串”和“部署连接串”,不要每次换机器改代码。以下是本地开发最常用的一套配置:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hotel_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true

几个关键参数说明一下。serverTimezone=Asia/Shanghai不加的话,MySQL 8 的 JDBC 驱动在多数机器上会报时区错误;map-underscore-to-camel-case让数据库的 room_number 自动映射成实体里的 roomNumber,这个是必开的,不然每张表都要手写 resultMap;jackson 的 date-format 决定接口返回的 LocalDateTime 是“2025-01-01 12:30:00”而不是“2025-01-01T12:30:00”,前端拿到后能直接展示。

3.2 预订房间的接口:并发才是酒店系统的真正难点

预订接口是整张嘴的核心,也是论文里最能体现设计能力的地方。很多毕设代码是先select查一下房间状态,发现是 AVAILABLE,再执行update改成 BOOKED。这段代码单独看没错,但在并发场景下会翻车:两个用户同时预订同一间房,都先查到了“空闲”,然后先后更新,结果两个订单同时挂在了一间房上。解决这个问题不一定要用 Redis,数据库层面加一把悲观锁就够了。

@Transactional(rollbackFor = Exception.class) public Order createOrder(OrderCreateRequest req) { Room room = roomMapper.selectByIdForUpdate(req.getRoomId()); if (room == null) { throw new BizException("房间不存在"); } if (!RoomStatus.AVAILABLE.name().equals(room.getStatus())) { throw new BizException("房间状态不可预订:" + room.getStatus()); } int conflict = orderMapper.countDateConflict( req.getRoomId(), req.getCheckInDate(), req.getCheckOutDate()); if (conflict > 0) { throw new BizException("该时间段已有订单,请换房或改期"); } Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setCustomerName(req.getCustomerName()); order.setCustomerPhone(req.getCustomerPhone()); order.setRoomId(req.getRoomId()); order.setCheckInDate(req.getCheckInDate()); order.setCheckOutDate(req.getCheckOutDate()); order.setStatus(OrderStatus.CREATED.name()); long nights = req.getCheckOutDate().toEpochDay() - req.getCheckInDate().toEpochDay(); order.setTotalAmount(room.getDailyPrice().multiply(BigDecimal.valueOf(nights))); orderMapper.insert(order); roomMapper.updateStatus(req.getRoomId(), RoomStatus.BOOKED.name()); return order; }

对应的 SQL 必须用FOR UPDATE加行锁,而不是普通 select:

<select id="selectByIdForUpdate" resultType="com.example.hotel.module.room.Room"> SELECT id, room_number, room_type, floor, status, daily_price, create_time FROM tb_room WHERE id = #{id} FOR UPDATE </select>

这里有两个要点。一是@Transactional必须加在 createOrder 方法上,锁是在方法事务里持有的,如果事务没提交就释放锁,那FOR UPDATE形同虚设。二是订单金额的计算用toEpochDay()差值得出住宿晚数,DECIMAL金额参与乘法时用 BigDecimal,避免 fload 的二进制精度问题。

3.3 订单列表分页:MyBatis 分页插件的标准用法

酒店管理系统后台要展示订单列表,几乎都是分页查询,MyBatis 分页插件 PageHelper 是这里最常用的方案,它的核心用法非常固定。先封一个查询参数对象,再在调用 Mapper 方法之前启动分页:

public PageResult<OrderVO> pageOrders(OrderQuery query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); List<OrderVO> list = orderMapper.selectOrderPage(query); PageInfo<OrderVO> pageInfo = new PageInfo<>(list); return PageResult.of(pageInfo); }

Mapper 里的动态查询 SQL 长这样:

<select id="selectOrderPage" resultType="com.example.hotel.module.order.OrderVO"> SELECT o.id, o.order_no, o.customer_name, o.customer_phone, o.check_in_date, o.check_out_date, o.total_amount, o.status, r.room_number, r.room_type FROM tb_order o LEFT JOIN tb_room r ON o.room_id = r.id <where> <if test="customerName != null and customerName != ''"> AND o.customer_name LIKE CONCAT('%', #{customerName}, '%') </if> <if test="status != null and status != ''"> AND o.status = #{status} </if> <if test="startDate != null"> AND o.check_in_date &gt;= #{startDate} </if> </where> ORDER BY o.create_time DESC </select>

PageHelper 的工作原理是拦截紧接着的这条 select,自动生成 count 查询和 limit 拼接。三个使用习惯很重要:startPage之后必须紧跟一条查询,中间不能穿插其他数据操作;不要在循环体内反复调用startPage,第二次会把前一次的分页效果覆盖掉;排序字段要写在查询语句里,不要依赖分页插件去处理。XML 中&gt;=是>=的转义写法,这个很容易被忽略,写错会出现 MyBatis 解析异常。

4. 酒店管理系统避坑指南:5 个最容易翻车的问题

4.1 重复预订:并发下两个线程同时读到了可用房间

现象:同一个房间在很短时间被创建了两笔订单,状态都是待支付,一张房态表被更新了两次。

原因:代码写成“先查状态,再更新状态”,两次查询之间没有锁保护。Java 层面这是典型的 check-then-act 竞态问题。

解决:我在 3.2 里给出的 select for update 就是标准解法。把“查房间”和“改房间状态”放在同一个事务里,数据库行锁保证第二个事务拿到锁后看到的是 BOOKED 而不是 AVAILABLE。如果不想用行锁,也可以改成UPDATE tb_room SET status = 'BOOKED' WHERE id = ? AND status = 'AVAILABLE'后判断影响行数,影响行数为 0 就说明房间已经被占用,但这种做法在判断日期冲突时不够直观。

4.2 入住天数永远差一天:日期边界没定清楚

现象:客户 12 月 1 日入住、12 月 3 日退房,订单金额按每晚 300 元算是 900 元,实际算出来只有 600 元。

原因:按照日历天计算,12 月 1 日到 12 月 3 日确实是 2 天,但酒店行业按床位晚计费,这叫“三晚”。代码里直接做日期差没有加一。

解决:所有涉及按天计费的地方统一口径为“含首不含尾”或“含头不含尾”,先定规则再写代码。比如 order 创建时用checkOutDate.toEpochDay() - checkInDate.toEpochDay()得到的差值就是晚数,不需要加一;但退房时间在中午 12 点前、下午 6 点前、晚上 6 点后要按不同的计费规则另算,这种边界只写在文档里不够,要写成一个独立的calculateNights()工具方法,配套单元测试。

4.3 金额用 double 计算,结算单出现 0.30000000000000004

现象:订单总金额有时会出现一长串小数,比如 0.30000000000000004,前端展示十分尴尬。

原因:Java 的 double 和 float 采用二进制浮点表示,0.1、0.2 这类十进制小数无法精确表达。数据库里虽然存了 DECIMAL,但代码里如果先转成 double 再乘法精度就会丢。

解决:金额参与运算时统一用 BigDecimal,构造 BigDecimal 时不要直接传 double,应该用new BigDecimal("268.00")或BigDecimal.valueOf(268.00)。数据库层的 daily_price、total_amount、actual_amount 全部用 DECIMAL(10,2),避免 JDBC 驱动做隐式转换。最后入库时再setScale(2, RoundingMode.HALF_UP)统一四舍五入。

4.4 退房后房间秒变“空闲”,保洁流程被跳过

现象:客人退房后房态立刻变成 AVAILABLE,下一单可以直接预订这一间,但房间根本没打扫。

原因:退房接口只改了 room 表的 status,没有引入“打扫中”这个中间状态。这在真实酒店流程里不可能发生——客房没有经过保洁确认,前台是不允许把它标记为可售房的。

解决:在退房事件中,把房态改成 CLEANING 而不是 AVAILABLE。新增一个保洁完成的接口,只有当保洁员确认后,CLEANING 才流转为 AVAILABLE。顺带在订单表上可以加一个cleaned_time字段记录时间,论文里作为“业务流程完整性”的论据。

4.5 前端联调跨域报错,日期格式又是一坨

现象:Vue 或微信小程序调接口时浏览器报 CORS 错误;后端返回的时间是 2025-01-01T12:00:00,前端解析后显示格式不符合中文习惯。

原因:前后端分离开发时后端没有处理跨域,Spring Boot 默认不允许跨域请求;LocalDateTime 被 Jackson 默认序列化成 ISO 格式。

解决:写一个配置类实现 WebMvcConfigurer,注册跨域规则;同时在 application.yml 里配置 jackson 的 date-format 和 time-zone。跨域配置里要允许 GET、POST、PUT、DELETE 四个方法,并支持 OPTIONS 预检请求。如果后续部署到同一域下就不需要跨域配置,所以这个类可以加上@Profile("dev")只在本地方生效。

5. 论文、源码和答辩:怎么把 Spring Boot 毕设交付到位

5.1 论文章节与代码模块的对应关系

毕设论文最怕写成一本文档说明书,章章节节都在描述界面怎么点,评审看十页也看不到与代码的对应关系。我建议按下面这种对应关系组织整本论文:

论文章节对应代码位置评审重点
需求分析module/order 的用例场景有没有画出房态状态图、订单状态图
系统设计resources/db/init.sqlER 图里三张表关联关系是否一致
系统实现-预订模块OrderService.createOrder事务注解、并发锁、金额计算
系统实现-房态管理RoomService 状态流转方法打扫中状态是否被体现
系统测试src/test 下的核心用例是否覆盖重复预订、日期边界、退房流程

我见过很多同学把论文里的功能列表写得很丰满,比如“支持会员积分兑换”“支持多酒店连锁”,代码里却找不到对应实现。评审老师随机点一个功能让现场演示,翻车的是最常见的情况。所以论文里出现的每一个功能,代码必须能跑通;代码里已经写好的功能,论文必须有所交代。宁可砍掉没做完的功能,也不要扩写没做的功能。

5.2 源码交付:让拿项目的人能一次跑通

交付毕设源码不是把一个 zip 压缩包丢过去就完事,收源码的人多半是下一届学弟或者评审老师,他们本地环境和你不一样。一个完整的源码交付目录至少要有工程源码、SQL 初始化脚本、README 和使用说明。README 要写清楚 JDK 版本、Maven 版本、MySQL 版本,以及数据库初始化步骤。

我习惯把配置文件按环境拆开:application.yml 放公共配置,application-local.yml 放本地数据库账号,application-prod.yml 放服务器部署配置。系统里涉及文件上传之类的路径也不要写死,用配置项引用。启动命令固定为:

mvn clean package -DskipTests java -jar target/hotel-0.0.1-SNAPSHOT.jar

一个常见的交付教训是:只给一个编译好的 jar 包,不附带工程源码。有人想从 jar 反编译出项目来学习,反编译工具确实能把 class 还原成 Java 源码,但注解、注释、resources 目录里的配置会被剥离或错位,反编译结果很难编译回去,更不能当作毕设源码提交。正确做法是交付完整源码工程,jar 只是让你验证运行结果用的。反编译这条路只适合应急,不适合作为交付手段。

5.3 答辩被追问最多的 3 个技术问题

毕业答辩的问题本质上是面向 Java 基础知识的面试。很多学校答辩老师会直接拿代码里的某个点变成 java 面试题来问,提前准备几个高频问题能省下很多临场压力。

第一个问题:@Transactional 加在 private 方法上为什么不生效?答案:Spring 通过代理实现事务,private 方法不走代理,自然拿不到事务管理器。

第二个问题:数据库的 DECIMAL 和 Java 的 BigDecimal 什么关系?答案:DECIMAL 是数据库精确小数类型,BigDecimal 是 Java 侧的对等类型,double 会产生二进制浮点误差,所以金额数据必须用这对组合。

第三个问题:如何防止房间被并发重复预订?答案:在事务内用 select for update 对房间行加锁,或者用 update where status 的乐观锁写法,核心是把 check 和 act 合成一个原子操作。

6. 收尾技巧:把演示数据固化成可重复回归的环境

快到答辩那几天,我遇到最多的情况是:系统明明昨天跑得好好的,今天现场演示时数据库被测试数据搞乱了,某个房间状态停在“已入住”,重新演示要手动改好几张表。解决这个问题有个笨但有效的方法:写一个可重复执行的演示数据初始化脚本,每次演示前重置整个数据库。

DELETE FROM tb_checkin; DELETE FROM tb_order; UPDATE tb_room SET status = 'AVAILABLE'; INSERT INTO tb_room (room_number, room_type, floor, status, daily_price) VALUES ('101', 'SINGLE', 1, 'AVAILABLE', 268.00), ('102', 'SINGLE', 1, 'AVAILABLE', 268.00), ('201', 'DOUBLE', 2, 'AVAILABLE', 388.00); INSERT INTO tb_order (order_no, customer_name, customer_phone, room_id, check_in_date, check_out_date, total_amount, status) VALUES (CONCAT('DEMO', DATE_FORMAT(NOW(), '%Y%m%d%H%i%s')), '演示客人', '13800000000', 1, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 1 DAY), 268.00, 'CHECKED_IN'); UPDATE tb_room SET status = 'OCCUPIED' WHERE id = 1;

这个脚本的价值不只是给演示用。每次对服务层代码做改动之后,执行一遍这个脚本,再跑一遍预订、入住、退房主流程,数据库会回到已知状态,任何因为脏数据导致的偶发问题都能快速复现。我更推荐把它再接一层自动化:在 Spring Boot 的 test 环境里写一个@SpringBootTest用例,调用 createOrder、checkIn、checkOut,用断言验证状态流转和金额字段。以后再有人改坏了逻辑,一个mvn test就能发现问题,不用依赖人工点点点。

我自己的习惯是:每次交付前先把 demo.sql 跑一遍,然后把预订、入住、退房全流程截图放进论文的测试章节。这个习惯帮我挡掉过好几次现场演示的尴尬。希望帮到你。

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

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

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

立即咨询