ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue民宿预订系统实战:从建表到下单闭环全解析

SpringBoot+Vue民宿预订系统实战:从建表到下单闭环全解析 做民宿预订系统这个坑我前前后后填了三个版本才真正落地。从最初只写了个能登录取餐的CRUD到后来把“在线预定、房间库存、订单状态机、管理统计”全部串起来才算对得起标题里“管理系统”四个字。这个项目的技术栈很标准SpringBoot Vue Java MySQL MyBatis几乎是国内中小团队做业务系统的首选组合源码也在GitHub上活了很久。如果你正打算做一个完整的Java全栈项目来面试、毕设或者给民宿业务方做内部工具这篇文章值得你花二十分钟看完——我会把从建表到下单闭环怎么一步步打通、哪些坑必须绕开全部用实际踩过的经历讲清楚。市面上写“SpringBootVue民宿预订系统”的资料不少但大多数是拿开源模板改个皮真正能把订单、库存、支付回调、管理端统计这几块的边界讲明白的极少。我会先拆业务模型再给表结构然后把后端核心流程、前端页面和部署方案一个个过最后放一个常见问题排查表。思路不绕弯直接按“能跑、能改、能上线”的标准来。1. 项目整体设计与需求拆解1.1 民宿系统到底在解决什么业务问题民宿在线预定和普通电商下单最大的区别在于商品粒度是“房间”不是“SKU”而房间的库存是和时间绑定的。一间大床房今天能订明天可能就被人锁了用户选“7月20日入住7月22日离店”他占用的是这间房两个晚上的时间片。所以你不能像卖手机一样只在商品表里减一个stock字段而是要设计一套“时间房间”维度的可售状态判断。从角色上看系统天然分成两类用户一类是C端的游客他们要搜民宿、看详情、选日期、下单支付、查订单另一类是B端的管理员他们维护房源、改价格、关房、处理订单和退款、看经营数据。这两个角色如果要硬塞进一套页面里后期一定会乱。我第一版就是没拆分结果用户管理界面里躺着几十个“后台菜单”登录逻辑也一团乱麻。第二版把管理后台和用户端彻底分开用户走/home管理走/admin自定义路由守卫按角色跳转清爽太多。所以做这类系统的第一步不是写代码而是把边界划清楚用户端只关心“找房子、订房子、看订单”管理端只关心“管房子、管订单、看数据”支付和消息通知属于公共模块谁都可以调。1.2 为什么选SpringBootVueMyBatis这套组合这套组合在国内这么流行不是因为“最先进”而是因为“最不折腾”。SpringBoot解决了传统SSH/SSM配置地狱的问题一个内嵌Tomcat、一个application.ymlJar包一打服务器上Java -jar一条命令就启来Vue拿来写前后端分离的页面组件化开发对民宿预订这种多步骤流程搜索、筛选、详情、下单、支付特别友好MyBatis的XML SQL在复杂条件查询和结果映射上几乎随心所欲写一个动态SQL就能应对“城市日期人数价格区间”的任意组合筛选。还要提一下为什么不用JPA。民宿这个项目里订单表、房间表、民宿表之间有大量自定义查询比如“查询某时间段内某房间的所有冲突订单”“统计各民宿本月入住率”这种SQL用JPA写简直想砸键盘用MyBatis就是一行带where标签的动态语句。当然MyBatis也有它的短板比如缓存默认行为容易让人踩坑这个我到第7节专门说。1.3 版本选型和环境准备版本这个事看似小事实际能劝退一批人。我建议SpringBoot用2.7.x不要一上来就3.x。原因很朴素3.x要求Java 17起步很多教学资料、视频和开源组件还是基于Java 8写的旧习惯而且2.7.x对于MyBatis、PageHelper、Druid这些常用组件的兼容性最稳。你在网上搜“springboot版本太高”的报错帖十有八九是3.x和某个引入的旧依赖干架。前端建议Vue3 Vite配Element Plus组件库。Vue2虽然还在存量市场但新项目再用Vue2就有点给面试官留话柄了。Vite开发启动比Webpack快一个量级HMR体验也好。数据库选MySQL 5.7或8.0都行8.0记得驱动用com.mysql.cj.jdbc.Driver并且URL里显式带上serverTimezoneAsia/Shanghai不然时间差8小时的经典坑就来了。Java环境上我用的是JDK 1.8配Maven 3.6.3这两样足够了别追新。新建工程时用Spring Initializr把Web、MySQL驱动、MyBatis依赖选上一个空壳就出来了。前端用npm create vitelatest然后npm install element-plus axios vue-router pinia这四个包是主力其他按需加。装依赖这件事我记得花了不少时间如果网络不好可以把npm镜像切成淘宝源也就是把registry.npmjs.org替换成registry.npmmirror.com速度立刻不一样。2. 数据库表结构设计与关系梳理2.1 核心表拆解从用户到订单的链路先给结论这套系统我最终拍了7张表user用户、homestay民宿、room房间、order订单、payment支付记录、review评价、admin管理员。另外搞一张collection收藏表给用户端加一点粘性。user表字段不多username、password、phone、avatar、create_time。密码存的是BCrypt加密后的串千万别明文存这是红线。admin表类似但权限字段直接用role_level控制1是超级管理员2是民宿操作员避免引入Spring Security的RBAC大概念——不是说RBAC不好是一个民宿管理平台的管理员就那么两三个人搞庞大权限模型纯属给自己加戏。homestay表我建议把核心展示字段放在主表里name、city、address、cover_img、description、facilities、score、status1上架0下架、create_time。设施不要硬拆十几张关联表用JSON字符串或者逗号分隔存进一个字段查询时直接取出来解析展示场景远远大于统计场景。room表是最容易被忽视的核心。一个民宿下往往有多种房型每种房型有自己的价格和库存所以room表要有homestay_id做外键字段包括room_type、area、bed_info、price、stock整个房型一共几间、room_status。我需要特意提示价格这里存的是“每晚价格”而不是“整单价格”。有些新手把整晚价格和总价混在一起存等到做多日订单的时候算钱都算不清楚。订单总价应该根据入住天数动态计算公式很简单就是每晚价格 * 入住天数 * 间数。order表是整个系统的核心字段我列一下order_no唯一订单号、user_id、homestay_id、room_id、check_in_date、check_out_date、room_count、total_price、status、create_time、pay_time、cancel_time。status我用整数枚举0待支付、1已支付/已确认、2已取消、3已完成、4退款中。为什么不用状态表因为订单这个场景的状态流转是强逻辑枚举写在代码里最直观真要做状态机也是在后端Service里控制不是靠数据库表去约束。2.2 索引设计与库存处理逻辑索引不过度设计但该有的必须有。order表上order_no建唯一索引user_id建普通索引用户查订单homestay_id建普通索引民宿查订单。room表上homestay_id建普通索引查询一个民宿下所有房间就靠它。homestay表上city建普通索引因为首页搜索的基本盘是城市。什么“复合索引”“覆盖索引”在这种数据量级下意义不大真正的性能瓶颈在后端循环查库的N1问题这个到MyBatis那节讲。这里重点说说房间库存。民宿房间的“库存”不是物理库存而是时间片上的剩余量。同一间大床房7月20日可订7月21日被订了那7月21日这天的剩余就是0。我第一版直接用room.stock字段减一结果用户选了不同日期同一间房在不同日期上明明没有冲突系统却因为全局库存减一导致买不到这就是把“时间维度”丢了之后的典型翻车。正确做法是在生成订单时先去查订单表里有没有“时间重叠”的已支付或待支付订单。重叠判断逻辑是check_in_date 新订单的check_out_date AND check_out_date 新订单的check_in_date两条条件同时成立就是冲突。这属于典型的数据库区间查重一条SQL就能查出来。库存充足时直接下单库存不足就提示用户换日期或换房型。为了支撑“按日期查可售”我建议加一张room_calendar表room_id、date、stock、price。这样每个房间每天一个库存行价格也可以支持“节假日加价”的运营操作。前台选完日期直接查这个日历表把范围内的日期一汇总就知道哪天满房哪天还有价。管理端改价格也直接改日历表映射对应的日期价格不用影响其他日期。这张表的代价是数据量膨胀但一个民宿几百个房间一年也就十几万行MySQL完全扛得住。3. 后端核心流程与实现细节3.1 项目工程结构和启动配置后端我按MVC分包controller→service→mapper外加entity、dto、config、common放统一返回体和异常处理。controller只做参数接收和路由定义业务全压在service层这样代码换壳比如从Controller换成RESTful风格不用动业务逻辑。application.yml有几个关键配置要提前写好端口我用8080上下文路径不设数据源用Druid连接池网上说是国产开源里文档最全的用完确实省事MyBatis配置里map-underscore-to-camel-case设为true这样数据库的create_time能自动映射到Java的createTime省去一堆Resultsmybatis.mapper-locations指到classpath:mapper/*.xml所有SQL统一写在XML里不写注解SQL——理由后文详述。提示SpringBoot 2.7.x 的MyBatis Starter要用mybatis-spring-boot-starter不要误用mybatis-plus-boot-starter后者虽然流行但会改变一部分MyBatis原生行为如果你都没搞明白什么是二级缓存和逻辑删除先别急着引入Plus。3.2 统一返回体与全局异常处理前后端分离的项目必须统一接口返回格式。我定义一个ResultT结构字段就三个code200成功500失败401未登录、msg、data。所有Controller的返回值都包装成Result前端Axios拦截器里直接判断code不用每次写res.data.data的三层嵌套。全局异常处理用RestControllerAdvice定义两个Handler一个是BizException业务异常比如“该房间在当前日期已满房”返回500和自定义msg另一个是兜底的Exception把异常栈打到日志里返回“系统繁忙请稍后重试”。这个设计的意义在于你不可能在每个service方法里都try-catch统一处理以后controller层就非常干净。拦截器上我做了登录校验。用户端和管理端共用一个JWT工具类前端登录后把token存到localStorage后续每次请求在Axios拦截器里往header塞Authorization: Bearer {token}。后端写一个AuthInterceptor对需要登录的接口校验token有效性白名单是登录、注册、民宿列表、民宿详情这些不需要身份的接口。JWT的秘钥、过期时间放在配置文件里秘钥用一串随机字符串别用“secret”这种路人甲默认。3.3 民宿搜索与详情动态SQL是核心民宿列表页是用户端的门面筛选条件有城市、入住日期、离店日期、入住人数、关键词、价格区间和排序方式。这些条件全部可选不能写死SQLMyBatis的where和if标签就是为这种场景设计的。核心逻辑先筛选出符合城市和关键词的民宿再用子查询或者JOIN关联日历表判断所选日期范围内该民宿是否存在可售房间。我用的SQL骨架是这样select idsearchHomestayList resultTypecom.demo.entity.Homestay SELECT DISTINCT h.* FROM homestay h WHERE h.status 1 if testcity ! null and city ! AND h.city #{city} /if if testkeyword ! null and keyword ! AND (h.name LIKE CONCAT(%, #{keyword}, %) OR h.address LIKE CONCAT(%, #{keyword}, %)) /if AND EXISTS ( SELECT 1 FROM room r where r.homestay_id h.id if testcheckIn ! null and checkOut ! null AND r.id IN ( SELECT room_id FROM room_calendar WHERE date gt; #{checkIn} AND date lt; #{checkOut} AND stock 0 GROUP BY room_id HAVING COUNT(*) DATEDIFF(#{checkOut}, #{checkIn}) ) /if /where ) if testsortType priceAsc ORDER BY h.min_price ASC /if if testsortType priceDesc ORDER BY h.min_price DESC /if /select这里有一个关键细节date gt; #{checkIn} AND date lt; #{checkOut}右边的离店日期是不等号因为最后一天客人中午退房不算占用库存。HAVING COUNT(*) DATEDIFF(#{checkOut}, #{checkIn})才是精髓如果范围内每一天都有库存行数恰好等于天数任何一天没房行数就少民宿直接出局。这一个SQL就把“按日期查有房民宿”解决了是全网题里很少讲透的点。分页我建议直接用MySQL的LIMIT语句配合PageHelper插件。PageHelper的好处是拦截器自动帮你执行COUNT查询不用手写两遍SQL但用的时候要注意一定要在PageHelper.startPage()之后第一句查询才是分页生效的中间不能塞别的查询不然“分页分错表”的灵异现象就来了。3.4 下单流程与支付状态机下单是整个项目最需要小心的地方。我的流程分四步第一步校验参数。入住日期不能早于今天离店日期必须晚于入住日期入住人数不能超过房间最大入住这些基础校验写在service入口不要指望前端校验。第二步校验房间在时间段内是否空闲。执行的SQL就是前面说的区间重叠查询SELECT COUNT(*) FROMorderWHERE room_id #{roomId} AND status IN (0,1) AND check_in_date #{newCheckOut} AND check_out_date #{newCheckIn}这个count大于0就说明有冲突订单直接抛BizException。第三步计算价格。价格要按日历表的每日价格累加不能直接拿room表单价乘天数。节假日加价场景下日历表的价格和基础价格不一致是常态。累加的时候注意BigDecimal计算不要用double算钱double精度在金融计算里是事故多发地。第四步创建订单并触发支付。订单号我这样生成yyyyMMddHHmmss 用户ID后四位 三位随机数重复概率够低也好排查订单状态初始化为0待支付。然后调支付接口。这里的支付是模拟支付因为真实对接微信/支付宝需要商户号和企业资质学生项目和个人练手基本走不到那步。模拟支付接口直接返回“支付成功”订单状态更新为1。状态机要管好待支付订单能不能被取消能。已确认订单能不能在入住前一天取消视商家退款策略而定我做成可以取消并自动退款。已完成订单能不能再改不能只能评价。这些判断不能散落在Controller里我统一放在OrderService.changeOrderStatus()方法里通过一个Map旧状态, List允许的新状态做状态合法性校验新状态不在列表直接抛异常这样不会出现“用户取消了一个已退款的订单”这种乌龙。4. Vue前端从零搭建到跑通4.1 Vite工程与目录规划前端工程我建在项目根的frontend目录下和后端源码分开部署时再合并。用npm create vitelatest frontend -- --template vue创建一个干净的Vue3项目。装完依赖后在src下建views页面、components公共组件、router路由、storePinia状态、api接口封装、utils工具函数几个目录。路由设计上用户端和管理端彻底分开两个路由层级。/home、/homestay/:id、/order、/order/:id、/user这些给用户用/admin、/admin/rooms、/admin/orders、/admin/stats这些给管理员用。用router.beforeEach统一做守卫访问/admin开头的路由先检查token再检查userInfo.roleLevel是不是管理员两个条件不满足就redirect: /login。Pinia我主要用来存用户信息和购物车式的“待确认订单信息”。从民宿详情页选完日期和房型点击“立即预订”跳转订单确认页中间状态如果只靠路由参数传递会非常冗长日期、间数、价格、民宿名全是参数用Pinia存一个JSON对象是最舒服的。刷新页面会丢这个状态所以订单确认页要在onMounted里做一次兜底Pinia里没数据就根据路由参数重新查询后端接口。4.2 核心页面拆解与组件复用民宿列表页是整个前端的重头戏。我在左侧放筛选栏包含城市选择、日期范围、入住人数、价格区间右侧放民宿卡片列表。日期范围用Element Plus的el-date-picker设置typedaterange和value-formatYYYY-MM-DD注意value-format必须设否则拿到是Date对象传给后端序列化成时间戳很容易出bug。筛选栏的每个变化都要重新拉取列表接口所以我把所有筛选条件放进一个reactive对象里用watch监听变化就重查。民宿详情页有四个区块要做轮播图Element Plus的el-carousel、基本信息、房间列表每个房间一个卡片标出每晚价格和可订日期、评价列表。房间卡片上要动态显示未来7天价格和库存状态这个数据从room_calendar接口拿用一个小表格在卡片下方展开。评价区是独立的ReviewList.vue组件接收homestayId内部拉取评论数据并支持分页。订单确认页核心是核对信息。从上一步带来的数据包展示“民宿名、房型、入住/离店日期、间数、总价”下面让用户填入住人姓名和手机号最后点“提交订单”。提交按钮必须做防重复点击处理点击后立即把按钮置为loading禁用成功或失败后再恢复。这个方法看似简单却救了我无数次——用户在弱网环境双击导致的重复下单后端还没做幂等判断的时候这个前端兜底尤其重要。订单列表页按状态切Tab待支付、已预订、已取消、已完成。每个Tab对应一个状态码过滤后端接口按status参数返回对应订单。订单项里展示民宿封面缩略图、名称、入住时间、总价“待支付”状态的订单要能跳转“支付模拟页”“已完成”的订单要能跳转“评价页”。4.3 Axios拦截器与接口统一管理我在src/api/request.js里封装了一个Axios实例baseURL设为/api生产环境由Nginx把/api转发到后端8080端口这样前后端联调和部署不用改代码。请求拦截器里从Pinia拿token塞到Header响应拦截器里统一判断res.data.code200直接返回res.data.data401跳登录页并清掉过期token500用Element Plus的ElMessage弹出msg。接口文件按业务模块拆分homestay.js列表、详情、日历、order.js创建、列表、详情、取消、user.js登录、注册、信息、admin.js民宿管理、订单管理、统计。每个接口导出的是一个箭头函数返回Promise对象组件里const res await getHomestayList(params)拿到的直接就是业务数据少套一层。这样封装的好处是后端接口如果改名你只需要改api目录下的一个文件前端页面不用动。5. 管理后台与经营数据5.1 民宿上下架与房间日历管理管理员登录后进入管理后台首页左侧菜单有“民宿管理”“房间管理”“订单管理”“评价管理”“数据统计”。民宿管理列表展示所有民宿支持按状态筛选和下架操作。编辑民宿信息时图片上传我用Element Plus的el-upload组件后端提供一个接收MultipartFile的上传接口返回图片URL前端把URL存进民宿表单。房间管理是后台操作频率最高的模块。点进一个民宿能看到它的所有房间列表每个房间右侧有个“管理日历”按钮弹出日历面板——这个日历面板要按月翻页每一格显示对应日期的价格和剩余库存管理员可以直接改价格、把某天库存清零相当于关房。这个功能的技术方案不复杂就是查room_calendar表对应月份的数据Vue渲染成一个二维数组修改后批量调保存接口。但开发时容易被“日历组件难写”吓到其实用Element Plus的el-calendar组件包一层自定义header就能一个月一个月翻页。运营中一定会遇到“今天有人下了一个待支付订单把某房间锁了好几天但这个用户其实不打算付钱”的尴尬。所以管理端要有“释放锁定订单”的入口待支付订单超过30分钟自动取消。我后台写了一个定时任务用SpringBoot的Scheduled注解每5分钟跑一次把所有待支付且创建时间早于当前时间30分钟的订单批量置为取消状态并释放对应的库存时间片。定时任务毫不复杂但要有不然房间会被占着不付钱。5.2 订单金额、退款与统计报表订单管理列表核心是看金额和状态。每一单要展示total_price、pay_time、status管理员可以执行“确认退款”。退款逻辑是把订单状态改成已取消并同步往payment表里插一条退款记录金额为负数因为后续要和支付记录对账。对个人项目来说不需要对接真实支付通道但退款记录的数据链路不能断否则“钱去哪了”说不清。数据统计我用ECharts画两个图一个柱状图展示最近7天订单量一个饼图展示各民宿收入占比。SQL很简单订单表按create_time分组统计收入按homestay_id分组求和条件是status1已支付。统计模块建议在后面接一个“导出Excel”的小功能用EasyExcel导出订单列表业务方看着比线上页面直观。这个功能加起来不到一百行但客户满意度能拉高一大截属于低成本高感知的隐形亮点。6. 前后端打包与部署发布6.1 本地开发环境联调要点本地联调最常见的坑是跨域。Vite开发服务器默认在5173端口后端在8080端口浏览器直接请求必然会遇到CORS跨域。解决方案有两个一个是后端写CORS配置类允许所有跨域请求另一个是在Vite配置里加server.proxy代理把/api开头的请求转发到http://localhost:8080。两种方式我都用过Vite代理更适合“假装前后端同源”能规避掉cookie跨域和Authorization头跨域两件事。如何在vite.config.js里配置export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })后端代码不要写CrossOrigin了既然前端已经代理就没有跨域了两个方案不要重复上重复了容易造成访问源头混乱。6.2 服务器部署Nginx SpringBoot Jar部署我采用经典一前一后架构前端打包成静态文件交给Nginx后端打Jar包用systemd守护进程拉起。前端执行npm run build生成的dist目录上传到服务器/usr/share/nginx/html后端执行mvn package -DskipTests把生成的Jar放到/opt/app目录。Nginx配置里两个关键块一是location /指向前端静态目录并配置try_files $uri $uri/ /index.html不然刷新一个深层路由比如/homestay/12会报404这个坑十个人里九个踩二是location /api/做反向代理到http://127.0.0.1:8080/注意末尾的斜杠proxy_pass http://127.0.0.1:8080/;表示把/api前缀去掉再转发后端Controller里就不用再写/api前缀了。数据库初始化时直接导入项目的sql/init.sql脚本里面包含建库、建表和测试数据。测试数据这一步别偷懒我建议手工造10家民宿、30个房间每个房间生成未来30天的日历库存前台打开才像样。生成日历数据可以用一段简单的SQL或者写个一次性Python脚本循环插入三个月的数据量完全在可控范围。7. 常见问题与排查技巧实录我做这个项目时遇到最频繁的坑列一个速查表都是实际发生过的。现象根因解决方案前端请求接口报No Access-Control-Allow-Origin前后端分离但没走代理直接跨域前端配Vite proxy或后端加全局CORS配置刷新房源详情页404Nginx没有回退到index.html加try_files $uri $uri/ /index.html数据库时间比本地早8小时MySQL连接URL没指定serverTimezone在JDBC URL加serverTimezoneAsia/Shanghai分页数据重复或漏数据排序字段有重复值ORDER BY加主键/id作为第二排序字段双人同时抢同一房间最后一天都没查数据库直接减库存下单前走区间冲突查询事务加行锁下架民宿在列表里还能搜到列表SQL没过滤status列表WHERE强制加h.status 1房间日历库存显示错误日历表日期类型存成了时分秒日期字段统一用DATE类型不要用DATETIME另外还有三个特定技术点展开说一下。第一MyBatis的缓存默认行为要心里有数。一级缓存是和SqlSession绑定的Spring事务里每次都新建SqlSession所以一级缓存基本不生效二级缓存默认关闭因为开启以后一个namespace的查询缓存会覆盖另一个表的更新跨表查询会出现脏数据。我的建议是别开二级缓存。项目里查询重点在动态SQL和索引优化上而不是靠缓存掩盖SQL问题。第二MyBatis的N1查询问题是民宿列表性能杀手。如果只查民宿列表然后循环去查每个民宿的房间和日历一个列表10条数据数据库要执行30次查询。解决方案是使用多表关联查询一次性查出房间信息比如用一个HomestayVO接收左连接后的结果集再用collection标签映射子集合。但这个方案也有坑分页时如果在一对多结果集上用PageHelper统计行数会算错所以要“先分页出民宿ID再IN查询房间数据”两条SQL稳如老狗。第三Vue前端源码转移时最容易炸的是node_modules目录。你把自己的工程发给别人时一定要把node_modules删掉对方拿到源码后执行npm install自己装依赖。不然这个文件夹动辄几百MB传到一半就断了。我见过好几个人发了个带node_modules的压缩包微信传半天传不过去最后问我怎么解决其实删了重新npm install比传包快得多。最后说一点体会这套系统做到后面真正耗时间的不是功能代码而是数据一致性下单和取消之间、日历库存和订单状态之间对不上就乱套。我试过用Redis分布式锁再把并发控制升级一版效果确实更好但对一个中小型民宿平台而言靠MySQL事务加唯一索引已经能覆盖绝大多数场景先把基础架构的逻辑盘清楚再考虑上中间件否则项目只会越做越重。你先把这个版本跑通后续扩展的方向也很清晰接入真实支付、消息推送、多民宿主入驻、价格日历优化——每个方向都是独立课题但这次的骨架撑得住。
返回列表