去年接了个做毕设辅导的活儿需求方给我的文档里就一句话“基于SpringBoot的酒店预定系统要有前台订房和后台管理能跑起来演示就行。”话说得轻巧真从零开始做起才发现一个“订房”的口子背后牵扯着房间库存、订单状态机、价格策略、并发超卖这些一连串的硬骨头。这篇就把整个项目的设计过程和落地经验整理出来从技术选型到核心表结构从下单链路到部署演示全部展开讲透。这套思路不只适用于酒店凡是“管理交易”类型的系统——民宿、自习室、会议室预定——都可以参考。1. 先梳理业务酒店预定系统真正要管的是哪几件事1.1 用户的完整操作链路动手写代码之前我先把需求拆成了两条主线用户侧和管理员侧。用户侧其实很简单一条闭环链注册登录 → 按城市/日期搜房 → 浏览房型详情 → 提交订单 → 支付演示项目里通常是模拟支付 → 到店入住 → 退房。整个过程里用户能感知到的核心动作就是“查”和“订”但后台要支撑的事情远比这多。管理员侧则是维护酒店和房型信息 → 管理具体房间 → 设置不同日期的价格 → 查看和处理订单 → 办理入住/退房登记 → 看基本的营业统计。实际做下来管理端的功能量大约是用户端的两倍这也是很多新手容易低估的点——页面一多、权限一区分项目的复杂度就上来了。1.2 MVP功能清单与做减法项目启动时功能范围很容易失控。比如需求方一开始提到想要钟点房、会员积分、对接携程美团渠道、短信通知甚至语音房。我的建议很直接第一版全部砍掉只保留能形成完整业务闭环的核心功能。我最终确定的MVP清单是前台注册登录、按城市/日期查询可用房型、房型列表与详情、创建订单、模拟支付、我的订单列表。后台房型管理、房间管理、订单管理确认入住、办理退房、基础数据统计。通用JWT登录拦截、统一异常处理、统一返回结构。砍掉钟点房的原因很简单——它涉及按小时计费的定价模型和库存规则跟按晚计价的逻辑是两套东西会和主流程抢时间。会员积分、渠道对接这类功能对毕设或练手项目来说属于加分项第一版做进去只会拖慢节奏。1.3 页面级的原型先行我以前接过不少项目最忌讳一上来就建工程写表结构。这个项目我第一步做的是用原型工具把用户端和管理端的核心页面画出来前前后后画了二十来张页面用户端约八张管理端约十二张。原型阶段花的时间不多大约两天但价值极大。页面画完导航层级、页面跳转关系、字段要求全部一目了然后面的数据库设计和接口设计直接对着原型走就行。而且原型还能发给需求方确认避免做了半天才发现不是对方想要的。2. 技术选型为什么是SpringBoot MyBatis-Plus这套组合2.1 框架选择的逻辑技术选型这件事不求最潮但求最稳。项目定的技术栈是SpringBoot MyBatis-Plus MySQL Redis Vue Element UI。为什么用MyBatis-Plus而不是Spring Data JPA我的判断是酒店预定这类业务里查询条件非常灵活——按日期、城市、房型、价格区间、入住人数组合筛选JPA虽然也能做但写动态查询时要么用Specification要么用QueryDSL学习曲线陡而且出了问题时排查SQL不如MyBatis直观。MyBatis-Plus既保留了手写SQL的能力又提供了单表CRUD的BaseMapper开发效率和可排查性两头都占。至于为什么不直接用原生的MyBatis——很明显MyBatis-Plus把insert、update、selectById这些重复劳动都省了还能用LambdaQueryWrapper代码写起来干净很多。2.2 认证方案JWT加拦截器而不是Spring Security系统需要区分普通用户和管理员登录后大部分接口都要校验身份。常见的方案是Spring Security或Shiro但我最后选了JWT 拦截器的轻量方案。原因很实际Spring Security功能强大但配置门槛高SecurityFilterChain、UserDetailsService、密码编码器这一套对新手很不友好。对于“用户/管理员两种角色、接口级权限校验”这种需求一个拦截器判断请求头里的Token解析出用户ID和角色再按角色放行或拒绝逻辑足够清晰也方便在答辩时把原理讲明白。实际代码结构大约是这样登录成功后用符合JWT规范的工具生成Token返回给前端前端存到localStorage每次请求在Authorization头里带上后端写一个AuthInterceptor拦截非白名单接口解析并校验Token再把用户信息塞进ThreadLocal或请求属性里供Controller直接用。2.3 版本选型与兼容性问题版本这里我吃过一次亏。项目最开始图新鲜用了Spring Boot 3.2结果开发环境还是JDK 8一堆兼容问题后来老老实实退回2.7。最终的版本组合是组件版本选择理由Spring Boot2.7.18稳定兼容JDK 8生态资料最多JDK1.8大多数毕设和旧服务器环境都支持MyBatis-Plus3.5.x使用率高文档齐全MySQL8.0功能完善云端部署方便Redis6.x做缓存和分布式锁都够用Vue2.6配合Element UI最成熟提示如果自己练手且环境允许Spring Boot 3.x带着JDK 17也值得尝试但如果是毕设或者给甲方交付选2.7版本能避开大量“版本太高导致配置失效”的坑。项目搜索热度中关于“springboot版本太高”的讨论基本都是这类问题。2.4 Redis到底用了什么场景很多人一听Redis就只知道缓存。这个项目里我实际用了两个场景一是房型热门榜单的缓存把用户端首页的热门房型列表缓存起来5分钟刷新一次减轻数据库压力二是下单时的分布式锁防止同一个房间被两个人同时锁定。如果嫌Redis部署麻烦也可以全用数据库实现但演示“分布式锁防超卖”这个亮点时有Redis明显加分。3. 核心表设计房型、房间、订单是怎么串起来的3.1 房型表和房间表必须拆开酒店业务里“房型”和“房间”是两个概念。房型指的是“豪华大床房”“商务双床房”这类出售的商品而房间是具体的物理存在比如“豪华大床房”有三间房号分别是801、802、803。这个区别必须体现在表结构上否则后面会很难办。我的设计是CREATE TABLE t_hotel ( id bigint NOT NULL AUTO_INCREMENT, hotel_name varchar(64) NOT NULL, city varchar(32) NOT NULL, address varchar(128) DEFAULT NULL, PRIMARY KEY (id) ); CREATE TABLE t_room_type ( id bigint NOT NULL AUTO_INCREMENT, hotel_id bigint NOT NULL, type_name varchar(64) NOT NULL, area decimal(6,2) DEFAULT NULL, bed_count int DEFAULT NULL, max_occupancy int DEFAULT NULL, base_price decimal(10,2) NOT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ); CREATE TABLE t_room ( id bigint NOT NULL AUTO_INCREMENT, hotel_id bigint NOT NULL, room_type_id bigint NOT NULL, room_number varchar(16) NOT NULL, status tinyint DEFAULT 0 COMMENT 0可售 1占用 2维修, PRIMARY KEY (id) );为什么这么拆因为价格挂在房型上而占用状态挂在具体房间上。做查询时房型表负责展示“什么价、能住几个人”房间表负责回答“还有没有空闲的”。如果不拆每个房间都要存一份价格字段维护价格时就得多更新N条记录容易漏。3.2 订单表的状态机设计订单是整个系统里状态最多的实体也是最容易写乱的地方。我第一版写的时候想到哪写到哪导致订单状态有七种其中三种语义重叠后来重构成下面的状态机订单状态数值说明待支付0下单成功但未支付超时自动关闭已支付/待入住1支付成功等待客人到店已入住2前台办理入住登记已退房3退房完成订单完结已取消4用户取消或管理员取消已关闭5超时未支付自动关闭状态迁移的规则必须写死在代码里不能由前端随便传待支付可以取消或变为已支付已支付可以取消或变为已入住已入住只能变为已退房。像“已退房”又改成“已入住”这种操作无论如何都要禁止。订单表还有两个关键设计。一是把check_in入住日期和check_out离店日期作为独立字段存而不是存“共几晚”。这样查询时直接拿日期做区间判断要计算房费时再用两个日期相减求天数。二是加了一个version乐观锁字段后续下单防超卖时有用。3.3 价格策略从固定价到平假日定价MVP阶段的价格可以只用一个base_price字段。但真实酒店场景里周末和节假日的房价和平时不一样退房时间也可能影响费用。我采用的是“基础价日期价格覆盖表”的轻量设计CREATE TABLE t_price_plan ( id bigint NOT NULL AUTO_INCREMENT, room_type_id bigint NOT NULL, price_date date NOT NULL, price decimal(10,2) NOT NULL, PRIMARY KEY (id) );核心逻辑查某天某房型的价格时先去价格计划表找该日期有没有特殊定价有就取特殊定价没有就回落房型表的base_price。这样既不用每天给每个房型都生成一条记录又能支持节假日提价、淡季促销这类真实需求。代码层面就是一个私有方法getDailyPrice(roomTypeId, date)两步逻辑非常简单。计算整个订单总价时遍历入住期间的每一天把每天的房价加总就是订单金额。4. 订房核心链路查询、下单、取消是怎么实现的4.1 可售房间查询核心是日期区间不重叠用户最核心的动作就是搜“某城市某时间还有没有房”。这个需求的SQL是整张系统里含金量最高的地方。先明确业务规则一个订单占用某房间的条件是入住日期和离店日期构成的区间有重叠。两个区间不相交的数学条件是checkIn 已存在订单的checkOut或者checkOut 已存在订单的checkIn。取反就是相交条件// 区间 [checkIn, checkOut) 与已有订单占用区间相交 o.check_in #{checkOut} AND o.check_out #{checkIn}注意这里用的是“左闭右开”的约定入住当天算第一晚的开始离店当天中午前退房所以check_in当天被占用checkOut当天不占用。查询可用房型时找出那些整段时间内房间仍可售的房型SELECT rt.*, COUNT(r.id) AS available_count FROM t_room_type rt JOIN t_room r ON r.room_type_id rt.id WHERE rt.hotel_id #{hotelId} AND rt.id NOT IN ( SELECT rt2.id FROM t_room_type rt2 JOIN t_room r2 ON r2.room_type_id rt2.id JOIN t_order o ON o.room_id r2.id WHERE o.status IN (1, 2) AND o.check_in #{checkOut} AND o.check_out #{checkIn} ) GROUP BY rt.id;刚开始我犯过一个错误只按check_in日期判断库存比如用户查“3月10日到3月12日”我判断3月10日有没有房。这就漏掉了那种“3月9日入住、3月11日退房”的订单它占用着3月10日那晚但按旧逻辑根本看不见它。换成区间重叠判断后这个问题就彻底解决了。4.2 下单防超卖事务加锁而不是“先查再减”下单是整个项目里并发风险最高的环节。两个用户同时看到还剩一间房同时点击下单如果处理不当两个订单都创建成功卖出了一间不存在的房。最容易想到也最容易踩坑的方案是“先查询是否可订再插入订单然后更新房间状态”。这个方案在并发下完全不可靠两个请求都查到“可订”然后都插入订单于是超卖。这个我实测过是的真的会发生。我最后用的方案分两层第一层数据库层面加唯一约束和条件更新。如果旅馆支持固定房间分配那订单表可以加(room_id, check_in, status)的唯一索引但因为MVP里支持房型级预订下单时只指定房型到店再分配房间所以我在房型维度做防超卖控制。第二层事务加行锁。创建订单时在事务内先对房型所在记录执行SELECT ... FOR UPDATE把这一个房型的库存行锁住后续的检查和插入都串行进行等事务提交后锁才释放Transactional(rollbackFor Exception.class) public OrderCreateResult createOrder(CreateOrderRequest req) { // 1. 加锁查询当前房型信息 RoomType roomType roomTypeMapper.selectByIdForUpdate(req.getRoomTypeId()); // 2. 在事务内重新检查该区间是否可订 int count orderMapper.countAvailableRoom(req.getRoomTypeId(), req.getCheckIn(), req.getCheckOut()); if (count 0) { throw new BizException(该时间段房间已满); } // 3. 生成订单状态为待支付 Order order buildOrder(req, roomType); orderMapper.insert(order); // 4. 更新房型剩余可订数量或用Redis预扣库存 return new OrderCreateResult(order.getId()); }配合上述逻辑如果项目里引入Redis还可以用Lua脚本做库存预扣DECRBY room:stock:typeId 1扣减成功才允许创建订单创建失败时回补库存。这套方案的好处是把库存压力的承载点从数据库转移到了Redis高并发下表现更好而且Lua脚本保证原子性不存在扣多扣少的问题。4.3 取消订单与状态回滚取消订单看起来简单但要和支付状态联动。逻辑是待支付订单和已支付订单都可以取消已入住订单不能直接取消必须先办理退房再走结算待支付订单如果用户超过30分钟不支付要有一个定时任务或延迟任务把它自动关掉。取消已支付订单时除了把订单状态改成“已取消”还要把支付的记录做退款标记同时释放对应日期区间的房间库存。在这个系统里“释放库存”没有实际物理动作——因为库存是实时计算出来的订单状态改成已取消后查询区间可用房间时这个取消订单就不再占用房间了。经验不要把“订单状态”和“库存占用”放两张表手动维护一致性很容易出现订单取消成功但库存没释放的脏数据。最好的做法是库存基于“有效订单”实时计算订单状态一变库存自动就变了。5. 开发中踩过的三个记忆最深的坑5.1 日期边界零点、跨月和“到底住几晚”日期处理是我在整个项目里重写次数最多的逻辑。第一次实现“共几晚”时我用(checkOut - checkIn) / (24*60*60*1000)结果跨夏令时或日期格式解析不当就出偏差。后来统一用LocalDate加ChronoUnit.DAYS.between(checkIn, checkOut)干净利落。另外一个典型坑是跨月场景。比如入住3月31日离店4月2日虽然只有两天但如果用“按月拆分的日期字符串比较”这种笨办法很容易判断失误。统一用LocalDate的isBefore、isAfter做区间判断就完全不受月份影响。5.2 金额计算double的精度问题订单金额涉及多天房价加总、优惠减免、退款金额。我最初图省事用了Double后来测试时发现计算“199.00 * 3 88.00 * 2”这类式子时偶尔出现899.9999999999这种结果。排查原因是浮点数的二进制存储导致精度丢失。解决方案是全面替换Java侧金额一律用BigDecimal数据库金额字段一律用DECIMAL(10,2)Vue前端显示时用toFixed(2)。换算价格时用BigDecimal.valueOf(doubleValue)而不是new BigDecimal(doubleValue)后者仍然会带上浮点误差。这块改动最早做掉后面的订单金额计算才没有出现连环错账。5.3 MyBatis-Plus分页插件失效项目做后台订单列表时用了MyBatis-Plus的分页功能写了PageOrder page new Page(pageNum, pageSize); orderMapper.selectPage(page, wrapper);结果返回的记录一直是全量分页完全不生效。查了一圈发现原因是MyBatis-Plus的3.5版本需要手动注册分页拦截器不是引入依赖就自动生效的Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个坑好多人都会踩排查过程也不难但如果没有看过官方文档直接抄网上的老配置就会卡很久。6. 从打包到演示毕设项目如何体面地交付6.1 环境配置分离项目本地开发和服务器部署一定不能用同一套配置。我建了三个配置文件application.yml放公共配置application-dev.yml放本机数据库和Redis地址application-prod.yml放服务器地址。启动时通过spring.profiles.active指定环境。服务器上的MySQL、Redis密码不要明文写在配置文件里再提交代码可以用环境变量注入。对于毕设项目至少要做到配置文件和代码分离演示时不会因为忘改数据库地址而现场翻车。6.2 构建与部署命令后端打包很常规mvn clean package -DskipTests打出来的jar包直接扔到服务器java -jar hotel-system.jar --spring.profiles.activeprod前端Vue项目执行npm run build后把dist目录里的静态文件放到Nginx里或者为了省事直接放在后端项目的resources/static目录下让SpringBoot一并托管。后者对演示场景非常方便一个jar包包含了前后端全部内容拷贝到哪都能跑。6.3 给做同类项目的人几个实在建议先做页面原型再写代码。原型阶段发现问题成本极低代码写一半发现页面流程不对改起来欲哭无泪。不要过度设计。这个项目最开始还想上消息队列、ElasticSearch后来全部砍掉对MVP阶段毫无帮助。Redis留一个场景做亮点就够了。录一个演示视频。不管要不要答辩都建议把核心操作流程录成视频5分钟左右覆盖用户订房和管理员处理订单的完整路径。这一步在交作业和面试展示时都很有用。答辩讲业务闭环。不要只讲“用了SpringBoot、MyBatis”要把业务链路串起来讲从用户搜房到下单到退房再到后台订单状态怎么流转这是最能体现项目含金量的地方。这个项目在电脑里的归档名就是标题里那串“gc4d84jj”当时是图省事直接拿来当项目代号。如今回头看整个项目最大的收获不是CRUD写得有多溜而是明白了业务流程和技术实现之间的映射关系——状态机、区间查询、并发控制这些概念不再是面试题里死记硬背的条目而是实实在在解决过问题的工具。如果你也准备做一个类似系统建议按这篇的路线走先砍需求、再定表结构、最后再写代码顺序对了效率会高很多。
阅读完成 · 觉得有帮助?