简介:这份资源是面向高校计算机专业毕业设计场景的体育馆使用预约平台完整项目包,采用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 或 11,Maven 3.6 以上,MySQL 5.7 或 8.0。版本不匹配是新手翻车最多的地方,尤其是 MySQL 8.0 的驱动类名和 5.7 不一样,连接串参数也有差异。
先看pom.xml里几个关键依赖,确认版本没有被改乱:
<!-- pom.xml 关键依赖片段 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.6</version> <!-- 2.7.x 对 JDK8 友好,3.x 需要 JDK17 --> </parent> <dependencies> <!-- Web 层:提供 REST 接口 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus:简化单表 CRUD,预约平台大量单表查询 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.2</version> </dependency> <!-- MySQL 驱动:8.0 用 com.mysql.cj.jdbc.Driver --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.30</version> </dependency> <!-- JWT:登录态无状态化,前后端分离必备 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> </dependencies>这段依赖里,spring-boot-starter-web负责把 Controller 暴露成 HTTP 接口,MyBatis-Plus 负责把实体类映射到 MySQL 表,JWT 负责登录后签发 token。参数上最需要注意的是 MySQL 驱动版本:如果本地装的是 MySQL 8.0,驱动必须用 8.x,连接串要加serverTimezone=Asia/Shanghai,否则启动时报时区错误。
接着改application.yml,这是整个后端能不能连上数据库的黑匣子:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/gym_booking?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false 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' doesn't exist,说明导入的 sql 文件不完整。
2.2 前端 Vue 工程的安装与接口联调
前端一般是 Vue 2 或 Vue 3 的脚手架工程,目录下会有package.json。先确认 node 版本,Vue 2 项目建议 node 14 或 16,Vue 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换成sass(dart-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 场地与时段的数据表设计
预约平台的核心难点不在增删改查,而在「同一场地同一时段不能被两个人同时占用」。这个约束要在数据库层面和代码层面双重保证。先看表设计,这是整个系统的地基。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| venue | id, name, type, price_per_hour, status | 场地表,type 区分羽毛球/篮球/乒乓球 |
| time_slot | id, venue_id, start_time, end_time, status | 时段表,可预生成也可动态算 |
| booking_order | id, user_id, venue_id, slot_date, start_time, end_time, status, order_no | 订单表,status 是状态机核心 |
| user | id, 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); List<BookingOrder> list = orderMapper.selectList( new QueryWrapper<BookingOrder>() .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 默认可能是 UTC,JVM 是 Asia/Shanghai,差 8 小时。
解决:连接串加serverTimezone=Asia/Shanghai,实体类时间字段用LocalDateTime而不是Date,application.yml里可以加spring.jackson.time-zone: GMT+8。存库前打印一次时间,查出来再打印一次,对比确认。
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 的体育馆预约平台,技术栈不新,但把时段冲突、状态流转、核销闭环这三件事做扎实,就已经超过大部分同类课程设计了。希望帮到你。
本文还有配套的精品资源,点击获取