简介:整套源码基于Java+Springboot+Vue搭建,是典型的前后端分离民宿预订管理系统,面向计算机专业毕业生、在校生以及需要快速上手企业级项目的初中级开发者。系统后端由79个Java源文件构成,涉及服务端逻辑处理、数据持久化、业务逻辑层、接口定义等层次,覆盖民宿信息管理、用户认证授权、订单处理、支付接口对接等功能;前端则包含38个Vue组件、24个TypeScript文件与20个JavaScript文件,负责页面渲染与用户交互,TypeScript的引入提升了代码可读性与模块化程度;另有10个XML配置文件管理项目结构和数据库连接,19个PNG与125张JPEG图片提供界面视觉素材。压缩包共381个文件,大小10.17MB,以zip格式打包,代码按web前端与server后端分目录组织,目录层级清晰,并附带代码说明.txt、readme.md、表结构文档及SQL脚本,便于理解设计思路和快速二次开发。目前已有475人学习下载,适合作为毕业设计、课程设计或前后端分离项目的教学参考案例,也可用于Java开发的项目复盘与技能提升。
1. 民宿预订管理系统为什么适合用 Java + Spring Boot + Vue 这套组合
做民宿预订管理系统,选型最忌讳一上来就堆微服务。常见的做法是先用单体应用把业务跑通,等并发量真正上来了再拆。Java + Spring Boot + Vue 的组合恰好覆盖了民宿预订这类中小型业务系统的绝大多数需求:Spring Boot 负责 RESTful API 层和业务逻辑,Vue 负责前端交互和状态管理,前后端通过 JSON 通信实现真正的工程分离。相比 JSP 时代的前后端耦合方案,这种架构让前端同学和后端同学可以并行开发,而且未来做小程序端或移动端时,后端接口可以直接复用,不需要做任何改动。
从数据流来看,民宿预订系统的核心链路很简单:用户在前端页面浏览民宿和房态日历,提交预订订单,后端校验房间在选定日期内是否可订,扣减库存并生成订单记录,最后通过短信或邮件发送确认通知。这套链路里最容易出问题的地方是房态控制——不同民宿对同一房型的库存管理方式不一样,有的按间数扣减,有的按日期粒度锁定,所以数据模型的设计比接口设计更需要想清楚。
本文适合正在做这类业务系统的工程师阅读,无论你是要自己从零搭建一套民宿预订管理平台,还是接手一个类似的预订类项目需要理清脉络,下面这些内容都会围绕实际可运行的代码展开:从建表 SQL 到 Spring Boot 接口,再到 Vue 页面的完整对接。需要说明的是,这里不依赖任何特定开源项目,只用社区最主流、最容易替换的依赖组合,让你换掉其中任何一层都不会被锁死。
2. 数据模型设计:民宿、房型、订单与库存表的关系
2.1 库存表是整套系统的核心枢纽
民宿预订系统的数据模型并不复杂,但库存表的设计往往决定了后面的并发控制难度。很多刚接触这类系统的同学喜欢在订单表里直接存入住日期和离店日期,然后每次查询时用日期区间去判断房间是否冲突。这种做法在小数据量下没问题,但一旦用户并发提交订单,用区间判断是否需要加锁、怎么加锁就会变得非常棘手。常见的实现方案是单独拆出一张每日库存表room_stock,以“房型 + 日期”为粒度去记录每天的可售数量。
下面这份建表 SQL 是这类系统的标准骨架,覆盖了民宿、房型、每日库存、订单、订单明细五个核心实体:
-- 民宿表 CREATE TABLE `homestay` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL COMMENT '民宿名称', `address` varchar(255) DEFAULT NULL COMMENT '地址', `cover_url` varchar(512) DEFAULT NULL COMMENT '封面图地址', `status` tinyint(4) DEFAULT '1' COMMENT '状态:1上架 0下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='民宿表'; -- 房型表 CREATE TABLE `room_type` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `homestay_id` bigint(20) NOT NULL COMMENT '所属民宿ID', `name` varchar(64) NOT NULL COMMENT '房型名称,如大床房、双床房', `price` decimal(10,2) NOT NULL COMMENT '挂牌价', `daily_stock` int(11) NOT NULL DEFAULT '1' COMMENT '每日可售房间数', `area` decimal(6,2) DEFAULT NULL COMMENT '面积', `bed_info` varchar(64) DEFAULT NULL COMMENT '床型信息', `max_occupancy` int(11) DEFAULT '2' COMMENT '最大入住人数', PRIMARY KEY (`id`), KEY `idx_homestay` (`homestay_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房型表'; -- 每日库存表(核心表) CREATE TABLE `room_stock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `room_type_id` bigint(20) NOT NULL COMMENT '房型ID', `stock_date` date NOT NULL COMMENT '日期(精确到天)', `total_stock` int(11) NOT NULL COMMENT '初始总库存', `booked_stock` int(11) NOT NULL DEFAULT '0' COMMENT '已预订数量', `version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_room_date` (`room_type_id`, `stock_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日库存表'; -- 订单表 CREATE TABLE `booking_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `user_id` bigint(20) NOT NULL COMMENT '用户ID', `room_type_id` bigint(20) NOT NULL COMMENT '房型ID', `check_in_date` date NOT NULL COMMENT '入住日期', `check_out_date` date NOT NULL COMMENT '离店日期', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0待支付 1已支付 2已入住 3已完成 4已取消', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';拆出每日库存表最直接的好处是:扣减库存不再依赖日期区间计算,而是对booked_stock字段做原子更新。version字段是乐观锁版本号,当两个用户同时预订同一个房型时,只有一个更新操作能成功,另一个会因版本号不匹配而失败,这时后端可以友好地提示“房间刚被订走,请重新选择日期”。uk_room_date唯一索引保证了同一房型在同一个日期只存在一条库存记录,避免初始化库存时重复插入。
2.2 订单状态机的边界处理
订单状态流转是这类业务系统里最容易产生歧义的地方。booking_order表中的status字段虽然只有几个简单的枚举值,但状态之间的临界条件需要明确约定。正常流程是从待支付流转到已支付,再到已入住、已完成;取消操作只允许在待支付和已支付两个状态下触发,已入住之后不允许取消,只能走退房流程。
在设计接口时,比较实用的做法是把状态变更收敛到独立的服务方法中,而不是让每个业务方都直接拼接更新 SQL。例如在OrderService中定义cancel(orderNo, operatorId)方法,方法内部先使用条件更新语句UPDATE ... SET status = 4 WHERE order_no = #{orderNo} AND status IN (0, 1),通过影响行数判断是否发生了非法状态变更。如果返回 0,说明订单当前状态不支持取消操作,直接抛出业务异常即可。这样既不需要在代码里加分布式锁,也不用担心多个线程同时修改同一条订单记录的状态。
日期边界同样需要在数据层面做好约束。订单里存的是check_in_date和check_out_date,这两者之间是左闭右开区间——离店日当天房间可以被下一位客人入住,不需要做夜间清洗。库存初始化的任务则交给定时任务,每天凌晨生成未来 90 天的房态数据,或者用懒加载策略在客户查询房态时按需生成。后者的优点是无需定时任务组件,缺点是第一次查询时接口响应会稍微变慢,需要做好逻辑判断避免每次都去生成。
3. 从零搭建 Spring Boot 后端:民宿接口与预订接口的实现
3.1 项目结构与核心依赖
民宿预订系统的后端服务用 Maven 管理依赖,Spring Boot 版本建议选 2.7.x 这条稳定分支,因为 3.x 分支要求 JDK 17,而很多生产环境目前还停留在 JDK 8。如果你的服务器已经升级到了 JDK 17,可以直接用 3.x,代码写法差异不大。核心依赖只需要spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java、lombok和spring-boot-starter-validation这五个,JWT 认证可以用jjwt或者 Spring Security + OAuth2 Resource Server,这里选择轻量的jjwt来减少配置复杂度。
application.yml的核心配置项需要注意三个地方:数据库连接、MyBatis Plus 的逻辑删除配置、Jackson 的时间格式化。时间格式化尤其容易被忽略,默认的LocalDateTime序列化结果是2024-05-20T10:30:00,带一个字母 T,前端 Element Plus 的日期组件拿到这个格式后需要额外处理才能正常显示:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/homestay_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里的map-underscore-to-camel-case必须设置为true,数据库字段room_type_id才能自动映射到 Java 属性roomTypeId。如果少了这个配置,你的实体类里每个字段都得写@TableField注解来指明映射关系,代码会变得非常繁琐。logic-delete-field是配置逻辑删除的全局字段名,注意我们的建表 SQL 里并没有deleted字段,如果启用了这个配置,需要同时把实体类对应的表补上这个字段,否则查询的时候会报列不存在。
3.2 查询可用民宿与房型列表接口
民宿列表接口是前端首页的核心数据来源。设计上要考虑两个维度:一是按条件过滤,比如用户选择了城市、入住日期和离店日期;二是联表查询时避免 N+1 问题。这里用 MyBatis Plus 的Wrapper构造查询条件,加上Page分页参数,实现一个带日期库存过滤的查询接口:
@RestController @RequestMapping("/api/homestay") public class HomestayController { @Resource private HomestayService homestayService; /** * 按条件分页查询可预订民宿 * @param city 城市名称 * @param checkInDate 入住日期 * @param checkOutDate 离店日期 * @param page 页码 * @param size 每页大小 */ @GetMapping("/list") public Result<Page<HomestayVO>> list(@RequestParam(required = false) String city, @RequestParam String checkInDate, @RequestParam String checkOutDate, @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { return Result.success(homestayService.queryAvailable(city, checkInDate, checkOutDate, page, size)); } }queryAvailable方法的实现逻辑分成两步:第一步用LambdaQueryWrapper按城市和上架状态查出符合条件的民宿主记录;第二步遍历这些民宿,逐个查询它们是否存在满足库存需求的房型。如果某家民宿的所有房型在预订区间内都没有可售库存,这家民宿就不会出现在结果列表里。这里的核心 SQL 逻辑是判断库存表中某个日期范围内的booked_stock是否都小于total_stock。
3.3 预订下单接口与乐观锁防超卖
预订接口是并发压力最集中的地方。常见的误用做法是先查询库存是否充足,再执行 UPDATE 扣减库存,这种查改分离的模式在并发场景下一定会出现超卖。正确的方式是把检查库存和扣减库存合并到一条 UPDATE 语句中,利用条件判断和受影响行数来确保原子性:
@Transactional(rollbackFor = Exception.class) public BookingOrder createOrder(OrderCreateRequest request) { // 1. 参数校验:日期合法性、房型是否存在 validateRequest(request); // 2. 计算需要占用的日期列表(左闭右开区间) LocalDate checkIn = request.getCheckInDate(); LocalDate checkOut = request.getCheckOutDate(); List<LocalDate> dateList = new ArrayList<>(); while (checkIn.isBefore(checkOut)) { dateList.add(checkIn); checkIn = checkIn.plusDays(1); } // 3. 逐日扣减库存,任一日期失败则整体回滚 for (LocalDate date : dateList) { int updated = roomStockMapper.deductStock(request.getRoomTypeId(), date); if (updated == 0) { throw new BizException("所选日期房间库存不足或已售罄"); } } // 4. 生成订单号并落库 BookingOrder order = new BookingOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setRoomTypeId(request.getRoomTypeId()); order.setCheckInDate(request.getCheckInDate()); order.setCheckOutDate(request.getCheckOutDate()); order.setStatus(OrderStatus.PENDING_PAYMENT.getCode()); order.setTotalAmount(calculateAmount(request.getRoomTypeId(), dateList.size())); bookingOrderMapper.insert(order); return order; }对应的 Mapper XML 中,deductStock的 SQL 是防超卖的关键:
<update id="deductStock"> UPDATE room_stock SET booked_stock = booked_stock + 1, version = version + 1 WHERE room_type_id = #{roomTypeId} AND stock_date = #{stockDate} AND booked_stock < total_stock </update>booked_stock < total_stock这个条件隐含在 UPDATE 中,数据库的行锁会在条件不满足时直接返回 0,我们通过检查updated是否为 0 来判定是否发生了超卖冲突。这里需要额外说明的是@Transactional的作用范围:整个循环是包在同一个事务里的,所以如果第 3 天扣减失败,前两天的booked_stock会被自动回滚,不会出现数据不一致。但这意味着事务会持有三天的库存行锁,如果同时有大量并发请求落在同一个时间段上,锁等待的时长会增加。面对这种情况,可以考虑把库存扣减和订单生成拆成两个事务,库存扣减成功但订单生成失败时通过补偿机制回滚库存。
4. Vue 3 + Element Plus 前端:从项目初始化到民宿预订页
4.1 Vite 创建项目和代理配置
前端部分的常见选型是 Vue 3 配合 Vite 构建工具和 Element Plus 组件库。Vite 的开发服务器自带热更新,相比 Webpack 配置简洁很多,启动速度也有明显优势。创建项目的标准命令如下:
npm create vite@latest homestay-frontend -- --template vue cd homestay-frontend npm install npm install element-plus @element-plus/icons-vue axios pinia vue-router npm run dev这里需要解释一下--template vue的含义:Vite 官方提供了多种模板,vue模板默认包含 Vue 3 单文件组件的基础配置,不包含 TypeScript。如果你的团队习惯用 TypeScript,可以改成vue-ts。项目创建完成之后,第一件事是配置开发环境的代理,因为前端跑在5173端口,后端跑在8080端口,浏览器直接向后端发请求会碰到跨域问题。一种方案是在后端加 CORS 配置,另一种方案是通过 Vite 的 proxy 把/api前缀的请求转发到后端:
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })代理配置放在开发阶段用,生产环境则是由 Nginx 统一转发前端静态资源和后端 API 请求,Nginx 的配置思路和这里的 proxy 类似,只是把 target 指向真正的后端服务地址。changeOrigin: true的作用是修改 HTTP 请求头中的 Host 字段,有一部分后端框架会校验这个字段,不设置可能被拒绝。
4.2 民宿预订表单与可用房态查询
民宿预订页面是这个系统前端最核心的业务组件。需要处理的关键交互是:用户选择入住日期和离店日期后,系统实时展示可预订的民宿列表。这个场景下使用 Element Plus 的DatePicker组件的type="daterange"模式,可以一次性选择两个日期,减少用户操作次数。需要注意的是,日期选择器返回的数组元素是Date对象,向后端传参时要先格式化成yyyy-MM-dd的字符串,否则 Spring Boot 的@RequestParam接收日期参数时会解析失败。
一个关键的交互细节是禁用不可选日期。民宿预订场景里,过去的日期肯定不能选,另外有些房型已经售罄的日期也应该变成灰色。Element Plus 的DatePicker组件提供了disabled-date属性,接受一个返回布尔值的函数,用来控制哪些日期不可选。这个函数可以结合后端返回的售罄日期列表来判断:
<template> <el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="入住日期" end-placeholder="离店日期" :disabled-date="disabledDate" @change="handleDateChange" /> </template> <script setup> import { ref } from 'vue' import { searchHomestays } from '@/api/homestay' import dayjs from 'dayjs' const dateRange = ref([]) const soldOutDates = ref([]) const disabledDate = (date) => { const today = dayjs().startOf('day') if (date.isBefore(today)) return true return soldOutDates.value.some(d => dayjs(d).isSame(date, 'day')) } const handleDateChange = async () => { if (!dateRange.value || dateRange.value.length !== 2) return const params = { checkInDate: dayjs(dateRange.value[0]).format('YYYY-MM-DD'), checkOutDate: dayjs(dateRange.value[1]).format('YYYY-MM-DD') } homestayList.value = await searchHomestays(params) } </script>这里用到了dayjs库做日期运算,它是 Moment.js 的轻量替代方案,API 基本兼容,打包体积却小了 8 倍以上。date.isBefore(today)用来判断日期是否在今天之前,isSame(date, 'day')用来比较日期是否指向同一天。比较时按“天”粒度,避免因为时分秒不同导致判断失误。
4.3 订单列表与支付状态流转的界面控制
订单列表页的数据展示逻辑相对简单,核心是按订单状态做 tab 切换展示,同时配合不同颜色的 tag 让用户对订单状态一目了然。Element Plus 的el-tabs组件配上el-tag可以实现这种效果:type为success的 tag 表示已支付,info表示待支付,danger表示已取消。
支付按钮的显隐逻辑需要和后端的状态机保持一致。如果订单状态是待支付,前端显示“去支付”和“取消订单”两个按钮;一旦支付成功,立即轮询后端查询订单最新状态,同步刷新按钮区域,防止用户重复提交支付请求。这里有一个常见的坑是在前端用定时器轮询支付结果,但页面切到后台后定时器会被浏览器节流,导致状态刷新不及时。更可靠的做法是支付成功后直接向后端发一次查询请求,拿到明确结果后再停止轮询。
5. 通用问题排查:跨域、令牌失效与慢 SQL 的三类常见根因
5.1 跨域问题的三个排查层次
前后端联调时跨域问题出现的频率最高,很多同学遇到Access-Control-Allow-Origin报错就去网上找 CORS 配置代码,其实这类问题通常有三个不同层面的原因。第一层是后端没有允许跨域,需要在 Spring Boot 中配置CorsFilter或者用@CrossOrigin注解。第二层是前端请求路径不对,比如后端接口实际是/api/homestay/list,前端写的却是/homestay/list,这时也会出现跨域报错但实际上是对应不到资源。第三层是在生产环境中没有正确配置 Nginx 代理。
排查顺序建议先看浏览器 Network 面板中请求的实际 URL,如果请求的响应状态是 404,那就不是跨域问题,而是路由没对齐;如果响应状态是 200 但控制台报 CORS 错误,才需要检查后端配置。开发阶段用 Vite proxy 基本可以规避掉前端跨域烦恼,生产环境直接让 Nginx 把/路径的静态资源和/api路径的动态请求统一代理到后端端口,前端代码里不需要写任何跨域处理逻辑。
5.2 JWT 令牌失效的场景与处理策略
民宿预订系统的登录态一般用 JWT 实现,服务端不存储会话,客户端在请求头Authorization: Bearer token中携带令牌。JWT 的特点是无状态、天然支持横向扩展,但无状态也带来了踢人难、撤销难的问题。最常见的场景是用户在两个浏览器标签页登录,旧令牌还能继续使用;或者用户修改密码后,旧令牌依然有效。
处理这个问题的常见做法是在 Redis 中维护一份令牌黑名单。用户修改密码或退出登录时,把当前令牌的jti(JWT ID)写入 Redis,过期时间设置为令牌剩余的有效期。后端在拦截器里校验令牌合法性后,再查一下 Redis 黑名单,如果命中则返回 401。这个方案保留了 JWT 无状态的扩展性,又把敏感操作的撤销能力补了回来。代码实现很简单,但一定是先查 Redis 再放行,顺序不能反。
5.3 慢 SQL 排查的标准动作
民宿预订系统的慢查询主要集中在两个场景:一是房态库存表的数据量累积到百万级别后,按日期范围查询库存明细变慢;二是订单表按用户 ID 分页查询时排序字段没有走索引导致文件排序。排查慢 SQL 不要靠猜,打开 MySQL 的慢查询日志是最直接的手段:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SHOW VARIABLES LIKE 'slow_query_log_file';打开慢查询日志后,跑一两个小时的线上流量,再查看慢日志文件,找出执行时间超过 1 秒的 SQL。对每条慢 SQL 执行EXPLAIN,重点看type列的值是否为ALL(全表扫描)或index(全索引扫描),key列是否为空。如果是订单表按user_id查询慢,优先检查是否建了idx_user_id索引;如果是按create_time排序慢,优先考虑把排序字段加入联合索引,比如(user_id, create_time)。
6. 并发测试验证:用 JMeter 压测预订接口的防超卖效果
验证乐观锁是否正确生效,要实测不能靠看代码。JMeter 是最容易上手的压测工具,即使没有图形界面也可以在命令行模式下执行。下面是模拟 100 个用户同时预订同一房型同一日期的测试方案,预期结果应该是成功订单数等于该房型的当日总库存,多余请求全部返回“库存不足”提示。
首先在src/test/jmeter目录下创建测试计划homestay_order.jmx,用 JMeter 图形界面添加一个线程组,设置线程数为 100,Ramp-Up Period 设为 0(表示所有线程同时启动),循环次数为 1,这样 100 个请求会在同一瞬间涌向后端。HTTP 请求的路径为/api/order/create,请求方法为 POST,请求体用 JSON 格式发送需要预订的房型 ID、入住日期和离店日期,再添加一个 HTTP Header Manager 设置Content-Type: application/json。
在监听器中选择“聚合报告”和“查看结果树”,勾选“仅日志错误”避免结果树输出过多数据。命令行压测时,使用下面的命令直接读取测试计划执行:
jmeter -n -t homestay_order.jmx -l result.jtl -e -o report/压测结束后查看report目录下的 index.html 统计报告,关注两个指标:错误率和吞吐量。在防超卖验证场景下,错误率不是越低越好——库存只有 5 间时,95 个请求“失败”是正确行为。真正的验证方法是查询数据库中的room_stock表,确认booked_stock的值刚好等于初始库存,没有出现负数或者超过total_stock的情况。同时查看booking_order表,确认成功订单数不超过总库存数。
压测过程中如果发现booked_stock超过总库存,说明乐观锁的 WHERE 条件可能被绕过了,优先检查 SQL 中是否把判断条件写成了先查再改的两个独立步骤。另外并发压测时数据库连接池的默认配置往往不够,Spring Boot 默认的 HikariCP 连接池只有 10 个连接,100 个并发线程进来大部分会阻塞在等待获取连接上。在application.yml中把maximum-pool-size调到 50 或者更大,能明显改善压测吞吐量中的尖刺现象。
最后一步是验证事务回滚是否正确。在 JMeter 结果树中找到一条成功的订单记录,手动删掉这条订单后观察room_stock表的booked_stock是否还原。如果还原了,说明事务边界没有覆盖到订单删除以外的补偿逻辑;如果没有还原,说明扣减库存已经提交成功,后续订单被删除时库存并没有回补——那还需要在订单取消的接口里补上库存释放的逻辑。
本文还有配套的精品资源,点击获取