☰
Spring Boot体育场馆预约系统毕设全攻略:从数据库到并发控制
2026/10/10 4:25:03 网站建设 项目流程

每年毕业季,辅导群里问得最多的就是这类题目。今天把这个“基于Spring Boot的体育场馆设施预约系统”从头到尾拆一遍,从设计思路、数据库、核心代码到避坑清单,一次说清楚。这篇文章不是给你抄代码的,是帮你搞清楚这个题目到底怎么拿高分、怎么答辩不被问倒、怎么在最后一个月把项目稳稳落地。适合正在做毕设的同学、带毕设的导师参考,也适合刚入行想练手Spring Boot的开发者。

1. 课题拆解:这个毕设到底在考你什么

1.1 表面上是一个预约系统,本质考察三层能力

很多同学拿到题目第一反应就是“不就是增删改查吗?”——对,也不对。体育场馆设施预约系统确实以增删改查为主体,但毕业设计考察的从来不是CRUD本身,而是你在CRUD之上怎么处理业务规则、怎么设计数据关系、怎么应对并发场景。

我总结下来,这个题目真正考察三层能力。

第一层是基本功:能不能把用户、场馆、场地、预约、订单这些实体正确建模,能不能用Spring Boot把它们串成完整业务流程。这一层过了,项目就能跑起来。

第二层是业务建模能力:预约有状态,状态会变化——待支付、已支付、已取消、已完成,这些状态谁能触发、在什么条件下触发,写的时候必须想清楚。更核心的是冲突检测,同一个羽毛球场地,周六下午2点到4点被A同学约了,B同学再来就不能约同一时段。这个规则写不明白,答辩时一问就露馅。

第三层是工程能力:有没有考虑过并发下单?有没有处理过异常?代码分层是堆在一起还是controller-service-mapper清晰分离?这才是导师和评委拉开差距的地方。

想清楚这三点,你的时间和精力分配就有数了:核心放在预约表结构和冲突检测上,外观功能差不多就行。

1.2 功能架构与角色权限设计

基于Spring Boot的体育场馆设施预约系统,角色基本就是两类:普通用户和管理员。用户端要做的事很明确,注册登录、浏览场馆、查看空闲场地、选择时间段提交预约、查看我的预约记录、取消预约。管理员端要做的就是场馆和场地的维护、预约记录的查看与处理、用户管理、公告发布,再加一个简单的数据概览页面。

权限控制在毕设里不建议折腾太复杂的框架。用一张用户表,加一个role字段,1表示普通用户,2表示管理员,然后写一个拦截器判断Session里存的用户角色,权限不够就跳转到提示页。思路清晰、代码量少、答辩也讲得明白。Spring Security当然也能做,但学习成本和配置成本高很多,对于一个预约系统来说属于过度设计。

1.3 技术选型与推荐组合

技术栈网上方案很多,我推荐一套最稳的组合,也是我实际带项目用过多次的:

  • 后端:Spring Boot 2.7.x + MyBatis-Plus
  • 数据库:MySQL 5.7或8.0
  • 前端:Thymeleaf模板 + Bootstrap + jQuery
  • 额外工具:Lombok、Hutool(工具类)、Postman、Navicat

为什么选这套?Spring Boot 2.7配JDK8,资料最多、踩坑最少,网上随便一搜都是答案。MyBatis-Plus管单表CRUD非常省事,不用写一堆XML,把时间花在核心业务上。前端选Thymeleaf是因为前后端不分离,写完一个Controller直接返回页面,项目结构简单,工作量远小于Vue前后端分离。如果你本身Vue很熟,也可以前后端分离,但要注意CORS跨域和打包问题,对毕设来说不划算。

2. 数据库设计:把预约系统的地基打牢

2.1 核心数据表及关系

一个体育场馆设施预约系统,核心表我用五张就能覆盖:用户表、场馆表、场地表、预约表(同时承载订单信息)、公告表。有人喜欢把预约和订单拆成两张表,理论上有道理,但毕设场景里合在一张表更实用,少一次Join,状态管理也简单。

用户表没什么好说的,id、用户名、密码、昵称、手机号、角色、创建时间。密码别忘了存MD5或BCrypt加密后的值,别用明文写进数据库,这个细节答辩会被问到。场馆表和场地表要分开,因为一个体育馆里有多个场地——羽毛球馆有1号场、2号场,游泳馆可能分成浅水区和深水区。如果只建一张场地表,字段冗余会非常严重。场馆表放名称、位置、简介、封面图、开放时间、状态;场地表放所属场馆ID、名称、类型、每小时价格、可容纳人数、状态。

预约表是整张数据库设计的核心,也是出彩点,下一节专门讲。

2.2 预约表:状态机与关键字段

预约表的字段设计,我建议这样:

预约ID、用户ID、场地ID、预约日期、开始时间、结束时间、订单金额、状态、创建时间、支付时间、取消时间、备注。

这里有三点要特别强调。第一,为什么时间要拆成“预约日期 + 开始时间 + 结束时间”三个字段?因为预约场景天然跨天,比如晚上10点到次日凌晨0点,存一个开始时间一个结束时间,再单独存日期,查“某一天有哪些预约”最方便,也方便做日期维度的统计。

第二,状态字段用整数,规定好枚举含义。我常用的约定是:0-待支付,1-已支付/已预约,2-已完成/已使用,3-已取消,4-已退款。为什么要用整数不用字符串?因为判等方便、存储体积小、前端显示时用一个Map映射成中文就行。更重要的是,把状态值固定在代码常量里,不允许乱写,后面所有业务逻辑都围绕这套状态流转展开。

第三,金额字段用DECIMAL(10,2),不要用float或double。这些浮点类型在计算时有精度问题,金额算错在答辩演示时会非常尴尬。

2.3 预约冲突检测:核心设计思路

先想一个问题:怎么判断一个场地在某个时间段是否已被预约?

最直观的方案是区间判断法。查一下该场地在目标日期、目标时间段内,是否存在开始时间小于目标结束时间、并且结束时间大于目标开始时间的已占用的预约记录。这个SQL写出来很经典:

SELECT COUNT(*) FROM reservation WHERE court_id = ? AND reserve_date = ? AND status IN (0, 1) -- 待支付和已支付都算占用 AND start_time < ? -- 目标结束时间 AND end_time > ? -- 目标开始时间

但我觉得更优雅、更适合毕设演示的思路是时间片法。把每个场地每天的时间切成固定片,比如从早上8点到晚上22点,每2小时一片,一共7片。场地表关联一张时间片表,每个场地每天生成7条记录,每条记录有一个is_booked字段。用户预约时,其实就是选择一个未预约的时间片,然后把这个时间片标记为已约。

时间片法的好处非常明显。第一,冲突检测从复杂的时间区间判断退化成一条等值查询,逻辑大大简化,写起来几乎不可能错。第二,用户前端看到的界面也更直观——像电影院选座一样选时间片,体验好。第三,并发控制更容易做,直接对这个时间片记录加乐观锁或唯一索引,就能挡住重复预约。

有同学会担心,2小时一片不够灵活怎么办?比如用户只想约1小时。解决办法是把切片粒度缩小,比如每30分钟一片,场地价格按片累加计算。毕设里把粒度定成1小时或2小时,既好实现也够演示,没必要追求极致的灵活。

2.4 建表SQL参考

我贴一个精简版的建表SQL,直接用就可以。字段和索引我都标了注释,方便你改。

CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码(MD5/BCrypt)', `nickname` varchar(50) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `role` tinyint DEFAULT 1 COMMENT '1-用户 2-管理员', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `venue` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '场馆名称', `location` varchar(255) DEFAULT NULL COMMENT '位置', `description` text, `cover_image` varchar(255) DEFAULT NULL, `open_time` time DEFAULT NULL, `close_time` time DEFAULT NULL, `status` tinyint DEFAULT 1, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `court` ( `id` bigint NOT NULL AUTO_INCREMENT, `venue_id` bigint NOT NULL COMMENT '所属场馆', `name` varchar(100) NOT NULL COMMENT '场地名称', `type` varchar(50) DEFAULT NULL COMMENT '场地类型', `price_per_hour` decimal(10,2) DEFAULT NULL, `max_people` int DEFAULT NULL, `status` tinyint DEFAULT 1, PRIMARY KEY (`id`), KEY `idx_venue_id` (`venue_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `reservation` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `court_id` bigint NOT NULL, `reserve_date` date NOT NULL COMMENT '预约日期', `start_time` time NOT NULL COMMENT '开始时间', `end_time` time NOT NULL COMMENT '结束时间', `amount` decimal(10,2) DEFAULT NULL, `status` tinyint DEFAULT 0 COMMENT '0待支付 1已支付 2已完成 3已取消 4已退款', `create_time` datetime DEFAULT NULL, `pay_time` datetime DEFAULT NULL, `cancel_time` datetime DEFAULT NULL, `remark` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_court_date` (`court_id`, `reserve_date`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

如果你用时间片方案,额外加一张time_slot表,字段为ID、场地ID、日期、开始时间、结束时间、是否已约,并在court_id + date + start_time上建唯一索引。

3. 核心业务逻辑:预约与冲突检测的实现

3.1 完整预约流程梳理

预约业务是整个系统的主干,流程必须完整跑通。用户登录后进入场馆列表,点击某个场馆查看场地详情,选择日期,前端显示出该场地当天的时间片,挑一个空闲的,确认价格后提交预约。系统要做的事按顺序是:检查登录状态 -> 再次检查时间片是否被占用 -> 创建预约记录(状态为待支付) -> 标记时间片已占用 -> 跳转支付页面 -> 模拟支付 -> 更新状态为已支付。

这里注意一个细节:前端已经做了空闲时间片的展示,但后端在提交接口里依然要再做一次占用检查。这个检查不是多余的,前端展示只是辅助,后端校验才是真正的防线。这个思想在答辩时可以重点讲,叫作“前端约束体验,后端保证安全”。

模拟支付怎么做?不需要真接支付平台,做一个支付页面,点击“确认支付”,后端把订单状态从0改成1,记录支付时间,再跳转到支付成功页。如果要做得出彩一点,可以在支付前加一个二次确认页面,显示订单概要,这样演示流程会更完整。

3.2 冲突检测代码的三种写法与选择

冲突检测的写法至少有三种,我逐一分析,大家按自己的技术水平和导师口味来选。

第一种:基于时间段区间的SQL查询。前面已经给过SQL了,用count判断是否大于0。优点是灵活,支持任意起止时间;缺点是人一多,复杂度上来了,而且区间查询容易漏掉边界情况。边界条件务必测试:目标时间刚好接在前一条预约的结束时间上,应该视为不冲突;提前或延后一分钟,就必须视为冲突。

第二种:基于时间片表的等值查询。提交时先查time_slot表里的某个片段is_booked是否为0,是则插入预约并更新is_booked为1。这个方案的核心代码很简单:

// 1. 查询目标时间片 TimeSlot slot = timeSlotMapper.selectOne( new LambdaQueryWrapper<TimeSlot>() .eq(TimeSlot::getCourtId, courtId) .eq(TimeSlot::getSlotDate, reserveDate) .eq(TimeSlot::getStartTime, startTime) ); if (slot == null || slot.getIsBooked() == 1) { throw new BizException("该时间段已被预约"); } // 2. 创建预约记录 reservationService.create(...); // 3. 更新时间片状态 slot.setIsBooked(1); timeSlotMapper.updateById(slot);

代码非常直观,答辩讲解时评委理解成本低。缺点是要维护时间片表,每天的新场地需要定时生成当天的时间片数据。

第三种:数据库唯一索引加事务兜底。在time_slot表上建唯一索引(court_id, slot_date, start_time),然后is_booked标记用0和1表示。插入预约时,先将对应时间片记录更新为已约状态,让数据库自己处理并发冲突。当两条相同请求同时到达时,后执行那条会在更新时匹配不到行数或者违反唯一约束,直接报错,代码里catch住异常返回“该时间段已被预约”。

第三种方案如果写在答辩里,是明显的加分项,因为它体现了你对并发场景的思考。

3.3 并发控制:抢同一个场地的两个人

毕设答辩有一个极品高频问题:两个用户同时点击预约同一个时间片,你的系统怎么保证不重复?

如果代码只是“先查询is_booked == 0,再插入”,在高并发场景下会出现问题。A和B同时查到这个时间片空闲,同时往下执行插入,最终两人都预约成功,场地重复占用。这就是经典的竞态条件。

解决办法有三个层次。最简单的,给时间片表的更新语句加上条件判断,做一个乐观锁式的更新:

UPDATE time_slot SET is_booked = 1 WHERE court_id = ? AND slot_date = ? AND start_time = ? AND is_booked = 0

然后判断受影响行数,如果为0就说明被别人抢了。这个写法不需要额外加version字段,是我最推荐的做法,简单有效还讲得通。

更正统的乐观锁是在表上加version字段,更新时Where条件带version,更新成功后version加1。类的Version字段上标@Version注解,MyBatis-Plus的updateById会自动带上版本条件。这种方式写起来也很干净,适合作为答辩讲解素材。

最硬核的是用Redis分布式锁或者数据库悲观锁。SELECT ... FOR UPDATE锁住时间片记录,事务提交后再释放。但这套对毕设来说太重了,除非你论文想写高并发方向,否则没必要。

3.4 状态流转与超时取消

预约状态流转要梳理清楚,答辩时画一张状态迁移图会很加分。我画文字版给你:

  • 创建订单后状态为0(待支付)
  • 用户支付成功,状态由0变成1(已支付/已预约)
  • 预约时间到达并结束,状态由1变成2(已完成)
  • 用户主动取消,待支付时取消为3;已支付时取消为4(已退款),并释放场地
  • 超时未支付,系统自动把状态由0改成3

这里有个隐藏业务规则:待支付订单不能一直占着场地不释放。比如A同学选好时间片没付款,B同学看到时间片已被占就不能约了,这样会导致场地资源被白白锁死。解决方式是在创建时间和预约日期之间加一个支付时限,一般预约至少提前半天,所以给15分钟或30分钟的支付缓冲就够了。用Spring的@Scheduled注解写一个定时任务,每分钟扫一次待支付订单,超过时限就自动取消并释放时间片。

这个定时任务很小,代码十几行,但它在演示时非常有用:你现场下一个单不支付,等定时任务跑完再刷新,订单自动取消,场地恢复空闲。评委一看就知道你考虑了真实业务场景。

4. 从空项目到完整演示:搭建与实现记录

4.1 项目骨架搭建

Spring Boot项目建议直接用Spring Initializr生成,选Java 8、Spring Boot 2.7.x,依赖勾选Spring Web、MyBatis-Plus(这个在Initializr里没有,需要手动加)、MySQL Driver、Lombok、Thymeleaf。

MyBatis-Plus依赖手动添加时注意版本兼容,我之前用mybatis-plus-boot-starter 3.5.x配Spring Boot 2.7没有任何问题。如果是Spring Boot 3,需要选mybatis-plus-spring-boot3-starter。

项目结构按约定来即可,分包清晰比什么都重要。controller包放接口,service包放业务逻辑,service.impl包放实现类,mapper包放数据库操作,entity包放实体类,common包放统一返回结果和异常处理,config包放配置类。实体类字段用驼峰命名,数据库字段用下划线命名,MyBatis-Plus默认开启驼峰映射,无需额外配置。

4.2 后端接口设计与统一返回

接口设计要有统一规范。我建议用一个Result类包住所有返回结果,结构包含code、message、data三个字段。成功返回Result.success(data),失败抛异常后在全局异常处理器里统一返回Result.error(code, message)。不要在Controller里到处try-catch,用@RestControllerAdvice做全局异常处理,代码会整洁非常多,这个也值得在答辩里提。

核心接口清单大概长这样:

用户模块:注册、登录、登出、获取当前用户信息。

场馆模块:场馆分页列表、场馆详情(含场地列表)。

预约模块:查询某场地某天的空闲时间片、提交预约、取消预约、我的预约分页列表、支付模拟接口。

管理端:场馆增删改查、场地增删改查、预约列表(按状态筛选)、预约状态处理、公告管理。

接口路径用REST风格,比如GET /api/venue/list、POST /api/reservation、PUT /api/reservation/{id}/cancel。注意,所有需要登录的接口都要经过拦截器校验,前端页面跳转不到的接口也要防一手,这是安全习惯。

4.3 前端页面实现思路

前端用Thymeleaf模板,首页做好看一点,印象分很关键。推荐的页面清单:登录页、注册页、场馆列表页、场馆详情页(带场地和时间片选择)、订单确认页、支付页、我的预约页、个人中心、管理端布局页、场馆管理页、场地管理页、预约管理页、数据概览页。

时间片选择是整个前端最核心的交互。我的做法是:页面加载时先用Ajax请求后端接口,拿到某场地某日期的时间片列表,用一个表格或者宫格渲染。空闲的显示为可点击状态,被占用的置灰不可点。用户点击时间片后,下方汇总显示总价,然后点击“提交预约”跳转订单确认页。

样式直接用Bootstrap,不自己写复杂CSS。Bootstrap的栅格系统做卡片式列表很方便,数据概览页用简单统计卡片堆出总用户数、今日预约数、今日营收即可。

4.4 演示数据与联调测试

项目做完之后最重要的事情,就是造一部像样的演示数据。空数据库点开全是没有记录的页面,给评委的印象非常差。我一般会写一个DataInitializer类,在项目启动时检查数据量,如果为空就自动插入:4个场馆、每个场馆3-5个场地、今天和未来三天的时间片数据、两个普通用户加一个管理员、若干条历史预约记录。

时间片数据怎么生成?写一个定时任务,每天晚上凌晨把未来三天的未生成时间片补上。这个逻辑看起来小,但实际能省很多事,否则你手动往数据库里插几百条记录得累死。

联调测试我习惯用Postman先测一遍所有接口,注册、登录、场馆列表、提交预约、重复提交、取消预约、支付、管理员处理,把能想到的正常流程和异常流程都跑一遍。然后不用Postman,直接从页面过一遍完整流程,不同浏览器的兼容情况也确认一下。

5. 常见问题与排查技巧实录

5.1 高频报错与解决办法

这个项目里,大家最常遇到的报错基本集中在几个地方,我先按经验排个优先级。

第一个是密码或用户名登录失败。排查方向很清晰:先看数据库里有没有这条用户记录,再看密码比对逻辑是明文还是加密后比对。很多同学注册时加密,登录时忘了解密或者比对方式不一致,就会一直登录不上。密码校验只推荐一种做法:注册时MD5(可加盐),登录时对输入密码做同样的MD5计算后和数据库比对。

第二个是时间格式化问题。LocalDateTime类型默认序列化出来是一长串数组,前端显示极其难看。解决办法是加jackson配置,全局指定日期格式:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

同时在前端使用Thymeleaf展示时,用#temporals格式化日期。

第三个是Thymeleaf页面加载报错,常见的是“Error resolving template”。这个一般是模板路径不对,Spring Boot默认去src/main/resources/templates/下找模板,返回页面时Controller里写的字符串名字要和模板文件名匹配。用了@RestController又返回字符串,会被当作接口数据返回而不是页面跳转,这也是新手常犯的错误——页面跳转要用@Controller,接口用@RestController。

第四个是高并发下重复预约。前面讲了,InnoDB下先查后插会出问题。如果你用我推荐的条件更新写法,基本能挡住;如果还出问题,检查事务隔离级别,以及更新语句受影响行数的判断逻辑。

第五个是跨域问题。如果你选了前后端分离,前端Vue访问后端接口时会遇到CORS报错。解决方式是在后端写一个CorsFilter配置类,允许所有来源访问,或者更精细地配置允许的来源。如果不想折腾,就回到Thymeleaf方案,从根上避免了跨域。

5.2 答辩被追问的技术点清单

我整理过一份高频答辩问题清单,基本覆盖这个题目的所有死角:

为什么选Spring Boot?回答思路:相比传统SSH/SSM,Spring Boot提供自动配置和起步依赖,开发者不需要配置繁琐的XML就能快速搭建独立可运行的Spring应用,内嵌Tomcat容器让部署也更简单。

MyBatis-Plus用了哪些特性?回答思路:通用Mapper自动实现单表CRUD,条件构造器LambdaQueryWrapper避免了拼接SQL的繁琐,分页插件实现物理分页。同样要会解释MyBatis-Plus和原生MyBatis的区别。

怎么防止场地被重复预约?这是核心中的核心。回答思路:数据库层通过时间片唯一索引保证同一场地同一时间只有一个有效预约,再在应用层用条件更新判断受影响行数,双保险。还能补充一句“如果后续并发量增大,可以引入Redis分布式锁”。

预约状态是怎么流转的?把0到4的流转路径完整背一遍,说明哪些动作触发哪些状态变化,以及超时取消的定时任务逻辑。

数据库表为什么这么设计?回答思路:把场馆和场地拆开避免冗余,预约表冗余订单金额省去Join,时间字段拆成日期和起止时间方便统计和冲突判重,时间片表用空间换简化逻辑。

如果用户下单后不支付怎么办?回答思路:定时任务扫描待支付订单,超过设定时间自动取消并释放时间片。

系统的安全性怎么保证?回答思路:密码加密存储、登录拦截器、后端参数校验、全局异常处理。

每个问题都要做到脱稿能讲出来,不要背书面语言,用自己的话把逻辑说顺。你讲得清楚,评委就不追问;你眼神躲闪,他反而越问越深。

6. 一些经验之谈

带过的学生里,做完这个题目的不下几十个。我的体会是:真正拉开分差的不是代码量,而是对业务规则的理解。同样是预约系统,有的人做完只是换了皮的CRUD,有的人讲冲突检测、状态机、并发控制讲得头头是道,这就是高分和及格分的区别。

给正在做的同学三个具体建议。第一,先把数据库表和状态流转想清楚再写代码,我见过太多人表结构改了四五版,后面代码跟着推翻重来。第二,控制项目规模,不要贪多贪全——场馆评论、积分系统、会员等级这些功能选题里没要求就别加,把核心链路打磨好比多几个鸡肋功能有价值得多。第三,给自己留至少一周的缓冲时间,用来写文档、录演示视频、准备答辩PPT,别把时间卡死在写代码上。

最后分享一个小技巧:答辩前自己完整走一遍用户流程,从注册登录到下单支付再到管理员后台管理,边点边自言自语解释每一步的实现逻辑。这个方法很土,但真的有效,你越熟练,现场就越自信。加油,把这段路走完,你的Java Web能力会有一个实实在在的跃升。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询