1. 酒店预订系统的业务边界:先想清楚做哪些事
做这类管理系统,最容易犯的错就是一上来就建表、敲代码。酒店预订系统看着简单,无非是"用户选房间、下单、管理员管房间和订单",但真上手以后你会发现,业务边界不划清楚,后面每个环节都要返工。我这个基于SpringBoot + Vue的酒店预订项目,在动手写第一行代码之前,花了整整一个晚上整理业务流程,这一步的价值在后续开发中被反复验证了。
先说这个系统划定的核心角色,一共三类:
| 角色 | 核心诉求 |
|---|---|
| 普通用户(游客/注册用户) | 浏览酒店与房型、按日期查询可订房间、下单预订、查看/取消自己的订单 |
| 酒店管理员 | 维护酒店信息、维护房型与库存、查看订单列表、处理订单状态 |
| 系统管理员 | 用户管理、基础数据管理、统计报表 |
围绕这三个角色,我把功能拆成两条主线:
- 用户端:注册登录 → 浏览酒店列表 → 酒店详情 → 房型列表 → 选入住日期 → 提交订单 → 我的订单 → 取消订单。
- 管理端:登录 → 酒店管理(增删改查) → 房型管理(增删改查) → 订单管理(查看/确认/取消) → 用户管理。
这里要特别说一个取舍:支付环节建议做成"到店支付/线下支付"的占位状态,而不是真的对接支付宝或微信。原因很简单——大多数课程设计和毕设场景不需要真实支付通道,真实支付涉及商户资质、证书、回调验签,很容易把项目的复杂度从"管理系统"拉高到"金融系统"的级别。订单状态里保留"待支付/已支付"字段即可,方便后续扩展,但不引入SDK。如果你之后想做真实的支付演示,在这个字段的基础上再接微信/支付宝,也不会伤筋动骨。
另外一个容易被忽略的点是房型的"库存"语义。酒店系统里的库存不是普通电商的"件数",而是"某一天可预订的房间数量"。同一个房型,比如"大床房"有5间,7月1日被订了3间,那7月1日就还剩2间可订;7月2日被订了1间,那7月2日还剩4间。这个按日期维度拆解库存的逻辑是整张数据表的灵魂,后面第三节我会专门展开。
提示:如果你只是照着网上的代码抄,很多"酒店预订系统"的库存其实是假的——要么只有总数没有日期维度,要么订了之后根本不减库存。但是做项目的人得自己心里有数,这块做不做得对,直接决定你答辩或面试的时候能不能把系统讲得滴水不漏。
2. 技术栈选型:SpringBoot + Vue组合到底是省心还是折腾
这个项目取名为"基于SpringBoot + Vue的酒店预订系统",不是拍脑袋定的。当前前后端分离的全栈项目里,SpringBoot + Vue确实是最稳的答案,但"稳"是有前提的,版本和配套工具没选对,再好的组合也会变成灾难现场。
2.1 后端选型:SpringBoot 2.7 + MyBatis Plus
SpringBoot 2.7.x 是目前教程资源最多、兼容性最稳的版本。如果你不是对SpringBoot 3有明确需求(比如必须用Jakarta EE新特性),我建议别选3.x。原因很实际:3.x要求JDK 17+,而很多学校机房、云服务器、老教程还停留在JDK 8,到时候环境适配会浪费大量时间。我身边就有同学因为追新用了SpringBoot 3.2,结果Maven仓库里某个依赖版本不兼容,卡了整整两天。做项目,稳定跑起来永远比版本新更重要。
持久层我选 MyBatis Plus 而不是 JPA,也不是原生 MyBatis,理由比较务实:
- 单表 CRUD 直接用内置方法搞定,省掉大量重复的 XML 映射文件;
- 分页插件用起来顺手,管理端列表页必须要有分页,不然数据多了页面直接卡死;
- 相比 JPA,国内社区资料更丰富,面试时也更好解释"为什么用这个"。
2.2 前端选型:Vue 2.7 + Element UI
前端这块我要泼一点冷水:如果是为了稳妥跑通项目,Vue 2.7 + Element UI 比 Vue 3 + Element Plus 更省心。不是说 Vue 3 不好,而是市面上毕设教程、组件库资料、老项目源码,Vue 2 生态的存量太大。你随便搜一个"Vue酒店管理系统",大概率是 Vue 2 的代码,参考设计的难度天然更低。
Vue 2.7 是官方最后支持 Vue 2 的版本,它把 Composition API 也兼容进来了,所以既可以用传统 options API,也能用 setup 语法,算是过渡期的甜点位。Element UI 的组件在酒店管理系统里完全够用,表格、表单、日期选择器、分页、对话框,这些管够。
2.3 数据库与中间件的选配
MySQL 8.0,这个不用解释。配置上我建议字符集统一utf8mb4,因为酒店名称、地址里可能出现特殊符号,排序规则选utf8mb4_general_ci就够。缓存如果要做,Redis 在 SpringBoot 里整合很顺,但这个项目初期可以不加,不然环境要求又多一项。原因很简单:每次给别人部署时,多一个中间件就多一个出问题的点。先跑通主流程,Redis 作为后期优化的选项比作为前置条件更合理。
提示:整个项目最核心的依赖版本,我用的这套组合是 Spring Boot 2.7.6 + MyBatis Plus 3.5.3 + JJWT 0.9.1 + Hutool 5.8.x。这些版本我实测互相兼容,不会有令人抓狂的 jar 包冲突。第一次跑项目的时候,不要在"尝鲜最新版"上花时间,先把流程跑通比什么都重要。
3. 数据库设计:订单冲突和库存问题的根子在表结构
酒店预订系统最怕的事是什么?同一间房、同一个日期段,被两个用户同时订走。这个问题在代码层面很难完美解决,必须靠数据库的根本性约束。我的表结构设计围绕"唯一性"和"状态可追溯"展开,这也是整个项目含金量最高的部分。
3.1 六张核心表的设计
我建了6张核心表,外加一些辅助表:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| user | 用户表 | id, username, password, nickname, role, phone, status, avatar |
| hotel | 酒店表 | id, name, city, address, stars, description, cover |
| room_type | 房型表 | id, hotel_id, room_name, price, area, max_people, bed_type, total_rooms |
| room_inventory | 房态库存表(关键) | id, room_type_id, date, total_count, booked_count, version |
| orders | 订单表 | id, order_no, user_id, hotel_id, room_type_id, check_in_date, check_out_date, total_price, status, create_time |
| sys_config | 系统配置表(可选) | 参数键值对,比如"是否开放注册" |
很多网上的代码没有 room_inventory 这张表,订单直接关联 room_type,然后假模假式地减一个"总库存"。这种做法在演示视频里看不出问题,但只要两个用户同时提交,数据就错了。我把库存拆到"房型 × 日期"维度,每一行代表该房型在某个具体日期还剩几间可订,这是稳妥的做法。
3.2 唯一约束与乐观锁:防止重复预订的三道防线
这是整个数据库设计的核心,我说三件事:
订单表防重复预订:给订单表建联合唯一索引
uk_user_room_date (user_id, room_type_id, check_in_date, check_out_date)。意思是同一个用户,同一房型,同一入住离店日期,只能有一条有效订单。第一次订成功后,重复提交直接数据库报错,应用层捕获后提示"您已预订过该房型"。这道防线把"同一用户重复下单"彻底堵死。库存日期的唯一性:
room_inventory表建联合唯一索引uk_room_date (room_type_id, date),同一房型同一天只有一条库存记录。更新时用乐观锁版本号,解决"超卖"问题。订单状态机:status 字段我定义成几个整型常量:0待支付、1已确认、2已入住、3已退房、4已取消。不要把"已取消"设计成删数据,保留记录才能追溯管理。管理端要根据状态做不同类型的操作,比如"已确认"的订单才能点"办理入住"。
关于并发处理的完整方案,我在第4节专门展开,因为这里涉及一个非常经典的"超卖"场景,属于后端业务逻辑最容易踩坑的环节。
3.3 初始化数据的小技巧
数据库脚本里我预置了3座城市的5家酒店、每个酒店3到5个房型,以及未来30天的库存记录。这里有个很实用的经验:库存表不要手工一条条插入,而是写一个脚本循环生成,把从"当前日期 + 1"到"当前日期 + 30"的每一天,针对每个房型都插入一条total_count = 房间数的记录。我第一次做的时候笨手笨脚在 SQL 里复制粘贴,结果日期错位,排查了半天才发现是粘贴漏了一行。
注意:MySQL 的日期函数、循环可以用存储过程实现,但更推荐你把初始化逻辑写进项目启动后的一个
DataInitializer(Spring Boot 的 CommandLineRunner),这样数据库脚本只留表结构,数据自动生成。别人部署的时候少一步手工操作,你交付的"数据库"部分也更专业。
4. 后端核心实现:JWT鉴权、拦截器与订单状态流转
后端代码我按经典分层来:controller → service → mapper,实体类用 MyBatis Plus 的注解标注表映射。写代码本身没什么玄学,但有几个点很值得单独拎出来讲,因为它们直接决定系统的安全性和数据正确性。
4.1 登录与鉴权:JWT 不只是"发个token"
密码存储我用的是 BCrypt 加密(Spring Security 的 BCryptPasswordEncoder,或者 Hutool 里的 BCrypt 工具),千万不要明文存密码——这是项目最基本的底线。如果数据库泄露,明文密码等于把所有用户的账号安全都交出去了。
JWT 我用了 JJWT 库,签发的时候把 userId、username、role 存进 claims。这里有一个很多人容易忽略的坑:JWT 一旦签发,服务端无法主动让它失效。所以"用户修改密码后旧 token 还能用"这个问题在纯 JWT 方案里无解。我的处理方式是:在 user 表加一个token_version字段,用户修改密码时把它加 1,JWT 里存这个版本号,每次请求校验时对比数据库,不一致就拒绝。这个方案轻量、有效,比引入 Redis 黑名单简单得多,很适合中小项目。
拦截器用 Spring Boot 的 HandlerInterceptor,校验逻辑就三步:取 Header 里的 Authorization → 去掉 "Bearer " 前缀 → JWT 解析 → userId 查询用户状态 → 放行。管理端接口额外校验 role 字段,这里的逻辑我在第5节路由守卫里还会再提一次。
4.2 创建订单的完整业务链:库存扣减的顺序不能乱
创建订单时,事务方法的执行顺序极其重要。我按下面的链条写:
1. 校验用户登录态(从 token 拿 userId) 2. 校验酒店、房型存在且状态正常 3. 校验入住日期晚于今天、离店日期晚于入住日期 4. 计算入住天数 = check_out - check_in(按天) 5. 循环查询 [check_in, check_out) 每一天的房态库存 6. 如果每一天的可用数都 >= 1,继续;否则抛出"该日期段已被订完" 7. 对每一天的库存行执行乐观锁更新(UPDATE ... SET booked_count = booked_count + 1 WHERE version = ?) 8. 生成订单号、计算总价、插入订单表 9. 返回订单号给前端注意第5步,日期区间是左闭右开的:7月1日入住、7月3日离店,实际占用的是7月1日和7月2日两晚,7月3日那天的库存不动。这个"算清楚住几晚"的逻辑,每隔一段时间就会出现一个写错的人,你如果做项目,务必自己拿日历推演一遍,或者直接写单元测试验证。
4.3 乐观锁更新:超卖问题的解法与重试机制
我在第5步做了"查库存",第7步做"更新库存",这中间必然有时间差。两个用户同时读到"有房",然后同时执行更新,如果不用乐观锁,就会把booked_count从 0 改成 1 两次——明明只剩一间,却卖出两单。
乐观锁的 SQL 语义是:
UPDATE room_inventory SET booked_count = booked_count + 1, version = version + 1 WHERE room_type_id = ? AND date = ? AND version = ?如果影响行数为1,说明抢到了;影响行数为0,说明 version 不匹配,别人已经改过了。这时候要么重试,要么直接返回"无房"。我把这个逻辑写成一个带重试的小组件,最多重试3次,3次都失败就返回友好提示。实测单机环境下这个方案足够稳。
提示:如果你做的不是课程设计而是真正高并发的线上系统,乐观锁重试会有性能瓶颈,那时候得上 Redis 分布式锁,或者把库存预热到 Redis 做原子扣减。但作为这套项目,数据库乐观锁已经是最稳妥、最好讲解的方案了。答辩的时候把这个"为什么选乐观锁而不是悲观锁"讲清楚,是加分项。
4.4 订单编号生成的讲究
订单号我用了时间戳 + 随机数组合:yyyyMMddHHmmss + 6位随机数。如果嫌冲突概率高,可以再加一个雪花算法生成器。这里有一个实际教训:不要在事务里用System.currentTimeMillis()做唯一主键的组成部分,并发高一点就可能撞号。简单的随机数虽然不能保证绝对唯一,但加上数据库的唯一索引兜底,业务上完全够用。
5. Vue前端:路由守卫、Axios封装与页面组织
前端这块在别人眼里好像只是"套页面",但真正决定一个项目能不能顺利演示、答辩时能不能流畅操作,前端骨架的合理性非常关键。我见过太多后端逻辑没问题、前端一操作就报错的项目,问题大多出在请求封装和路由控制上。
5.1 前端工程结构与依赖
用 Vue CLI 创建项目(vue create hotel-frontend),依赖我控制在最小集,避免装一堆用不上的包:
- vue 2.7.x
- vue-router 3.x
- axios
- element-ui 2.15.x
路由我分成两组:用户端路由和管理端路由。用户端的页面有首页(酒店列表)、酒店详情(含房型)、订单提交、订单列表、登录/注册页;管理端独立嵌套在/admin前缀下,包含酒店管理、房型管理、订单管理、用户管理。
页面组织上,我习惯把通用组件(比如酒店卡片、房型卡片、订单状态标签)抽出来放components/,页面组件放views/,这样每个页面代码量不大,维护起来也清爽。
5.2 Axios 拦截器:统一处理 token 和错误码
axios 封装是我每次都要强调的一个点。很多初学者每个页面单独发请求,token 失效了也不知道,报错信息一堆乱码。我的做法是定义一个request.js:
import axios from 'axios' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:自动带 token 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 if (res.code === 200) { return res.data } else if (res.code === 401) { localStorage.removeItem('token') localStorage.removeItem('role') router.push('/login') return Promise.reject(new Error('登录已过期')) } else { return Promise.reject(new Error(res.msg || '请求失败')) } }, error => { return Promise.reject(error) } ) export default service这样每个页面里只需要关心业务数据,不用重复写错误处理。日期格式化、天数计算这类通用逻辑,我也抽成了utils/date.js里的工具函数,避免在订单提交页里堆一大坨代码。
5.3 路由守卫:管理端页面必须防"裸进"
管理端的路由统一加了meta: { requiresAuth: true, role: 'admin' },然后在全局前置守卫里判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') return } if (to.meta.role === 'admin' && localStorage.getItem('role') !== 'admin') { next('/') return } next() })这一步很基础,但很多参考源码根本没有。没有路由守卫的结果就是:用户直接在地址栏输入/admin/hotel,就能进到管理端页面的框架,虽然拿不到真实数据(后端有拦截器),但前端体验和安全性都会显得很不专业。前后端双重校验,是我做这个项目始终坚持的原则。
5.4 日期选择与联调的坑
前端做日期范围选择时,Element UI 的el-date-picker在value-format没设置时返回的是时间戳或 Date 对象,提交给后端前必须格式化。我在第一次联调的时候踩了个大坑——前端显示正常的日期,一提交到后端全变成了2025-07-01T00:00:00.000Z这种带时区的 ISO 格式,数据库里存进去直接偏移了8小时。后来统一用value-format="yyyy-MM-dd",后端接收时用统一的@JsonFormat注解,问题才彻底消失。
提示:前后端分离项目跨域是必然要处理的。我在后端写了一个全局 CORS 配置类,开发阶段允许所有来源;如果正式部署用 Nginx 做
/api反向代理,那就不需要 CORS 了。开发阶段图省事可以直接用 CORS,正式部署请走 Nginx。
6. 让项目跑起来:环境配置、数据库脚本与文档配套
标题里写了"源码+数据库+文档",这套交付物不是拿来凑数的,而是一个项目能不能被别人快速复现的关键。我按"别人拿到项目 → 十分钟跑起来"的目标来整理,这也是整个项目完成度的重要体现。
6.1 环境要求与启动步骤
| 组件 | 版本要求 | 说明 |
|---|---|---|
| JDK | 1.8 或 11 | SpringBoot 2.7 兼容 |
| Maven | 3.6+ | 用 IDEA 自带的也行 |
| MySQL | 8.0+ | 5.7 也可以,注意字符集 |
| Node.js | 14+ | 建议 16,Vue CLI 兼容 |
启动步骤我写在 README 里,按下面的顺序来:
- 执行
db/hotel.sql初始化数据库; - 修改后端
application.yml里的数据库用户名密码; - IDEA 里启动
HotelApplication.java,端口默认 8080; - 前端目录执行
npm install,然后npm run serve,端口默认 8081; - 浏览器访问
http://localhost:8081。
有一个特别容易折腾人的点:npm install 时如果报依赖版本错误,大概率是 Node 版本和 Vue CLI 的版本不匹配。建议 Node 用 16.x,如果用的是 Node 18+,有些老项目的依赖会编译报错。这时候不要傻傻地去升级代码,装个 nvm 切换 Node 版本更快。
6.2 数据库脚本的排版规范
很多参考源码的 SQL 脚本是"一坨"导出的,Navicat 导出的文件动辄几千行,夹杂着外键检查、字符集声明,别人导入时眼花缭乱。我建议手动整理 SQL 脚本,按清晰的顺序写:
-- 创建数据库 CREATE DATABASE IF NOT EXISTS hotel_db DEFAULT CHARACTER SET utf8mb4; USE hotel_db; -- 用户表 DROP TABLE IF EXISTS `user`; CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `role` tinyint DEFAULT 0 COMMENT '0用户 1管理员', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `status` tinyint DEFAULT 1 COMMENT '1正常 0禁用', `create_time` datetime DEFAULT NULL COMMENT '注册时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';每个表写清楚注释,字段注释也写上。这种"干净脚本"比 Navicat 直接导出更能给看项目的人留下好印象,你自己后续复盘代码也省心。更重要的是,用DROP TABLE IF EXISTS保证脚本可以重复执行,这一点在反复部署时非常有用。
6.3 文档里最该写的内容
我见过的很多项目文档就是"简介 + 截图 + 代码贴一遍",几乎没有参考价值。我写文档的时候固定了几个章节,你可以直接参考:
- 项目概述:一句话讲清楚系统做什么、给谁用;
- 技术栈说明:后端、前端、数据库、版本号,以及为什么选这些;
- 功能清单:用户端/管理端一张表列完整,谁登录能看到什么;
- 数据库设计说明:每张表的作用、关键字段、状态字段的含义;
- 部署运行指南:从零到跑起来的每一步,包括截图;
- 核心业务与关键技术:库存并发控制、JWT鉴权、日期区间计算这三个点最值得写;
- 测试账号:
admin/admin123(管理员)、user/user123(普通用户),方便别人直接进门体验。
写完这些,这个项目才真正称得上"源码+数据库+文档"三件套,而不只是一堆能跑但不能看的代码。
7. 三天联调的核心Bug复盘:日期边界、库存不一致与时间戳陷阱
最后这部分是我在联调过程中真实遇到的三个Bug,每一个都值得你提前预防。这些问题用正常开发流程很难一次发现,但一旦踩到,排查时间都是按小时计的。
7.1 日期边界Bug:跨月与跨年金
用户选择7月30日入住、8月2日离店,日期循环如果从check_in到check_out用普通的 for 循环逐天加1,月份切换时会变成7月31日、7月32日、7月33日——轻则查不到数据,重则 MySQL 直接把非法日期当 0 值报错。正确做法是通过LocalDate.plusDays(1)逐天增加,而不是自己对getDayOfMonth()加1。写日期计算的代码,务必用 Java 8 的LocalDate而不是过时的Date和Calendar,这点在跨月、跨年场景下特别重要。
7.2 库存表没有初始化而导致的假"满房"
第一次跑通时,数据库里房型有数据,但room_inventory是空的,导致前端选任何日期都提示"无房"。排查了很久发现不是查询逻辑错,而是根本没有库存记录。这个问题再次印证前面说的:要么准备完整的初始化数据脚本,要么写启动时自动生成数据的初始化器,一定要把"演示数据齐备"当作交付标准的一部分。我当时把这个问题写成了一键初始化的CommandLineRunner,后面每次重置数据都特别省事。
7.3 时间戳时区的"8小时"陷阱
这个是联调时的经典问题。MySQL 驱动连接串里如果没有serverTimezone=Asia/Shanghai,并且前端传的时间还是带T和Z的 ISO 格式,存进数据库的时间很容易差8小时。订单的"入住日期"一旦差了一天,整个库存和订单就对不上——用户以为自己订的是7月2日,实际数据库里是7月1日。解决方案要在三处同时下手:前端用value-format="yyyy-MM-dd"统一格式化,后端application.yml里设置spring.jackson.date-format和time-zone: GMT+8,数据库连接串加serverTimezone=GMT%2B8。三处对齐,日期就不会飘。
我个人做这类全栈项目最大的体会是:系统的技术难度并不高,真正费时间的是业务边界的梳理、数据一致性的设计,以及联调时那些"差一点就看不出来"的数据细节。如果你正在做酒店预订系统,或者准备拿它作为毕设、课程项目,不要急着写代码,先把角色、流程、表和状态字段画清楚。数据模型稳了,后面的代码只是把设计翻译成实现而已。