简介:基于SpringBoot的酒店客房预定管理系统,同时整合了JavaWeb酒店官网源码,是一套面向毕业设计、课程项目与前后端初学者的完整项目,覆盖管理员后台和官网展示两类场景。系统功能涵盖用户登录注册、角色管理、菜单管理、客房管理、库存管理、客房类型管理、连锁管理等模块,可帮助读者理解权限控制、数据关联与业务分层思想。压缩包共555个文件,大小约7.11MB,主要包含140个Java后端源码、139个JavaScript脚本、46个HTML页面、37个PNG图片、36个CSS样式及20个XML配置,另有数据库SQL脚本和Spring Boot的yml配置文件,目录结构清晰,便于按功能定位代码。前台基于Bootstrap等样式框架搭建,适合直接运行或二次修改;目前已有1917人学习下载,借助完整源码可快速获得可运行的系统雏形,学习Spring Boot与JavaWeb页面资源的整合方式,并参考客房库存联动、角色权限分配等业务实现,用于个人课设或毕业设计。
1. 这套系统到底在解决什么问题
把「SpringBoot 酒店客房预定管理系统 + javaweb 酒店官网」这个标题拆开看,它其实是一个很典型的「管理系统 + 门户展示」双体项目:一端是给酒店前台和运营用的后台管理,管房型、管房价、管订单状态;另一端是给住客看的官网,展示客房图片、价格、房态,并支持在线下单。这种结构在 Java 就业和毕设场景里出现频率极高,原因是它把 Web 开发的核心知识一次都覆盖了——表结构设计、会话管理、权限拦截、订单状态流转、模板渲染,一个不落。
这类项目的难度不在 CRUD(增删改查),而在两个点:一是订单状态机怎么设计才不会被业务带乱,二是官网查询房态时如何避免同一间房被重复预订。前者考验对业务的理解,后者考验对数据库并发控制的理解。SpringBoot 在这里的价值是帮你把工程骨架搭好,让你能把精力放在业务边界上;如果你拿到手的是一份可以直接导入的源码并配了 .sql 数据库脚本,那要做的第一件事也不是急着跑起来,而是先看懂订单表和房态表的关系。这篇文章就按「跑通 → 建模 → 双模块 → 排错」的顺序,把它讲透。
2. 先用最小代价把 SpringBoot 项目跑通并确认源码结构
2.1 技术栈选型:为什么这个组合是行业里最稳的
拿到标题中的项目,常见的版本组合是 SpringBoot 2.7.x + MyBatis-Plus 3.5.x + MySQL 5.7 或 8.0 + Thymeleaf,构建工具为 Maven 3.6+,JDK 用 8 或 11。这组选型在「javaweb项目完整案例 mysql」的检索结果里几乎是标准答案,原因是它的兼容性边界清楚:SpringBoot 2.7 是 3.x 之前的最后一个稳定大版本,MyBatis-Plus 自动填充和分页插件在这套组合下不需要额外适配。如果你拿到的是更老的 version,比如 SpringBoot 1.5 或 2.1,也不要急着升,先看 pom.xml 里有没有 druid 和 mybatis-plus 的版本声明,改版本的成本往往比迁移代码高。
一个很多人忽略的问题是项目里同时存在「官网」和「管理系统」两个面,这意味着模板文件会被拆到两个目录:官网页面是用户直接访问的,管理系统则要判断登录状态。于是 Thymeleaf 的拦截器配置就成了第一个技术分水岭——要不要排除静态资源?后台路径前缀统一为 /admin 还是 /manager?这些在跑通之前就得在配置文件里确认。另外我一般会先看三层结构有没有按 controller / service / mapper 分包,如果是这种结构,后面改业务点会省很多事;如果所有逻辑堆在 controller 里,建议在跑通后自行重构成三层,别急着写新功能。
2.2 修改 springboot 配置:数据源、端口和 MyBatis-Plus 参数
拿到源码后第一个入口就是 src/main/resources/application.yml,它决定了项目能不能连上你的本地数据库。一个典型的配置长下面这样,注意注释里的几个关键参数:
server: port: 8080 servlet: context-path: / spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hotel_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 type: com.alibaba.druid.pool.DruidDataSource thymeleaf: prefix: classpath:/templates/ suffix: .html cache: false mybatis-plus: mapper-locations: classpath:/mapper/**/*.xml type-aliases-package: com.example.hotel.entity configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里每个参数都有实际意义。url中serverTimezone=Asia/Shanghai是排查 MySQL 8.x 报时区错误的关键;allowPublicKeyRetrieval=true解决 MySQL 8 的 caching_sha2_password 认证连不上问题,这两个都是新手最容易卡住的地方。configuration下的StdOutImpl负责把每条 SQL 打印到控制台,排错时不要关掉它。logic-delete-field是 MyBatis-Plus 的逻辑删除字段,一旦打开,所有 update 和 select 都会被自动追加deleted=0条件,如果你的表没这个字段,记得注释掉这一段,否则启动时建 SQL 会挂。
2.3 导入数据库脚本并核对核心表
项目根目录下一般会有 hotel.sql 或 db.sql,用 Navicat 或命令行执行前,先建一个空库,再导入,避免脚本里的CREATE DATABASE和你现有编码冲突。导入后别急着启动,先核对这几张核心表存不存在:admin(后台管理员)、member(前台注册用户)、room_type(房型)、room(具体房间)、reserve(预订订单)、comment(评价)。这六张表是酒店类 javaweb 项目的骨架,缺了哪张都说明脚本不完整。
表关系上,room表和room_type是多对一,room表和reserve是一对多,member和reserve也是一对多。最值得确认的是reserve表里有没有room_id、member_id、start_date、end_date、state这五个字段,它们是后面预订流程的根基。如果只有订单号没有state状态字段,说明这份源码的订单管理是简化版,后续加「入住/退房」流程时要自己补。跑通项目的技术顺序是先数据库后配置再启动,任何一步提前都会浪费时间。
3. 预订核心链路的数据建模与状态机设计
3.1 客房表和订单表怎么建才不返工
酒店预订业务里,房型和房间必须拆成两张表。房型表存「大床房、双床房、行政套房」的描述性信息,房间表存具体的「801、802」,这样设计的好处是查询状态时可以精确到具体某一间房,也方便后期给同一房型的不同房间差异定价。下面是 room 和 reserve 表常见建表语句,我在字段命名上保留了业务语义,Ctrl+C 前先看注释:
CREATE TABLE `room` ( `id` int NOT NULL AUTO_INCREMENT, `room_no` varchar(10) NOT NULL COMMENT '房间号,如 801', `room_type_id` int NOT NULL COMMENT '关联房型表', `floor` int DEFAULT NULL COMMENT '所在楼层', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0空闲 1入住 2打扫 3维修', `deleted` tinyint NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `uk_room_no` (`room_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物理房间表'; CREATE TABLE `reserve` ( `id` int NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `member_id` int NOT NULL COMMENT '下单会员', `room_id` int NOT NULL COMMENT '预定的房间', `start_date` date NOT NULL, `end_date` date NOT NULL, `total_price` decimal(10,2) NOT NULL, `state` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1已确认 2已入住 3已退房 4已取消', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_room_date` (`room_id`,`start_date`,`end_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预订订单表';注意reserve表上的联合索引idx_room_date,它直接服务「查某间房在某个日期区间是否被占」这个高频查询。别小看这条索引,数据量上去后,没有它全表扫描会拖垮官网的房态查询接口。status用 tinyint 而不是 varchar 存中文,是为了排序、统计和程序判断时少写字符串匹配逻辑。
3.2 订单状态机:不画状态图也能说清的状态流转
订单表里state字段有五个值,这五个值之间的流转约束,比字段本身更重要。规则如下:新建订单为 0(待支付),支付成功后为 1(已确认),办理入住后为 2(已入住),退房后为 3(已退房)。0 和 1 状态下的未入住订单可以被取消,但 2 和 3 不能。取消后如果房间仍处于可订状态,其他用户可以直接预订。
| 当前状态 | 可执行操作 | 目标状态 | 触发条件 |
|---|---|---|---|
| 0 待支付 | 取消 | 4 已取消 | 用户主动取消或超时未支付 |
| 0 待支付 | 支付 | 1 已确认 | 支付回调成功 |
| 1 已确认 | 取消 | 4 已取消 | 入住前可取消 |
| 1 已确认 | 入住 | 2 已入住 | 前台办理入住 |
| 2 已入住 | 退房 | 3 已退房 | 前台结算离店 |
最常见的源码缺陷是把state当字符串用,在 controller 里直接if (order.getState() == 0)这种写法看着没毛病,但扩展新状态时要改的判断点太多。我一般会用一个常量类或枚举维护这些状态值,比如OrderState.PAID,这样状态机里每次转换只需要判断一次前置条件,后续加「已退款」状态时只要再加一个常量和一个转换分支。对于毕设或小型管理系统,用枚举足够;如果将来拆微服务,再考虑引入状态机框架也不迟。
3.3 预订 Service:事务、锁与并发边界
预订房间的核心动作包含三步:校验房间在目标日期区间可订、生成订单、扣减或锁定库存。这三步必须在一个事务里完成,解决「同一间房被两个人同时下单」的问题。MySQL InnoDB 下常见的处理方式是在事务内用SELECT ... FOR UPDATE锁定房间行,但要注意锁的是room行而不是订单表,否则两个不同房间的预订会互相阻塞。一个简化版代码示例如下:
@Transactional(rollbackFor = Exception.class) public ReserveOrder createReserve(ReserveRequest req) { // 1. 锁定房型下某个空闲房间,防止并发下同一间房被重复预订 Room room = roomMapper.selectFreeRoomForUpdate(req.getRoomTypeId(), req.getStartDate(), req.getEndDate()); if (room == null) { throw new BusinessException("该时段无空闲房间"); } // 2. 生成订单并计算金额 String orderNo = "H" + System.currentTimeMillis(); ReserveOrder order = new ReserveOrder(); order.setOrderNo(orderNo); order.setRoomId(room.getId()); order.setStartDate(req.getStartDate()); order.setEndDate(req.getEndDate()); order.setTotalPrice(calcPrice(room.getPrice(), req.getStartDate(), req.getEndDate())); order.setState(OrderState.UNPAID); reserveMapper.insert(order); // 3. 房间状态置为“打扫”或“占用”,占用期不再参与空闲查询 roomMapper.updateStatus(room.getId(), RoomStatus.OCCUPIED); return order; }这段代码里最关键的是selectFreeRoomForUpdate这条 SQL,它的底层是SELECT r.* FROM room r WHERE r.status = 0 AND r.id NOT IN (SELECT rr.room_id FROM reserve rr WHERE rr.start_date < #{endDate} AND rr.end_date > #{startDate} AND rr.state IN (0,1)) LIMIT 1 FOR UPDATE。括号里日期交叉条件的语义是「旧订单的开始日期早于新订单结束日期,且旧订单结束日期晚于新订单开始日期」,这比用BETWEEN判断更准确,覆盖了入住离店日期相切的边界。参数startDate和endDate建议用java.time.LocalDate类型接收,避免java.util.Date带时分秒导致日期区间判断差一天。
3.4 金额与日期的边界:BigDecimal 和跨月计算
价格计算是 Java 基础里最常翻车的地方,double算房费会出现 0.1 累加精度问题,所以total_price字段和 Java 侧金额全部用BigDecimal。按晚数算钱时,晚数 =endDate.toEpochDay() - startDate.toEpochDay(),这个差值天然忽略时分秒,比用毫秒差除以 86400000 安全。跨月、跨年、闰年都无特殊处理,因为toEpochDay是自 1970-01-01 起的天数结果。
还有一个业务细节容易踩:订单取消后要不要释放房间状态。如果取消时把room.status改回 0(空闲)而原订单的state变为 4(已取消),要注意原订单记录依然存在,下次查询可订房间时NOT IN条件里的state IN (0,1)已经把已取消订单排除了,所以查房态时不会把取消的日期也算成占用。这就是状态机和房间状态双写的一致性难点——房间状态是物理占用,订单状态是业务状态,两者通过「订单有效且日期交叉」来关联,取消时只改订单状态、房间状态通过查询条件自动释放,这个方案比取消时手动改房间字段更不容易错。
4. 官网展示与后台管理的双模块落地
4.1 会话管理和登录拦截:一个拦截器管前后台
双模块项目里,官网页面和管理系统页面往往共用同一个 SpringBoot 应用,区别只在路径前缀和是否需要登录。我在工程里习惯把后台接口统一挂在/admin/**下,官网及房源查询挂在/room/**和/下,然后用一个HandlerInterceptor做白名单式拦截。白名单里放登录页、注册页、官网首页、静态资源(css/js/images),其余/admin/**全部要求 session 中存在登录用户。实现方式很直接:
@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 后台管理路径强制校验管理员会话 if (request.getRequestURI().startsWith("/admin/")) { Object admin = request.getSession().getAttribute("adminUser"); if (admin == null) { response.sendRedirect("/admin/login"); return false; } } return true; } }注册拦截器时要配addPathPatterns("/**")和excludePathPatterns里的静态资源路径。Thymeleaf 页面中静态资源经常用th:href="@{/css/style.css}"这种语法,如果拦截器放行配置漏了/css/**、/js/**、/images/**,表现出的症状就是页面上的样式全部丢失,而且只在登录后才出现,排查起来容易怀疑人生。官网首页若允许游客访问,也要在排除列表里显式加"/"和"/room/list",否则会把你自己的拦截器卡在登录页死循环里。
4.2 后台管理系统核心查询:统计报表与房态一览
管理系统里最有含金量的页面不是表单页,而是统计页。一个合格的后台至少要能回答三个问题:本月卖了多少间夜、各房型的入住率多少、当前哪些房间空闲。对应三条 SQL 你能直接抄进 Mapper 的 XML 里:
-- 按月统计营收:只统计已支付和已入住的订单,已取消的不算钱 SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, SUM(total_price) AS revenue FROM reserve WHERE state IN (1, 2, 3) GROUP BY DATE_FORMAT(create_time, '%Y-%m') ORDER BY month DESC; -- 房型入住率:已确认/已入住订单的间夜数 / 所有房间可售间夜数 SELECT rt.name AS typeName, COUNT(DISTINCT rr.id) AS soldRoomNights FROM room_type rt LEFT JOIN room r ON r.room_type_id = rt.id LEFT JOIN reserve rr ON rr.room_id = r.id AND rr.start_date < #{endDate} AND rr.end_date > #{startDate} AND rr.state IN (1, 2) GROUP BY rt.id; -- 当前可用房间列表:空闲且未来 3 天无有效订单 SELECT r.id, r.room_no, r.floor FROM room r WHERE r.status = 0 AND NOT EXISTS ( SELECT 1 FROM reserve rr WHERE rr.room_id = r.id AND rr.start_date < DATE_ADD(CURDATE(), INTERVAL 3 DAY) AND rr.end_date > CURDATE() AND rr.state IN (0, 1) );第一条 SQL 的DATE_FORMAT是按月分组的常见做法,注意它依赖create_time的索引,数据量大时按月查询建议加create_time条件范围;第二条用于房态热力分布,LEFT JOIN保证没有订单的房型也能显示 0;第三条的NOT EXISTS在 MySQL 优化器里往往会转成子查询物化,配合reserve表的联合索引性能不错。报表里的每个数字都要回到明细,所以这些查询的 Mapper 方法名我一般会叫selectRevenueByMonth、selectRoomOccupancy,和页面上的中文名一一对应,方便后续接 ECharts 图表时定位数据源。
4.3 官网房态查询:日期参数要从前端传后端正确处理
官网的核心搜索场景是用户输入入住时间和离店时间,查询有哪些房型可订。这里最容易踩的坑是前端传了字符串日期,后端用@DateTimeFormat解析失败导致接口直接 400。用 LocalDate 接收日期参数时要加注解:
@RequestMapping("/room/search") public String search(@RequestParam("start") @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate start, @RequestParam("end") @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end, Model model) { // 日期非法或入住日期晚于离店日期时,直接返回官网首页并提示 if (start == null || end == null || !start.isBefore(end)) { return "redirect:/?error=bad_date"; } List<RoomTypeVO> available = roomTypeMapper.selectAvailableTypes(start, end); model.addAttribute("types", available); model.addAttribute("start", start.toString()); model.addAttribute("end", end.toString()); return "search_result"; }Controller 里Model往 Thymeleaf 模板塞数据时,日期一律转成字符串再塞,避免模板层出现时区偏移。前端表单提交的是yyyy-MM-dd,后端LocalDate不会带时分秒,拼 SQL 时用#{startDate}和#{endDate}参数化查询,杜绝字符串拼 SQL 引发的注入风险。返回页面search_result.html里用th:each遍历types列表展示房型卡片,每个卡片上的价格要用th:text="${#numbers.formatDecimal(price,1,2)}"格式化,这个 Thymeleaf 内置的numbers工具类是官网页面显示金额最省事的方法。
5. 打包部署与预订链路验证的细节技巧
5.1 用 Maven 打包并在服务器上部署运行
本地开发跑mvn spring-boot:run即可,要部署到 Linux 服务器时,先执行mvn clean package -DskipTests,然后在 target 目录拿到 jar 包。SpringBoot 内嵌 Tomcat,所以不需要额外装 Tomcat,直接运行方式如下:
mvn clean package -DskipTests nohup java -jar target/hotel-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod > hotel.log 2>&1 &nohup和&配合让进程脱离终端后台运行,> hotel.log重定向日志输出,排错时tail -f hotel.log实时看日志。若当前机器装的是高版本 JDK(17 及以上),而项目是 SpringBoot 2.x,启动有可能报IllegalArgumentException或反射相关错误,因为 Spring Framework 5.x 对高版本 JDK 的兼容有限,建议装 JDK 8 或 11 再运行,这是「springboot版本太高」这个热词背后最常见的真实原因。
5.2 三个必查的启动报错与对应处理
| 报错关键字 | 原因 | 处理手段 |
|---|---|---|
Access denied for user | 数据库账号密码错误或远端未授权 | 检查application.yml的username/password,MySQL 里执行GRANT ALL PRIVILEGES ON hotel_db.* TO 'root'@'%' |
Unknown database 'hotel_db' | 数据库未创建 | 先执行CREATE DATABASE hotel_db DEFAULT CHARACTER SET utf8mb4再导入 .sql |
Port 8080 was already in use | 端口被占用 | lsof -i:8080或netstat -ano找出进程杀掉,或改server.port为 8081 |
启动成功后,控制台会打印Tomcat started on port(s): 8080,这时先访问官网首页确认静态资源加载,再访问/admin/login确认登录页能出来。若页面能打开但样式全乱,回到拦截器配置检查静态资源是否被排除。
5.3 手把手验证完整预订链路
要确认整个系统没有隐藏问题,我建议按「注册→搜索→下单→支付→后台确认→入住→退房」的完整链路走一遍,过程里重点看三项结果:第一,官网搜索到的房间数和下单后台实际可订的房间数是否一致;第二,会员端订单列表的状态变化是否和前面状态机表格一致;第三,后台「当月营收」的 SUM 是否等于各订单金额之和。用两个浏览器账号同时抢同一间房,验证是否会有一个被拒绝,这是测试并发预订最粗暴也最有效的方法。
这套验证方法不只针对这份源码,而是所有 SpringBoot 酒店管理类项目通用的验收基线。跑完这条链路,你才算真正掌握这个项目从数据表到页面渲染的完整逻辑,后续无论改成民宿短租还是会议室预约,换的都是业务对象,技术骨架不会变。
本文还有配套的精品资源,点击获取