
做民宿生意的朋友应该都有体会——订单一旦多起来微信聊天记录里找客人信息、Excel表格里算房费、日历上标记房态这套手动流程迟早会把人逼疯。我自己就经历过民宿旺季被重复预订搞到赔钱的教训才下定决心自己动手搓一套管理系统。这篇文章要说的就是基于Java Vue这套组合落地的一套民宿平台管理系统后端用Spring Boot MyBatis Plus前端用Vue 3 Element Plus带着完整的源码、数据库脚本和部署文档。不管你是想直接拿来改造成自己的管理后台还是想学一个完整的全栈项目练手这套系统里该有的模块——民宿管理、房型管理、订单流转、入住退房、评价管理、统计分析——都有落地的实现可以对照。1. 民宿经营的真实痛点与系统功能清单先说清楚一个问题为什么民宿老板需要一套管理系统而不是继续用记事本和Excel1.1 手工管理模式下最容易翻车的三个场景我最早做民宿是在一个二线城市手里七八套房源。刚开始订单少微信上聊两句、记个账本还行。但到了五一、国庆这种旺季三个场景直接让手工模式崩盘房态冲突客人A在OTA平台上订了5月1号到3号客人B在微信上问同一间房房东脑子一热回了有房结果两边都给了钱最后只能打电话道歉退款还要赔平台违约金。价格算错住了4天其中有两天是周末价一天是平台活动折扣各种优惠叠加下来Excel公式套错一位小数一单就少收两三百。订单信息散落客人的入住时间、身份证号、押金记录、钥匙交接情况一部分在微信收藏里一部分在纸上还有一部分在OTA后台。退房时对账对到半夜还经常漏掉浴室损坏的赔偿。这些问题单靠更细心的手工操作解决不了本质上是需要一套统一的信息模型房态、价格、订单、客人、账单这五类数据必须有明确的结构和流转规则。1.2 系统要管住的六件事做需求梳理时我把民宿平台管理系统拆成了六个核心功能域这也是整套代码的模块划分依据功能域核心职责典型操作民宿与房型管理管理房源信息、房间类型、设施配置新增房源、上下架房型、维护房间照片房态日历实时记录每间房每天的预订状态查看空闲/已订/锁定手动锁房订单管理从下单到退房的完整状态流转创建订单、确认入住、办理退房价格策略设置平日价/周末价/节假日价批量设置价格按日期覆盖客人管理维护客人档案与入住历史录入客人信息、查看历史订单数据统计查看入住率、营收、月度趋势按房源/时间维度汇总报表这套功能清单对应的就是项目里的homestay民宿表、room_type房型表、booking订单表、price_calendar价格日历表、guest客人表这几张核心表。2. 技术选型为什么是 Java Vue 这套组合很多做小项目的人会纠结用 PHP 快、用 Node.js 新潮、用 Python 写起来简单为什么要用 Java Vue2.1 后端选 Spring Boot MyBatis Plus 的真实理由我做这套系统选择 Spring Boot不是因为它最潮而是因为它在业务系统的稳定性和生态成熟度上确实最稳。版本上我选了 Spring Boot 2.7.x对应 Java 8兼容性最好也最容易部署到便宜的云服务器上1核2G就能跑起来。ORM 层面用 MyBatis Plus 而不是 JPA核心原因是民宿这种业务SQL 的掌控感太重要了。比如查某时间段内哪些房型有空房这种条件比较复杂用 MyBatis Plus 的QueryWrapper拼条件非常直观而 JPA 的 Specification 写起来绕。另一个贴心理由是 MyBatis Plus 自带代码生成器根据数据库表结构一键生成实体类、Mapper、Service 的基础代码能省掉起码一天的手写量——这也是热门搜索里mybatisplus根据Java实体类生成创建表的SQL语句被反复搜的原因。2.2 前端选 Vue 3 Element Plus 的取舍前端我用 Vue 3 Vite Element Plus Pinia Vue Router这套组合在中小型后台管理系统里基本是事实标准。选 Vue 3 不选 Vue 2 的理由很简单Composition API 在业务逻辑复用上确实清爽比如订单状态流转这个逻辑我抽成一个useOrderStatus()组合式函数在订单列表页、订单详情页、对账页三处复用不需要 mixin 那种命名空间污染。UI 组件库用 Element Plus 而不自己写组件理由更实际民宿后台的管理表格、日期选择器、表单校验这些组件自己写要花大量时间而 Element Plus 的el-table配合el-date-picker开箱即用外观还过得去。2.3 认证方案JWT 而不是传统 Session民宿系统有房东管理员和客人两种角色我用 JWT 做认证。没选 Session 的原因是项目可能会拆分成前后端分离部署Session 的 cookie 跨域问题比较麻烦JWT 的 token 放在请求头里前后端各管各的逻辑更干净。具体实现上后端用jjwt库生成 token登录接口校验用户名密码后签发 token有效期为 24 小时。前端在 axios 拦截器里统一从 Pinia store 取 token 塞进请求头遇到 401 响应就跳转到登录页。代码不长但这是整套系统安全性的地基。3. 数据库设计把房源、房态、订单的模型一次想清楚数据库设计是最容易一开始随便建几张表后面改到想哭的部分。我在这套系统里把表结构整理成了五组下面逐个说明关键的建模思路。3.1 核心表结构与字段职责第一版数据库脚本我写了 9 张表核心的几张如下用户表sys_userid, username, password, role, phone, create_time。角色字段用role区分ADMIN和GUEST不需要单独建权限表民宿这种小规模系统用枚举角色就够了。民宿表homestayid, name, address, cover_image, description, owner_id, status, create_time。status控制上架/下架下架后前端列表不可见但历史订单数据保留。房型表room_typeid, homestay_id, type_name, bed_count, area, price, max_guest, status。这里有个细节price只是默认价格基线真正每天的价格存在price_calendar表中后面细说。订单表bookingid, order_no, homestay_id, room_type_id, guest_name, guest_phone, check_in_date, check_out_date, total_amount, status, remark, create_time。价格日历表price_calendarid, room_type_id, date, price, is_available。按日期粒度记录每天的价格与放房状态。3.2 价格模型为什么会单独建一张 price_calendar这是这套系统最值得说的设计决策。民宿的价格不是恒定的工作日和周末差一截节假日翻倍碰到淡季还得搞特价活动。如果在room_type表里只放一个price字段那5月1号这间房卖600、5月2号卖500这种需求就完全没法表达。所以我把价格拆到price_calendar表按room_type_id date的唯一组合来记录每一天的价格。查询订单金额时不是用默认价格乘以天数而是查这个时间段内每一天的价格累加SELECT SUM(price) AS total FROM price_calendar WHERE room_type_id #{roomTypeId} AND date BETWEEN #{checkIn} AND DATE_SUB(#{checkOut}, INTERVAL 1 DAY);注意check_out_date存的是退房日期按民宿行业惯例退房当天不计算房费所以 SQL 里要用DATE_SUB往前减一天否则客人住两晚会被算成三晚。这个细节我在代码注释里专门标注过太容易错了。3.3 订单状态机的定义与流转订单状态我用一个status字段表达取值定义为状态值含义说明0待确认客人下单尚未支付1已支付支付完成等待入住2已入住办理入住后3已退房完成退房结算4已取消取消订单5已关闭超时未支付系统自动关闭状态流转的约束我放在 Service 层用 switch 判断而不是在数据库层做触发器。原因是业务规则会变比如取消订单是否需要退款、已入住的订单能不能取消这些逻辑在代码里改更灵活。实际项目中我用了一个OrderStateMachine类集中管理允许的流转路径避免在 Controller 里到处写状态判断导致逻辑散落。4. 后端核心模块接口拆解与业务逻辑实现4.1 民宿搜索与房态查询接口客人端最重要的接口是查可用房源入参是入住日期、退房日期、城市、人数返回匹配的民宿与房型。这个接口的核心 SQL 是反直觉的——不要先查民宿再判断有没有房而是先找被占用的房型再排除掉它们。// 伪代码展示核心思路 ListRoomType occupiedRoomTypes roomTypeMapper.findOccupiedRoomTypes(checkIn, checkOut); // 排除掉被占用的剩下的就是可订房型 QueryWrapperRoomType wrapper new QueryWrapper(); wrapper.notIn(id, occupiedRoomTypes.isEmpty() ? Collections.singletonList(-1) : occupiedRoomTypes.stream().map(RoomType::getId).collect(Collectors.toList())); wrapper.eq(status, 1);这里有个小坑当occupiedRoomTypes为空时notIn条件里的集合不能为空否则 SQL 会生成NOT IN ()直接报错。我见过不少人在这一步栽跟头顺手补了一个空集合兜底为-1的处理。4.2 下单与金额计算逻辑下单接口是这套系统里业务逻辑最重的一个接口流程分五步参数校验入住日期不能早于今天退房日期必须晚于入住日期。并发校验再次确认目标房型在时间段内未被占用。这步不能省因为前面的搜索接口是查询这里需要写库级别的确认。金额计算调用PriceCalendarService.getTotalPrice(roomTypeId, checkIn, checkOut)遍历日期累加价格。生成订单号我用的规则是yyyyMMddHHmmss 4位随机数保证并发下的唯一性。插入订单记录状态置为待确认同时锁定房态。第 5 步的锁定房态是很多新手容易忽略的下单后如果不锁定日历另一个客人搜索时还会看到这间房有空。我的实现是更新price_calendar表中对应日期段的is_available字段为 0。当然这粒度比较粗——正常应该用订单与房态的关联表来支持一间房多张订单的场景但对单体民宿系统来说直接锁日历最简单可靠。4.3 后台管理接口的分层设计管理端接口我遵循一个原则查询接口全部支持分页与条件组合。比如订单管理页面管理员需要按状态筛、按日期段筛、按房源筛这三个条件有大量的组合方式。实现上用 MyBatis Plus 的分页插件Page配合LambdaQueryWrapper前端传一个查询参数对象后端动态拼条件public IPageBooking queryBookingPage(BookingQuery query) { PageBooking page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperBooking wrapper new LambdaQueryWrapper(); wrapper.eq(query.getStatus() ! null, Booking::getStatus, query.getStatus()); wrapper.ge(query.getStartDate() ! null, Booking::getCheckInDate, query.getStartDate()); wrapper.le(query.getEndDate() ! null, Booking::getCheckOutDate, query.getEndDate()); wrapper.eq(query.getHomestayId() ! null, Booking::getHomestayId, query.getHomestayId()); wrapper.orderByDesc(Booking::getCreateTime); return bookingMapper.selectPage(page, wrapper); }实操建议LambdaQueryWrapper的条件方法第一个参数传布尔值为false时自动忽略该条件。这样前端不传某参数时SQL 不会拼上对应的 where 条件查询结果自然就是全部。这个技巧在写管理端接口时几乎天天用。5. 前端实现Vue 3 项目的目录结构与关键页面5.1 用 Vite 初始化项目后的目录划分前端项目我用 Vite 的create-vue模板初始化目录结构按业务模块划分而不是按views/components这种技术类型划分。现在的结构长这样src/ ├── api/ # 接口请求封装按模块拆分 │ ├── homestay.js │ ├── booking.js │ └── auth.js ├── assets/ ├── components/ # 通用业务组件 │ └── RoomCalendar.vue # 房态日历组件 ├── router/ # 路由配置 ├── stores/ # Pinia 状态管理 │ ├── user.js │ └── order.js ├── views/ │ ├── admin/ # 管理端页面 │ │ ├── Dashboard.vue │ │ ├── HomestayList.vue │ │ ├── BookingList.vue │ │ └── PriceManager.vue │ └── guest/ # 客人端页面 │ ├── Home.vue │ ├── HomestayDetail.vue │ └── BookingConfirm.vue ├── App.vue └── main.js这样划分的好处是新功能进来时先在api里加接口函数再到views对应模块里加页面路由文件里登记一下基本不用去翻别的目录。5.2 管理后台的表格页与表单页实现后台管理页面基本是表格 弹窗表单的模式。以民宿列表页为例核心其实是el-table的列配置与el-dialog里表单的校验规则。表单校验我用 Element Plus 内置的rules几个关键字段的校验规则直接贴给你参考const rules { name: [{ required: true, message: 请输入民宿名称, trigger: blur }], address: [{ required: true, message: 请输入地址, trigger: blur }], price: [ { required: true, message: 请输入默认价格, trigger: blur }, { pattern: /^\d(\.\d{1,2})?$/, message: 价格格式不正确, trigger: blur } ] };这里有一个隐藏的细节trigger我用了blur而不是change。对于 input 输入框blur在失焦时才校验体验更自然change会在每次输入变化时都触发校验输入过程中频繁弹红字非常烦人。5.3 axios 封装与接口调用的统一约定前端调用后端接口我统一在api/request.js里封装了一个 axios 实例做了三件事baseURL 指向后端地址、请求拦截器附加 JWT token、响应拦截器统一处理错误码。import axios from axios; import { useUserStore } from /stores/user; import { ElMessage } from element-plus; import router from /router; const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }); request.interceptors.request.use(config { const userStore useUserStore(); if (userStore.token) { config.headers.Authorization Bearer ${userStore.token}; } return config; }); request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { ElMessage.error(登录已过期请重新登录); router.push(/login); } else { ElMessage.error(error.response?.data?.message || 请求失败); } return Promise.reject(error); } ); export default request;后端接口统一返回{ code: 200, message: success, data: ... }这样的结构前端拿到的response.data就是整个包裹对象每个具体接口再取data。这个约定写进接口文档里前后端对照着做能省掉大量联调时的扯皮。6. 房态日历与价格批量设置民宿特有的复杂业务后台管理里我最想单独讲的是房态日历和价格设置因为这是民宿系统和普通酒店管理系统差异最大的地方。6.1 房态日历组件的设计前端我写了一个RoomCalendar组件按月展示某房型 30 天的房态。数据来源是一个按月查询的接口返回每天的状态码0表示空闲、1表示已订、2表示锁定。组件核心是用 Element Plus 的el-calendar改的但默认的单元格渲染太丑我通过#date-cell插槽自定义了每天的展示el-calendar v-modelcurrentMonth template #date-cell{ data } div classcalendar-cell :classstatusClass(data.day) span{{ data.day.split(-)[2] }}/span span v-ifdayStatusMap[data.day] classstatus-text {{ dayStatusMap[data.day] }} /span /div /template /el-calendar管理员在日历上点击空闲日期可以一键把该天锁定比如房东自己要住也可以点击已订日期查看对应订单信息。这个交互把日历和订单两个概念绑在了一起比单纯列表页直观得多——这也是民宿老板最习惯的使用方式。6.2 批量设置价格的实现思路价格管理页我做了两个入口按房型批量设置月度价格、按特定日期段设置活动价。批量设置的逻辑是前端选择一个房型、一个日期范围、一个价格策略固定价或者按平日/周末分段后端接收后先删除该范围内已有的price_calendar记录再重新插入新记录。用先删后插而不是逐条更新是因为用户可以修改日期范围逐条对账太容易出错。Transactional public void batchSetPrice(BatchPriceRequest request) { // 先删除范围内的旧价格 priceCalendarMapper.delete( new LambdaQueryWrapperPriceCalendar() .eq(PriceCalendar::getRoomTypeId, request.getRoomTypeId()) .between(PriceCalendar::getDate, request.getStartDate(), request.getEndDate()) ); // 再按日期插入新价格 LocalDate cursor request.getStartDate(); while (!cursor.isAfter(request.getEndDate())) { PriceCalendar pc new PriceCalendar(); pc.setRoomTypeId(request.getRoomTypeId()); pc.setDate(cursor); pc.setPrice(calculatePriceForDate(cursor, request)); pc.setIsAvailable(true); priceCalendarMapper.insert(pc); cursor cursor.plusDays(1); } }这里必须加Transactional注解。先删后插的操作如果中途报错不加事务会导致旧价格已经被删、新价格没插入日历上出现一片空白客人下单时金额直接算错。6.3 房态数据的缓存与刷新策略房态日历是管理端高频访问的页面对于只有几百间房的民宿系统其实不需要引入 Redis 缓存。我在 Service 层加了一个简单的Cacheable注解基于 Spring Cache Caffeinekey 是roomTypeId 月份缓存时间为 5 分钟。在订单创建、状态变更、价格修改这几个写操作上主动调用CacheEvict清除对应月份的缓存保证数据最好不超过 5 分钟的延迟。这套方案比直接上 Redis 简单得多性能上对民宿体量绰绰有余。等到房源量超过几千间再换成 Redis 分布式缓存也不迟。7. 部署打包与真实踩坑记录最后一部分聊聊一套系统从代码到能跑起来会经历哪些坑。这里记录几个我这套项目里真实遇到过的问题给后面接手的人省点时间。7.1 环境配置JDK、Maven、Node 的版本兼容项目用的是 Java 8 Maven 3.8 Node 16 Vite 4这个组合我已经在干净环境里验证过多次按以下顺序安装基本不会出大问题安装 JDK 8配置JAVA_HOME环境变量。安装 Maven 3.8.x配置settings.xml的镜像源为阿里云镜像否则拉依赖会很慢。安装 Node 16/18用npm install -g pnpm装包管理器。后端导入 IDEA 后先执行mvn clean package -DskipTests打包确认依赖能拉下来。前端在frontend目录执行npm install再npm run dev启动开发环境。热门搜索里那个 failed to load tsconfig vue/tsconfig/tsconfig.web.json 的报错我在新版本 create-vue 里也踩过。原因是全局安装的 TypeScript 版本和项目依赖的vue/tsconfig版本不匹配解决办法是删掉node_modules和package-lock.json后重新npm install保证版本解析全部基于本地 package.json。7.2 前端开发环境跨域与接口联调前端在开发模式下要请求本机后端的localhost:8080必然遇到跨域。我推荐用 Vite 的代理配置解决而不是在后端写CrossOrigin——生产环境下跨域配置容易成为安全隐患。// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } });这样前端代码里请求/api/homestay/list开发时会自动转发到http://localhost:8080/homestay/list后端不需要感知前端的端口。生产环境我建议直接用 Nginx 做反向代理把前端静态资源和后端接口都挂在同一个域名下从根源上消除跨域问题。7.3 后端打包的常见坑数据库连接池与端口占用后端部署时最容易出的两个问题一是打包后运行报数据库连接失败。排查方向不是先看密码而是先确认 MySQL 的wait_timeout是否过短。我用的是 HikariCP 连接池配置里connection-test-query: SELECT 1并设置了max-lifetime为池中连接的最长存活时间低于 MySQL 的wait_timeout避免半夜没有请求时连接被 MySQL 断开后继续复用导致报错。二是8080端口被占用。这个坑最无语但最常见。用lsof -i :8080查到 PID 后直接kill -9或者启动命令里指定--server.port8081换个端口都能快速解决。7.4 数据库脚本的初始化细节项目自带的数据库脚本init.sql包含了建库、建表、初始数据三部分。执行时注意一点mysql -u root -p init.sql如果是在线上服务器执行建议先CREATE DATABASE homestay DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;再切换库执行建表语句。如果直接跑整个脚本要确保脚本开头有USE homestay语句否则表可能被建到默认库里后面项目启动时一脸懵。另一个容易被忽略的细节MySQL 8 默认的认证插件是caching_sha2_password而 JDBC 驱动版本如果太老可能不兼容报Unable to load authentication plugin。解决方法是把 mysql-connector-j 升级到 8.0.x 以上或者建用户时指定IDENTIFIED WITH mysql_native_password BY 密码。写在最后的一点个人建议这套民宿平台管理系统从数据库建模到前后端实现前前后后花了我大概三周的空余时间。回过头看最有价值的不是写完了多少行代码而是把民宿经营里房态、价格、订单这三件最容易出乱子的事用清晰的数据模型和状态机固定了下来。哪怕你最终不打算自己写代码只是在经营中用这套系统的思路去梳理自己的房源台账也能少踩很多坑每天的价格单独记录而不是拍脑袋定、订单状态必须有明确的流转路径、房态一旦被锁就要保证全局唯一。如果你准备基于这套源码二次开发我建议第一个要扩展的方向是接入真实支付渠道把待确认到已支付这步打通第二个方向是给价格策略加上自动化规则让节假日调价不用再手动批量设置。这两步做完它就能从一个课程项目级别的系统变成一个真正能支撑日常经营的工具。