☰
Spring Boot + Vue.js 酒店客房管理系统:从架构设计到前后端联调实践
2026/10/20 11:36:50 网站建设 项目流程

简介:这是一套基于Vue.js和SpringBoot的酒店客房管理系统,面向高校毕业设计、Java全栈开发学习者及需要快速搭建后台管理系统的开发者。系统分管理后台与用户网页端,支持管理员和会员角色,覆盖会员管理、清洁工管理、客房管理、用户预约与入住等核心模块,可有效支撑酒店预订与客房管理场景下的业务流程。压缩包共1788个文件,包含354个Java源码、120个Vue前端组件、306个JavaScript脚本,以及SQL、XML等配置资源,整体39.36MB,目录结构清晰,便于按前后端模块检索和学习。资源附带B站项目录屏、启动教程及讲解视频,目前已有92人学习。借助这些材料,读者可以快速掌握系统启动流程、业务模块设计与代码结构,同时借鉴其分层架构和接口实现方式,用于课程设计或实际项目二次开发。

1. 酒店客房管理系统:从一张纸质房态表引发的翻车现场说起

某酒店前台靠一张纸质房态表管理客房,客人退房后忘了更新状态,半小时后新客人点名要同一间房,前台坚持说房间没退,两拨客人站在前台僵持了二十多分钟。这个场景不是段子,而是很多小酒店每天都在发生的翻车现场。这套酒店客房管理系统正是为解决这类问题而生:后端用 Java 写 Spring Boot 接口,前端用 Vue.js 做操作界面,数据落在 MySQL,把房态查询、预订、入住、退房、订单管理全部流程从纸质记录搬成 Web 操作。它适合两类人:想完整跑通前后端分离全栈项目的新手,以及需要一套可改造的酒店管理工程用来做课程设计或内部系统底座的开发者。

2. 整体架构与技术选型:Spring Boot 做引擎、Vue.js 做门面、MySQL 做仓库

2.1 技术栈全景:三层职责怎么划分

这个系统是典型的“前后端分离 + 关系型数据库”结构,三个技术栈各管一段,谁也不抢谁的活。后端跑在 Spring Boot 上,职责是接收 HTTP 请求、做参数校验、跑业务逻辑、操作数据库,最后把结果序列化成 JSON 返回;前端是 Vue.js 的单页应用,负责房态卡片展示、预订表单、订单列表这些交互渲染,数据全靠 axios 向后端要;MySQL 在最底层存业务数据,不参与任何逻辑判断。

我一般会提醒第一次接触这套组合的开发者:不要被“全栈”两个字吓住,这个项目里前后端没有共享任何代码,唯一约定就是接口文档。前端不碰 SQL,后端不拼 HTML,双方只认 JSON 结构体和 HTTP 状态码。拿这个项目的“查可订房”功能举例,前端只需要传两个日期参数,后端判断哪些房间没被订单占用,这个过程前端完全不需要知道。

技术选型上,Spring Boot 的价值在于自动装配和内嵌 Tomcat,打包成 jar 丢服务器上一条命令就能跑;Vue.js 的价值在于组件化开发和响应式数据绑定,房态变更时页面不用刷新就能更新;MySQL 则是稳定且容易找资料的关系型数据库,配合 MyBatis 写 SQL 足够灵活。这套组合在课程设计和中小型企业内部系统里出现频率极高,跑通一个就能触类旁通。

2.2 后端工程目录:Spring Boot 的分层结构拆解

下载解压后你会看到两个主目录,一个是后端工程,一个是前端工程。后端是标准的 Spring Boot 分层架构,大致分包方式如下:

com.hotel ├── controller # 控制层:接收请求、参数校验、返回响应 │ ├── RoomController.java │ ├── OrderController.java │ ├── UserController.java │ └── AuthController.java ├── service # 业务层:订房逻辑、日期冲突判断、状态校验 │ ├── RoomService.java │ ├── OrderService.java │ └── UserService.java ├── mapper # 持久层:MyBatis 的 Mapper 接口 │ ├── RoomMapper.java │ ├── OrderMapper.java │ └── UserMapper.java ├── entity # 实体类:Room, Order, User, RoomType ├── config # CORS 跨域配置、登录拦截器 └── common # 统一返回体 Result 和全局异常处理

熟悉这套结构之后,加功能就按路线走:实体类加字段 → Mapper 加方法 → Service 加业务判断 → Controller 加接口。Controller 层不要写业务逻辑,也别把 SQL 拼在 Controller 里,这是新手最容易走歪的地方。这个项目里 Controller 普遍很薄,拿到参数直接交给 Service,返回结果包一层Result.success(data)或Result.error(msg),前端只看code字段就知道请求成没成。

前端目录按 Vue 常规组织:src/views放页面级组件,src/components放房态卡片、订单表格这类复用组件,src/api放 axios 封装和后端接口方法,src/router管理路由。大多数下载包的前端工程已经配置好了开发代理,Vue 开发服务器会把/api开头的请求转发到localhost:8080,省掉了开发期的跨域烦恼。生产环境部署时,由 Nginx 做反向代理把两个服务拼到同一个域名下。

2.3 数据库设计:MySQL 里四张核心表怎么关联

酒店客房管理系统的核心数据逃不出四张表:room_type房型表、room客房表、orders订单表、user用户表。这里有个关键设计,“房型”和“客房”分开存:房型表管价格、床型、面积这类共享属性,客房表管房号、楼层、位置和当前状态。

这样拆的好处是调价方便。比如标准间的价格要从 298 调到 328,只要改room_type表里一行数据,所有标准间客房自动生效,不用逐间房去更新。orders表是整张关系网的中心,存了房间 ID、用户 ID、入住日期、离店日期和订单状态,把客户、房间和时间段串在一起。

一段简化的建表 SQL 长这样:

CREATE TABLE room_type ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30) NOT NULL, price DECIMAL(10,2) NOT NULL, bed_type VARCHAR(20), area INT ); CREATE TABLE room ( id INT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(10) NOT NULL, floor INT, room_type_id INT, status TINYINT DEFAULT 0, -- 0空闲 1已订 2入住 3脏房 FOREIGN KEY (room_type_id) REFERENCES room_type(id) ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, room_id INT, user_id INT, check_in_date DATE, check_out_date DATE, status TINYINT DEFAULT 0, -- 0待入住 1已入住 2已完成 3已取消 create_time DATETIME, FOREIGN KEY (room_id) REFERENCES room(id), FOREIGN KEY (user_id) REFERENCES user(id) );

参数说明:room.status用 TINYINT 存数字状态,0 表示空闲,1 表示已被预订,2 表示客人已入住,3 表示脏房待保洁。orders.status同理,0 待入住、1 已入住、2 已完成、3 已取消。重点理解一点:房态不是随便改的,它由订单状态驱动——客人下单后房间变“已订”,办入住后房间变“入住”,退房后房间变“脏房”,保洁确认后才回到“空闲”。这四张表加上这个状态联动规则,就是整个系统的骨架。

3. 核心模块实现:从查房请求到订单落库的完整链路

3.1 后端接口:一个“查可订房”请求的三层链路

用“查询可订房列表”这个高频功能来拆解后端实现。前端发起GET /api/room/available?checkIn=2026-05-20&checkOut=2026-05-22,后端先由 Controller 接收请求参数,再交给 Service 处理业务判断,最后 Mapper 执行 SQL 返回结果。

Controller 层代码:

@RestController @RequestMapping("/api/room") public class RoomController { @Autowired private RoomService roomService; @GetMapping("/available") public Result getAvailableRooms(@RequestParam String checkIn, @RequestParam String checkOut) { List<RoomVO> rooms = roomService.findAvailableRooms(checkIn, checkOut); return Result.success(rooms); } }

逻辑说明:@RestController注解让返回值自动序列化成 JSON,前端拿到的是一个包含 code、msg、data 三个字段的标准响应体,前端判断 code 为 0 才展示数据。@RequestParam绑定 URL 上的两个查询参数,checkIn和checkOut是客人计划入住和离店的日期,后面所有“哪些房间能订”的判断都围绕这两个日期展开。

Service 层是业务逻辑最集中的地方:

@Service public class RoomServiceImpl implements RoomService { @Autowired private RoomMapper roomMapper; @Autowired private OrderMapper orderMapper; @Override public List<RoomVO> findAvailableRooms(String checkIn, String checkOut) { // 1. 查出所有在入住区间内时间冲突的订单 List<Integer> occupiedRoomIds = orderMapper.selectOccupiedRoomIds(checkIn, checkOut); // 2. 房间类型关联房型表,排除已占用房间后返回 if (occupiedRoomIds.isEmpty()) { return roomMapper.selectAllWithType(); } return roomMapper.selectAvailableRooms(occupiedRoomIds); } }

逻辑说明:查可订房不能只看房间当前状态,因为可能某间房现在是空的,但客人已经预订了后天的入住。所以核心是查订单表里时间段重叠的记录——已有订单的入住日期早于本次查询的离店日期,且已有订单的离店日期晚于本次查询的入住日期,这两个条件同时成立就说明时间段有交集,房间不可订。selectOccupiedRoomIds先把冲突房间的 ID 查出来,再排除掉返回剩余房间。

Mapper 接口和对应 XML 如下:

@Mapper public interface OrderMapper { List<Integer> selectOccupiedRoomIds(@Param("checkIn") String checkIn, @Param("checkOut") String checkOut); }
<select id="selectOccupiedRoomIds" resultType="java.lang.Integer"> SELECT DISTINCT room_id FROM orders WHERE status IN (0, 1) AND check_in_date &lt; #{checkOut} AND check_out_date &gt; #{checkIn} </select>

参数说明:status IN (0, 1)只查“待入住”和“已入住”状态的订单,已取消和已完成的订单不参与占用判断。check_in_date < #{checkOut}和check_out_date > #{checkIn}这两个条件合起来判断两个时间段是否重叠——只要有一个条件不成立,说明这个订单和本次查询的日期不冲突。这里有个必须知道的细节:MyBatis 的 XML 文件里直接写<会被当成标签解析报错,所以要用&lt;和&gt;转义,这是新手常踩的坑。

3.2 前端页面:Vue.js 组件如何对接接口数据

前端在“查询可订房”页面里,核心动作是调接口、拿数据、渲染卡片。先看src/api/room.js里的接口封装:

import axios from 'axios' export function getAvailableRooms(checkIn, checkOut) { return axios.get('/api/room/available', { params: { checkIn, checkOut } }) }

逻辑说明:把所有 axios 请求集中放到src/api目录下,页面组件里只引用方法名,不直接拼 URL。这样做的好处是后端接口路径变了,只需要改一个文件。params里的字段名要和后端@RequestParam的参数名严格一致,大小写都不能差,否则后端拿不到参数会直接抛异常。

页面对应的 Vue 组件:

<template> <div class="room-list"> <el-card v-for="room in rooms" :key="room.id" class="room-card"> <h4>{{ room.typeName }} - {{ room.roomNo }}</h4> <p>楼层:{{ room.floor }} · 价格:¥{{ room.price }} / 晚</p> <el-button type="primary" @click="handleBook(room)"> 立即预订 </el-button> </el-card> </div> </template> <script> import { getAvailableRooms } from '@/api/room' export default { data() { return { checkIn: '', checkOut: '', rooms: [] } }, methods: { async fetchRooms() { const res = await getAvailableRooms(this.checkIn, this.checkOut) if (res.data.code === 0) { this.rooms = res.data.data } else { this.$message.error(res.data.msg) } }, handleBook(room) { this.$router.push({ path: '/book', query: { roomId: room.id } }) } } } </script>

逻辑说明:el-card和el-button来自 Element UI 组件库,v-for循环渲染可订房卡片。fetchRooms方法在点击查询按钮或页面初始化时触发,请求成功后把数据赋值给rooms数组,Vue 的响应式系统会自动更新 DOM,不需要手动操作节点。请求失败时用$message.error弹出后端返回的错误信息,而不是让页面白屏无反馈。handleBook跳转到订单确认页,通过query把所选房间的 ID 带过去,下单页就能直接拿到房间信息。

3.3 订单状态机:预订、入住、退房的流转规则

订单状态机是这套系统的业务骨架,操作、状态变更和接口的对应关系如下:

操作订单状态房间状态触发接口
提交预订0 待入住1 已订POST /api/order
前台办入住1 已入住2 入住PUT /api/order/{id}/checkin
退房结账2 已完成3 脏房PUT /api/order/{id}/checkout
保洁确认状态不变0 空闲PUT /api/room/{id}/clean
取消订单3 已取消回退空闲DELETE /api/order/{id}

退房接口的实现是状态机中比较典型的一段:

@Transactional public void checkOut(Integer orderId) { Order order = orderMapper.selectById(orderId); if (order.getStatus() != 1) { throw new BizException("当前订单状态不可退房"); } // 订单状态改为已完成 order.setStatus(2); orderMapper.updateStatus(order); // 房间状态改为脏房,等待保洁 roomMapper.updateStatus(order.getRoomId(), 3); }

逻辑说明:退房涉及两个数据表的更新——订单表状态改为已完成、房间状态改为脏房。这两个动作必须保证原子性,所以方法上加@Transactional注解,中间任何一步抛异常都会整体回滚,不会出现“订单退了但房间还显示入住中”的脏数据。这里的order.getStatus() != 1是一道防重防护,防止用户在前端连续点击两次退房按钮导致状态错乱。这类状态校验在入住、退房、取消订单三个接口里都应该有,否则接口一旦被直接调用就会把数据打乱。

4. 运行与避坑:从零跑通这套系统的排查记录

4.1 先把系统跑起来:后端与前端各自需要的操作

第一次拿这套系统,先把两个工程跑起来再谈改代码。后端是 Spring Boot 工程,先用 Maven 命令mvn clean package -DskipTests打包,或者直接用 IDE 运行主类。启动成功后控制台会打印内嵌 Tomcat 的端口号,默认是 8080,看到Started字样就说明后端起来了。

前端是 Vue.js 工程,进入前端目录后先npm install安装依赖,然后npm run serve启动开发服务器。默认端口通常是 8081,启动成功后浏览器访问http://localhost:8081就能看到登录页。如果前端配置了代理,开发服务器会自动把/api请求转发到后端 8080 端口,不需要额外处理跨域。

数据库需要先建好并导入项目里带的hotel.sql脚本,然后在后端application.yml里确认数据库连接配置正确。下面是我常用的配置参考:

spring: datasource: url: jdbc:mysql://localhost:3306/hotel_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver

参数说明:serverTimezone=Asia/Shanghai指定时区,不配的话 MySQL 8.x 会报时区错误;allowPublicKeyRetrieval=true解决 MySQL 8 的 caching_sha2_password 认证插件导致的公钥获取问题;useSSL=false是在本地开发环境关掉 SSL 加密连接,避免证书校验干扰。

4.2 跨域请求拦截:前端一切正常但接口全部超时

现象:前端页面能打开,登录接口一调用就报CORS错误,浏览器控制台提示跨域请求被拦截,后端日志里根本没有接收到这条请求。

原因:浏览器同源策略拦截了跨端口请求。前端跑在 8081,后端跑在 8080,端口不同就属于跨域。后端接口没有返回Access-Control-Allow-Origin响应头,浏览器就拒绝读取响应数据。

解决:在 Spring Boot 后端加一个 CORS 配置类,常见做法是实现WebMvcConfigurer接口,代码片段如下:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:8081") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true); } }

逻辑说明:addMapping("/api/**")限定了只有/api路径下的请求走跨域放行,allowedOrigins填入前端开发服务器的地址。如果前端开发时用了代理转发,这一步其实可以省掉;但你要是直接在前端代码里写死了后端地址,就必须配上这个类。生产环境里建议用 Nginx 反代把前后端合并到同一个域名,从根上消除跨域问题。

4.3 MySQL 连接报错:时区和公钥校验的连环坑

现象:后端启动时抛The server time zone value '乱码' is unrecognized,或者报Public Key Retrieval is not allowed,应用起不来。

原因:MySQL 8.x 默认时区配置和认证插件跟 JDBC 驱动之间存在兼容问题。JDBC URL 里没有指定serverTimezone时,驱动拿不到合法时区就报错;caching_sha2_password插件要求客户端在建立连接时先从服务器取公钥,没开allowPublicKeyRetrieval就连不上。

解决:把application.yml里的 JDBC URL 改成 4.1 小节里那串完整的配置,三个关键参数一个都不能少,缺哪个就报哪个错。如果你本地用的是 MySQL 5.7,driver-class-name可以用com.mysql.jdbc.Driver,但 8.x 必须用com.mysql.cj.jdbc.Driver。

4.4 明明有空房却显示满房:日期格式不一致导致判断失效

现象:后台明明有空房,前端查某段时间却提示“暂无空房”,查另一个时间段又能正常显示,排查了半天觉得业务逻辑没问题。

原因:前端把日期作为字符串传给后端,yyyy-MM-dd格式能正常比较,但有些浏览器传的是带时间的完整格式,比如2026-05-20T00:00:00.000Z,直接放进 SQL 比较就会出现判断错位。还有另一类情况是前端把日期转成了时间戳数字,后端String接收后和DATE类型比较时隐式转换出了问题。

解决:前端在传参前统一格式化日期,后端接参后也统一做一次标准化。常见做法是前端用dayjs或原生方法格式化到yyyy-MM-dd,后端接口接收后用LocalDate.parse清洗一遍。

@GetMapping("/available") public Result getAvailableRooms(@RequestParam String checkIn, @RequestParam String checkOut) { LocalDate inDate = LocalDate.parse(checkIn); LocalDate outDate = LocalDate.parse(checkOut); List<RoomVO> rooms = roomService.findAvailableRooms(inDate, outDate); return Result.success(rooms); }

逻辑说明:LocalDate.parse遇到非法格式会直接抛异常,这其实是好事,等于让后端在入口拦住脏数据,比数据进到 SQL 里才出错更容易定位。从那以后我改接口都会先看一眼参数类型,能用日期类型就用日期类型,而不是全程拿字符串硬扛。

4.5 npm 安装翻车:node-sass 编译不过的处理

现象:执行npm install时卡在node-sass,报错信息里有gyp ERR!或Python not found,依赖装不上,前端起不来。

原因:项目白发较早时用了node-sass,这个包需要根据当前 Node 版本从源码编译原生模块。如果本机 Node 版本太新或太旧,和依赖锁定的版本不匹配,就会编译失败。

解决:先看项目package.json里node-sass的版本,再查本机node -v的版本是否匹配。实在不匹配就卸掉node-sass换sass或sass-embedded,这段是常见做法:

npm uninstall node-sass npm install --save-dev sass

逻辑说明:Dart Sass 是官方推荐的新方案,不再依赖 Python 和编译环境,安装快、兼容性好。项目里只要写的是一般的 SCSS 或 scoped style,替换后基本不用改代码。如果你不想动依赖,另一个方案是安装与 node-sass 版本匹配的 Node 版本,用nvm切过去再装。但换 Sass 是更省心的路线,我这套系统跑通后就没再碰 node-sass。

5. 二次开发实战:给系统加上会员积分模块的完整改造

拿到这套系统后,最常见的改造需求是“在现有订单流程上加业务”。我用加一个会员积分模块来演示完整套路:下单成功后给用户加积分,订单列表显示本次获得积分,用户中心展示累计积分。

5.1 加一张积分表

积分和订单是一对一关系,最简做法是不动老表,新增points_record表记录积分流水,保证不破坏原系统的订单逻辑。表结构加一列存储订单 ID 和用户 ID 即可,查询积分时直接汇总。

5.2 后端积分接口与前端展示联动

后端在订单创建成功后调用积分计算并记录,前端在订单列表里展示积分数字。核心改动只有一处:在OrderService的创建订单方法末尾追加积分写入逻辑,其余接口不用动,改造面很小。

5.3 验证改造的正确性

改完别急着提交,先跑这三条验证路径:正常下单后确认积分表多一条记录;重复点击下单按钮时确认积分只加一次,靠orders表的状态字段防重;查询订单列表时确认积分字段正确返回。跑完这三条再测旧功能,确认原有订房流程没有回归。

整套系统拆完以后,我最大的体会是:这种前后端分离的管理系统,业务复杂度不在单个接口里,而在状态流转和日期判断这类跨模块约束里。从那以后我每次拿到新项目,都强制自己先把状态字段和表关系梳理一份再动代码,避免改到一半发现状态机前后矛盾,只能推倒重来。这套客房系统的代码量适合做练手,也适合做二次开发的底座,希望我的这些拆解能帮你在自己的改造道路上少走几步弯路。

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

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

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

立即咨询