
简介这份资源是面向高校计算机专业学生与Java Web开发初学者的体育馆使用预约平台完整项目采用Spring Boot后端、Vue前端与MySQL数据库构建可作为毕业设计选题或课程实训参考帮助解决场地预约信息管理中流程不规范、容错率低、人工处理数据费时费力等问题。压缩包共750个文件约22.42MB涵盖101个Java源码文件、58个Vue组件、156个JavaScript脚本、49个CSS样式表以及SQL建库脚本、yml配置、bat启动脚本和论文文档等前端资源还包含svg、png、gif等图片素材结构完整、层次清晰。项目已实现场地管理、用户管理、论坛管理、公告管理与场地订单管理等核心模块并附有论文与部署说明便于读者理解业务逻辑、梳理数据库设计、掌握前后端分离开发流程也可作为二次开发与功能扩展的基础模板。目前已有79人学习下载适合需要完整实战案例与排错思路的开发者参考。1. 体育馆预约平台从场地冲突到订单闭环一套能跑通的实现路径羽毛球馆周五晚上的黄金时段前台还在用纸质登记本排场电话预约和现场排队的信息对不上同一个场地被卖了两次——这是很多中小型体育馆的真实日常。基于 Spring Boot Vue MySQL 的体育馆使用预约平台要解决的核心就是这件事把场地、时段、订单、用户四者用数据库约束锁死让「谁在什么时候占了哪块场地」变成一条可查询、可追溯、不可重复的记录。这套技术栈之所以成为课程设计和中小型项目的主力军是因为 Spring Boot 把后端配置压到最低Vue 把前后端分离的交互做顺MySQL 负责最终的数据一致性。适合有 Java 和前端基础、想完整走一遍「建表→接口→页面→联调」流程的开发者也适合拿它当毕业设计或练手项目的同学。下面按实际落地顺序拆开讲。2. 数据模型先立住场地、时段、订单三张表怎么设计才不打架预约类系统的成败八成在数据库设计阶段就决定了。页面做得再漂亮如果表结构允许同一场地同一时段插入两条有效订单后面写多少校验代码都是补丁。这一章先把实体关系和字段定清楚再谈代码。2.1 核心实体关系与字段定义一个体育馆预约平台最小可用模型是四张表用户表、场地表、时段表、订单表。场地表存物理资源羽毛球1号场、篮球全场等时段表存可预约的时间切片订单表是用户和时段的关联凭证。场地表venue的关键字段字段类型说明idbigint主键自增namevarchar(64)场地名称如「羽毛球1号场」typetinyint场地类型1羽毛球 2篮球 3乒乓球price_per_hourdecimal(10,2)每小时单价statustinyint0可用 1维护中 2停用open_timetime每日开放起始时间close_timetime每日开放结束时间时段表time_slot不要按「每天生成一批记录」来做那样数据量会爆炸。正确做法是存「时段模板」比如 08:00-09:00、09:00-10:00 这样的固定切片用slot_index标识第几段实际预约时用「日期 时段ID」组合。订单表reservation是核心字段设计直接决定并发安全CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, venue_id BIGINT NOT NULL, slot_id BIGINT NOT NULL, reserve_date DATE NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已使用 3已取消, order_no VARCHAR(32) NOT NULL UNIQUE, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_venue_slot_date (venue_id, slot_id, reserve_date, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个关键点uk_venue_slot_date这个唯一索引把status也放进去了。为什么因为已取消的订单status3不应该阻止别人重新预约同一时段。但这样设计有个副作用——同一场地同一时段只能有一条 status3 的记录第二次取消会冲突。更稳妥的做法是用 MySQL 8.0 的部分索引思路或者干脆把取消的订单归档到历史表。我一般会选后者主表只留有效订单取消时把记录移到reservation_history主表物理删除。这样唯一索引就干净了。2.2 用唯一索引兜底而不是只靠代码判断很多新手写预约逻辑是这样的先select查有没有冲突没有就insert。这在单机低并发下能跑但两个请求同时进来就会双写。血泪经验是数据库层的唯一约束才是最后一道防线代码里的查询只是给用户看的友好提示。正确的插入逻辑应该捕获唯一索引冲突异常Service public class ReservationService { Autowired private ReservationMapper reservationMapper; public Result createReservation(Long userId, Long venueId, Long slotId, LocalDate date) { Reservation r new Reservation(); r.setUserId(userId); r.setVenueId(venueId); r.setSlotId(slotId); r.setReserveDate(date); r.setStatus(0); r.setOrderNo(generateOrderNo()); try { reservationMapper.insert(r); return Result.ok(r.getId()); } catch (DuplicateKeyException e) { // 唯一索引冲突说明该时段已被占用 return Result.fail(该时段已被预约请选择其他时间); } } }DuplicateKeyException是 Spring 对SQLIntegrityConstraintViolationException的封装捕获它比手动查重可靠得多。参数说明generateOrderNo()建议用「日期 场地ID 随机数」拼接长度控制在 32 位以内避免用 UUID 因为太长且无序。status默认 0 表示待支付配合定时任务做超时释放——15 分钟未支付自动取消把时段让出来。2.3 时段可用性查询的 SQL 写法前端选场地选日期后要展示哪些时段可约。这个查询不要用「查所有时段再循环判断」直接用左连接一次查出SELECT ts.id AS slot_id, ts.start_time, ts.end_time, CASE WHEN r.id IS NULL THEN 1 ELSE 0 END AS available FROM time_slot ts LEFT JOIN reservation r ON r.slot_id ts.id AND r.venue_id #{venueId} AND r.reserve_date #{date} AND r.status IN (0, 1) WHERE ts.venue_id #{venueId} ORDER BY ts.slot_index;available1表示可约0表示已被占。注意r.status IN (0,1)只算待支付和已支付已取消和已使用的不算占用。这个查询走uk_venue_slot_date索引即使订单表到百万级单场地单日查询也在毫秒级。如果场地数量多建议在venue_id reserve_date上再加一个联合索引。3. Spring Boot 后端接口分层、参数校验与订单状态机后端不是把 CRUD 写完就完事。预约平台的特殊性在于「状态流转」——订单从待支付到已支付到已使用每一步都有业务规则。这一章把 Controller、Service、Mapper 三层怎么分参数怎么校验状态怎么流转讲清楚。3.1 项目结构与依赖配置用 Spring Initializr 建项目时勾选Spring Web、MyBatis-Plus或 MyBatis、MySQL Driver、Lombok。pom.xml核心依赖dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependencyapplication.yml里数据库连接池用 HikariCPSpring Boot 默认关键配置spring: datasource: url: jdbc:mysql://localhost:3306/gym_booking?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password hikari: maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 30000serverTimezoneAsia/Shanghai必须加否则reserve_date字段可能出现日期偏移一天的问题这个坑我踩过不止一次。连接池大小按「CPU核数 * 2 磁盘数」估算中小型体育馆日订单量几百条10 个连接绰绰有余。3.2 预约接口的参数校验与防重Controller 层用Validated做基础校验但业务校验要放在 ServiceRestController RequestMapping(/api/reservation) public class ReservationController { Autowired private ReservationService reservationService; PostMapping(/create) public Result create(RequestBody Validated ReservationDTO dto) { // 校验日期不能是过去 if (dto.getReserveDate().isBefore(LocalDate.now())) { return Result.fail(不能预约过去的日期); } // 校验时段是否在场地开放时间内 return reservationService.createReservation( dto.getUserId(), dto.getVenueId(), dto.getSlotId(), dto.getReserveDate()); } }ReservationDTO上用NotNull标注必填字段FutureOrPresent标注日期。但注意FutureOrPresent对LocalDate的校验在部分版本有兼容问题我一般手动在 Service 里判断更可控。防重逻辑除了数据库唯一索引还可以在 Redis 里加一层「场地ID日期时段ID」的分布式锁但中小项目没必要唯一索引足够。3.3 订单状态流转与定时任务订单状态机0待支付 → 1已支付 → 2已使用0待支付 → 3已取消超时或用户主动。状态流转必须用条件更新防止并发改状态// 支付回调只有待支付状态才能改成已支付 Update(UPDATE reservation SET status 1 WHERE id #{id} AND status 0) int paySuccess(Param(id) Long id); // 超时取消只有待支付状态才能取消 Update(UPDATE reservation SET status 3 WHERE id #{id} AND status 0) int cancelTimeout(Param(id) Long id);定时任务每 5 分钟扫一次超时订单Scheduled(cron 0 */5 * * * ?) public void releaseTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); ListReservation list reservationMapper.selectList( new LambdaQueryWrapperReservation() .eq(Reservation::getStatus, 0) .lt(Reservation::getCreateTime, deadline)); for (Reservation r : list) { reservationMapper.cancelTimeout(r.getId()); } }cron表达式0 */5 * * * ?表示每 5 分钟执行一次。注意Scheduled默认单线程如果订单量大要配线程池。另外取消后要把记录归档到历史表主表删除保持唯一索引干净。4. Vue 前端场地选择、时段渲染与订单提交的完整交互前端不是把接口数据往页面上一贴就完事。预约场景的交互难点在于用户选场地、选日期、选时段三个维度联动还要实时反映可用状态。这一章用 Vue 3 Element Plus 把这条链路走通。4.1 项目初始化与接口封装用 Vite 建 Vue 3 项目npm create vitelatest gym-frontend -- --template vue cd gym-frontend npm install element-plus axios vue-router piniaaxios封装统一处理 token 和错误// src/utils/request.js import axios from axios import { ElMessage } from element-plus 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 }) request.interceptors.response.use( res { if (res.data.code ! 200) { ElMessage.error(res.data.msg) return Promise.reject(res.data.msg) } return res.data }, err { ElMessage.error(网络异常) return Promise.reject(err) } ) export default requestbaseURL: /api配合 Vite 的server.proxy做开发代理避免跨域。生产环境用 Nginx 转发。code ! 200的判断是约定后端统一返回格式{code, msg, data}。4.2 场地与时段联动渲染页面结构顶部选日期左侧选场地右侧显示该场地该日期的时段网格。template div classbooking-page el-date-picker v-modelselectedDate typedate :disabled-dated d new Date() changeloadSlots / div classvenue-list div v-forv in venues :keyv.id :class{ active: v.id selectedVenueId } clickselectVenue(v.id) {{ v.name }} - ¥{{ v.pricePerHour }}/小时 /div /div div classslot-grid div v-fors in slots :keys.slotId :class[slot, { disabled: !s.available }] clicks.available toggleSlot(s) {{ s.startTime }}-{{ s.endTime }} /div /div el-button typeprimary :disabled!selectedSlots.length clicksubmitOrder提交预约/el-button /div /template script setup import { ref, onMounted } from vue import request from /utils/request const selectedDate ref(new Date()) const venues ref([]) const slots ref([]) const selectedVenueId ref(null) const selectedSlots ref([]) const loadVenues async () { const res await request.get(/venue/list) venues.value res.data } const loadSlots async () { if (!selectedVenueId.value) return const res await request.get(/reservation/slots, { params: { venueId: selectedVenueId.value, date: formatDate(selectedDate.value) } }) slots.value res.data selectedSlots.value [] } const selectVenue (id) { selectedVenueId.value id loadSlots() } const toggleSlot (slot) { const idx selectedSlots.value.findIndex(s s.slotId slot.slotId) if (idx -1) selectedSlots.value.splice(idx, 1) else selectedSlots.value.push(slot) } const submitOrder async () { for (const s of selectedSlots.value) { await request.post(/reservation/create, { venueId: selectedVenueId.value, slotId: s.slotId, reserveDate: formatDate(selectedDate.value) }) } ElMessage.success(预约成功) loadSlots() } onMounted(loadVenues) /script关键点disabled-date禁止选过去日期toggleSlot支持多选时段提交时逐个调用接口每个时段一条订单。如果要做「批量预约」接口后端用Transactional包住循环插入任何一条冲突就整体回滚。4.3 订单列表与状态展示用户中心页展示我的订单按状态过滤el-table :dataorders el-table-column proporderNo label订单号 / el-table-column propvenueName label场地 / el-table-column propreserveDate label日期 / el-table-column propslotTime label时段 / el-table-column label状态 template #default{ row } el-tag :typestatusType(row.status) {{ statusText(row.status) }} /el-tag /template /el-table-column el-table-column label操作 template #default{ row } el-button v-ifrow.status 0 clickpay(row)支付/el-button el-button v-ifrow.status 0 clickcancel(row)取消/el-button /template /el-table-column /el-tablestatusType映射0→warning1→success2→info3→danger。支付按钮在真实项目里要接支付网关课程设计可以用「模拟支付」直接改状态。5. 避坑与排查预约平台最容易翻车的五个地方这一章全是踩过的坑每条按「现象→原因→解决」写。新手照着排查能省大量时间。5.1 时段显示已占用但数据库里查不到订单现象前端时段网格显示某时段不可约但去数据库查reservation表没有对应记录。原因available判断逻辑写反了或者LEFT JOIN的条件放错位置。常见错误是把r.status IN (0,1)写在WHERE里而不是ON里导致左连接退化成内连接没订单的时段反而查不出来。解决确认r.status条件在ON子句里。WHERE里只放ts.venue_id的过滤条件。用EXPLAIN看执行计划确认走的是uk_venue_slot_date索引。5.2 并发预约同一时段两条订单都插入成功现象压测时同一场地同一时段出现两条 status0 的订单。原因唯一索引没建或者建了但status字段不在索引里导致取消的订单也占位。另一种可能是用了INSERT IGNORE但没检查 affected rows。解决确认uk_venue_slot_date存在且包含status。插入后检查affectedRows为 0 说明冲突。或者直接捕获DuplicateKeyException。不要用「先查后插」的写法。5.3 日期字段差一天现象前端选的 3 月 15 日数据库存的是 3 月 14 日。原因JDBC 连接串没配serverTimezone或者配了UTC而服务器在东八区。MySQL 的DATE类型在时区转换时容易偏移。解决连接串加serverTimezoneAsia/Shanghai。实体类用LocalDate而不是java.util.Date。如果还不行检查 MySQL 全局时区SELECT global.time_zone。5.4 定时任务没生效超时订单一直占着时段现象订单创建后 15 分钟没支付但状态还是 0时段没释放。原因启动类没加EnableScheduling或者cron表达式写错。另一个常见原因是Scheduled方法所在的类没被 Spring 管理。解决启动类加EnableScheduling。cron用在线工具验证。方法所在类加Component。如果订单量大配ThreadPoolTaskScheduler避免单线程阻塞。5.5 前端跨域接口 404 或 CORS 报错现象开发环境前端调接口报Access-Control-Allow-Origin错误或者 404。原因Vite 代理没配或者后端CrossOrigin没加。404 通常是baseURL和代理路径不匹配。解决vite.config.js配代理export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })后端 Controller 加CrossOrigin或全局配 CORS。生产环境用 Nginx 同域部署不存在跨域。6. 进阶技巧用状态机 乐观锁把订单流转做扎实前面讲的订单状态流转用的是条件更新已经能防住大部分并发问题。但如果业务再复杂一点——比如「已支付订单可以申请退款退款后时段释放」——就需要更严谨的状态机。我一般会引入一个status流转表或者用枚举 乐观锁版本号。先看乐观锁方案。订单表加version字段ALTER TABLE reservation ADD COLUMN version INT DEFAULT 0;更新时带上版本号Update(UPDATE reservation SET status #{newStatus}, version version 1 WHERE id #{id} AND status #{expectStatus} AND version #{version}) int updateStatusWithVersion(Param(id) Long id, Param(expectStatus) Integer expectStatus, Param(newStatus) Integer newStatus, Param(version) Integer version);调用时先查出当前订单拿到status和version再执行更新。如果返回 0说明状态已被别人改过提示用户刷新。这个方案比单纯条件更新多一层版本保护适合退款、改期这类敏感操作。再进一步把状态流转规则抽成枚举public enum OrderStatus { PENDING(0, 待支付), PAID(1, 已支付), USED(2, 已使用), CANCELLED(3, 已取消), REFUNDING(4, 退款中), REFUNDED(5, 已退款); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public static boolean canTransfer(int from, int to) { if (from 0 (to 1 || to 3)) return true; if (from 1 (to 2 || to 4)) return true; if (from 4 to 5) return true; return false; } }canTransfer方法把合法流转路径写死Service 里先判断再更新。这样即使以后加状态改一个枚举就行不用满项目找if-else。验证方法用 JMeter 或 Apache Bench 对/api/reservation/create发 100 并发同一场地同一时段预期只有 1 条成功99 条返回「已被预约」。如果成功数大于 1说明唯一索引或事务有问题。我一般会在本地跑这个压测确认数据库约束生效再上线。最后说个习惯每次改订单状态相关的代码我都会在本地把「待支付→支付→使用」「待支付→超时取消」「已支付→退款→释放时段」三条链路手动走一遍确认数据库里的status和version变化符合预期。这个习惯帮我挡掉过好几次「状态改错但页面看着正常」的玄学 bug。希望帮到你。本文还有配套的精品资源点击获取