
说实话每年毕设季我都会碰到好几个拿酒店客房预订系统来问事的同学。这个题目在Java方向里属于经典的万能选题——酒店人人住过、业务不陌生用户、房型、房间、订单几张表就能把一套完整的CRUD闭环跑通再配上Spring Boot这个就业市场上最主流的框架技术选型上也不会被老师挑刺。但恰恰因为做的人多想做出区分度反而比那些冷门题目更考验你对业务的理解深度。这篇文章就把Spring Boot酒店客房预订系统的设计与实现完整拆一遍从需求边界划定、表结构设计、核心查询与下单逻辑一路讲到打包部署和答辩避坑。我默认你已经有一点Java基础、知道什么是MyBatis如果你还停留在只会照着教程跑起一个hello world的阶段这篇文章同样能让你看到一条完整的落地路径知道每一步在解决什么问题。1. 先搞清楚酒店客房预订系统到底要解决什么问题1.1 业务角色与核心流程任何业务系统第一步都不是写代码而是搞清楚谁在用、用它在干什么。酒店客房预订系统里最核心的角色就两个住客和酒店前台。住客要能浏览房型、按日期查询可用房间、下单预订、取消订单、查看自己的订单记录酒店前台要能维护房型和房间信息、确认订单、办理入住和退房、查看简单的经营统计。两条角色线交汇成一条核心业务链查询房型 → 按入住日期和离店日期查可用房间 → 提交预订生成订单 → 前台确认 → 到店入住 → 退房结束。整个系统所有的表、接口、前端页面本质上都是在服务这条链。你把这个链条能跑通项目就已经完成了八成剩下的用户管理、评价、统计都是在这个骨架上做加法。很多同学一上来就想着我要做一个像携程一样的东西于是把支付、优惠券、会员积分全塞进需求里结果做两个月还在跟支付回调死磕。作为一个过来人我劝你清醒一点毕设的评分标准是逻辑完整、能自圆其说而不是功能堆得多。1.2 需求边界该做的不做不该做的不碰毕设不是商业系统需求边界必须主动去划。哪些东西坚决不碰第一真实支付。微信支付、支付宝支付需要商户号、营业执照、软著证书学生个人根本申请不下来就算用沙箱环境也会把大量时间耗在签名、回调、证书这些跟业务无关的琐事上。第二微服务架构。单机单应用就能解决的业务量级硬拆成Nacos、Gateway、多个服务只会让部署复杂度爆炸答辩时一个服务起不来就全线崩溃。那什么是该做的可用房间的查询逻辑、订单状态机的流转、并发情况下防止同一间房被重复预订这三个点才是这套系统的灵魂也是答辩老师最容易深挖的地方。把这三个点做扎实、能讲清楚比堆十个花哨功能都管用。2. 技术选型Spring Boot怎么搭才不给自己埋雷2.1 版本选择2.7还是3.x这是个真问题Spring Boot目前的主线早就到3.x了但网上能找到的大量教程、博客、GitHub项目还停留在2.x时代。这两个版本之间最大的坑是包名的变化3.x把javax.*整体迁移成了jakarta.*你复制一段老代码时要么报包不存在要么需要手动改一堆import。对于毕设来说我个人的建议是除非导师明确要求用3.x否则直接用Spring Boot 2.7.18配JDK 8或11资料最多、坑最少、报错搜索时能搜到无数现成答案。如果你非要用3.x也不是不行但要记住三个适配点JDK最低要求17、javax.servlet全部改成jakarta.servlet、MyBatis Plus和部分第三方starter的版本必须选支持Spring Boot 3的。每一条单独看都不难难的是它们会在你查代码时同时冒出来让排查成本成倍上升。2.2 后端组合与前端方案的选择逻辑后端框架组合我推荐Spring Boot MyBatis Plus MySQL Redis这套组合在毕设里几乎是标配理由很实际MyBatis Plus对单表CRUD做了极致的简化不需要手写Mapper XML也能完成selectById、分页查询、逻辑删除这些高频操作能把你的时间省出来放到核心业务逻辑上。而像房间可用性查询这种复杂SQL又可以用自定义Mapper方法写出来SQL是你完全可控的不会像JPA自动生成的查询那样在答辩时被问住。为什么不用Spring Data JPA不是说它不好而是在国内的教学和企业语境里MyBatis系的使用率明显更高而且当你要解释NOT EXISTS子查询怎么判断日期冲突时手写SQL比讲JPA的Specification直观太多。Redis在这里也不是必需品但哪怕只在房型列表热点缓存这种简单场景用一下也足以在答辩时展示你对缓存技术的理解代价很小。前端方案有两条路一条是Vue 3 Element Plus做前后端分离一条是Thymeleaf服务端渲染。前后端分离的优点是演示效果好、答辩时能展示前端能力缺点是你要处理跨域、两个项目的构建和部署Thymeleaf的优点是不存在跨域问题、打包成一个jar就能跑缺点是页面交互能力弱做不出那种像一个真实系统的感觉。我的建议是你前端框架玩得转就果断前后端分离前端实在心虚就选Thymeleaf别两头都不精还硬撑分离架构最后部署环节把自己坑哭。2.3 权限方案别一上来就抱住Spring Security不放很多同学一听说系统需要登录认证就直接引Spring Security然后被那套Filter链、SecurityConfig配置折腾得怀疑人生。对于一个只有一个住客角色和一个管理员角色的毕设系统Spring Security属于典型的杀鸡用牛刀。我推荐的做法是JWT 一个HandlerInterceptor拦截器十几行代码就能解决。用JWT做登录认证的思路很简单用户登录成功后后端生成一个token返回给前端前端每次请求在Header里带上Authorization: Bearer xxx后端写一个拦截器解析token、把用户信息放进ThreadLocal再配合自定义注解判断哪些接口需要管理员权限。这套方案你能讲清楚原理代码量又小答辩时被问为什么不用Shiro/Spring Security你完全可以答角色模型简单拦截器足够支撑同时我对Spring Security的Filter链也有了解。话术上留有余地老师基本不会继续为难你。3. 数据库设计这张表怎么建决定了你要不要加班3.1 核心表结构与关系梳理数据库设计是这个系统所有坑的源头。表结构设计合理后面写SQL一路顺风表结构设计得别扭你会发现每天都在为这个字段放哪张表而纠结。核心表我建议按下面这张拆分表名用途需要重点关注的核心字段user用户表id, username, password, phone, real_nameroom_type房型表id, name, bed_type, max_people, area, price, room_countroom物理房间表id, room_type_id, room_no, floor, statusreservation预订单表id, order_no, user_id, room_id, check_in_date, check_out_date, statuscomment评价表id, order_id, user_id, content, rating这里有一个值得停下来想清楚的设计决策预订单到底关联到房型还是关联到具体的物理房间两者方案各有道理。关联房型的做法更贴近真实酒店订的是大床房这个品类具体哪一间到店再分但可用房间的查询得基于房型总数减去已占订单数的计数逻辑内部其实掩盖了物理房间的分配问题答辩时容易被老师追问那会不会把同一间房分给两拨人。关联具体物理房间的做法是预订时系统查出哪些具体房间在目标日期区间内可用用户选一间或系统自动分配一间预订单直接存room_id。这样做的好处是可用性查询的逻辑非常清晰就是房间维度的日期冲突判断一次NOT EXISTS就能搞定答辩时可以在白板上画出时间线来讲。坏处是与你平时住酒店的真实体验略有出入但这完全在毕设可接受的范围内。我推荐后者因为它能让你把核心难点变成一个可演示、可验证的SQL问题。3.2 日期字段与状态字段两个最容易翻车的地方日期字段的选型是我每次都要强调的点。check_in_date、check_out_date请一定要用LocalDate不要用java.util.Date更不要图省事存成String。用Date会带来时区问题用String会带来格式和比较问题只有LocalDate语义干净它就是年月日不掺时间、不掺时区日期区间计算直接清晰。再讲一个很多人忽略的边界约定订单的入住日期和离店日期采用左闭右开区间。也就是说5月1日入住、5月3日离店实际占用的是5月1日和5月2日两个晚上5月3日当天中午前退房房间可以给下一位客人从5月3日开始入住。这个约定写进你的日期冲突判断逻辑里就变成了这条核心条件res.check_in_date 目标离店日期 AND res.check_out_date 目标入住日期这条判断在后面的SQL里会原样出现建议你背下来。至于订单状态字段用int类型加一组常量或枚举不要用字符串更不要在代码里到处写魔法数字。我用了一张状态机表来统一管理状态流转这一点在后面下单逻辑里细讲。下面给出一个简化版的核心建表SQL你可以根据自己的字段习惯调整CREATE TABLE room_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 房型名称, bed_type VARCHAR(20) COMMENT 床型, max_people INT DEFAULT 2 COMMENT 最多入住人数, price DECIMAL(10,2) NOT NULL COMMENT 门市价, room_count INT NOT NULL COMMENT 房间总数, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除 ); CREATE TABLE room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_type_id BIGINT NOT NULL COMMENT 所属房型, room_no VARCHAR(20) NOT NULL COMMENT 房间号, floor INT COMMENT 楼层, status TINYINT DEFAULT 0 COMMENT 0空闲 1维修, version INT DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_room_no (room_no) ); CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL, room_id BIGINT NOT NULL, check_in_date DATE NOT NULL, check_out_date DATE NOT NULL, guest_name VARCHAR(50), guest_phone VARCHAR(20), status TINYINT DEFAULT 0 COMMENT 0待确认 1已确认 2已入住 3已离店 4已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_room_date (room_id, check_in_date, check_out_date) );注意reservation表上我建了一个idx_room_date联合索引这个索引直接服务日期冲突查询。数据量小的时候感觉不到差别但答辩时被问到查询性能怎么保证你可以直接说联合索引让NOT EXISTS子查询的回表次数明显下降。这句话很加分。4. 核心功能实现可用房间与订单状态机4.1 可用房间查询一次NOT EXISTS讲清日期冲突现在来到整套系统最核心的业务逻辑给定一个房型给定入住日期start和离店日期end找出这个房型下所有在日期区间内空闲的房间。用关联具体房间的设计这个查询可以写成这样select idselectAvailableRooms resultTypecom.example.hotel.dto.RoomDTO SELECT r.id, r.room_no, r.floor, rt.name AS roomTypeName FROM room r LEFT JOIN room_type rt ON r.room_type_id rt.id WHERE r.room_type_id #{roomTypeId} AND r.status 0 AND NOT EXISTS ( SELECT 1 FROM reservation res WHERE res.room_id r.id AND res.status IN (1, 2) AND res.check_in_date lt; #{endDate} AND res.check_out_date gt; #{startDate} ) /select解释一下这段SQL的意图。NOT EXISTS的意思是不存在任何一条预订记录它同时满足订的是这个房间订单还处于有效状态并且它的日期区间和目标区间有交集。只要存在一条这样的记录说明房间在被查询的时间段内已经被占用了要把这个房间排除掉。res.status IN (1, 2)很关键——已取消的订单不应该再占用房间所以不能把status4算进来。为什么日期冲突条件是小于开始和大于结束这两个严格不等式回到左闭右开区间去理解。假设已有一个订单占用了5月1日到5月3日你现在想订5月3日到5月5日那么这条已有订单的check_out_date是5月3日它大于目标入住日期5月3日这个条件为假所以不会冲突房间是可订的——这意味着5月3日中午退房后当天就能接纳新客人。反过来你想订5月2日到5月4日已有订单的check_in_date(5月1日)小于5月4日为真check_out_date(5月3日)大于5月2日也为真冲突成立房间不可订。这个逻辑建议你在纸上画一个时间轴把已有订单和你要订的区间标上去多画几组不同情况答辩时你就能非常流利地讲出来。4.2 预订下单行锁加冲突校验把并发问题关在门外查询接口是告诉用户现在有哪些房间可用但真正下单那一刻可能有两个用户同时看上了同一间房。如果你的代码只做查询到可用就插入订单在高并发场景下就会出现同一间房在重叠日期被下两单的问题这就是俗称的超卖。毕设的系统未必真的有并发压力但答辩老师一定会问你怎么防止这个问题。我在下单事务里加了一把数据库行锁思路是用MySQL InnoDB的SELECT ... FOR UPDATE把目标房间的那一行锁住直到事务提交才释放。锁住之后再执行一次完整的冲突校验最后插入订单。整个操作放在同一个事务里代码如下Transactional public Reservation createReservation(CreateOrderDTO dto) { // 1. 锁定房间行阻止并发下重叠日期订单 Room room roomMapper.selectByIdForUpdate(dto.getRoomId()); if (room null) { throw new BizException(房间不存在); } if (room.getStatus() RoomStatus.REPAIR) { throw new BizException(该房间正在维修暂不可预订); } // 2. 在锁的保护下重新做冲突校验 int conflict reservationMapper.countConflict( room.getId(), dto.getCheckInDate(), dto.getCheckOutDate(), OrderStatus.ACTIVE_CODES); if (conflict 0) { throw new BizException(手慢了该房间在所选日期已被预订); } // 3. 生成订单 Reservation res new Reservation(); res.setOrderNo(generateOrderNo()); res.setUserId(dto.getUserId()); res.setRoomId(room.getId()); res.setCheckInDate(dto.getCheckInDate()); res.setCheckOutDate(dto.getCheckOutDate()); res.setStatus(OrderStatus.WAIT_CONFIRM.getCode()); reservationMapper.insert(res); return res; }对应的Mapper方法select idselectByIdForUpdate resultTypecom.example.hotel.entity.Room SELECT * FROM room WHERE id #{id} FOR UPDATE /select这里我想多说一句为什么用悲观锁而不是乐观锁。乐观锁适合冲突概率低的场景通过版本号让先到者成功、后到者重试悲观锁适合冲突概率高或不允许任何一次失败写入的场景。酒店预订恰恰是后者——同一间热门房在节假日被多人同时下单的概率极高你不希望用户看到请重试这种体验而是希望系统直接告诉他已被订走。用行锁配合事务后到的人在锁释放后会看到最新的冲突数量直接被业务逻辑拦下来语义非常干净。还要提一个很多人会踩的坑Transactional不是随便一加就万事大吉。它默认只回滚RuntimeException如果你在事务里捕获了异常然后吞掉事务是不会感知到的它也不能处理同类this.createReservation()这种内部自调用因为此时走的是对象本身而非Spring代理对象注解不会生效。我的习惯是事务里只写数据校验、更新和插入任何异常都向上抛由全局异常处理器统一转成友好提示。4.3 订单状态机用一张迁移表管住所有状态变化订单从创建到结束会产生多个状态如果每个状态下单逻辑都穿插在业务代码里后面改需求就是一场灾难。我建议把这套状态流转建模成一张状态机表写代码之前先在文档里定清楚当前状态允许的操作目标状态待确认(0)用户取消已取消(4)待确认(0)管理员确认已确认(1)待确认(0)超时自动取消已取消(4)已确认(1)用户/管理员取消已取消(4)已确认(1)办理入住已入住(2)已入住(2)办理退房已离店(3)有了这张表写取消订单的逻辑就变成了先判断当前状态是否允许取消再执行数据变更Transactional public void cancelOrder(Long orderId, Long operatorId) { Reservation res reservationMapper.selectById(orderId); if (res null) { throw new BizException(订单不存在); } boolean canCancel res.getStatus() OrderStatus.WAIT_CONFIRM.getCode() || res.getStatus() OrderStatus.CONFIRMED.getCode(); if (!canCancel) { throw new BizException(当前订单状态不可取消); } res.setStatus(OrderStatus.CANCELLED.getCode()); reservationMapper.updateById(res); }状态机的价值在于把你从一堆if-else中解放出来。你看这段代码判断条件只涉及当前状态在不在允许迁移的集合里如果以后要增加已入住后不允许取消之类的规则只需要在状态机表上加一行约束而不是去所有调用点找逻辑。答辩时你可以把这张状态表打印出来当附件老师会觉得你是真的有工程意识的人。再补一个实用功能待确认订单超过30分钟自动取消。用Spring Boot自带的Scheduled就能实现不用引入任何额外组件Component public class OrderTimeoutTask { Scheduled(fixedRate 60000) public void autoCancel() { LocalDateTime deadline LocalDateTime.now().minusMinutes(30); ListReservation expiredList reservationMapper.selectWaitConfirmBefore(deadline); expiredList.forEach(res - { res.setStatus(OrderStatus.CANCELLED.getCode()); reservationMapper.updateById(res); }); } }如果老师追问为什么不使用延迟消息队列你可以说单机毕设场景每分钟扫描一次已经足够引入MQ只是增加部署复杂度但你了解RabbitMQ的延迟队列方案知道生产环境会用它。这种回答既证明你有知识广度又不至于把系统做复杂。5. 实操过程从搭项目到打包部署走一遍5.1 项目骨架与基础配置实际动手时我习惯先把项目骨架搭好用Spring Initializr生成一个基础项目再往里面加依赖。核心依赖就这几个spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j、lombok、jjwt。目录结构建议按controller、service、mapper、entity、dto、common、config分层不要在controller里直接写SQL。application.yml里最容易出错的就是数据库连接串。我用的是这样一份配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hotel_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0serverTimezoneAsia/Shanghai是很多人会漏掉的一项。如果不加MySQL驱动会拿本地时区去解析连接某些版本直接报The server time zone value的错误某些版本则会在日期时间上出现莫名其妙的8小时偏移。map-underscore-to-camel-case是让数据库里的create_time自动映射到Java实体的createTime不加的话你会反复被字段找不到折磨。5.2 统一返回、全局异常与跨域配置为了让前端对接省心我把所有接口的返回结构统一成ResultT包含code、msg、data三个字段。成功统一code200失败统一走全局异常处理器返回。配合RestControllerAdvice业务代码里只需要throw new BizException(提示信息)不用在每个方法里catch了又到处返回错误对象。接口的返回结构统一之后写一个东西就够了这个习惯在毕设答辩演示时会让你特别从容前端不管请求成功失败都按同一种格式解析出错了弹一个msg就能告诉用户原因不需要针对每个接口单独处理错误分支。跨域问题也很常见前后端分离时前端跑在5173端口后端跑在8080端口直接从浏览器发请求会被跨域策略拦下。在配置类里加一个CorsFilter就能解决允许的来源、方法、请求头都显式配置上不要把allowedOrigins(*)和allowCredentials(true)同时开这会触发浏览器的安全校验报错。5.3 打包部署一个jar跑起来还是用Docker开发完成后前后端分离的项目会有两个构建产物后端的Spring Boot jar包和前端的dist静态文件目录。最简单的部署方式是把dist里的文件复制到Spring Boot项目的src/main/resources/static下重新打包成一个jar这样部署时只需要一个Java进程端口也只有一个。这种方式演示最省心缺点是每次改前端都要重新构建后端。另一种是我个人更推荐练一下的Docker方式。写一个多阶段构建的Dockerfile第一阶段用Maven镜像编译后端第二阶段用JRE镜像运行jar。整个项目一行docker build -t hotel .就能出镜像docker run -p 8080:8080 hotel就能跑起来。如果前端和后端分开部署那就用Nginx反代后端接口/api前缀转发给后端容器。这个流程你会跑通之后以后不管是课程设计还是进公司实习部署环节都不会再焦虑。6. 高频报错与答辩深挖提前把脸护住6.1 一表看懂开发中常见的报错把我在带学生过程中遇到的高频报错整理成一张速查表你开发时直接对号入座报错现象根本原因解决方案The server time zone value Öйú±ê׼ʱ¼ä连接串没指定时区url加serverTimezoneAsia/ShanghaiFailed to configure a DataSource引入了数据库starter但没配数据源检查application.yml里datasource配置Invalid bound statement (not found)Mapper XML没被扫描或方法名不匹配检查MapperScan、XML的namespace和idCannot resolve symbol javax.servletSpring Boot 3.x包名迁移改用jakarta.*或退回2.7.x前端请求跨域报错No Access-Control-Allow-Origin前后端不同端口配置CorsFilter注意allowCredentials规则日期字段少一天或多8小时时区没配置连接串和Jackson时区同时设置事务没生效数据写了一半Transactional自调用或异常被吞保证代理调用、异常向上抛出查询可用房间时把已取消订单也算进去冲突校验没过滤status在SQL里加status IN (1,2)有效状态修改前端后页面不更新浏览器缓存或dist没重新构建强制刷新、检查重新打包流程这里我想特别展开一条开发时的经验每次写完一个接口先不要急着调前端直接用Postman或Apifox把接口调通、把边界情况测一遍。我的固定习惯是列表查全部、分页查第二页、传入不存在的id、传入结束日期早于开始日期、跨月跨年的日期区间这五类用例各跑一遍。毕设写论文时需要大量截图Postman有现成的请求记录和响应展示截图比你临时开前端调数据快十倍。6.2 关于这套系统我的几条实在体会每次带毕设我都会说一句话这套系统的复杂度天花板不在代码量而在于你是否真的理解业务约束从哪里来。日期冲突的约束来自真实酒店的房间不能同时租给两拨人订单状态机的约束来自真实前台什么状态下能取消、什么状态下能入住行锁的约束来自并发场景两个人争夺同一资源。把这三个约束建模清楚你写的就不是CRUD流水账而是一个有业务逻辑的系统。具体到答辩准备我的建议是重点准备三个演示点第一演示日期冲突判断用一个已经被订单占用的日期区间去查询展示它确实被过滤掉了第二演示状态流转展示待确认订单被确认、被取消、超时自动取消这几个路径第三如果可以开两个浏览器窗口同时抢同一间房做并发测试用行锁拦住第二个请求。这三个演示点每个都能讲出背后的SQL或事务原理胜过硬背一百个面试题。最后再分享一个小技巧把订单号order_no的生成规则设计成日期随机数而不是自增ID同时在数据库加唯一索引。这样做的直接好处是订单号看起来更像真实系统间接好处是你在答辩时能顺带讲一句唯一索引是防止订单号重复的最后一道防线这句话会显得你对数据库约束有意识。项目做到这一步剩下的事情就是沉住气把论文写扎实、把演示录屏准备好这套系统足够支撑你体面地走完整个毕设季。