ARTICLE DETAIL

建站实战干货

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

SpringBoot+Vue体育场馆预约系统:从并发控制到Nginx部署的完整实战

2026/9/13 9:08:03 拓冰建站 浏览量
SpringBoot+Vue体育场馆预约系统:从并发控制到Nginx部署的完整实战 简介这是一套基于SpringBootVue技术栈的体育场馆运营管理系统完整源码工程采用前后端分离架构适合有一定SpringBoot与Vue基础的开发者作为毕业设计、课程设计参考为场馆管理员提供场地预定、课程与教练管理、会员及收入管理等核心功能并支持在线预约与支付流程。zip压缩包共1074个文件大小24.35MB其中包含259个Java后端源码、81个Vue组件、164个JS脚本及HTML/CSS前端资源并附带SQL数据库脚本、bat一键部署脚本和docx说明文档Vue组件负责前端交互Java源码实现业务逻辑SQL脚本初始化数据库整体目录结构清晰便于按模块检索和学习。已有906人学习浏览。随包提供部署说明、系统介绍和逐行源码解释可帮助理解系统分层设计、API接口和业务逻辑直接用于课程设计或二次开发其设计思路也能迁移到酒店、交通等同类运营管理系统中参考价值较高。1. 体育场馆运营管理系统拿到 SpringBootVue 源码包后先做什么一套带源码、部署说明、系统介绍和源码解释的体育场馆运营管理系统听起来像“解压即跑”实际上时间主要花在两件事上让它在你的机器上起来把“预约一个场地”这条链路读懂。这个领域不复杂核心数据只有三类——场地资源、预约单、会员账户最容易翻车的是并发预约时同一个时段被订走两次。这篇按我拿到这类项目的习惯来说先理清业务模块和前后端边界再把 SpringBoot 接口与 Vue 页面这条预约主链路拆开讲最后补上部署说明里没细说参数以及源码解释的用法。适合正在做这类系统、要接手预约类系统的工程师也适合把它当毕设项目快速跑通并讲清楚“为什么这么设计”的人。2. 场馆业务的模块边界与 SpringBootVue 的分工先理清给谁用、管什么这类系统通常有三类使用方场馆前台负责接待和办卡会员通过网页或小程序预约场地运营管理员在后台维护场地价格、查看经营数据。三个角色看到的页面完全不同所以模块边界不该按“页面”划分而应该按“业务域”划分。下面这套划分方式是当前体育馆、羽毛球馆类项目里最常见的一种。2.1 业务域拆成四个资源、预约、账户、统计资源域管的是“有什么可约”场馆、场地、时段模板、节假日价格。场馆和场地是一对多每个场地拥有独立的可预约时段列表时段价格建议直接挂在时段上而不是挂在场地类型上否则节假日调价时不好处理。预约域是核心管的是“某个会员在某天订了哪个场地的哪个时段”。一条预约记录的生命周期包括创建、取消、完成取消时要释放时段库存。这里的库存不是数字而是时段状态本身一个时段在同一时刻只能属于一个预约单。把这层关系说清楚后面写 Service 才不会绕进“库存字段加减”的误区。账户域管会员卡、余额和消费流水。体育场馆常见两种模式次数卡和储值卡也可以混合。金额计算要避开浮点类型这个放到最后再说。统计域基本是只读查询按日期聚合预约数和收入查询频次高于写入索引比代码优化更关键。四个域的依赖方向也简单预约域依赖资源域和账户域统计域只读另外三个域不要让统计逻辑反过来写资源数据。2.2 技术选型理由单体 SpringBoot 加 Vue 组件化够用且好招人为什么这套系统用 SpringBootVue 而不是微服务或模板渲染因为它的用户量级通常在小几百到几千一台 2C4G 的服务器就能跑完整套后端业务复杂度主要在“同一时段的并发占用”而不是横向扩展。SpringBoot 的 starter 生态天然把这类业务需要的 Web、数据源、校验、参数绑定都串好了配合 MyBatis-Plus 处理单表 CRUD代码量能压得很低。Vue 侧的价值在组件化。场馆列表、时段网格、预约确认弹窗这些 UI 都是重复出现的抽成组件后用户端和管理端可以共用同一套时段组件。Vue Router 负责区分配置页面和用户页面axios 封装则统一处理 token 和错误提示。版本选择上最稳妥的是 JDK8 SpringBoot 2.7 Vue2 Element UI如果是新起项目JDK17 SpringBoot 3.x Vue3 Element Plus 也可以。需要注意 SpringBoot 3.x 里javax.*变成了jakarta.*压缩包里的代码如果用的是 2.x迁移到 3.x 时改包名是个细活。热门话题里总有人问“SpringBoot 版本太高怎么办”大多数情况不是版本本身有问题而是 JDK 和框架版本不匹配。2.3 数据模型先定四张表场地、时段、会员、预约单不管压缩包里的表名怎么变核心逃不出这四张。列一个最简结构表名关键字段作用venueid, name, type, status场馆/场地status控制上下架venue_time_slotid, venue_id, start_time, end_time, price, status可约时段status1可约member_accountid, name, balance, card_type会员账户balance存余额或次数booking_orderid, order_no, member_id, venue_id, slot_id, book_date, amount, status预约单注意 status 字段在不同表里含义完全不同venue.status 表示上下架venue_time_slot.status 表示是否可约booking_order.status 表示预约单状态。不要为了省事把所有 status 都塞进同一个字典表三个枚举分开定义改动时互不影响。预约单要提前考虑两个索引一个是 (member_id, book_date, slot_id) 的唯一索引作为重复预约的兜底另一个是 (book_date, venue_id) 的联合索引支撑“某天某场馆约了多少”的统计。数据量不上百万时这两个索引就足够了不需要额外做复杂的查询优化。提示设计时段表时不建议把“周一至周日”写死在字段名里比如 monday_status。正确做法是存具体的开始时间和结束时间日期维度放到预约单上否则节假日、临时闭馆都会让你改表结构。统计域最常用的查询长这样SELECT book_date, venue_id, COUNT(*) AS booked_count FROM booking_order WHERE status 0 AND book_date BETWEEN 2025-07-01 AND 2025-07-31 GROUP BY book_date, venue_id;这条 SQL 用到了 (book_date, venue_id) 联合索引统计时不需要回表读订单明细直接走索引覆盖扫描即可。3. 把预约主链路写出来SpringBoot 接口、Vue 页面与 axios 对接系统别的模块可以慢慢看预约这条主链路必须最先打通前端选择一个日期和时段点击预约后端在一个事务里完成“锁时段 生成预约单”。这一节直接给出这两个端的最小可运行代码并说明每个参数的含义。3.1 用 SpringBoot 把“预约场地”做成一个事务Service 与 Mapper 的关键代码后端如果是从零创建项目在 IDEA 里新建 SpringBoot 项目时勾上 Spring Web、MySQL Driver、Lombok再手动引入 MyBatis-Plus比用在线 Initializr 更可控。核心逻辑放在 Service 层而不是 Controller 里。Controller 只负责接收参数和返回结果Service 负责事务边界。下面是一段最简实现Override Transactional(rollbackFor Exception.class) public BookingResult createBooking(BookingRequest req) { // 1. 用条件更新锁定时段只有 status1(可约) 时才能成功 LambdaUpdateWrapperVenueTimeSlot wrapper new LambdaUpdateWrapper(); wrapper.eq(VenueTimeSlot::getId, req.getSlotId()) .eq(VenueTimeSlot::getBookDate, req.getBookDate()) .eq(VenueTimeSlot::getStatus, 1); int updated venueTimeSlotMapper.update( new VenueTimeSlot().setStatus(2), wrapper); if (updated 0) { throw new BizException(该时段已被预约请更换时间); } // 2. 取时段的真实价格前端传的金额一律不信任 VenueTimeSlot slot venueTimeSlotMapper.selectById(req.getSlotId()); BookingOrder order new BookingOrder(); order.setOrderNo(generateOrderNo()); order.setMemberId(req.getMemberId()); order.setVenueId(req.getVenueId()); order.setSlotId(req.getSlotId()); order.setBookDate(req.getBookDate()); order.setAmount(slot.getPrice()); order.setStatus(0); bookingOrderMapper.insert(order); return new BookingResult(order); }这段代码有两个关键点。第一锁时段用的是“条件更新”而不是“先查再改”UPDATE venue_time_slot SET status2 WHERE id? AND book_date? AND status1这条语句由 update 方法生成数据库会保证同一时刻只有一个事务能把 status 从 1 改成 2另一个事务的受影响行数是 0。很多预约系统超卖就是因为写成了先 select 再 update两个线程都读到 status1。第二Transactional(rollbackFor Exception.class)把时段状态更新和订单插入放在同一个事务里。如果插入订单失败时段的 status2 会被回滚成 1不会出现“时段被占用但订单没生成”的脏数据。注意 Spring 默认只对 RuntimeException 回滚自定义 BizException 要继承 RuntimeException否则事务不生效。Controller 层就简单了RestController RequestMapping(/api/booking) public class BookingController { PostMapping public ResultBookingResult create(RequestBody Valid BookingRequest req) { return Result.ok(bookingService.createBooking(req)); } }Valid会触发参数校验比如 memberId 不能为空、bookDate 格式必须是 yyyy-MM-ddResultT是统一返回体前端依据里面的 code 字段判断业务是否成功。bookDate 建议直接用 LocalDate 接收不要用 java.util.Datedate 类型在跨时区部署时容易出现日期偏移。3.2 Vue 侧页面与 axios 封装从创建项目到点击预约按钮前端从创建项目开始一般是用vue create venue-admin或npm create vuelatest初始化随后安装依赖。vue 安装依赖时如果 npm install 报 ERESOLVE 错误通常是因为依赖树里存在 peerDependencies 冲突把 node_modules 和 package-lock.json 删掉重新 install 就能解决或者干脆换一个镜像源重装。先把 axios 请求封装统一掉// src/api/request.js import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) export default requestbaseURL 写成/api好处是所有接口都不带服务器地址。开发环境由 vue.config.js 的 proxy 转发到后端生产环境由 Nginx 统一转发代码因此不需要区分环境。timeout 是请求超时毫秒数预约类接口一般 3 秒内能返回给 10000 是保守值。预约页面里点击事件大致是async function onBook(slot) { const resp await request.post(/booking, { venueId: venueId.value, slotId: slot.id, bookDate: date.value }) if (resp.data.code 0) { ElMessage.success(预约成功) loadSlots() } else { ElMessage.warning(resp.data.msg) } }预约成功之后别只弹个提示要重新拉取一次时段列表因为时段的 status 已经被后端改成占用本地页面状态和数据源可能已经不一致。失败的时候直接展示后端返回的 msg比如“该时段已被预约”比前端统一弹“网络错误”更利于用户理解。页面和接口要靠路由串起来最简单的路由配置是const routes [ { path: /login, component: Login }, { path: /booking, component: BookingPage, meta: { requiresAuth: true } } ] router.beforeEach((to) { if (to.meta.requiresAuth !localStorage.getItem(token)) { return /login } })路由守卫的逻辑是“没有登录就跳到登录页”。meta 里的 requiresAuth 是路由参数用来标记需要登录的页面这个判断放在前端只是体验优化真正的权限控制必须依赖后端拦截器。前端守卫被绕过是常有的事后端接口一定要自己校验 token。3.3 接口清单与统一返回体前后端约定先对齐再联调主链路涉及的接口不多列出来就是一个清晰的清单接口方法核心入参说明/api/venue/listGET无查询可用场馆列表/api/venue/slotsGETvenueId, date查询某场馆某日可约时段/api/bookingPOSTvenueId, slotId, bookDate创建预约/api/booking/cancelPOSTorderNo取消预约联调之前先打开后端的 Result 类确认 code 的含义。绝大多数系统约定 0 或 200 表示成功非 0 表示业务失败401 表示未登录。前端拦截器里要区分“HTTP 状态码”和“业务 code”HTTP 401 走登录失效处理业务 code 非 0 则直接展示 msg。两者混在一起处理排错时会非常痛苦。提示如果压缩包里的返回结构不统一优先在 axios 响应拦截器里做兼容转换避免每个页面重复处理。后端新增接口也要先过 Result 这一层而不是随手 return 一个 Map。4. 部署体育场馆管理系统SpringBoot 打包、Vue 静态资源与 Nginx 代理的实配参数部署说明通常只写“改一下数据库配置打包运行”但真正动手时时区、驱动类名、静态资源路径、路由模式每一个都能卡半小时。这一章按后端、前端、代理三条线来给实配参数。4.1 后端打包和 MySQL 连接参数三个坑集中在时区、驱动类名和编码后端最常改的是 application.yml数据源配置如下server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/venue_admin?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driverurl 里的三个参数分别是useUnicode 和 characterEncoding 决定中文不乱码useSSLfalse 关闭 MySQL 8 默认的 SSL 握手告警serverTimezoneAsia/Shanghai 解决 “The server time zone value ... ” 的启动报错。驱动类名也要看 MySQL 版本8.x 用 com.mysql.cj.jdbc.Driver5.x 用 com.mysql.jdbc.Driver很多部署说明里这两者会混着写。首次部署时先执行 init.sql注意脚本开头有没有建库语句。常见做法是CREATE DATABASE IF NOT EXISTS venue_admin DEFAULT CHARACTER SET utf8mb4; USE venue_admin;建库语句漏掉的话后面所有表都会建到默认库里SpringBoot 启动时却连 venue_admin结果就是报 “Table doesnt exist”。打包和启动就两条命令mvn clean package -DskipTests java -jar target/venue-admin-1.0.0.jar-DskipTests 跳过单测避免测试类里连不上测试库导致打包失败。如果打包时报 “Execution default-cli of goal ... failed”先查 JDK 版本和 pom 里的 spring-boot-starter-parent 版本是否匹配不要先怀疑代码。4.2 前端 build 与 Nginx 代理history 路由刷新 404 和 /api 前缀前端部署顺序是安装依赖、打包、把 dist 目录交给 Nginxnpm install npm run build打包后看 dist/index.html 里资源路径是相对路径还是绝对路径。若资源路径以 / 开头而站点部署在子目录所有 js/css 都会 404页面就会只剩下 HTML 骨架表现为“打包后布局异常”。Vue CLI 项目在 vue.config.js 里设 publicPath: ./Vite 项目在 vite.config.js 里设 base: ./重新打包即可。Nginx 配置里三个位置容易错server { listen 80; server_name venue.example.com; root /opt/venue/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }第一个是 try_files它解决了 Vue Router history 模式下刷新 /booking 页面 404 的问题找不到对应文件时回退到 index.html。第二个是 proxy_pass 是否带尾斜杠写成http://127.0.0.1:8080不带斜杠Nginx 会把完整的 /api/xxx 转发给后端如果写成http://127.0.0.1:8080/带斜杠/api 前缀会被剥掉。后端接口路径含 /api 时用不带斜杠的写法最省心。第三个是后端接口不能只依赖前端守卫鉴权Nginx 层如果不需要做 IP 白名单就不要在这里加额外限制保持代理层尽量薄。项推荐值说明try_files$uri $uri/ /index.html刷新路由不 404proxy_passhttp://127.0.0.1:8080不带斜杠保留 /api 前缀publicPath / base./子目录部署资源不丢client_max_body_size10m场馆图片上传默认太小4.3 两个高频现场SpringBoot 版本太高、Vue 打包后布局异常先说 SpringBoot 版本太高。常见组合是 JDK8 SpringBoot 3.x 启动失败报错信息里带着 “java.lang.UnsupportedClassVersionError”这是 class 文件版本高于 JDK 造成的。先确认java -version再打开 pom.xml 看 spring-boot-starter-parent 的版本。JDK8 对应 SpringBoot 2.7.xJDK17 以上才适合 SpringBoot 3.x。3.x 换成 2.7 时还要把代码里的 jakarta.servlet 改回 javax.servlet反之亦然。反过来也有一种情况JDK17 SpringBoot 2.x 偶尔会报类似 “Caused by: java.lang.reflect.InaccessibleObjectException” 的反射告警或错误这不是业务代码问题是 Spring 5 对高版本 JDK 的模块访问限制最快解决方式是给 JVM 加--add-opens参数或者直接升到 SpringBoot 3.x。Vue 打包后布局异常先在浏览器开发者工具里看 Network 面板如果 CSS 文件请求的状态是 404就按 4.2 里的 publicPath/base 处理。如果资源路径正常但页面还是乱排检查 body 和 #app 的样式是否被全局重置常见原因是压缩包里带了 reset.css而自己的模板里也写了一个打包后顺序变化导致样式互相覆盖。用 Vue Devtools 查看组件树和最终计算样式能快速定位是哪个选择器优先度更高。这两个场景排查顺序固定先静态资源再组件样式不要一上来就怀疑打包工具有问题。5. 把“源码解释”变成排查工具从后端入口到前端路由的走读路线压缩包里带的源码解释通常按目录逐类说明这类文档适合查不适合从头看到尾。如果真想理解系统不要从第一个类开始读而是先建三张地图。5.1 不按文件顺序读先建三张地图启动类、异常处理、权限入口第一张地图是启动类。在 IDEA 里按两次 Shift搜索含 SpringBootApplication 的类看它的 MapperScan 扫了哪个包这决定了 Mapper 接口不需要一个个加 Mapper 注解。第二张地图是全局异常处理器搜索 RestControllerAdvice这类会兜住所有 Controller 抛出的异常把 BizException 转成 codemsg 的 JSON。不去读这个类后面用 curl 测接口时看到的错误信息会非常难懂。第三张地图是权限入口。可能是拦截器 HandlerInterceptor也可能是 Filter搜索 “OncePerRequestFilter” 或 “implements HandlerInterceptor” 能找到。看放行了哪些路径/api/login 和静态资源一般放行其余接口校验 token。记住这三张地图遇到任何一个接口 401 或 500先在对应的地图上定位而不是全局搜索。如果不习惯用 IDE终端里直接 grep 也能定位这些入口grep -rn RestControllerAdvice src/main/java grep -rn OncePerRequestFilter src/main/java grep -rn SpringBootApplication src/main/javagrep 适合在没有图形界面的服务器上快速确认类的位置输出结果里能看到类名和行号比展开目录树更直接。提示工具类、常量类、配置类先跳过。只有排查特殊问题时才需要回头读它们不影响主流程理解。5.2 预约流程的十个落点从页面按钮到 SQL 的调用链一个预约操作从前端到数据库中间会经过十个固定落点。把这十个落点记住任何一次联调都能顺着查落点文件/位置关键内容1 点击事件BookingPage.vue 的 onBook预约参数来源2 请求封装src/api/request.jsbaseURL、token 注入3 路由守卫src/router/index.jsrequiresAuth 判断4 开发代理vue.config.js 的 proxy/api 转发目标5 ControllerBookingController接收 RequestBody6 ServiceBookingServiceImplTransactional 与条件更新7 Mapper 接口BookingOrderMapper继承 BaseMapper 或自定义方法8 SQLresources/mapper 或注解实际执行的 SQL9 数据库venue_admin 库表结构与索引10 返回解析onBook 里的 resp 处理code 判断与错误展示读这十个落点时最常用的操作是“反查调用链”。在 IDEA 里把光标放在某个方法名上按 CtrlAltF7VS Code 用全局搜索实现同样效果可以看到谁调用了它、它又调用了谁。把预约接口走一遍整条链路的时序就清晰了比死记源码解释里的类图高效得多。5.3 源码解释的用途是“快速查证”而不是通读基于 SpringBootVue 的这类管理系统源码解释文档最大的价值是告诉你某个模块的入口类叫什么以及表之间的关系。遇到问题时应该先翻文档定位模块再打开真实代码确认逻辑而不是顺着文档从头读到尾。文档和代码不一致时永远以代码为准因为源码解释大概率滞后于最后一次改动。另外读业务源码不需要深入到 MyBatis 内部实现。看到 orderMapper.insert(order) 时知道它最终会生成 INSERT 语句就够了想研究 SQL 是怎么拼的可以单开一次 mybatis 源码阅读但不要混在业务走读里。带着“这条数据怎么从页面到数据库”的问题去读比带着“每个类负责什么”的问题去读收获大得多。6. 上线前用 10 分钟验证体育场馆预约并发超卖、幂等与 BigDecimal部署和阅读都通了预约接口还要做一次最简单的并发冒烟。这一步能筛掉大多数“看着能用一压就挂”的问题。6.1 用并发请求压一下“同一时段被订两次”后端跑起来后拿同一个 slotId 发 20 个并发请求看成功数是否是 1for i in $(seq 1 20); do curl -s -X POST http://localhost:8080/api/booking \ -H Content-Type: application/json \ -d {venueId:1,slotId:2,bookDate:2025-07-20,memberId:1001} done wait命令里让 curl 并行执行wait等所有请求结束。curl 的 -s 是静默模式避免进度条刷屏-X POST 指定方法-H 传请求头-d 是请求体。期望的输出是一个请求返回成功其余返回“该时段已被预约”。如果成功数大于 1说明 Service 层没有用条件更新锁状态或者事务没生效。本机压测的结果不能代表生产环境的吞吐能力但这个脚本能验证逻辑正确性够用。6.2 幂等与金额精度的收尾检查并发请求只是防了超卖用户双击按钮、前端网络重试还会导致同一个用户重复下单。后端二次判断能拦截一部分但并发窗口下两次请求可能同时通过判断所以要在数据库层面兜底ALTER TABLE booking_order ADD UNIQUE KEY uk_member_slot (member_id, book_date, slot_id);这个唯一索引会让第二个插入直接报 duplicate entry再把异常转成“请不要重复预约”。金额方面预约单的 amount 必须来自服务端的时段价格前端传任何价格都不能信余额扣减用 BigDecimal 而不是 double否则累计几次会出现 0.10.2 不等于 0.3 的精度问题。这两处收尾补上再跑一遍上面的脚本返回结果就该是唯一成功。本文还有配套的精品资源点击获取