每个高校的体育场馆管理,基本都有一段混乱期。体育课要用场地,社团要排练,教职工约羽毛球,遇上期末考试前更是抢成一团。管理员拿着纸本和Excel排班,学生想预约却不知道哪个场空着,到了现场才发现被其他组织占了位置。我当时在选题系统里抽到11802号题目《springboot校园体育场馆(设施)使用管理网站》,索性把它做成了一个真正能上手的预约系统:SpringBoot做后端,Vue做前端,MySQL存数据,覆盖场馆查询、在线预约、管理员审核、设施报修、统计导出这些功能。这篇文章就把我从需求拆解到表设计、从后端核心逻辑到前端打包部署的完整过程,连同踩过的坑都写下来。如果你正准备做类似的校园信息化系统,不管是课程设计、毕业设计还是想给学院做个实用工具,都能直接照搬思路。
1. 需求拆解:校园体育场馆管理要解决什么
1.1 四个真实痛点
先别急着写代码,我调研了一圈学院里场馆管理员和学生的实际使用场景,真正的问题其实就四个。
第一个是信息不透明。学生想看场地状态,只能加群问管理员或者线下跑一趟;社团负责人想预约固定训练时间,只能靠微信私聊。管理员把Excel表贴在群里,大家各说各话,最后对不上。
第二个是预约冲突。体育课固定时段、校队训练、教职工活动、学生自由预约,四类需求同时压在场地上。人工排班完全靠人眼判断有没有重叠,遇到高峰期基本靠抢,管理员一天要被问几十遍“这个时段还能约吗”。
第三个是设施损坏后没人管。篮球框架松动、羽毛球网破了、乒乓球台掉了块板,学生看到了也只能忍受,管理员不知道,维修人员更不知道。这个环节断掉了,会直接影响使用体验。
第四个是统计困难。学期末学院要数据:每个场馆用了多少次、哪些时段利用率高、各院系用户的使用时长是多少。用Excel手工统计,光是把几百条预约记录整理出来就够熬两个通宵。
这四个痛点对应到系统里就是四件事:场馆状态实时展示、预约冲突自动校验、设施报修闭环处理、预约数据可统计导出。
1.2 模块角色与功能边界
系统我拆成了三类角色:普通用户(学生、教职工)、场馆管理员、系统管理员。角色不同,入口和处理流程也不同。
| 角色 | 核心功能 |
|---|---|
| 学生/教职工 | 浏览场馆、在线预约、取消预约、设施报修、查看通知公告 |
| 场馆管理员 | 审核预约、管理场馆与设施、处理报修工单、发布公告 |
| 系统管理员 | 用户管理、角色分配、数据统计与导出 |
很多同学做这类系统时喜欢把所有功能堆到一个页面上,这其实是权限设计的大忌。预约审核和预约提交必须分开,普通用户看不到审核按钮,管理员不用替学生填预约单。不然等到后期加权限,就要改一堆接口。
1.3 技术选型:为什么是SpringBoot加MyBatis加Vue
技术栈我直接选了SpringBoot + MyBatis + MySQL + Vue,这套组合在校园场景里属于“怎么选都不会出大错”的方案。SpringBoot自动配置极大降低了搭建成本,一个空的Web项目从创建到跑起来,几分钟就能搞定;MyBatis写复杂统计SQL和预约冲突判断SQL非常灵活;Vue配合Element Plus做管理后台,组件成熟,页面效果也拿得出手。
有人可能会问,为什么不直接用SpringBoot + Thymeleaf模板引擎做前后端不分离,这样部署还能更省事。我当时也犹豫过。不分离方案的优点是不用管跨域、不用做Vue打包,缺点是前端页面堆在模板里,答辩时讲不清楚页面交互逻辑,而且后续要加移动端适配会很痛苦。我用的是“前后端分离开发、最后打包进SpringBoot”的方式,开发和部署两头兼顾。
另外提一点版本选择的经验。SpringBoot 3.x现在已经是主流,但它从javax.*换成了jakarta.*包名,很多网上教程还是2.x的写法。如果照着2.x教程配3.x项目,启动时各种类找不到、包名对不上。我的建议很简单:如果参考项目是2.x,就用SpringBoot 2.7.x;如果新写项目且JDK是17以上,直接用3.x然后统一按官方文档写。
2. 表结构设计:把预约冲突消灭在数据库层
2.1 核心表怎么设计
表设计是整个项目的地基,我一开始没想清楚,后来越改越痛苦。把表重新梳理一遍后,整个项目一下子顺了。核心表我建议做这七张:
| 表名 | 关键字段 | 作用 |
|---|---|---|
| user | id, username, password, real_name, role, phone, student_no, credit_score, status | 用户与角色信息 |
| venue | id, name, type, location, capacity, open_time, close_time, status | 体育场馆基础信息 |
| facility | id, venue_id, name, count, status | 场馆下的设施明细 |
| reservation | id, user_id, venue_id, reserve_date, start_time, end_time, purpose, status, review_by, review_comment, create_time | 预约主表 |
| repair_order | id, facility_id, venue_id, user_id, description, images, status, create_time, finish_time | 设施报修工单 |
| notice | id, title, content, create_time | 通知公告 |
| time_slot | id, venue_id, slot_date, start_time, end_time, bookable, version | 固定场次(可选优化) |
这里稍微展开说一下reservation表。它几乎承载了整个系统最核心的业务逻辑,所以状态字段一定不能省。我当时第一版忽略了status,结果“待审核”“已确认”“已取消”全靠删记录来实现,逻辑非常混乱,后来忍痛加字段改接口才理顺。凡是涉及流程的,都要显式设计状态字段。
2.2 预约状态机
预约记录的status我用整数存,方便前后端统一约定:
| 状态值 | 含义 | 谁触发 |
|---|---|---|
| 0 | 待审核 | 用户提交预约 |
| 1 | 已确认 | 管理员审核通过 |
| 2 | 已完成 | 定时任务在结束时间后自动置为完成 |
| 3 | 已取消 | 用户在有效期内取消 |
| 4 | 已过期 | 定时任务把未使用且结束的记录置为过期 |
| 5 | 已驳回 | 管理员驳回并填写原因 |
状态机的好处是,代码里所有逻辑都围绕状态流转展开,不会出现“删记录”这种失控操作。比如取消预约,只允许把0或1改成3;管理员审核,只允许把0改成1或5。谁在什么条件下改什么状态,全都枚举清楚,后面写接口就快了。
2.3 预约时间冲突判断:一段SQL就解决90%的问题
预约系统最核心的技术难点,就是冲突检测。给定一个场地、一个日期、一个起始时间和结束时间,要判断这个时段已经有预约占了。很多人第一反应是用BETWEEN,然后调了半小时边界还是不对。
正确写法是重叠区间判断:
SELECT * FROM reservation WHERE venue_id = #{venueId} AND reserve_date = #{date} AND status IN ('0', '1') AND start_time < #{endTime} AND end_time > #{startTime}这个判断的逻辑很简单:两个区间[A, B)和[C, D)有重叠,当且仅当A < D 且 C < B。换成业务字段就是start_time < 传入的结束时间且end_time > 传入的起始时间。
这里要注意,为了支持“前一个时段结束、后一个时段马上开始”这种连续预约,我用的是严格小于和严格大于。比如8点到10点的预约和10点到12点的预约,边界刚好重合,不应该算冲突。如果用了<=和>=,连续时段会被误判,用户就会收到莫名其妙的“时段冲突”提示。
2.4 防并发“抢场”的两种方案
上面这段SQL解决了“预约时判断冲突”的问题,但还有一道坎:两个用户同时提交同一个场地的同一时段,都执行了查询,发现都没冲突,然后都插入成功。这在真实的并发场景里是有可能发生的。
方案一,给reservation表加唯一索引,比如(venue_id, reserve_date, start_time)。但问题在于end_time不同时,比如8点到10点和8点到9点,虽然起始时间相同,实际并不完全冲突,唯一索引就容易误伤,不够灵活。
我更推荐方案二:增加一张time_slot场次表,预约时对场次实行乐观锁更新。先查出场次的当前version,用户确认预约后执行:
UPDATE time_slot SET bookable = 0, version = version + 1 WHERE id = #{slotId} AND bookable = 1 AND version = #{oldVersion}如果影响行数为1,说明抢场成功;如果为0,说明别人已经抢先订走了。配合@Transactional,把“扣场次 + 写预约记录”放在同一个事务里,基本不会出现重复预约。
3. SpringBoot后端落地:核心模块与实现细节
3.1 项目初始化与目录结构
我用IDEA新建SpringBoot项目,勾选Spring Web、MyBatis、MySQL Driver、Lombok这几个依赖。项目的包结构分了五层,清晰也方便答辩讲解:
com.example.gym ├── controller ├── service ├── mapper ├── entity ├── config ├── common └── GymApplication.javacommon里放统一返回值、异常处理、通用工具类;config里放拦截器、定时任务配置、跨域配置。application.yml里关键是数据库连接和Jackson时间格式:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/gym?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.gym.entity spring.jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8数据库连接的serverTimezone=Asia/Shanghai一定要加上,不然本地MySQL和SpringBoot之间容易出现8小时时差问题。这个问题特别常见,稍后再细说。
3.2 登录鉴权:轻量JWT加拦截器
登录鉴权我没用Spring Security,理由是这个小项目的角色只有三种,用Spring Security会引入大量配置,对新手不友好,答辩时也不方便一句话讲清楚。我用的方案是JWT + 拦截器 + 自定义注解,总共四个类,逻辑非常直白。
用户登录成功后,后端用用户ID和角色生成一个JWT令牌返回;前端存在localStorage里,每次请求在Header带上Authorization: Bearer <token>。后端写一个拦截器解析token,并把用户信息放进ThreadLocal。然后定义一个@RequireRole("ADMIN")注解,加在需要权限的Controller方法上,管理员提交审核、管理场馆这类接口就受保护了。
密码存储这块,我用的是BCrypt而不是MD5。MD5虽然也能跑通,但现在安全评审都会盯这个,用BCrypt也就多几行代码,而且BCrypt每次生成的哈希都带随机盐,安全性高一个档次。
3.3 预约接口开发:事务、乐观锁与防重复提交
预约接口是整个系统的心脏。我这里给出核心流程的简化代码,方便对照理解:
@Transactional(rollbackFor = Exception.class) public Result createReservation(CreateReservationDTO dto) { Venue venue = venueMapper.selectById(dto.getVenueId()); if (venue == null) return Result.error("场地不存在"); // 1. 检查是否在开放时间范围内 if (dto.getStartTime().isBefore(venue.getOpenTime()) || dto.getEndTime().isAfter(venue.getCloseTime())) { return Result.error("不在场馆开放时间范围内"); } // 2. 检查信用分,低于60分限制预约 User user = userMapper.selectById(dto.getUserId()); if (user.getCreditScore() < 60) { return Result.error("信用分过低,请联系管理员"); } // 3. 冲突检测 List<Reservation> conflicts = reservationMapper.selectConflict( dto.getVenueId(), dto.getReserveDate(), dto.getStartTime(), dto.getEndTime()); if (!conflicts.isEmpty()) { return Result.error("该时段已被预约,请选择其他时间"); } // 4. 写入预约 Reservation reservation = new Reservation(); reservation.setUserId(dto.getUserId()); reservation.setVenueId(dto.getVenueId()); reservation.setReserveDate(dto.getReserveDate()); reservation.setStartTime(dto.getStartTime()); reservation.setEndTime(dto.getEndTime()); reservation.setPurpose(dto.getPurpose()); reservation.setStatus(0); reservationMapper.insert(reservation); return Result.success(); }这里最关键的几件事,我在实际项目中都踩过:
第一,@Transactional必须加在Service层,而不是Controller层。Spring的事务是通过AOP代理实现的,只有经过Spring管理Bean的方法才能拦得到。加在Controller上,事务经常不生效,报错后数据还会留在库里。
第二,同类内部调用this.createReservation()这种方式会导致事务失效。因为SpringBoot默认使用CGLIB代理,内部调用直接走的是目标对象而不是代理对象,事务拦截器根本感知不到。如果需要在一个方法里调用同类另一个事务方法,要么拆到不同的Bean里,要么自己注入代理对象。
第三,如果用了乐观锁场次表,把UPDATE time_slot语句放在事务最前面去锁场次,比先查询再更新更稳妥。影响行数为0时直接抛异常回滚,预约记录就不会写入。
3.4 定时任务:每日生成场次与过期订单自动释放
系统里有两个场景必须靠定时任务:一个是预先生成未来7天的可预约场次,另一个是把超时未用的预约置为过期,释放场地资源。
SpringBoot里用@EnableScheduling开启定时任务,然后在方法上加@Scheduled。比如每天0点批量生成场次:
@Scheduled(cron = "0 0 0 * * ?") public void generateTimeSlots() { LocalDate startDate = LocalDate.now().plusDays(1); for (int i = 0; i < 7; i++) { // 遍历所有正常状态场馆 // 根据场馆的open_time和close_time生成固定时段 // 插入time_slot表时捕获DuplicateKeyException跳过已存在的日期 } }定时任务里一定要处理重复执行的情况。如果系统重启后任务跑了两遍,同一天同个场馆的场次就会重复生成。我在time_slot表加了唯一索引(venue_id, slot_date, start_time),插入时捕获重复键异常,直接把已存在的跳过去,这样任务重复跑了也不会出乱子。
过期清理任务我放在每个整点执行,扫描所有status = 1且end_time < now的记录,把状态改成2已完成;再扫描status = 0且create_time超过设定时间(比如2小时)未审核的预约,把状态改成4已过期。这两个任务配合信用分体系,能让整系统的数据一直保持干净。
4. Vue前端:从页面设计到打包进SpringBoot
4.1 页面结构与交互要点
前端我用Vue + Vue Router + Element Plus,页面不算多,核心是这几块:登录页、首页场馆列表、预约详情页、我的预约页、管理后台(包含审核预约、场馆管理、报修管理、公告管理、数据统计)。
前端开发时最容易忽略的是时间控件的限制。用户在选预约日期时,过去日期应该置灰不可选;选择时间段时如果后端返回的可用时段有标记,前端就禁掉已经满的场次。很多同学只顾把预约表单做出来,忘了前端交互约束,结果用户选了过去的日期,提交后后端才提示“日期不能早于今天”,体验很差。
开发环境我配置了Vite代理,把前端的/api请求转发到本地的http://localhost:8080,这样联调阶段不用处理跨域。等到生产环境,前端打包后丢给后端静态目录,跨域问题自然就没了。
4.2 Vue打包放进SpringBoot的三步操作
这个是热搜词里经常出现的场景,我实际操作了一遍,其实就三步。
第一步,在Vue项目根目录执行:
npm run build第二步,把生成的dist目录下的所有文件复制到SpringBoot项目的src/main/resources/static目录下。SpringBoot启动时会把static作为静态资源根目录,所以访问http://localhost:8080/时,直接就会打开这里的index.html。
第三步,重新打包启动SpringBoot。只部署一个jar包,不需要额外启动Nginx,非常适合课程设计答辩演示。
但有个坑必须提醒:如果Vue Router用了history模式,刷新非首页路径时会出现404。因为前端路由是浏览器端接管,刷新后浏览器向服务器请求该路径,但服务器并没有对应的Controller。最简单的解决办法是用hash模式,URL里多一个#,不影响演示效果;如果一定要用history模式,后端需要做一个fallback转发,把所有非/api开头的请求都指到index.html。
4.3 与后端的API约定
前后端联调最怕各写各的。我在项目里定义了一个统一返回结构,所有接口都返回:
{ "code": 200, "message": "success", "data": {} }分页接口统一传pageNum和pageSize,返回结构里带total字段。时间参数统一用yyyy-MM-dd HH:mm:ss字符串,前端解析成日期对象后格式化展示。这样整套联调基本不会出现字段对不上的情况。
5. 部署、运行与问题排查速查
5.1 本地跑起来的完整流程
一个完整的本地运行流程是这样的:先建库,导入表结构SQL;然后改application.yml里的数据库用户名密码;后端执行mvn spring-boot:run,前端执行npm run serve。如果前后端分离开发,就可以并行开发调试。
有些同学会遇到“IDEA里不知道怎么改端口”的问题。其实不用改代码,IDEA的Run/Debug Configurations里面,在Program arguments一栏填入:
--server.port=8081或者把application.yml里的server.port改掉即可。用命令行启动就执行java -jar xxx.jar --server.port=8081。这个选项的优先级比配置文件高,不用重新打包就能换端口。
5.2 高频问题速查表
我把做这个项目时遇到的问题整理成了一张表,都是新手最容易卡住的:
| 问题现象 | 原因 | 解决办法 |
|---|---|---|
| 启动报数据库连接失败或时区错误 | JDBC链接缺少serverTimezone | 连接串加serverTimezone=Asia/Shanghai |
报错Invalid bound statement (not found) | MyBatis没扫到Mapper XML | 在yml里配mybatis.mapper-locations=classpath:mapper/*.xml |
| 启动后访问页面404 | dist没复制到static或没重新打包 | 确认静态资源在static根目录,重启应用 |
| 两个用户同时订到同一个场次 | 缺唯一约束或乐观锁 | 用场次表加乐观锁,或给预约表加唯一索引 |
| SpringBoot 3.x 项目用 2.x 教程代码报类找不到 | javax.*变jakarta.* | 统一按当前版本重写 import |
| Excel导出文件名为乱码 | Content-Disposition没处理编码 | 对文件名执行URLEncoder.encode(name, "UTF-8") |
| 本地时间比数据库早/晚8小时 | 时区不统一 | application.yml里设置time-zone: GMT+8,数据库连接串也加时区 |
5.3 一些项目层面的经验教训
后端写多了之后,我有一个很深的感觉:真正制约项目进度的不是代码量,而是表结构和状态设计。我第一版把预约记录和取消记录混在一起,后来统计利用率时发现数据对不上,只能重新写表结构。如果一开始就按状态字段把流程定义清楚,后面做定时任务、统计导出都会顺很多。
还有一个经验是日期时间类型统一用LocalDateTime,不要用java.util.Date。LocalDateTime配合Jackson的JavaTimeModule,序列化时不容易出现类型转换异常,处理时区也更直观。
6. 把项目做得比“及格”更好:加分项与答辩建议
6.1 低成本加分功能
基础功能做完之后,系统已经能用了,但要想在答辩或评审时更有亮点,可以加几个低成本、高性价比的功能。
信用分体系是最推荐的一个。用户完成预约后未到场,信用分扣10分;信用分低于60分,限制未来7天预约。这个功能只需要在预约和取消的逻辑里加几行判断,却能让整套系统看起来非常完整。报修闭环也值得做,用户提交报修单,管理员后台处理完改成“已维修”,前端能看到进度。
数据可视化也挺加分。用ECharts给后台加两个图表,一个统计近30天各场馆的预约次数,一个统计各场地类型的使用占比。数据从预约表里按月分组查出来,接口返回给前端渲染,工作量不大,但演示效果直接上一个档次。
6.2 答辩时讲什么、怎么讲
答辩时不要从代码第一行开始讲,评委最关心的是你的思路。我建议按这个顺序讲:先说痛点(人工排班冲突、信息不透明),再说技术选型(为什么用SpringBoot做后端),接着讲表设计和预约冲突的处理(这是核心亮点),最后提一下部署方式。
以下几个问题几乎必问,提前准备会有明显优势。SpringBoot自动装配原理,核心是@EnableAutoConfiguration会加载META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports里的配置类,再结合@ConditionalOnClass等条件注解按需装配。SpringBoot默认使用CGLIB代理,这跟事务失效的场景直接相关。版本选择上,为什么用SpringBoot 3.x而不是2.x,可以从JDK版本、Servlet API迁移方面回答。
最后说点我的个人体会。这个项目做完,我最深的感受是:系统复杂度的瓶颈往往不在写了多少行代码,而在于有没有把业务流程想清楚。预约冲突、状态流转、权限边界,这些都是只要动手写代码前用半小时梳理好,后面就不会反复返工的事。如果你也正在做类似的校园预约系统,别急着敲代码,先打开一个文档把角色、流程、状态、表关系画出来,一定比直接写代码快得多。