资讯动态

Spring Boot整合SSM实战:酒店预订系统的核心设计与踩坑记录

发布时间:2026/10/9 8:25:53 来源:尧图企业网站定制
最近把一个基于 Spring Boot SSM 的酒店预订管理系统完整实现了一遍今天把项目里的核心设计、关键代码和踩坑经历整理出来。酒店预订这类系统挺有意思看起来像普通的商品下单实际上涉及房态管理、订单状态机、并发锁房、日期区间查询这些典型问题很适合用来练习 Spring Boot、Spring MVC、MyBatis 这套 Java 技术栈的整合能力。如果你正在做类似的管理系统或者准备用 Spring Boot 完整实现一套带前后端交互的业务项目这篇内容可以直接当参考。1. 项目整体设计与技术选型先理清为什么用 Spring Boot SSM1.1 技术栈组合背后的取舍先说结论这个项目我选了 Spring Boot 2.7 Spring MVC MyBatis MySQL Maven前端部分是 Bootstrap Thymeleaf部分接口单独返回 JSON 给页面用 axios 调用。为什么不直接用传统 SSMSpring Spring MVC MyBatis传统 SSM 的配置太磨人了光 applicationContext.xml、spring-mvc.xml、mybatis-config.xml 三个文件来回调还要处理 jar 包版本冲突。Spring Boot 最大的价值不是替代 Spring MVC 和 MyBatis而是用自动配置把这些东西组装好我只需要在 application.yml 里写数据源、MyBatis 映射路径、端口这些实际有意义的配置。为什么持久层选 MyBatis 而不是 Spring Data JPA酒店预订系统的查询大多不是简单 CRUD比如查某个日期段内哪些房间可用、统计某个月的入住率这些 SQL 逻辑用 MyBatis 写出来更直观而且 mapper XML 里可以调优 SQL。JPA 在简单 CRUD 上效率高但这种多条件的复杂检索场景最终还是得写 Query 或者 Specification写起来并不比 MyBatis 的 XML 省事。这个组合还有一层考虑很多团队的旧系统本来就是 SSMSpring Boot 可以无缝承接 MyBatis 的 mapper 和 service 层代码迁移成本很低。项目里就是直接把原有 SSM 分层搬进来entity、mapper、service、controller 结构不变只是把 XML 配置替换成了注解和自动配置肉眼可见的清爽。另外Spring Boot 默认使用 CGLIB 代理这个特性在一些需要代理 Service 的切面场景下需要注意后面部署部分我会提一嘴。1.2 功能模块划分与工程结构酒店预订系统的核心角色就两类前台用户和管理员。用户端要能注册登录、查看房型、按日期查询可用房间、提交预订、查看和取消订单。管理员端要维护房型和房间信息、处理订单、办理入住和退房、查看统计报表。这些功能拆成模块后对应到工程结构上是这样的hotel-booking/ ├── src/main/java/com/example/hotel/ │ ├── common/ // 统一返回结果、异常处理、常量 │ ├── config/ // WebMvc、拦截器、MyBatis配置 │ ├── controller/ // 用户端接口、管理端接口 │ ├── entity/ // 数据库实体类 │ ├── mapper/ // MyBatis接口 │ ├── service/ // 业务逻辑层 │ └── util/ // 日期处理、Token工具等 ├── src/main/resources/ │ ├── mapper/ // MyBatis XML文件 │ ├── static/ // 静态资源 │ ├── templates/ // Thymeleaf模板 │ └── application.yml └── pom.xml这里要特别说一下分层。很多人拿到 Spring Boot 项目就往 controller 里堆代码几行还行一旦进入订房这种多步骤业务controller 里会膨胀到没法看。我的习惯是controller 只做参数接收、调用 service、返回结果所有业务规则比如校验房间状态、计算金额、更新状态全放在 service事务注解也加在 service 层。这样做的直接好处是同一个业务逻辑可以被不同接口复用比如管理员在后台代客下单和用户自己下单调用的都是同一个 createOrder 方法只是入参来源不同。2. 数据库设计酒店预订系统的表结构、状态与索引2.1 核心表字段设计数据库设计是这个项目里最值得花时间的地方。我一开始只建了一张“订单表”结果做到入住退房就发现数据没法统计房间和订单的关系也捋不清。后来重新梳理核心表拆成四张用户表user、房型表room_type、房间表room、订单表order_info外加一张入住记录表check_in_record。之所以订单表命名用 order_info 而不是 order是因为 ORDER 是 SQL 关键字直接用 order 在 MyBatis 动态 SQL 里很容易踩坑而且很多老司机都在这吃过亏。下面是几张表的关键字段我挑重点说。用户表id、username、password、phone、role1普通用户 2管理员、status。密码字段长度至少设 60因为 BCrypt 加密后的字符串长度固定是 60设成 32 的 varchar 后面插数据直接报错。房型表id、type_name、price挂牌价、area、bed_type、max_people、description、room_num该房型的总房间数。价格我这里用的是整数以分为单位存储而不是 decimal(10,2)避免前端浮点计算出现 0.1 0.2 的问题展示时再除以 100。房间表id、room_type_id、room_no、floor、status0空闲 1入住 2维修 3脏房。房间表和房型表是多对一关系一个房型对应多个房间。订单表id、order_no、user_id、room_id、room_type_id、check_in_date、check_out_date、guest_name、guest_phone、total_amount、status、create_time、cancel_time、remark。这里冗余了 room_id 和 room_type_id有人觉得可以只留 room_id 再 join但实际查询订单列表时经常需要按房型筛选冗余一个字段能少一次 join数据量大了以后这个取舍很值。创建表的 SQL 我就不全文贴了贴一个最核心的订单表注意字段注释和索引CREATE TABLE order_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单编号, user_id BIGINT NOT NULL COMMENT 下单用户, room_id BIGINT NOT NULL COMMENT 房间ID, room_type_id BIGINT NOT NULL COMMENT 房型ID, check_in_date DATE NOT NULL COMMENT 入住日期, check_out_date DATE NOT NULL COMMENT 退房日期, guest_name VARCHAR(64) NOT NULL COMMENT 入住人姓名, guest_phone VARCHAR(20) NOT NULL COMMENT 入住人电话, total_amount INT NOT NULL COMMENT 总金额分, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已预订 2已入住 3已退房 4已取消, create_time DATETIME NOT NULL, cancel_time DATETIME DEFAULT NULL, UNIQUE KEY uk_order_no (order_no), KEY idx_room_date_status (room_id, check_in_date, check_out_date, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.2 订单状态机设计状态字段我用了 tinyint 而不是字符串因为字符串状态在业务判断时要写一堆 if BOOKED.equals(status)int 配枚举类更稳。项目里我定义了一个 OrderStatusEnumpublic enum OrderStatusEnum { UNPAID(0, 待支付), BOOKED(1, 已预订), CHECKED_IN(2, 已入住), CHECKED_OUT(3, 已退房), CANCELLED(4, 已取消); private final int code; private final String desc; // 构造方法、getter 略 }状态流转是我在 service 里严格控制断的不允许随意跳转。允许的流转路径待支付 - 已预订支付成功或后台确认待支付 - 已取消用户超时未付已预订 - 已入住到店登记已预订 - 已取消用户取消已入住 - 已退房结账离店。禁止的操作已退房不能重新变成已入住已取消不能继续办理入住。实现上我会在状态变更方法里先查旧状态再判断当前状态是否在允许列表里而不是直接 update set status 3 where id ?否则数据一旦脏了很难排查。2.3 索引设计与查询优化订单表里最重要的索引是 idx_room_date_status联合索引覆盖了“查房间在指定日期段是否被占用”这个高频查询。酒店预订系统最常见的接口就是首页查可用房如果用全表扫描房间多、订单多以后接口响应会肉眼可见地变慢。加了联合索引之后查询会走索引下推效率能快一个数量级。还有一点订单状态变化频繁但不要为了省事把 status 放在联合索引最前面。SQL 通常按 room_id 日期范围过滤再叠加 status 条件所以 room_id 放最左日期其次status 最后。这正好是 MyBatis 动态 SQL 里where标签能匹配的查询模式。3. 核心业务流程实现查询可用房间、下单、入住退房3.1 日期区间查询怎么判断房间有没有被占用判断房间在某段时间是否可用本质是判断“要预订的日期区间”和“已有订单的日期区间”是否有交集。两个区间 [checkIn, checkOut) 和 [existingCheckIn, existingCheckOut) 重叠的条件是checkIn existingCheckOut AND checkOut existingCheckIn。注意这里我统一用的是左闭右开入住当天可以安排退房当天房间释放给下一位客人。所以同一个房间同一天的退房和新客人入住是可以同时发生的不会造成房间真空。查询可用房间的 SQL 我放到了 mapper XML 里核心语句是select idselectAvailableRooms resultTypecom.example.hotel.entity.Room SELECT r.*, rt.price, rt.type_name FROM room r JOIN room_type rt ON r.room_type_id rt.id WHERE r.status 0 AND rt.id #{roomTypeId} AND r.id NOT IN ( SELECT oi.room_id FROM order_info oi WHERE oi.room_id r.id AND oi.status IN (1, 2) AND #{checkIn} lt; oi.check_out_date AND #{checkOut} gt; oi.check_in_date ) /select这里有个细节NOT IN 子查询里不要写成 oi.status ! 3 AND oi.status ! 4直接 IN (1,2) 更清楚因为只有已预订和已入住状态的订单才锁定房间待支付订单可以超时释放已取消和已退房不占用房态。SQL 里和要转义成lt;和gt;很多新手在这里报 XML 解析错误。3.2 提交预订事务与并发锁房预订下单是整个系统里最容易出并发问题的地方。两个用户同时看上了同一间房同时提交订单如果代码不处理就会出现同一房间同一天被预订两次的情况。之前我写过一个版本先查询房间是否可用再插入订单结果并发测试一压就出问题这就是典型的先查后写导致的竞态。我的解决办法是在事务里加悲观锁。注意锁一定要加在最终的房态校验处并且使用 SELECT ... FOR UPDATE锁住房间记录后再做校验和插入保证同一时刻只有一个事务能处理这间房的预订。参考实现Transactional(rollbackFor Exception.class) public OrderInfo createOrder(CreateOrderParam param) { Room room roomMapper.selectRoomForUpdate(param.getRoomId()); if (room null || room.getStatus() ! RoomStatus.AVAILABLE) { throw new BizException(房间不存在或不可预订); } int available orderMapper.countConflictOrders(param.getRoomId(), param.getCheckInDate(), param.getCheckOutDate()); if (available 0) { throw new BizException(该房间在所选日期已被预订); } // 生成订单号、计算金额、插入订单 OrderInfo order new OrderInfo(); order.setOrderNo(generateOrderNo()); order.setTotalAmount(calcAmount(param)); orderMapper.insert(order); return order; }selectRoomForUpdate 对应的 SQL 就是SELECT * FROM room WHERE id #{id} FOR UPDATE。加了这个锁之后同一间房的并发预订会被串行化虽然极端高并发下会有点性能损耗但酒店房间一天就那几十间完全够用。如果后续要走集群部署悲观锁会变成数据库行锁的瓶颈那时候再改成 Redis 分布式锁或者乐观锁版本号方案也不迟。下单后的金额计算总金额 房型单价分 × 住宿天数住宿天数 checkOut - checkIn。这里不要自己写日期减法用 LocalDate 的 Period.between 或者 ChronoUnit.DAYS.between可以避开跨月、跨年的坑。3.3 入住登记与退房结算入住办理比较简单管理员根据订单号找到订单校验订单状态是已预订把状态改成已入住同时把房间状态改成 1入住中顺手插入一条入住记录。退房时反向操作但多一步费用结算。实际酒店会有早餐费、加床费等额外项目我在退房接口里留了一个 extraAmount 参数退房结算金额 订单总额 额外消费页面展示清楚明细分项即可。退房时另一个容易踩坑的是有些订单提前退房但下单时已经按照原计划天数算好价格。如果要支持提前退房退款需要把结算逻辑改成按实际住宿天数结算。我在项目里先做的是按实际天数计算退房时用退房日期减去入住日期得到实际天数再乘以单价。这样虽然复杂一点但逻辑上更接近真实业务也给后面接支付退款留了接口。4. 登录、权限与安全设计别把管理接口裸奔4.1 登录认证方案选择这个项目里用户和管理员共用一套认证体系我用的是 JWT 拦截器。选择 JWT 而不是 Session主要考虑是接口要同时给页面和移动端用Session 在移动端场景要额外维护 Cookie而 JWT 是前端拿到 token 后放在 Authorization 头里后端无状态校验扩展起来省事。Spring Boot 里集成 JWT 只需要引入 jjwt 依赖写一个 JwtUtil 做生成和解析再写一个 HandlerInterceptor 拦截需要认证的路径。当然JWT 也不是没有代价。token 在服务端不能主动失效用户注销后 token 在有效期内依然能用。所以我在拦截器里每次请求都会去查一下用户是否被禁用如果用户状态异常直接拒绝。这个查询频率可以利用本地缓存挡一下不至于每个请求都打数据库。4.2 密码加密与角色控制密码存储绝不能明文。我用的是 Spring Security Crypto 里的 BCryptPasswordEncoder加盐哈希每次加密结果都不一样即使两个用户密码相同密文也不同。为什么要用 BCrypt因为它的计算代价可以调暴力破解成本极高MD5 那种几十毫秒算一个的算法在这个时代已经不适合存密码了。用户注册时加密登录时 matches 校验。角色控制上我用的就是最朴素的方式登录成功后把 role 字段放进 JWT 的 claims 里拦截器里判断请求路径前缀/admin/** 开头的接口要求 role 为 2否则返回 403。项目小的时候这种方式比引入 Shiro 或 Spring Security 省太多事等模块多了再考虑框架级权限控制。Controller 里也可以用注解方式做角色校验比如自定义 RequireRole(2)用 AOP 切面统一检查比在每个接口方法里写 if 判断干净。我实际项目里两种都试过最后留在代码里的是拦截器 路径规则原因是团队成员比较容易理解排查权限问题时少翻代码。4.3 参数校验与防重复提交接口参数校验我用的是 javax.validation 的注解比如 NotBlank、Email、NotNullController 方法参数上加 Valid 就能触发。这里有个小坑日期参数如果是 GET 请求通过 query string 传递Spring MVC 默认要用 DateTimeFormat(pattern yyyy-MM-dd) 才能正确绑定否则前端传 2025-06-10 会直接 400。我在全局配置里加了 Formatter 注册免去每个方法加的麻烦。防重复提交是很多人忽略的点。用户在预订页面手一抖点了两次提交按钮会出现两条相同的待支付订单而且由于前面有并发锁第二个请求可能直接报“已被预订”用户以为没成功再点一次就真的没房间了。我的处理方式前端提交后立刻禁用按钮后端在订单表加了一个业务唯一约束同一个用户在同一房间同一入住日期只能存在一条未取消的订单插入冲突时捕获 DuplicateKeyException 统一提示“请勿重复提交”。这里用数据库约束收底最可靠纯粹靠前端按钮禁用是不保险的。5. 打包部署与实战排查本地没问题一到服务器就翻车5.1 时区、中文乱码和数据库连接池这个项目部署到 Linux 服务器后遇到的第一个问题就是时间少了 8 小时。原因是 MySQL 连接串没指定 serverTimezoneJVM 和数据库各用各的时区。我的处理是连接串统一写成jdbc:mysql://localhost:3306/hotel?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai注意 serverTimezone 一定要写在连接串里不能只靠在 yml 里配置。另外建库建表时字符集统一用 utf8mb4而不是 utf8因为 utf8mb4 才能存下 emoji 和一些特殊字符。如果发现页面显示问号先在数据库执行SHOW VARIABLES LIKE character_set_server确认服务端字符集再检查连接串绝大多数问题出在连接串。数据库连接池我用的 HikariCPSpring Boot 2.x 默认就是它基本不用额外调参。但有一个参数建议显式配置maximum-pool-size小型项目 10 就够不要盲目调到几百每次数据库连接在线程里占着内存池子越大内存占用越高出了问题还不好排查。5.2 Spring Boot 版本与 MyBatis 映射细节如果你用的是 Spring Boot 3.x要注意 javax 包已经变成 jakarta网上很多教程里的import javax.persistence已经失效。我这个项目用的 Spring Boot 2.7是因为团队里一些老依赖对 3.x 适配不好。选择版本前先确认依赖兼容性比追新版本更重要。MyBatis 里还有一个高频坑是实体类属性下划线和驼峰映射。默认情况下 MyBatis 不会自动把 database columnroom_type_id映射成实体属性roomTypeId需要在 application.yml 里加mybatis: configuration: map-underscore-to-camel-case: true不加这个配置查询结果里的 roomTypeId 全是 null页面显示一片空白你还找不到原因因为 INSERT 通常不会受影响。除了这个还要给实体类写无参构造器MyBatis 底层反射创建对象依赖它。5.3 打包部署与外部化配置打包我是用 Maven 的 spring-boot-maven-plugin执行 mvn clean package 后生成一个可执行的 fat jar然后直接扔到服务器上跑java -jar hotel-1.0.0.jar --spring.profiles.activeprod生产环境的数据源和开发环境不一样我在 resources 下放了 application-dev.yml 和 application-prod.yml用 profiles 切换。还有一个操作细节不要在 jar 包旁边放同样的配置文件去覆盖考虑使用--spring.config.additional-location/opt/hotel/config/指定外部配置目录这样改配置不用重新打包 jar运维同学会感谢你的。5.4 接口性能优化Redis 缓存和分页项目上线后首页的房型列表和可用房查询压力最大我顺手做了一层缓存。用 Redis 存热门房型最近 5 分钟的可用房间 ID 列表键比如hotel:available:roomTypeId:start:end过期时间 300 秒。这样做有个问题有新订单进来后缓存不会立刻失效可能 5 分钟内订房会显示房源不足或多余。业务上可以接受因为酒店不会在 5 分钟内有巨大波动但如果要做严格实时可以在下单事务提交后主动删除相关缓存的键。列表查询我用的是 PageHelperService 层查询前设置分页参数返回 PageInfo 给前端前端分页组件直接渲染。注意分页参数要做上限校验防止有人传 pageSize10000 把数据库打爆。6. 扩展方向与代码层复盘酒店预订系统还能怎么长6.1 多酒店门店与批量预订这个项目目前是单门店模型。如果要做连锁酒店最小改动是在所有核心表上加 hotel_id 字段查询时统一带上这个条件数据库索引也要改成 (hotel_id, room_id, date)。批量预订则是另一个典型需求团队订房一次要订 5 间这时候就不能逐间下单否则部分成功部分失败很难处理。实现上可以把批量预订放在一个事务里循环锁房任何一间失败就整体回滚同时提示用户具体哪一间没房。6.2 对接支付与消息通知预订系统要真正跑起来支付是绕不开的。对接支付网关后订单状态就会增加“支付回调待确认”这种中间态回调接口要处理重复通知和幂等数据库订单表需要加支付流水号字段。另外预订成功后的短信通知可以用消息队列来做避免短信服务超时拖垮主流程。Spring Boot 整合 RabbitMQ 的代码量并不大核心思路是预订事务提交后发送消息消费者调用短信服务失败重试三次后进入死信队列人工补偿。6.3 代码层面值得重构的点复盘这个项目最值得重构的是状态变更逻辑。现在每个状态流转都散在 service 不同方法里订单状态多了以后容易漏判。我推荐用状态机模式或者策略模式重写定义一张流转表从当前状态 事件找到目标状态和处理器新增状态时只需要加处理器类不需要改动原有方法。这么做前期会显得“过度设计”但订单状态一旦超过 5 个收益就很明显。说实话这个项目做完之后我最大的感受是Spring Boot SSM 这套技术栈真的不难难的是数据库设计和业务状态流转的合理性。房间就那么多订单状态就那几个但把并发、日期、状态串起来之后里面全是细节。如果你正在做类似的管理系统我的建议是先别急着写代码把表结构和状态流转理清楚然后从查询可用房间和下单这两个核心接口开始写剩下的功能都是在这个骨架上长出来的。等你能把订单状态流转画明白的时候这个项目你就吃透了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑