1. 项目要解决什么问题,模块怎么拆
1.1 体育馆预约的三个真实痛点
我为什么会专门去写一套体育馆预约平台?起因是帮朋友学校的信息中心做调研时,发现大部分校内体育馆的预约方式还停留在“微信群喊一声 + 前台手写登记表”的阶段。羽毛球馆、篮球馆、乒乓球室这些热门场地,一到高峰期全靠管理员肉眼判断哪个场空着、哪个时段重叠了,用户跑过去发现场地被占的情况时有发生。这种模式的核心问题有三个:
第一,预约信息完全不透明。用户不知道场馆哪些时段可用,只能反复打电话问,或者到现场碰运气。管理员则要一遍遍回复相同的问题,傍晚高峰期一个人根本忙不过来。第二,冲突判断极其依赖人工。一个场地一天分十几个时段,用户A约了18:00-19:00,用户B想约18:30-19:30,管理员如果没有及时登记,这两个预约就会撞车。第三,数据统计基本靠Excel。到了月底想算一下各场馆利用率、哪些时段最热门,只能翻登记表手工核对,效率低还容易出错。
这三个痛点本质上指向同一个需求:把预约流程从“人管”变成“系统管”。体育馆预约平台要做的,就是让用户自己查看场馆、自己选时段、自己提交预约,让管理员在后台上架场馆、设置时段、审核或取消预约,同时把预约数据全部沉淀到数据库里,形成可统计、可追溯的记录。一套这样的系统做完,前台登记表可以彻底退休,场馆利用率也终于有真实数据可以看了。
1.2 角色权限与功能清单
做预约类系统,第一步不是写代码,而是先把角色分清楚。这套系统按使用方拆成两类角色:普通用户和管理员。普通用户是体育馆的使用者,管理员是体育馆的运营人员。两边的操作边界完全不同,功能设计上也必须分开。
| 角色 | 核心功能 |
|---|---|
| 普通用户 | 注册登录、浏览场馆列表、查看场馆详情、选择可用时段提交预约、取消自己的预约、查看个人预约记录 |
| 管理员 | 用户管理(禁用/启用账号)、场馆管理(增删改查)、场地与时段管理、预约审核、查看预约统计、发布公告 |
用户端的核心链路我把它简化成四条:登录后浏览场馆、选时段预约、查看我的预约、取消预约。管理端的核心链路则是:上架场馆、维护可预约时段、审核预约、处理取消请求。这里有一个容易被忽视的设计点:普通用户提交预约后,到底需不需要管理员审核?我见过不少项目直接把预约状态设为“已确认”,跳过了审核环节,这样做在真实场景里是会有问题的。体育馆管理员往往需要确认场地没有被临时占用、设备是否可用,再决定是否接受预约。所以这套系统里我保留了审核状态,预约状态分成“待确认”“已确认”“已取消”三种,管理员可以在后台一键切换状态。
功能清单理清楚之后,整个项目的技术实现就有方向了:后端需要提供用户、场馆、预约、公告四组核心接口,前端需要对应地组织页面和路由。模块边界清爽,开发起来才不会越改越乱。
2. 技术选型:SpringBoot + Vue + MyBatis 这一套为什么经得起折腾
2.1 后端骨架选 SpringBoot 的理由
这套系统采用前后端分离架构,后端骨架选了 SpringBoot,原因其实很直接:它把 Spring 生态里的配置成本压到了最低。早年用 SSM 写项目,光 spring-mvc.xml、mybatis-config.xml、web.xml 这些配置文件就要写半天,环境搭好了心情也耗尽了。SpringBoot 的自动配置把这些默认值全部内置,开发者只需要关注业务代码本身。
具体到体育馆预约平台这种中小型系统,SpringBoot 有几个身体能感知到的优势:起步依赖机制,一个 spring-boot-starter-web 就能把 Web 容器、Jackson、日志全部带进来,不用自己一个个引 jar 包;内嵌 Tomcat,打完 jar 包直接扔服务器上运行,省掉外部 Tomcat 安装和配置;对 MyBatis 的集成非常顺滑,mybatis-spring-boot-starter 一加,SQL 映射和事务管理都能按习惯方式工作。另外写接口的时候,@RestController、@RequestMapping、@Service 这套注解开发效率很高,一个预约功能从 mapper 到 controller 基本二十几行代码就搞定,非常适合这种业务逻辑清晰、接口数量在二三十个规模的项目。
选型的时候我也没考虑过 Spring Cloud 那套微服务方案,原因很简单:体育馆预约平台属于典型的单体业务系统,用户量级在几千人、并发量在几十人同时操作这个水平,单体架构足够稳定,部署也更省心。微服务在这个场景里只会引入服务注册、配置中心、网关这些与业务无关的复杂度,没有实际收益。能用单体解决的问题,不要为“技术先进感”买单。
2.2 Vue 到底用 2 还是用 3
前端选型时争议最多的就是 Vue 2 还是 Vue 3。我的建议很务实:如果你在学校课程里学的是 Vue 2,或者手头参考资料大多是 Vue 2 + Element UI 的组合,那直接继续用 Vue 2 心安理得,完全没有必要为了追新而升级。Vue 2 的生态非常成熟,Element UI 组件库、vue-router、axios 这些周边库的资料铺天盖地,遇到任何报错一搜就有答案。对课程设计、毕业设计或者练手项目来说,快速完成一个稳定可运行的系统比“用了最新版本”重要得多。
如果你的项目想上 Vue 3,那配套就是 Element Plus,组合式 API 写起来确实更清爽,TypeScript 支持也更好。但要注意一个问题:Vue 3 + Element Plus 的资料量比 Vue 2 少不少,很多细节问题要自己翻源码或者看 GitHub issues,对新手来说排查成本会明显增加。我的做法是:平时自己玩的项目用 Vue 3,交付型、课程型的系统用 Vue 2 + Element UI,稳定性优先。
这套系统里的前端页面结构我按 Vue 2 的经典方式组织:main.js 里挂载 Vue 实例,并注册 Element UI;router 里配置路由表和路由守卫;api 目录里封装 axios 请求;views 目录放页面组件。页面组件只负责渲染和用户交互,数据请求统一走 api 模块,这样后端接口路径改了,只需要在 api 文件里改一处,不用全局搜索字符串。
2.3 MyBatis 对比 JPA,实际开发中的差异在哪里
持久层框架选 MyBatis 而不是 Spring Data JPA,是我在写预约冲突查询时做过对比后做出的决定。JPA 能让你少写很多基础 CRUD 的 SQL,这对简单增删改查很友好,但一旦遇到“查某个场地某个日期有没有重叠时段”这种带条件、带逻辑的查询,用 JPA 的 Specification 拼接条件,代码可读性会明显下降,排查 SQL 性能问题也不太直观。MyBatis 的思路是 SQL 由开发者完全掌控,写出来什么就是什么,数据库的查询计划也能一目了然。
在体育馆预约平台里,MyBatis 最实用的三个特性都派上了用场:
第一是动态 SQL。预约列表查询要根据用户传入的场馆 id、预约状态、日期范围动态拼接 where 条件, 标签可以把条件拼装逻辑写得很清晰,避免在 Java 代码里做字符串拼接的蠢事。第二是 XML 文件管理 SQL。项目里所有 mapper 的 SQL 都放在 resources/mapper 目录下的 XML 文件里,修改查询逻辑不用重新编译 Java 代码,改完重启就能生效,联调阶段特别方便。第三是结果映射灵活。数据库字段名用下划线风格(如 create_time),实体类属性用驼峰风格(如 createTime),开启 mapUnderscoreToCamelCase 配置后自动映射,省掉一大批复赋值代码。
另外提一句 MyBatis 的分页,项目里列表页会用到分页插件 PageHelper,直接在 XML 里写普通 SQL,PageHelper 会通过拦截器在查询前自动拼接 LIMIT 语句,使用起来是零侵入的。
3. 数据库设计:预约类系统的核心都在表结构上
3.1 核心表结构设计
数据库设计是预约系统最关键的环节。我常跟朋友说,这类项目把表建对了,后面写代码基本就是流水线作业;表建错了,后面会一遍遍改 SQL、改实体类,折腾死人。这套系统的表结构我按“用户-场馆-预约”三条主线来设计,再加一张公告表:
用户表 user:id、username、password、real_name、phone、role(0用户,1管理员)、status(0正常,1禁用)、create_time。
场馆表 venue:id、name、type(羽毛球馆、篮球馆、乒乓球室等)、location、description、price_per_hour、cover_image、status(0启用,1停用)、create_time。
预约表 reservation:id、user_id、venue_id、reserve_date(预约日期)、start_time、end_time、status(0待确认,1已确认,2已取消)、remark、create_time。再加一个 version 字段,做乐观锁用。
公告表 notice:id、title、content、author、create_time。
预约表的字段设计有两个非常关键的决策点。第一个是时间段存法:我用 start_time 和 end_time 两个字段存储时段,而不是只存一个 time_slot 编码。虽然固定时段(比如每整点一个场次)用编码更省空间,但体育馆的场地预约往往是半小时起订、可以连续订多个时段,用时间区间的可扩展性明显更强。第二个是状态字段的数据类型:我用 tinyint 存状态,0、1、2 分别对应待确认、已确认、已取消,而不是直接用 varchar 存中文状态。用数字做业务状态的优点是数据体积小、查询效率高、不会出现同名不同写法的脏数据,缺点是需要开发人员记住状态含义。我的做法是后端专门写一个枚举类来定义这几个状态常量,代码里可读性就有了保障。
3.2 预约冲突判断 SQL 的写法
预约系统里最重要的一条 SQL,就是判断某个场地在某个时间段是否已经有预约。这条 SQL 写错了,系统所有的并发问题都会在这一刻爆发。先看实现:
SELECT COUNT(*) FROM reservation WHERE venue_id = #{venueId} AND reserve_date = #{reserveDate} AND status = 1 AND start_time < #{endTime} AND end_time > #{startTime}这条 SQL 的核心是两个时间区间的重叠判断。如果用一条一维数轴来理解:已存在的预约区间是 [start_time, end_time],新提交的预约区间是 [startTime, endTime],两个区间重叠的条件,就是老区间的开始小于新区间的结束,同时老区间的结束大于新区间的开始。这个判断条件比“开始时间落在已有区间内”或者“结束时间落在已有区间内”更严谨,能覆盖住新预约完全包含旧预约、新旧预约交叉包含等所有情况。这里我多次强调,因为踩过坑:只判断“新开始时间不在老区间内”是不够的,一旦新预约范围比老预约大,这种写法就会漏掉冲突。
推荐在这条 SQL 之外再加一个数据库层面的兜底:在预约表上建一个联合唯一索引,不过 MySQL 对区间重叠没法直接建唯一索引,所以业务层判断仍然是最主要的拦截手段。配合事务使用,冲突控制的效果在小并发场景下完全够用了。
3.3 软删除与时间字段的细节
预约记录不应该物理删除,用户取消预约后用 update 把 status 改成 2 就可以。这种软删除方案能保留完整的历史数据,之后统计各个时段的预约量、各场馆的热门程度都有数据可查。同理,场馆、用户记录我也不做物理删除,用 status 字段区分启用和停用,前端状态变化就能感知。
时间字段我统一用 datetime,create_time 设置默认值 CURRENT_TIMESTAMP,插入时不用专门赋值。实体类里对应字段用 java.time.LocalDateTime,和 MySQL 的 datetime 类型天然匹配。这里提醒一句:不要用 java.util.Date 和 MySQL datetime 混用,格式化、时区问题会折腾得让人怀疑人生。 JDK 8 以后 LocalDateTime 的序列化在 Jackson 里默认格式是类似 “2024-05-01T12:00:00” 的 ISO 格式,我习惯在 application.yml 里统一配置格式为 “yyyy-MM-dd HH:mm:ss”,前端展示时直接可用,省去前端再格式化。
4. 后端核心接口与业务逻辑实现
4.1 创建预约接口:从参数校验到落库
整个后端最核心的接口就是创建预约,这条接口的逻辑编排几乎是预约系统的“心电图”。我把完整流程拆成五步:
第一步,参数校验。接收前端传来的 venueId、reserveDate、startTime、endTime 四个必填参数。先判断日期是否是未来日期,再判断结束时间是否晚于开始时间。这两道校验在接口入口就执行,用 Spring 的 @Valid 注解或者手动 if 判断都行。
第二步,场地状态校验。根据 venueId 查场馆表,确认场馆存在且 status 为启用状态。如果管理员已经下架了这个场馆,用户端就不应该还能看到,但接口层仍然要检查,防止有人绕过前端直接调接口。
第三步,冲突查询。执行冲突判断 SQL,如果 COUNT 结果大于 0,直接抛业务异常,提示“该时段已被预约”。异常通过全局异常处理器捕获后,把中文提示返回给前端展示。
第四步,插入预约记录。把 user_id 从登录用户信息里取出,status 初始为 0(待确认),create_time 由数据库自动填充。
第五步,返回结果。把创建成功的预约记录 id 返回给前端,方便前端跳转到“我的预约”页面。
整个方法加 @Transactional 注解,因为冲突查询和插入记录是两个 SQL 操作,之间如果出现异常,必须保证事务回滚,不然会出现查询不到冲突但插入失败、用户收到错误提示但记录却残留一半的脏状态。
4.2 并发预约场景与乐观锁方案
体育馆预约平台的并发量通常不高,但“两个人同时抢同一块场地”的场景是真实存在的。假设用户 A 和用户 B 同时提交 18:00-19:00 的预约请求,后端两个线程同时执行冲突查询,会发现数据库里都还没有记录,然后同时插入,结果就是两个预约都成功、场地被重复预约。
解决思路有几种,我根据自己的实际场景选了最合适的一种:reservation 表增加 version 字段,插入时先查询当前版本号,更新状态时通过乐观锁语句检查版本号是否被改动。
UPDATE reservation SET status = #{targetStatus}, version = version + 1 WHERE id = #{id} AND version = #{version}如果 UPDATE 影响行数为 0,说明版本号已经被别人改过,预约状态更新失败,需要返回“预约已取消或已修改,请刷新后再试”。在这个系统里,冲突控制最大的压力在“插入”而不是“更新”,所以我还会在插入前用“查询 + 插入”配合唯一业务约束。由于场地和时间段的组合在业务上要求唯一,插入时再补一个状态判断即可。对于这种量级的系统,没有必要上 Redis 分布式锁,JVM 内部的事务隔离加上乐观锁,已经把问题控制住了。
4.3 MyBatis XML 里的几个实际坑
写 MyBatis XML 时,有几个坑是新手反复踩的,我这里集中记录下来。
第一个是参数传递。Mapper 接口方法如果有多个参数,必须加 @Param 注解,否则 XML 里用 #{userId} 或者 #{venueId} 是拿不到值的。加了 @Param 后,XML 里的参数名就用注解里的名字,比如 @Param("venueId") Long venueId,XML 里就写 #{venueId}。
第二个是 XML 转义问题。SQL 里的小于号 < 和大于号 > 在 XML 里面会被当成标签解析,直接写会报错。比如查询过期记录,要写 create_time < NOW(),或者用 包起来。我建议写成 CDATA 形式,可读性更好,尤其是复杂 SQL 里同时有大于和小于时。
第三个是动态 SQL 的 where 条件嵌套。用 标签包住条件列表时,第一个 前面不要写 AND 或 OR, 会自动处理多余的连接词。这个细节解决了“查询条件为空时 SQL 语法错误”的经典问题。
第四个是结果映射自动驼峰。在 application.yml 里加上:
mybatis: configuration: map-underscore-to-camel-case: true这样数据库的 create_time 字段会自动映射到实体类的 createTime 属性,不用每一个字段都写 resultMap。注意,这种自动映射对单条记录的查询是有效的,但涉及多表联查、字段名有歧义的时候,还是要手动写 resultMap 指定映射关系。
5. 前端 Vue 页面搭建与交互细节
5.1 项目目录结构与路由设计
前端项目用 Vue CLI 创建,目录结构如下:
src/ api/ # axios 请求封装,按模块拆文件 assets/ # 静态资源 components/ # 通用组件,如头部的导航栏 router/ # 路由配置 store/ # vuex,存登录用户信息 views/ # 页面组件 login.vue # 登录页 register.vue # 注册页 home.vue # 首页/场馆列表 venueDetail.vue # 场馆详情与预约 myReservation.vue # 我的预约 admin/ userManage.vue # 用户管理 venueManage.vue # 场馆管理 reservationManage.vue # 预约审核 noticeManage.vue # 公告管理路由设计有一个关键点:管理端页面需要做权限控制。用户登录后根据 role 字段判断是否管理员,是管理员才允许访问 /admin 开头的路由。这一步我用路由守卫处理:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else if (!token) { next('/login') } else if (to.path.startsWith('/admin') && localStorage.getItem('role') !== '1') { next('/home') } else { next() } })这套守卫逻辑虽然简单,但挡住了两个常见的访问问题:未登录用户看任何页面都被踢回登录页,普通用户手动输入 /admin 路径会被转移到首页。前端权限只是体验上的限制,真正的权限校验必须在后端接口层做,比如管理端接口统一检查当前登录用户的 role,这一点我在后端写过滤器时一并处理了。
5.2 预约页面的时间段选择逻辑
预约页面是用户操作最密集的地方,交互设计直接影响使用体验。我的做法是把“日期选择”“场馆信息”“时间段选择”三块内容放在一个页面里展示:日期用 Element UI 的 el-date-picker,预约日期只能选择今天及未来三天(体育馆通常只开放近期预约,太远的日期没有意义);场馆信息部分展示名称、位置、价格、剩余场地数量;时间段用 el-time-select 或者手写时间段列表展示。
时间段选择是一个细节坑比较多的控件。我直接把场馆当日可约的时段处理成下拉选项,数据来源于后端的“可用时段”接口。这个接口内部会执行冲突查询,把所有与当前预约时间重叠的时段排除掉,只返回仍可预约的时段。这样用户看到的时间段列表天然就是可约的,体验上比“先全选再挨个试错”顺畅很多。
前端还要处理一个校验环节:如果用户选择了日期但没有选时段,或者选了时段但场馆当天已经被约满,提交时必须给出清晰的提示。我的做法是提交前先用前端逻辑做一次简单校验:场馆状态、日期是否为空、结束时间是否大于开始时间。但请记住,前端校验只是辅助,核心的冲突判断必须以后端返回结果为准,因为前端拿到的“可用列表”有可能在用户选择后的几秒钟内就已经被其他人抢先预约了。
5.3 axios 封装与 token 携带
前后端分离项目必然遇到 token 传递问题。用户在登录接口成功后,后端返回一个 token 字符串,前端把它存到 localStorage 里。之后所有的业务请求都需要在请求头里带上这个 token,否则后端过滤器会直接返回 401。
我在 api 目录里统一封了一个 request.js:
import axios from 'axios' import { Message } from 'element-ui' import router from '../router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.clear() router.push('/login') } Message.error(error.response?.data?.message || '请求失败') return Promise.reject(error) } )这个封装解决了三个问题:每个请求不用手动写 Authorization 头;后端返回统一结构 { code, message, data } 时,业务代码直接拿到 data;token 过期或者账号被禁用时,前端统一跳回登录页。接口 mock 阶段我会关掉拦截器,等后端接口稳定之后再打开。
6. 部署配置与常见问题排查实录
6.1 项目完整启动步骤
很多朋友拿到的源码在自己电脑上跑不起来,九成是环境问题。我这里把一套经过验证的启动步骤完整记录下来,你照着走基本不会卡住。
环境版本建议:JDK 1.8,MySQL 5.7 或 8.0,Maven 3.6 以上,Node 14 左右。
第一步,初始化数据库。在 MySQL 中执行项目提供的 init.sql 脚本,建好名为 sports_booking 的数据库和所有表结构。注意 MySQL 8.0 和 5.7 的驱动写法不同,8.0 的驱动类是 com.mysql.cj.jdbc.Driver,5.7 是 com.mysql.jdbc.Driver,用错会直接报 ClassNotFound。
第二步,修改后端配置。打开 application.yml,把数据库地址、用户名、密码改成你自己本地的值。启动 SpringBoot 的 main 方法,看到 “Started Application in x seconds” 说明后端已经就绪。
第三步,启动前端。在项目根目录执行 npm install,装依赖成功后执行 npm run serve,控制台会输出访问地址,默认是 http://localhost:8081。
第四步,把浏览器打开访问前端地址,注册一个账号登录即可。注意端口冲突问题:如果本机 8080 端口被占用,SpringBoot 可以改成 8081 或其他端口,前端 vue.config.js 里的 proxy target 也要同步修改。
6.2 高频报错内容
| 报错信息 | 可能的原因 | 解决方案 |
|---|---|---|
| Access denied for user 'root'@'localhost' | 数据库账号或密码不对 | 检查 application.yml 中的 username 和 password |
| Unknown database 'sports_booking' | 数据库没有创建 | 先在 MySQL 里执行 CREATE DATABASE sports_booking 再导入脚本 |
| Public Key Retrieval is not allowed | MySQL 8.0 的加密规则问题 | jdbcUrl 后加 allowPublicKeyRetrieval=true&useSSL=false |
| 前端请求 404 | 跨域地址或接口路径不对 | 检查 vue.config.js 的 proxy 配置和后端 controller 注解路径 |
| LocalDateTime 序列化为数组 | Jackson 格式化问题 | 在 application.yml 配置 spring.jackson.date-format 和 time-zone |
| 端口被占用 | 其他程序占用了 8080 | 换端口或者在配置文件中修改 server.port |
第二行的 Unknown database 是最简单最容易被忽视的问题,很多新手理所当然地以为项目里自带建库功能,实际并非如此,建库脚本需要手动执行。 Public Key Retrieval 是 MySQL 8.0 特有的报错,驱动连接时的加密方式导致,网上搜索时一眼认出这个问题,直接按表格里的参数修复就行。
6.3 做这套系统最容易被低估的工作量
我不止一次看到有人以为“需求想清楚、框架选明白”就万事大吉了,觉得预约平台无非就是几个 CRUD 接口,真正做起来才会发现工作量集中在大量细节上。
接口统一返回结构就是一个被低估的工作点。如果每个接口都各自返回不同的 JSON 结构,前端联调时会写大量兼容代码。我一开始就定义统一的 Result 类,包含 code、message、data 三个字段,成功 code 为 200,业务异常 code 为 4xx,后端所有接口都返回这个结构,前端封装的 axios 拦截器只处理这一个结构,项目后期改起来非常舒服。
全局异常处理也是同样重要。业务代码里抛出的所有异常,包括参数校验失败、场地已满、场馆不存在,都应该统一在 @RestControllerAdvice 里捕获并转成 Result 返回。如果没有这层处理,SpringBoot 默认返回的 500 错误页会包含大量堆栈信息,既不美观也不安全。
接口文档我强烈建议先写再码。我做这套系统时把每个接口的路径、请求参数、返回示例写成一张表(甚至可以写在代码注释里),前后端并行开发时严格按这张表推进,联调阶段的摩擦会小非常多。等到项目快结束时,我发现自己最耗时间的不是写接口,而是改接口——早期没有规划好参数结构,后面好几个地方都在为当初的偷懒买单。这个教训反复出现,值得每个做类似系统的人记住:表设计多想十分钟,后端少改两小时;接口设计多想五分钟,前端少改半小时。
做完整套系统最深的体会是:体育馆预约平台这类业务系统,真正值钱的部分不在页面漂亮不漂亮,而在数据模型是否清晰、冲突判断是否严谨、异常处理是否完善。把这三点做到位,项目不管拿去答辩还是应付工作,底气都会足很多。