
很多人第一次拿到“SpringBoot Vue3 MyBatis MySQL 酒店管理系统”这类前后端分离项目时第一反应是先跑起来再说。这话没错但如果只是把源码启动成功对着界面点一遍菜单然后写进简历那这套系统的价值基本就被浪费了。酒店管理系统和普通CRUD后台有一个非常大的区别——它有强状态流转。从散客到店、办理入住、押金收取、换房、退房结算每一步都在改房间状态和订单状态两个状态必须保持一致。这个业务特性决定了它不是一个“增删改查生成器”能糊弄过去的项目而是能把SpringBoot事务、MyBatis动态SQL、Vue3响应式状态管理、权限路由这些后端和前端的关键技能全部串起来的完整案例。下面我直接按这套全栈项目源码的实际开发逻辑把数据库设计、后端关键实现、前端页面骨架、联调部署的注意点拆开讲清楚顺便把新手最容易踩的坑一并排掉。1. 先看清楚这套系统的边界客房和订单才是核心不是用户管理很多人在看这类系统源码的时候会被前端的菜单数量带偏。菜单再多本质上都只是在为两个核心实体服务房态和订单。开个房发生在哪张床位上、对应哪间房型、什么价格、住了几晚这是酒店业务的唯一主线。1.1 系统模块拆解哪些是表面功夫哪些是业务骨架一个标准的酒店管理系统前端页面通常包含登录页、系统首页运营看板、房间管理、房态图、预订管理、入住登记、退房结账、会员管理、用户权限管理。如果首页还带ECharts统计图那多半是展示当日入住率、营收趋势。这里我要给出一个明确的主次判断模块重要性原因房态图核心视觉化展示每间房当前是“空净/脏房/已入住/维修”状态是所有业务操作的入口订单入住/预订/退房核心每一次状态变化都要落库为一条订单记录金额计算和流转校验都在这里会员管理业务支撑散客转会员、会员价、积分扩展性强但不是“非有不可”用户/角色/菜单权限系统支撑前后端分离项目的标配权限演示点面试时最容易被追问如果你做二次开发务必先把订单表和房间表之间的关系理清楚。很多改崩的酒店项目问题不是不会写Java而是订单里没有保存“入住时的房型价格快照”结果退房时房价已经改了历史订单金额全乱掉。1.2 前后端分离的工程结构应该长什么样后端用SpringBoot做纯API服务前端用Vue3 Vite开发部署时一般是前端打包成静态文件交给Nginx后端以Jar包或Docker方式运行。源码里通常能看到这样的划分hotel-server # SpringBoot 后端工程 ├── src/main/java/com/hotel │ ├── controller # 接口层只做参数接收和结果封装 │ ├── service # 业务层事务边界在这里控制 │ ├── mapper # MyBatis Mapper接口 XML │ ├── entity / domain # 数据库实体 │ ├── dto / vo # 入参校验对象 / 返回视图对象 │ ├── config # 跨域配置、拦截器、WebMvc配置 │ ├── common / util # 统一返回体、异常处理、JWT工具 │ └── HotelApplication.java └── src/main/resources ├── mapper/*.xml # MyBatis 动态SQL和结果映射 └── application.yml hotel-web # Vue3 前端工程 ├── src │ ├── api # 按模块封装的axios请求 │ ├── assets │ ├── components # 通用组件上传、分页、表单弹窗 │ ├── layout # 后台主布局侧边栏顶部栏 │ ├── router # vue-router 路由包含动态路由逻辑 │ ├── store # Pinia 状态管理 │ ├── views # 页面 │ │ ├── login │ │ ├── dashboard │ │ ├── room │ │ ├── order │ │ └── system │ ├── utils/request.js # axios 实例请求/响应拦截器 │ └── main.js拿到源码第一步不是急着启动而是对照这个结构看清“请求从哪个前端函数发出打到了后端的哪个controller最后通过哪条mapper SQL落库”。把这个链路理清整个系统在你眼里就不再是一堆文件而是一条清晰的数据流。2. 数据库设计客房状态不靠猜靠状态机锁死我见过好几个仿写的系统最糟糕的设计是“查询某间房现在能不能入住”靠程序遍历当天订单去推算。这种设计上线后必出问题一旦存在脏数据或订单漏单房间状态就全对不上。正确做法是在房间表上直接维护一个可枚举的实时状态字段所有可能改变状态的入口都走同一套逻辑更新它这样才能保证房态图可以秒级刷新。2.1 几张核心表的建模思路房间表 t_room是房态图的直接数据来源大致字段如下CREATE TABLE t_room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(10) NOT NULL COMMENT 房间号, room_type_id BIGINT NOT NULL COMMENT 关联房型, floor INT DEFAULT NULL COMMENT 楼层, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-空闲 1-已入住 2-脏房 3-维修, delete_flag TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除标记, create_time DATETIME, update_time DATETIME );房间只存“状态”不存“谁在住”。谁在住是订单表的事两边通过房间ID关联不在房间表里冗余客人姓名电话否则退房之后的客人信息会残留在房态里极难清理。订单表 t_order是业务流水的核心要包含以下字段订单号业务编号给客人看或对接OTA用、房间ID、房型ID、房价快照下单时的价格、入住人姓名/电话、预计入住和离店日期、实际入住和离店时间、押金、订单总金额、状态。订单状态建议用整型状态机而不是字符串0-待支付 1-已确认(预订) 2-已入住 3-已退房 4-已取消为什么订单里要冗余一份“房型名称房价快照”因为房型价格是会被调整的如果不做快照三个月前的历史订单退查时价格会跟着当前价格变。好的系统讲究“订单一旦生成和后来价格变动无关”这是交易系统最基本的快照思想。2.2 MyBatis-Plus的delete_flag逻辑删除省事但会让你在关联查询时栽跟头很多源码会选择在每张表都有delete_flag并使用MyBatis-Plus的逻辑删除能力热词里我看到大量关于禁用逻辑删除和MyBatis关联查询的搜索开启方式很简单mybatis-plus: global-config: db-config: logic-delete-field: deleteFlag logic-delete-value: 1 logic-not-delete-value: 0刚用确实爽删除接口只要deleteByIdSQL会自动变成 UPDATE SET delete_flag 1。但问题随后就到——当你在XML里手写多表关联查询时MyBatis-Plus的自动过滤条件不会生效比如查询“所有包含已删除房型的房间”或者“某个客人的历史订单订单被软删”手写JOIN会直接把软删数据查出来或者忘记给别表补上delete_flag 0条件。我的建议是若整个项目逻辑删除滥用后续维护的人会非常痛苦。酒店系统里房间、订单这些核心数据确实需要保留历史痕迹但像用户-角色关联这类纯关系表根本不需要delete_flag硬删除即可。如果已经在用逻辑删除并且对应关联查询产生了脏数据排查方法也简单——打开MyBatis SQL日志热词中mybatis log、easyplus等工具本质上都是干这个的看数据库实际收到的查询语句。真正执行的是UPDATE还是多查了一条WHERE delete_flag条件一眼便知。2.3 金额字段设计decimal别用float和double这是老生常谈但还是会有人错。押金、房费、赔偿金这类型的字段一律用DECIMAL(10,2)Java侧对应BigDecimal。只要涉及金额用浮点类型累计对账时必然出现0.1 0.2 ! 0.3的戏码这是二进制的锅不是Java的锅。后端接收前端金额参数时也要用字符串或BigDecimal接收不要用double接否则大金额精度会被破坏。3. 后端设计真正的拦路虎是事务边界和状态校验后端不是写几个Controller然后调用Mapper就完了。酒店管理系统里最容易写错的一段逻辑是入住操作到底要更新多少张表。办理入住的完整动作是校验房间确实是“空闲/已预订且预订人一致”→ 将房间状态改为“已入住”→ 创建一条订单记录或把预订订单状态改成“已入住”→ 可能还要累计押金支付流水。这四个动作只要有一个失败其他必须全部回滚。3.1 状态流转放在一个事务里以SpringBoot实现为例最典型的方法如下Service RequiredArgsConstructor public class CheckInService { private final RoomMapper roomMapper; private final OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public void checkIn(CheckInRequest request) { // 1. 查房间并用乐观锁或行锁锁住 Room room roomMapper.selectByIdForUpdate(request.getRoomId()); // 2. 校验当前状态允许入住 if (!Integer.valueOf(0).equals(room.getStatus()) !Integer.valueOf(1).equals(room.getStatus())) { throw new BizException(当前房间状态不允许办理入住); } // 3. 更新房间为已入住 roomMapper.updateStatus(request.getRoomId(), 1); // 4. 创建入住订单 orderMapper.insert(buildOrder(request)); } }几个值得关注的点第一Transactional注释不能只写在Controller层一定要写到Service的业务方法上。第二并发场景下的校验要用SELECT ... FOR UPDATE行锁即上面的selectByIdForUpdate否则在高并发时两个前台同时办同一间房两次查询都是“空闲”随后都执行更新房间就会出现两个入住人。刚开始做系统可以不管并发但数据库行锁的思路如果有余力一定要掌握这是大并发系统最基础的方案。第三状态变更建议用带条件的UPDATE而非先查再改UPDATE t_room SET status #{newStatus} WHERE id #{roomId} AND status #{expectStatus}更新行数为0就表示状态已经被别人改过直接抛“房间状态已变化”。这种方式在接口层天然防并发不需要显式加锁。3.2 MyBatis在酒店管理里的高频写法动态SQL与分页多条件查询是管理后台最常见的需求订单列表要按订单号、手机号、状态、日期范围筛选。如果参数很多又允许为空那直接用MyBatis的动态SQL控制拼接条件比傻傻地写三个固定Mapper方法合适得多。例如订单查询的XML片段可能长这样select idselectOrderPage resultTypecom.hotel.entity.Order SELECT * FROM t_order where if testorderNo ! null and orderNo ! AND order_no LIKE CONCAT(%, #{orderNo}, %) /if if testphone ! null and phone ! AND phone #{phone} /if if teststatus ! null AND status #{status} /if if teststartTime ! null AND create_time gt; #{startTime} /if if testendTime ! null AND create_time lt; #{endTime} /if /where ORDER BY create_time DESC /select注意#{status}这种参数写法是预编译占位符能防止SQL注入而${}是字符串拼接无论什么场景都不建议直接拼到SQL里哪怕排序字段也尽量在Java侧白名单校验后再拼。分页优先推荐直接用MyBatis-Plus的分页插件配置一个分页拦截器就能用无非是Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后Service层调用page(new Page(current, size), queryWrapper)底层自动组装LIMIT语句不用自己写物理分页。3.3 登录鉴权和角色权限的实现思路很多酒店管理系统的权限设计分为三层用户表、角色表、菜单/路由表用户和角色多对多角色和菜单多对多。登录成功后后端根据用户角色返回其有权访问的菜单和按钮权限码前端用返回的权限码控制路由和按钮是否显示。经典的表结构可以是这样t_user - t_user_role - t_role - t_role_menu - t_menu在后端登录接口返回一个TokenJWT格式比较常见JWT里不存敏感信息只塞userId剩下的用户信息查库或走缓存。前端拿到Token后存在localStorage或Pinia中每次请求通过拦截器放在Authorization头里。后端注册一个拦截器除了登录接口外统一从请求头解析Token解析失败就返回401。我在实际项目中被追问最多的问题“你们前端路由真的安全吗”答案是前端隐藏菜单按钮只能改善体验真正的安全边界永远在后端接口。无论前端显不显示“删除房间”按钮后端接口都必须有权限校验否则懂技术的人直接调接口就能删数据。设计权限时后端权限校验优先级永远高于前端菜单隐藏。3.4 全局异常处理和统一返回体成熟源码会在工程里预置两个基础设施类ResultT和GlobalExceptionHandler。所有Controller接口返回ResultT业务里抛出BizException时由全局处理器统一转换成{ code:500, message:房间已被占用 }而不是让异常栈直接暴露给前端。这项规范看起来非常基础但对排障效率影响很大。如果项目里每个接口返回格式都不一样前端联调时的心智负担会大非常多。建议所有Service层遇到业务规则不满足时直接抛出带业务语义的异常不要返回null或false让Controller判断。4. Vue3前端技术选型不难难点在状态联动和权限路由后台管理类系统的前端大多数页面确实是表单表格弹窗的组合难点从来不是单个页面的复杂逻辑而是多个页面共享同一份业务状态时怎么样才不变来变去互相打架。房态图和订单列表就是典型例子我在房态图把某间空闲房改成“已入住”跳到订单列表时新订单要能立刻查出来而且侧边栏的菜单还得根据当前登录人动态变化。4.1 要“组合式API”还是“选项式API”现在新项目用Vue3基本都是Composition API加script setup语法糖优势很简单——跟业务逻辑相关的代码可以聚合在一块比如几个状态和操作这几个状态的方法写在一起替代了选项式里data、methods、watch被强行拆开的割裂感。使用ECharts或复杂数据联动时你不用把一个业务的所有代码从data区抄到methods区再抄到watch区体验会好非常多。4.2 动态路由不同角色看到的菜单由后端菜单表决定常见的做法是登录成功后前端拿到的返回结果里包含该用户可访问的路由name列表然后通过router.addRoute()动态注册路由。代码结构通常这样// 登录后拉取用户菜单信息 const userMenus await getUserMenus() // 把后端返回的菜单结构解析成 vue-router 的 RouteRecordRaw const dynamicRoutes generateRoutes(userMenus) dynamicRoutes.forEach(route { router.addRoute(Layout, route) }) // 一定注意重新next否则刷新页面后路由还没注册完就跳转 next({ ...to, replace: true })这里有个非常常见的坑刷新页面后Pinia数据被清空动态路由也失效页面直接白屏或404。解决思路是在全局前置守卫里加“是否已从后端拉取过菜单”的判断如果状态里没有菜单数据则先调接口拉菜单再放行否则直接next。4.3 房态图就是完整的状态驱动案例房态图可以做成网格布局每一格代表一个房间颜色标识状态空净绿色、脏房黄色、已入住蓝色、维修灰色。这个页面本身就是数据驱动的房间列表从后端接口一次性拉回来当前显示的房态在前端Pinia里维护每次入住或退房操作完成后刷新房间列表接口更新Pinia中的数据视图自动重渲染。这里不建议在多个页面各自调用房间接口后再同步状态用Pinia统一管理房间数组会更省心。一个实现细节如果房型太多且房间楼层分布在多个区域前端可以把房间数组按floor字段分组在模板里做成“楼层-房间号”二维网格点击一个空格子时弹窗表单带上默认的房型、房价再由后端在订单保存时二次校验价格是否符合当前房型定价。前端计算房价可以做展示但最终价必须以服务端为准。4.4 axios请求封装的方向一般的utils/request.js里核心逻辑是service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data // 后端统一返回 { code, message, data } if (res.code ! 200) { ElMessage.error(res.message || 系统异常) return Promise.reject(new Error(res.message)) } return res.data }, error { // 401 未授权则跳登录页 if (error.response?.status 401) { // 清理登录态跳转 /login } ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } )所有API模块再基于这个实例封装统一管理URL。文件不要散落得到处都是每个页面直接拼字符串否则后端接口一旦调整前缀改起来会非常痛苦。5. 联调与部署从“本地能跑”到“上线可用”之间隔着多少个坑本地源码跑通不难真正折磨人的通常是以下几个边角问题。5.1 跨域配置开发环境和生产环境是两码事开发环境Vite跑在5173端口SpringBoot跑在8080端口两个端口不同必然跨域。一般做法是在Vite的vite.config.js里配置代理而不是在后端开启全局CORS。道理很简单代理模式下浏览器看到的跨域请求是同源的后端不需要关心前端地址是什么也不会误放行外部域。配置// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } }后端接口如果都前缀有/api需要确认代理重写规则是否正确。前端请求地址是/api/login代理到后端是http://localhost:8080/login。如果不对齐刷半天页面全是404还以为是后端没启动。生产环境部署时不再走Vite代理而是把Vue打包成dist目录后交给NginxNginx配置里把/api反向代理到SpringBoot地址。前端请求的baseURL就写/api即可由Nginx负责转发这也是我推荐的部署形态。同时要在检查后端是否允许跨域如果都在同一个域名下后端其实可以不配跨域配了反而增加不安全面。5.2 MySQL连接串里的时区、SSL、驱动版本问题酒店管理系统若数据库中使用了DATETIME时间Java接上MySQL时常见的报错是The server time zone value Öйú±ê׼ʱ¼ä is unrecognized本质是MySQL连接串没指定serverTimezone部分地区环境默认读取了系统时区而中文系统时区名没被驱动识别。解决办法是在JDBC URL上显式加参数spring: datasource: url: jdbc:mysql://localhost:3306/hotel?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai另外MySQL版本和驱动版本一定要匹配。老项目的com.mysql.jdbc.Driver到了MySQL 8之后必须改成com.mysql.cj.jdbc.DriverMaven坐标也要从mysql-connector-java换成com.mysql:mysql-connector-j之类的新坐标否则会有启动时Driver类找不到这类问题。热词里有人搜springboot版本太高导致启动失败大概率就是SpringBoot 3和旧版驱动、旧版MyBatis-Plus不兼容导致建议直接看源码POM里锁定的starter版本不要无脑升级大版本。5.3 启动时堆内存不足的坑本地多人同时跑多个中间件时IDEA分配的内存挤爆是常态。报错类似java: OutOfMemoryError: insufficient memory先不要怀疑源码有问题大概率是构建时的编译内存不够。调IDEA里Settings - Build Tools - Maven - Runner的VM Options加到-Xmx1024m或更高Jar包运行则用java -Xms256m -Xmx1024m -jar显式指定。5.4 数据库初始化顺序源码里通常附一份SQL脚本。导入时一定要先建数据库再执行SQL并且确认脚本里没有外键约束导致导入顺序错乱的问题。如果脚本文件里同时存在建表语句和初始数据管理员账号、菜单初始化数据直接整个source导入即可。我建议拿到手先跑一遍跑完检查t_user表是否已经有admin用户如果没有很多源码启动后连登录界面都进不去。6. 二次开发顺序围绕这套源码真正吃透SpringBoot Vue3的实践路线这个项目是拿来面试也好拿来练手也罢“跑起来”只是刚开始。我最怕看到的是简历写着酒店管理系统问他“你们退房时候房间状态脏房怎么设置的”回答不上来。我在学习这套系统的过程中会按顺序做下面几件事第一步是彻底看懂订单生命周期。从创建预订到入住再到退房对照表结构把所有状态字段的流转画一遍可用纸笔不必画图工具搞清楚每个状态由哪个代码入口触发事务在哪里提交。不要上去改代码先把链路读通。第二步是自己动手改一个完整功能比如加一个“批量退房”功能或“预订到期自动取消”的定时任务。你会发现要改动的不仅是Controller和Mapper还牵扯事务边界和前端房态刷新。只要完整走一遍就真正掌握。第三步是给系统加上缓存。酒店系统的房型、房间列表基本是读多写少完全可以用Redis缓存房间状态订单创建后删除对应房间的缓存查询时先查缓存再查库。把这段写在项目里面试时就能讲清楚缓存与数据库的一致性而这是很多毕设项目没有的加分点。第四步是权限模型加固。把按钮级别的权限控制补上比如数据管理员可以查看订单但不能操作退款后端在接口上写注解校验。前后端分离项目里能否讲清楚动态路由、按钮权限、后端接口权限三者怎么配合是衡量是否真的懂权限设计的重要指标。最后一步是部署上线到云服务器把MySQL、Node、Nginx装好后后端Jar包用systemd守护进程方式跑前端Nginx托管然后配置SSL证书走HTTPS。这里可以顺带把Java环境变量配置、MySQL安装配置这些基本功一并梳理清楚——热词里为什么有那么多人搜mysql安装教程、java环境变量配置就是因为这块基础不牢真到部署环节全都卡住了——所以一旦走到部署这一步之前觉得枯燥的环境配置就全成了刚需。7. 结语酒店管理系统这个选题之所以经久不衰不是因为“酒店”这个业务有多热门而是它的业务复杂度刚好卡在练手阶段的天花板上比单纯CRUD复杂但又不至于需要微服务和分布式事务。完成这套源码最理想的收获不是那几分钟的“启动成功”兴奋感而是从一条数据流中真正理解服务端如何管理状态、前端如何展示状态、两者如何通过接口保持一致。最后再分享一个排查Bug的实用习惯无论前端报了什么问题先按F12打开Network面板看请求对应的HTTP状态码和响应内容。前端报错也好、白屏也好90%的真相藏在接口返回的JSON里而不是藏在Console的红色堆栈里。掌握这个排查顺序再配合后端把MyBatis日志打开看SQL执行情况这套酒店管理系统里你能排掉的坑基本能覆盖未来工作中大半的联调问题。