ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot+Vue民宿预订系统实战:从数据模型到并发控制

2026/9/16 14:06:10 拓冰建站 浏览量
Spring Boot+Vue民宿预订系统实战:从数据模型到并发控制 简介整套源码基于JavaSpringbootVue搭建是典型的前后端分离民宿预订管理系统面向计算机专业毕业生、在校生以及需要快速上手企业级项目的初中级开发者。系统后端由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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/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: GMT8 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 查询可用民宿与房型列表接口民宿列表接口是前端首页的核心数据来源。设计上要考虑两个维度一是按条件过滤比如用户选择了城市、入住日期和离店日期二是联表查询时避免 N1 问题。这里用 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 ResultPageHomestayVO 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(); ListLocalDate 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 iddeductStock UPDATE room_stock SET booked_stock booked_stock 1, version version 1 WHERE room_type_id #{roomTypeId} AND stock_date #{stockDate} AND booked_stock lt; total_stock /updatebooked_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 vitelatest 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组件的typedaterange模式可以一次性选择两个日期减少用户操作次数。需要注意的是日期选择器返回的数组元素是Date对象向后端传参时要先格式化成yyyy-MM-dd的字符串否则 Spring Boot 的RequestParam接收日期参数时会解析失败。一个关键的交互细节是禁用不可选日期。民宿预订场景里过去的日期肯定不能选另外有些房型已经售罄的日期也应该变成灰色。Element Plus 的DatePicker组件提供了disabled-date属性接受一个返回布尔值的函数用来控制哪些日期不可选。这个函数可以结合后端返回的售罄日期列表来判断template el-date-picker v-modeldateRange typedaterange range-separator至 start-placeholder入住日期 end-placeholder离店日期 :disabled-datedisabledDate changehandleDateChange / /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 中维护一份令牌黑名单。用户修改密码或退出登录时把当前令牌的jtiJWT 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 图形界面添加一个线程组设置线程数为 100Ramp-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是否还原。如果还原了说明事务边界没有覆盖到订单删除以外的补偿逻辑如果没有还原说明扣减库存已经提交成功后续订单被删除时库存并没有回补——那还需要在订单取消的接口里补上库存释放的逻辑。本文还有配套的精品资源点击获取