ARTICLE DETAIL

资讯详情

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

体育馆预约平台实战:Spring Boot + Vue + MySQL 从场地冲突到订单闭环

体育馆预约平台实战:Spring Boot + Vue + MySQL 从场地冲突到订单闭环 简介这份资源是面向高校计算机专业毕业设计场景的体育馆使用预约平台完整项目包采用Spring Boot后端、Vue前端与MySQL数据库组合开发适合正在准备毕设或需要Java全栈实战案例的学生与开发者参考。项目围绕场地预约信息管理不规范、容错率低等痛点实现了场地管理、用户管理、论坛管理、公告管理及场地订单管理等核心模块可帮助读者理解从需求分析到数据处理的完整业务闭环。压缩包共750个文件约22.42MB其中101个Java源文件承载后端业务逻辑58个Vue组件与156个JavaScript文件构成前端交互层另有SQL脚本、yml配置、bat启动脚本及论文文档、部署说明等目录结构清晰便于按模块查阅。目前已有79人学习下载。读者可获得一套可直接运行的源码工程、配套论文与部署指引并借助现成的分层结构与接口设计快速掌握Spring Boot与Vue前后端分离项目的搭建思路与排错方法。1. 体育馆预约平台从场地冲突到订单闭环这套 Spring Boot Vue MySQL 方案能跑通什么周三晚上八点羽毛球馆前台还在接电话“周五晚上七点到九点还有场地吗”前台翻着 Excel 表格一边看一边用笔划挂了电话又接到另一个学院的老师来问同一时段。这种场景在高校体育馆、社区体育中心、商业球馆里几乎每天都在发生。场地冲突、人工登记、电话占线、爽约无人追责是场馆运营最典型的四个痛点。基于 Spring Boot Vue MySQL 的体育馆使用预约平台要解决的就是把「查场地 → 选时段 → 下单锁场 → 核销入场」这条链路搬到线上让用户自助完成让管理员从电话和表格里解放出来。这套技术栈之所以成为课程设计和中小型场馆系统的常见选择原因很直接Spring Boot 把后端配置压到最低Vue 的前后端分离让页面交互跟得上现代用户习惯MySQL 足够撑住一个场馆每天几百到几千条订单的读写。标题里还带了源码、论文和部署说明说明它面向的是需要完整交付物的场景——毕业设计、课程大作业、小型场馆自建系统。读者如果是学生关心的是怎么把环境跑起来、代码结构怎么改如果是场馆技术负责人关心的是这套东西能不能直接上线、并发锁场怎么做、支付和核销怎么接。下面按「先跑通 → 再改对 → 再避坑 → 再进阶」的顺序拆开讲。2. 环境搭建与项目启动把 Spring Boot Vue MySQL 三件套跑起来2.1 后端 Spring Boot 工程的依赖与启动配置拿到源码包后第一步不是急着点运行而是先确认 JDK、Maven、MySQL 三个基础环境的版本匹配。常见做法是 JDK 1.8 或 11Maven 3.6 以上MySQL 5.7 或 8.0。版本不匹配是新手翻车最多的地方尤其是 MySQL 8.0 的驱动类名和 5.7 不一样连接串参数也有差异。先看pom.xml里几个关键依赖确认版本没有被改乱!-- pom.xml 关键依赖片段 -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.6/version !-- 2.7.x 对 JDK8 友好3.x 需要 JDK17 -- /parent dependencies !-- Web 层提供 REST 接口 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus简化单表 CRUD预约平台大量单表查询 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.2/version /dependency !-- MySQL 驱动8.0 用 com.mysql.cj.jdbc.Driver -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.30/version /dependency !-- JWT登录态无状态化前后端分离必备 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency /dependencies这段依赖里spring-boot-starter-web负责把 Controller 暴露成 HTTP 接口MyBatis-Plus 负责把实体类映射到 MySQL 表JWT 负责登录后签发 token。参数上最需要注意的是 MySQL 驱动版本如果本地装的是 MySQL 8.0驱动必须用 8.x连接串要加serverTimezoneAsia/Shanghai否则启动时报时区错误。接着改application.yml这是整个后端能不能连上数据库的黑匣子server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/gym_booking?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 你的密码 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 数据库 user_name 自动映射到 userName jwt: secret: gym-booking-secret-key-2024 expire: 86400 # token 有效期单位秒一天url里的gym_booking是数据库名需要提前在 MySQL 里建好。map-underscore-to-camel-case这个参数建议打开否则实体类字段和数据库列名对不上查出来全是 null这种问题排查起来很费时间。jwt.expire设 86400 表示登录后一天内免登录场馆系统一般够用太长有安全风险太短用户频繁掉线。启动命令很简单在项目根目录执行# 先确认数据库已建好并导入 sql 文件 mysql -uroot -p -e CREATE DATABASE gym_booking DEFAULT CHARSET utf8mb4; mysql -uroot -p gym_booking sql/gym_booking.sql # Maven 编译并启动 mvn clean package -DskipTests java -jar target/gym-booking-0.0.1-SNAPSHOT.jar看到Started GymBookingApplication in x.x seconds就说明后端起来了。如果报Access denied for user检查密码如果报Unknown database说明 sql 没导入如果报Table gym_booking.xxx doesnt exist说明导入的 sql 文件不完整。2.2 前端 Vue 工程的安装与接口联调前端一般是 Vue 2 或 Vue 3 的脚手架工程目录下会有package.json。先确认 node 版本Vue 2 项目建议 node 14 或 16Vue 3 项目 node 16 以上。node 版本过高会导致 node-sass 编译失败这是前端安装依赖时最常见的坑。# 进入前端目录 cd gym-booking-web # 安装依赖国内建议配淘宝镜像加速 npm config set registry https://registry.npmmirror.com npm install # 启动开发服务器 npm run servenpm install如果卡在node-sass或sass-loader两个办法一是降 node 版本到 16二是把package.json里的node-sass换成sassdart-sass后者不需要编译二进制兼容性更好。改完删掉node_modules和package-lock.json重新装。前端启动后默认跑在 8081 或 8082后端在 8080跨域问题就来了。Vue 项目里一般会在vue.config.js配代理// vue.config.js module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, // 后端地址 changeOrigin: true, pathRewrite: { ^/api: } // 去掉 /api 前缀再转发 } } } }这样前端请求/api/user/login会被转发到http://localhost:8080/user/login。changeOrigin: true是为了让后端看到的 Host 是目标地址避免某些安全校验拦截。如果联调时接口 404先看 Network 面板里请求的真实 URL再对照后端 Controller 的RequestMapping路径八成是前缀没对上。登录接口调通后拿到的 token 要存起来后续请求带上。常见做法是存 localStorage然后在 axios 拦截器里统一加 header// request.js import axios from axios const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token // 后端从 header 取 token 校验 } return config }) export default service这段拦截器的逻辑是每次发请求前从 localStorage 取 token 塞进 header。后端用一个拦截器或过滤器统一校验校验不过返回 401前端收到 401 就跳登录页。参数上timeout设 10000 毫秒预约下单接口如果涉及锁场可能稍慢可以单独给那个接口设更长超时。3. 场地预约核心逻辑时段建模、冲突检测与订单状态机3.1 场地与时段的数据表设计预约平台的核心难点不在增删改查而在「同一场地同一时段不能被两个人同时占用」。这个约束要在数据库层面和代码层面双重保证。先看表设计这是整个系统的地基。表名关键字段说明venueid, name, type, price_per_hour, status场地表type 区分羽毛球/篮球/乒乓球time_slotid, venue_id, start_time, end_time, status时段表可预生成也可动态算booking_orderid, user_id, venue_id, slot_date, start_time, end_time, status, order_no订单表status 是状态机核心userid, username, password, phone, role用户表role 区分普通用户和管理员时段建模有两种常见做法。一种是预生成每天凌晨定时任务把未来 7 天每个场地的每个小时段插进time_slot表用户下单就是改这个时段的状态。另一种是动态计算不存时段下单时用start_time和end_time去booking_order表里查有没有重叠。预生成的好处是查询快、状态直观坏处是场地营业时间一变就要重新生成动态计算灵活但每次下单都要算重叠并发高时压力大。中小场馆我一般推荐预生成简单可靠。订单状态机是另一个关键。状态不能乱跳否则会出现「已取消的订单又被核销」这种玄学问题。常见状态流转是待支付(0) → 已支付(1) → 已核销(2) ↓ ↓ 已取消(3) 已退款(4)待支付超时比如 15 分钟自动取消释放时段已支付可以申请退款退款后时段释放已核销是入场扫码后终态不可逆。每个状态变更都要写日志方便对账。3.2 冲突检测的 SQL 与代码实现冲突检测的本质是判断「新订单的时段」和「已有订单的时段」有没有交集。两个区间[s1, e1]和[s2, e2]重叠的条件是s1 e2 AND s2 e1。这个判断要放在下单事务里配合数据库行锁或唯一索引。先看查询已有冲突订单的 SQL-- 查询某场地某天是否存在与目标时段重叠的有效订单 SELECT COUNT(*) FROM booking_order WHERE venue_id #{venueId} AND slot_date #{slotDate} AND status IN (0, 1) -- 待支付和已支付都算占用 AND start_time #{endTime} -- 已有订单开始 新订单结束 AND end_time #{startTime}; -- 已有订单结束 新订单开始这条 SQL 的四个条件缺一不可。status IN (0,1)表示只有待支付和已支付才占场已取消和已退款的不算。两个时间比较就是重叠判断的核心。如果返回大于 0说明冲突直接拒绝下单。但光靠查询不够并发场景下两个请求同时查到 0然后都插入就超卖了。解决办法是加唯一索引或悲观锁。唯一索引的思路是把「场地 日期 时段」做成唯一键但时段是连续的没法直接做唯一键。更实用的做法是在事务里用SELECT ... FOR UPDATE锁住场地行Service public class BookingService { Transactional(rollbackFor Exception.class) public Result createOrder(BookingDTO dto) { // 1. 锁住场地行防止并发下单同一场地 Venue venue venueMapper.selectByIdForUpdate(dto.getVenueId()); if (venue null || venue.getStatus() 0) { return Result.fail(场地不存在或已停用); } // 2. 冲突检测 int conflict orderMapper.countConflict( dto.getVenueId(), dto.getSlotDate(), dto.getStartTime(), dto.getEndTime()); if (conflict 0) { return Result.fail(该时段已被预约请选择其他时段); } // 3. 计算金额并生成订单 BigDecimal hours BigDecimal.valueOf( Duration.between(dto.getStartTime(), dto.getEndTime()).toMinutes()) .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP); BigDecimal amount venue.getPricePerHour().multiply(hours); BookingOrder order new BookingOrder(); order.setOrderNo(generateOrderNo()); // 时间戳随机数 order.setUserId(dto.getUserId()); order.setVenueId(dto.getVenueId()); order.setSlotDate(dto.getSlotDate()); order.setStartTime(dto.getStartTime()); order.setEndTime(dto.getEndTime()); order.setAmount(amount); order.setStatus(0); // 待支付 orderMapper.insert(order); return Result.ok(order); } }selectByIdForUpdate对应的 SQL 是SELECT * FROM venue WHERE id ? FOR UPDATE它在事务提交前锁住这一行其他事务想锁同一行就得等。这样两个并发请求会串行执行第二个请求进来时第一个已经插入订单冲突检测就能查到。参数上要注意Transactional的rollbackFor Exception.class否则遇到非运行时异常不回滚订单可能插了一半。订单号生成也有讲究常见做法是「yyyyMMddHHmmss 4 位随机数」但高并发下可能重复。更稳的是用 Redis 自增或雪花算法。如果项目没引入 Redis用时间戳加用户 ID 后四位也能凑合但要在order_no上建唯一索引兜底。3.3 前端预约页面的时段选择与提交前端预约页面的核心交互是选日期 → 选场地 → 展示该场地该天的时段网格 → 点选时段 → 提交订单。时段网格的数据来自后端一个接口返回每个时段的占用状态。// 获取某场地某天的时段占用情况 async loadSlots(venueId, date) { const res await request.get(/booking/slots, { params: { venueId, date } }) // res.data 形如 [{start:08:00, end:09:00, occupied:false}, ...] this.slots res.data.map(s ({ ...s, selected: false, disabled: s.occupied // 已占用的置灰不可选 })) }, // 提交预约 async submitBooking() { const selected this.slots.filter(s s.selected) if (selected.length 0) { this.$message.warning(请至少选择一个时段) return } // 连续时段合并成一个订单不连续则提示 const start selected[0].start const end selected[selected.length - 1].end try { const res await request.post(/booking/create, { venueId: this.venueId, slotDate: this.date, startTime: start, endTime: end }) if (res.code 200) { this.$router.push(/order/pay/ res.data.orderNo) } else { this.$message.error(res.msg) // 后端返回的冲突提示 } } catch (e) { this.$message.error(网络异常请重试) } }这段代码里disabled: s.occupied让已占用时段不可点这是第一道防线。提交时把连续选中的时段合并成一个订单start取第一个end取最后一个。如果用户选了两个不连续的时段比如 8-9 和 10-11这种要么拆成两个订单要么前端直接禁止。我一般在前端做连续性校验不连续就提示用户重新选减少后端复杂度。后端返回冲突提示时前端要原样展示因为用户需要知道是哪个时段被占了。如果后端返回的是笼统的「预约失败」用户会反复试体验很差。参数上slotDate用yyyy-MM-dd字符串传后端用DateTimeFormat或JsonFormat转成LocalDate时区问题在前后端分离项目里很常见统一用字符串传日期能避开大部分坑。4. 避坑与排查预约平台上线前最容易翻车的 5 个地方4.1 时段重叠判断写反导致超卖现象两个用户同时下单同一场地同一时段两个订单都创建成功到场后才发现冲突。原因冲突检测的 SQL 条件写成了start_time #{startTime} AND end_time #{endTime}这个条件只能查出「完全被包含」的订单查不出部分重叠的。正确的重叠条件是start_time #{endTime} AND end_time #{startTime}。解决把 SQL 改成正确的重叠判断并且在事务里加FOR UPDATE锁场地行。上线前用两个浏览器同时下单同一时段做压测确认第二个请求返回冲突提示。4.2 待支付订单不释放时段现象用户下单后没支付时段一直被占着别人想约约不了。原因订单创建后状态是待支付冲突检测把待支付也算占用但没有超时取消机制订单永远挂在那里。解决加定时任务每分钟扫一次超过 15 分钟未支付的订单把状态改成已取消。或者用延迟队列下单时发一条延迟消息15 分钟后检查状态未支付就取消。定时任务简单适合中小项目Scheduled(cron 0 * * * * ?) // 每分钟执行 public void cancelTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); ListBookingOrder list orderMapper.selectList( new QueryWrapperBookingOrder() .eq(status, 0) .lt(create_time, deadline)); for (BookingOrder o : list) { o.setStatus(3); // 已取消 orderMapper.updateById(o); } }4.3 MySQL 时区不一致导致时间差 8 小时现象前端选的 19:00存到数据库变成 11:00或者查出来显示的时间对不上。原因连接串没配serverTimezone或者 JVM 时区和 MySQL 时区不一致。MySQL 默认可能是 UTCJVM 是 Asia/Shanghai差 8 小时。解决连接串加serverTimezoneAsia/Shanghai实体类时间字段用LocalDateTime而不是Dateapplication.yml里可以加spring.jackson.time-zone: GMT8。存库前打印一次时间查出来再打印一次对比确认。4.4 前端 token 过期后接口全部 401 但不跳登录现象用户挂着页面一段时间再操作时所有接口报 401页面卡住不跳登录页。原因axios 响应拦截器没处理 401或者处理了但没清 token导致死循环。解决在响应拦截器里统一处理 401清 localStorage 并跳登录页service.interceptors.response.use( res res.data, err { if (err.response err.response.status 401) { localStorage.removeItem(token) window.location.href /login // 用 href 强制刷新避免路由守卫拦截 } return Promise.reject(err) } )4.5 部署到服务器后前端接口 404现象本地跑得好好的部署到服务器后前端请求全部 404。原因本地用vue.config.js的 devServer 代理打包后代理失效需要 Nginx 转发。解决Nginx 配置里加 location 转发server { listen 80; root /usr/share/nginx/html; # 前端打包后的 dist 目录 index index.html; location / { try_files $uri $uri/ /index.html; # Vue history 路由刷新不 404 } location /api/ { proxy_pass http://127.0.0.1:8080/; # 转发到后端 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files那行是 Vue history 模式必须的否则用户刷新非首页路由会 404。proxy_pass末尾的斜杠要注意/api/转发到http://127.0.0.1:8080/会去掉/api前缀和后端接口路径对上。5. 从能跑到好用核销、统计与二次开发的取舍把系统跑起来只是第一步真正决定这套平台能不能在真实场馆用下去的是核销和统计这两个环节。核销是订单闭环的最后一环用户到馆后怎么证明他预约了常见做法是订单详情页生成二维码管理员用扫码枪或手机扫后端校验订单状态和时段把状态从已支付改成已核销。二维码内容就是订单号简单可靠。核销接口要做幂等同一个订单号重复扫码只返回「已核销」不能重复改状态。PostMapping(/verify) public Result verify(RequestParam String orderNo) { BookingOrder order orderMapper.selectByOrderNo(orderNo); if (order null) return Result.fail(订单不存在); if (order.getStatus() 2) return Result.ok(已核销请勿重复操作); if (order.getStatus() ! 1) return Result.fail(订单状态异常无法核销); // 校验是否在预约时段内提前太多或过期都不让核销 LocalDateTime now LocalDateTime.now(); LocalDateTime start LocalDateTime.of(order.getSlotDate(), order.getStartTime()); if (now.isBefore(start.minusMinutes(30))) { return Result.fail(未到核销时间); } order.setStatus(2); order.setVerifyTime(now); orderMapper.updateById(order); return Result.ok(核销成功); }这段代码里now.isBefore(start.minusMinutes(30))是允许提前 30 分钟入场太早不让核销防止用户约了晚上却早上就来占场。verifyTime记录核销时间方便后续统计实际到场率。统计功能是场馆运营方最看重的。至少要有三个维度按场地统计使用率、按时间段统计高峰低谷、按用户统计爽约次数。使用率就是「已核销订单时长 / 营业总时长」这个数据能帮运营方决定要不要调整场地用途。爽约次数是「已支付但未核销且已过时段」的订单数爽约多的用户可以考虑限制预约权限。二次开发时我一般建议先别急着加功能而是把日志和监控补上。预约平台最怕的是「用户说约了系统说没约」这种纠纷靠日志说话。下单、支付、取消、核销四个动作都要记操作日志字段包括订单号、操作人、操作时间、操作前状态、操作后状态。有了这个出问题能快速定位比任何功能都值钱。最后说一个我自己的习惯每次改完冲突检测或状态机相关的代码我都会手动构造三个场景跑一遍——同时下单同一时段、待支付超时后重新下单、已核销订单再次核销。这三个场景覆盖了最容易出 bug 的边界跑通了再提交。这套 Spring Boot Vue MySQL 的体育馆预约平台技术栈不新但把时段冲突、状态流转、核销闭环这三件事做扎实就已经超过大部分同类课程设计了。希望帮到你。本文还有配套的精品资源点击获取
返回列表