从Excel排房到在线预约:SpringBoot+Vue民宿预约系统核心设计复盘
发布时间:2026/10/1 5:10:49来源:尧图网络
做民宿预约系统这件事起因是我一个朋友在景区开民宿旺季排房全靠Excel两个前台对着同一个表格改来改去同一间房被两拨客人同时问价的尴尬每个月都要发生几次。后来我帮他做了一套景区民宿预约系统信息管理系统后端用SpringBoot前端用Vue数据库落在MySQL上前后端分离整套源码可以直接运行。今天这篇文章就把这套系统从业务拆解、数据建模、核心逻辑到部署运行的完整过程复盘一遍重点聊聊房间库存和预约状态是怎么处理的以及运行过程中最容易踩的几个坑。希望对正在做民宿、酒店、营地预约类系统的朋友有点参考价值。1. 从Excel排房到在线预约业务痛点与系统边界1.1 民宿预约和标准酒店预订的差异景区民宿和城市连锁酒店看着都是卖房实际业务形态差很多。连锁酒店的房型、价格、入住规则高度标准化一套PMS系统可以管所有店。景区民宿往往是几十间房却拆出大床房、双床房、亲子房、山景房、可加床房型房价还会因为周末、节假日、淡旺季剧烈波动甚至同一个房型今天卖三百下周就卖八百。这带来两个核心难点。第一是排房难民宿的房间不像酒店那样完全标准化客人问“16号到19号有没有两间能看山的双床房”前台要在脑子里完成日期交叉对比、房态匹配旺季根本忙不过来。第二是对账难很多景区民宿线下收定金、微信转账退改又靠口头沟通最后订单和钱对不上。所以要做的不是一套“看起来很美”的展示官网而是要把**“哪间房哪天能卖”**这件事变成系统里的一个可计算模型。这也是整套系统设计的起点。1.2 系统该做什么不该做什么我给它划定的功能边界是这样的用户C端侧民宿列表、房型展示、按日期查可用房、创建预约订单、模拟支付、我的订单列表、订单详情、取消订单。这个侧重点是“选日期看房、下单别冲突”。管理B端侧订单管理、订单确认、入住登记、退房、取消操作、房型与房间维护、每日价格维护。管理端重点是“能干预订单状态能改价”。明确不做的事我也列一下不接真实微信/支付宝支付、不做平台分销、不搞秒杀拼团。原因是景区民宿的系统复杂度不在营销而在库存和订单状态的一致性。第一次做版本支付用模拟回调先把主流程跑通比什么都强。后续要接真实支付架构上预留接口位置就行不会伤筋动骨。1.3 整体层次结构源码的结构是标准的前后端分离Vue单页应用通过axios调RESTful APISpringBoot按controller - service - mapper分层数据访问层用MyBatis-Plus简化CRUD数据库用MySQL的InnoDB引擎。之所以选InnoDB而不选MyISAM是因为预约系统对行锁、事务、故障恢复的要求很明确后面讲锁房逻辑时你会看到它作用很大。2. 技术选型复盘为什么是SpringBootVueMySQL这个组合2.1 后端的取舍选SpringBoot几乎没有犹豫。国内中小型项目里它最成熟生态最全做这种带管理后台的信息管理系统资料和踩坑案例都多。版本上这套源码默认用SpringBoot 2.7.x对应JDK 8。很多人现在新建项目会去用3.x3.x确实新但它要求JDK 17并且包名从javax迁到了jakarta如果照着2.7的代码抄编译期会挂一片。对一个“拿源码就要能跑”的项目来说保守版本更稳。数据访问层选了MyBatis-Plus理由也很实际订单管理、房源管理这类功能大多是单表查询加简单更新MyBatis-Plus的BaseMapper能让增删改查代码少写一半。价格日历这种需要手写SQL做行锁更新的地方又可以自己写XML或注解SQL灵活度刚好。2.2 前端的取舍前端用Vue 3 Vite Element Plus。选Vue 3不是因为必须追新而是组合下开发和构建都比较舒服Vite启动快Element Plus的表单、表格、弹窗组件能直接覆盖管理端80%的界面需求。用户端需要自己做一些定制布局组件库里拿基础件改改也能很快成型。如果你是第一次跑这个项目前端部分最需要注意的是Node版本。Vite 4要求Node 16以上不少同学机器上还是Node 14npm install会报一堆依赖异常这个我后面运行章节会专门说。2.3 数据库和关键版本匹配MySQL选5.7或8.0都可以SQL层面这套源码没用到8.0独有特性。但MySQL 8.0的默认认证插件是caching_sha2_password老驱动连的时候会报Public Key Retrieval错误这个坑在第七章会展开。一个可以直接抄走的匹配版本表组件推荐版本说明JDK8 或 172.7.x用JDK83.x必须JDK17SpringBoot2.7.x生态稳定jakarta迁移坑少MySQL5.7 / 8.0两者都测过推荐8.0Node16.18 / 18Vite 4的最低要求Vite4.x配Vue3启动块配置简单3. 数据建模房间、价格与预约状态如何落表3.1 表结构清单整套系统核心表六张民宿表、房型表、房间表、价格日历表、会员用户表、预约订单表再加一张管理员表。简表如下表名作用关键字段t_house民宿/景区主体名称、地址、封面、描述t_room_type房型定义房型名、床型、面积、可住人数t_room具体房间房型ID、房间号、楼层、状态t_price_calendar每日价格与锁房标记房间ID、日期、价格、库存状态t_member前台用户手机号、密码、姓名t_booking_order预约订单订单号、房间ID、入住/离店日期、总价、状态3.2 价格日历表是整个系统的核心这个表是整套系统里最值得讲的一张表它同时承担两个职责存价格和锁库存。网上很多民宿系统会把价格直接放在房型表里比如“大床房平时300周末400”。真运营起来会发现根本管不住节假日价格天天变同一个房型不同房间还可能因为景观朝向卖不同价。把价格单独拆到日历表里按“某个房间在某一天卖多少钱”来设计就灵活了。更关键的是库存锁定的解法。预约系统最容易出现的问题是超卖同一间房两拨客人都想订7月20日如果代码只是“先查一下那天有没有被订没被订就下单”高并发下两个请求同时通过检查就会超卖。我的解法是用价格日历表做行状态更新。建表SQL里有一行很关键CREATE TABLE t_price_calendar ( id bigint NOT NULL AUTO_INCREMENT, room_id bigint NOT NULL COMMENT 房间ID, biz_date date NOT NULL COMMENT 业务日期, price decimal(10,2) NOT NULL COMMENT 当日价格, stock_status tinyint NOT NULL DEFAULT 0 COMMENT 0可预订 1已锁定, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_room_date (room_id,biz_date) ) ENGINEInnoDB;唯一索引(room_id, biz_date)保证同一房间同一天只能有一条库存记录。有了这张表后面锁房才能用一行UPDATE完成不用笨重的“先查再插”。3.3 订单表的状态字段设计订单表不能省字段因为预约流程要支持常见业务操作。给一个精简版本供参考CREATE TABLE t_booking_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号, member_id bigint NOT NULL COMMENT 下单用户, room_id bigint NOT NULL COMMENT 房间ID, check_in_date date NOT NULL COMMENT 入住日期, check_out_date date NOT NULL COMMENT 离店日期, nights int NOT NULL COMMENT 预订夜数, total_price decimal(10,2) NOT NULL COMMENT 总价, guest_name varchar(50) NOT NULL COMMENT 入住人, guest_phone varchar(20) NOT NULL COMMENT 联系电话, status tinyint NOT NULL DEFAULT 0 COMMENT 状态, create_time datetime NOT NULL, pay_time datetime DEFAULT NULL, cancel_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_room_date (room_id, check_in_date, check_out_date) ) ENGINEInnoDB;状态值我定义成0待支付、1已确认、2已入住、3已完成、4已取消。这个状态机后面章节会结合后端逻辑展开。订单号和(room_id, 日期区间)各建索引是为了管理端订单筛选和重复下单校验更快。4. 后端实现预约状态机与锁房逻辑4.1 查询可用房源与价格试算前端选择日期后后端要返回“哪些房间在这些日期内可订每晚多少钱总价多少”。这一步不能只查房间表必须去价格日历表里把目标日期段的记录全部捞出来判断。核心SQL思路是这样SELECT r.id, r.room_no, rt.type_name, pc.price FROM t_room r JOIN t_room_type rt ON r.room_type_id rt.id JOIN t_price_calendar pc ON r.id pc.room_id WHERE pc.biz_date #{startDate} AND pc.biz_date #{endDate} AND pc.stock_status 0 AND r.id NOT IN ( SELECT room_id FROM t_price_calendar WHERE biz_date #{startDate} AND biz_date #{endDate} AND stock_status 1 )解释一下为什么用biz_date endDate如果入住16号、离店19号实际占用的是16、17、18三个晚上用半开区间[startDate, endDate)是最准确的不容易出现多锁一晚或少锁一晚的边界错误。价格试算就是把这几天每晚的price相加。注意这里用的是“每晚价格”而不是“每晚价格乘以房间数”因为房间里没有按床位拆分销售。如果后面要做“单床位售卖”价格日历表就要多设计一个可售数量字段这是另一个话题。4.2 下单事务与逐晚锁房这是整个系统最核心的代码路径目标是在并发请求下同一房间的同一个夜晚只能被一个订单锁住。我用的是“条件UPDATE判断受影响行数”的方式而不是“先SELECT再判断”。放在事务里执行Transactional(rollbackFor Exception.class) public BookingOrder createOrder(CreateOrderRequest req) { // 1. 校验房间存在、日期合法 Room room roomMapper.selectById(req.getRoomId()); if (room null) { throw new BizException(房间不存在); } // 2. 逐晚锁房试图把目标日期内的可预订记录全部改成锁定 int lockRows priceCalendarMapper.lockRoom( req.getRoomId(), req.getCheckInDate(), req.getCheckOutDate()); // 3. 如果锁定的行数不等于夜数说明有日期已经被别人占用 int nights calcNights(req.getCheckInDate(), req.getCheckOutDate()); if (lockRows ! nights) { throw new BizException(所选日期内房间已被预订请重新选择); } // 4. 计算总价 BigDecimal totalPrice priceCalendarMapper.sumPrice( req.getRoomId(), req.getCheckInDate(), req.getCheckOutDate()); // 5. 生成订单 BookingOrder order buildOrder(req, totalPrice, nights); bookingOrderMapper.insert(order); return order; }Mapper里的锁房SQL长这样UPDATE t_price_calendar SET stock_status 1, update_time NOW() WHERE room_id #{roomId} AND biz_date #{checkInDate} AND biz_date #{checkOutDate} AND stock_status 0这个设计巧妙在哪里它把“判断是否可订”和“执行占用”合并成了一步。数据库的UPDATE在InnoDB下会对命中的行加行锁并发请求同时执行时后一个请求会等前一个提交或回滚然后它的stock_status 0条件已经不成立受影响行数就是0从而判断锁房失败。这比“SELECT INSERT”的方案干净得多而且不容易出条件竞争。4.3 状态机的流转与取消释放订单状态如果只靠if-else散落在代码各处后面加一个“申请退款”或者“超时关单”的功能时就会很痛苦。我把状态流转集中收敛在OrderStatusEnum里当前状态可执行操作目标状态0 待支付支付成功后回调1 已确认0 待支付用户取消4 已取消1 已确认到店办理入住2 已入住1 已确认用户取消免费取消4 已取消2 已入住办理退房3 已完成每次状态流转都做合法性校验不合法直接抛业务异常。有一个操作特别容易遗漏取消订单时必须释放价格日历里的锁房标记否则这个房间以后再也卖不出去。取消逻辑这样处理Transactional(rollbackFor Exception.class) public void cancelOrder(Long orderId, Long memberId) { BookingOrder order bookingOrderMapper.selectById(orderId); // 校验订单归属和状态 if (!order.getMemberId().equals(memberId)) { throw new BizException(无权操作该订单); } if (order.getStatus() ! 0 order.getStatus() ! 1) { throw new BizException(当前状态不可取消); } // 释放锁房 priceCalendarMapper.releaseRoom( order.getRoomId(), order.getCheckInDate(), order.getCheckOutDate()); // 更新订单状态 bookingOrderMapper.updateStatus(orderId, 4); }释放的SQL就是把stock_status从1改回0UPDATE t_price_calendar SET stock_status 0, update_time NOW() WHERE room_id #{roomId} AND biz_date #{checkInDate} AND biz_date #{checkOutDate}这里的事务很重要释放锁房和更新订单状态必须同生共死否则可能出现订单取消了但库存还锁着的情况。我当时测试时就碰到过这种脏数据排查半天发现是方法上没有加Transactional。4.4 登录鉴权先够用再谈复杂这套系统登录用的是JWT不引入Spring Security全家桶。原因很简单一个民宿预约系统角色就两种用户、管理员写个拦截器比接一套Security配置快很多也更容易让接手源码的人读懂。实现方式登录成功后生成Token把memberId和角色塞进claims里前端请求时在请求头带Authorization: Bearer token后端用一个HandlerInterceptor统一解析放入ThreadLocalcontroller里直接取当前用户。管理员的几个接口单独校验角色字段即可。5. 前端实现日历选房与订单状态联动5.1 项目结构和路由拆分前端页面我按访问路径拆成了两组用户端和管理端。路由示例const router createRouter({ history: createWebHistory(), routes: [ { path: /, component: HomeView }, { path: /rooms, component: RoomListView }, { path: /rooms/:id, component: RoomDetailView }, { path: /order/confirm, component: OrderConfirmView }, { path: /orders, component: OrderListView }, { path: /login, component: LoginView }, { path: /admin, component: AdminLayout, redirect: /admin/orders, children: [ { path: orders, component: AdminOrderView }, { path: rooms, component: AdminRoomView }, { path: price, component: AdminPriceView } ] } ] });用户端路由用二级页面承载完整下单流程从房型列表选房间、选日期到确认订单、模拟支付、我的订单管理端单独提成Layout侧边栏放订单管理、房源管理、价格日历三个入口清晰也方便后续加菜单。5.2 日历选房组件的设计思路没有引入复杂的日历库因为需求本质是“选开始日期和结束日期”一个月视图表格完全能覆盖。组件交互设计成两步点击第一次点击设置入住日期第二次点击设置离店日期。点完之后组件把日期区间格式化成一个数组连同房间ID一起传给后端接口查可用价格。前端在界面展示上有一个细节某个日期如果已经被订完要立刻禁用不让用户点击。禁用判断来自查可用房接口返回结果里带上一个availableDates数组前端集合比较即可。这样体验比提交订单后才知道没房要好很多。订单确认页会显示每晚价格明细和总价明确列出“16日 280元 / 17日 280元 / 18日 520元合计1080元”让客人对得上账。这种明细在小景区民宿特别受用很多客人订房最怕的就是“系统显示总价但不知道怎么算出来的”。5.3 接口封装与登录态拦截axios封装是最值得先看的代码。统一实例里配好了baseURL、超时时间请求拦截器自动加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) { ElMessage.error(res.msg || 请求失败); return Promise.reject(new Error(res.msg)); } return res; }, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(error); } );后端接口统一返回{ code, msg, data }结构前端所有页面不必各自做错误判断拦截器处理掉一大部分页面里只关注成功数据。实际项目中维护这种统一结构比每个接口各回各的格式省心太多。5.4 管理端订单状态的可视化操作管理端订单列表的每一行按钮会根据订单状态动态禁用和变色。比如待支付状态只允许“取消”已确认状态会出现“办理入住”按钮已入住状态变成“办理退房”已完成和已取消不显示操作。前端做一层statusMap配置后端也做一遍状态流转校验两头都卡不会出现偷偷改接口绕过按钮状态的情况。6. 从源码到本地运行环境搭建、数据库初始化与双端启动6.1 环境版本与安装注意事项跑这套源码最小环境组合JDK 8、Maven 3.6、MySQL 5.7、Node 16.18。如果你本机已经是JDK 17建议要么装一个JDK 8备用要么确认源码用的是SpringBoot 3.x版本不要硬混。MySQL安装这里提醒一句Windows下用安装包经常报0xE0434352这个错误多数情况和运行库不完整有关。你如果卡在这先装最新版Microsoft Visual C Redistributable再以管理员身份运行安装程序还不行就直接改用zip免安装方式解压后用mysqld --initialize-insecure初始化然后启动服务。这个方法在开发机上最省事也最可控。6.2 后端启动三步走拿到源码后后端部分按这个顺序执行建库导数据。在MySQL里创建数据库库名建议和配置文件一致比如tourist_booking然后执行项目根目录下sql/init.sql。脚本里包含了建表语句和基础价格日历数据别只建库不导数据否则前端页面一片空白。改配置。打开application.yml确认数据源三件套URL、用户名、密码。URL里的serverTimezoneAsia/Shanghai要保留否则日期字段会差8小时。启动。在项目根目录执行mvn spring-boot:run或者用IDEA直接运行main方法。看到“Started Application in xx seconds”就是起来了默认端口8080。6.3 前端启动与代理前端部分在项目frontend目录下操作。先执行npm install这会花几分钟。如果Node版本过低会报错优先检查node -v。开发环境最重要的配置是Vite代理让前端请求自动转发到后端避免跨域。vite.config.js里这一段直接抄server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }然后npm run dev浏览器访问http://localhost:5173。前端所有接口路径都带/api前缀代理层会把它们原样转发到8080本地联调时完全感受不到跨域存在。6.4 “可直接运行”的验收清单我建议拿到源码后从头到尾测一遍这条链路而不是只看页面能打开就算跑通步骤预期结果打开首页进入房型列表能正常显示房源和封面选日期查询可用房日期不合法时前端有提示合法时显示每晚价格选择房间提交订单生成待支付订单价格数字正确模拟支付订单变为已确认管理端办理入住/退房状态分别变为已入住/已完成取消一条已确认订单订单变为已取消且同房间同日期重新可订最后一条最容易被忽略也是检验锁房释放逻辑是否正确的关键点。7. 运行调试过程中容易栽的跟头7.1 MySQL连接报SSL错误与Public Key Retrieval第一次启动后端最常见的两个报错一个是SSL connection error另一个是Caching SHA-2 password ... Public Key Retrieval。前者是MySQL 8默认开了SSL驱动和数据库协商不顺畅后者是MySQL 8默认认证插件的问题。解决方式在连接串里加两个参数jdbc:mysql://localhost:3306/tourist_booking?useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/ShanghaicharacterEncodingutf8useSSLfalse只适合本地开发和内网环境线上安全要求高时不要照抄这一条。7.2 SpringBoot版本过高导致的javax到jakarta问题网上很多代码分享是SpringBoot 2.x写的你拿到手图省事直接升级到3.x编译期就会报错很多类在javax.servlet包下找不到。原因是SpringBoot 3把Java EE的包名整体迁移到了jakarta.*。处理办法有两个一是保持原项目的2.7.x版本别手痒升级二是如果一定要用3.x全局替换javax为jakarta同时确认JDK是17。这个坑对新同学特别典型因为报错信息不会直接提示“你要换包名”而是提示“类不存在”容易让人误以为缺依赖。7.3 Vue Router history模式刷新404开发环境下Vite会默认处理history路由一般没问题。但如果你把前端build之后直接丢到Nginx或者Tomcat下刷新/orders这个二级页面会404因为服务器找不到对应的物理路径。Nginx下加一行兜底配置location / { try_files $uri $uri/ /index.html; }原理是让所有未命中的前端路由都回到index.html再由Vue Router自己根据URL去匹配页面。这个配置只要部署单页面应用都会用到值得记下来。7.4 跨域与Cookie携带问题开发环境通过Vite代理转发后基本不会接触跨域。但如果你脱离代理、直接让前端请求http://localhost:8080后端就得开放跨域。后端加了CorsFilter之后如果还涉及Cookie需要配置allowCredentials(true)同时前端axios要加上withCredentials: true两边必须配对不然Cookie带不过去。我的建议是能用代理就别开跨域代理是开发环境最省事的方式。等生产环境前后端同域部署更不会有这个问题。7.5 只有jar没有源码时的反编译找回思路最后说一个很多人问过的问题源码弄丢了本地只剩一个SpringBoot的可运行jar包怎么找回项目。这不是一个光彩的操作所以我先强调一下边界这个技巧只适合找回你自己写的项目或者你明确拥有授权的代码对别人发布的源码做反编译再打包既违反许可协议也可能踩法律红线。操作上主要靠三样东西JD-GUI、CFR、IDEA自带的反编译器。IDEA里直接打开jar包项目结构能被反编译成可读的Java类比命令行工具顺手。流程大致是用IDEA打开jar逐个包反编译把Java文件另存出来application.yml这类配置一般能从BOOT-INF/classes下找到依赖jar不需要反编译。反编译出来的代码能帮你理解逻辑、恢复业务层但注释、部分XML、原版目录结构会丢失想完全还原成一个可开发维护的工程还需要手工整理。8. 拿到源码之后的扩展方向这套系统的核心价值在于“房间可用性”和“订单状态”已经形成了一个完整闭环后续加功能都是在这个闭环上做增量不会推翻重来。我觉得比较自然的扩展方向有这么几个。第一个是接真实支付。在订单模块预留的模拟支付接口位置换成一个适配微信或支付宝的支付服务。支付回调里根据order_no更新订单状态为已确认同时把pay_time写进去。要注意的是回调接口不能直接信参数必须主动向支付平台查单或验签否则会被伪造回调刷单。第二个是多民宿改造。现在表结构里t_house是顶层单位但订单表没有冗余民宿ID多店运营时查询和管理维度都要按民宿过滤。可以给t_room加一个house_id所有管理端接口按当前管理员的民宿范围过滤。这一步改动不大但能让一个单店系统立刻变成可多店加盟的版本。第三个是入住率统计。价格日历表已经存了所有可售日期和价格订单表存了真实占用日期两张表一关联就可以算出任意时间段的入住率、营业收入、房型贡献。管理端加一个报表页面和几个统计接口这套系统就从“能用”变成了“能辅助经营决策”。我个人的体会是做这种源码项目真正值钱的不是页面漂不漂亮也不是用了多新的框架而是业务模型有没有想清楚。民宿和酒店的核心都是“把时间的使用权卖出去”价格日历加状态机这套设计换到营地、会议室、共享工位思路完全通用。你在研究这套源码时建议把重点放在第四章的锁房和取消释放上把那两条SQL吃透比多看十页前端代码有意思得多。
网站建设高端定制企业官网