SpringBoot+Vue3实现公共运动场地预约管理系统实战解析
1. 场地预约这件事痛点比想象中多得多先聊聊我为什么会对公共运动场地预约管理系统这个选题上心。之前帮单位团委做过一版内部场地预约小工具当时的情况就是典型的球场混乱现场乒乓球室永远有人占着却不知道是谁约的羽毛球场靠微信群接龙篮球场全靠谁来得早谁先打周末热门时段全凭嗓门大。预约靠人肉、改期靠电话、爽约靠脸皮厚管理基本靠人工统计和现场协调——这还只是个几十人的单位。放到高校、社区或全民健身中心场地多、时段多、用户规模大人工管理直接失控。所以这个项目的核心价值就一句话用一个前后端分离的系统把场地资源-用户预约-使用核销这条链路规范化、在线化。系统要管的不是系统本身而是时间和场地这两个有限资源怎么分配。这个项目选型为 SpringBoot Vue3 很典型也是目前Java方向毕设和企业内部系统里最常见的组合之一。如果你是准备做毕业设计或者想在简历上写一个完整的管理系统项目这个题目可以覆盖的技术点非常扎实SpringBoot后端、MyBatis-Plus操作数据库、JWT登录鉴权、Vue3组合式API、Element Plus组件库、前后端联调、Nginx部署——整套供应链下来是能撑住面试提问的。这篇文章我就按实际做这类系统的常见流程把从需求拆解到核心实现、从踩坑到部署的完整链路展开讲一遍。2. 需求拆解场地预约的单子本质上是一个资源调度系统2.1 三种角色与核心流程别看项目名叫预约管理系统它跟一般的CRUD管理系统差的不是一点半点。普通的用户管理、公告管理都是辅助真正的核心是预约流程的状态流转。按常见的需求做法系统里会有三类角色参与动作普通用户查看场地列表和可约时段发起预约申请查看我的预约取消未开始的预约。管理员维护场地信息新增场地、关闭维修、调整开放时段查看全部预约记录处理异常预约比如用户爽约后的标记以及简单的内容管理公告、场地类型。系统本身核心职责是两件事——判断某个时段是否可约以及防止同一时段被不同人重复占用。业务流程大致如下用户选定场地与日期系统加载该场地在当日的时段格子用户选择一个完整时段提交预约后端做冲突校验通过后生成预约记录状态为已确认有审核需求时也可以先待审核再已确认。用户实际到场使用管理员可以标记为已使用或系统按时间自动流转已完成用户在开场前可以主动取消否则迟到太久或爽约会被记录。这里我需要特别提醒一句如果这是毕业设计建议把状态机画清楚。预约状态至少要包含待确认/已确认/已取消/已完成/爽约。状态流转规则在答辩时几乎必问属于业务设计功底的直接体现。2.2 为什么是SpringBootVue3而不是其他方案这个选型很多人会有疑问——SpringBoot已经不算新Vue3也已经稳定了很久为什么还推荐这个组合我的观点是这个项目想看重的不是新技术而是工程化成熟度和知识覆盖面。SpringBoot的自动装配机制、starter生态、内嵌Tomcat这些特性让开发效率极高而且企业里存量最多的Java项目基本都是这个路线Vue3现在已经是前端主流组合式API配合Element Plus做管理端是绝对的主流玩法招聘市场也认这个技术栈。相对地如果换成GoReact路由、鉴权、部署的很多细节就得自己重新趟一遍对以完成一个可用系统为目标的场景来说性价比不高。技术栈清单如下模块选型说明后端框架Spring Boot 2.7.x稳定、资料多JDK8/11通用持久层MyBatis-Plus单表CRUD免写SQL分页、条件查询都很方便数据库MySQL 5.7/8.0经典组合鉴权JWT 拦截器无状态登录方案前端框架Vue3 ViteVite启动快组合式API编码组件库Element Plus表单、日期选择器、弹窗日历组件一应俱全状态管理PiniaVue3官方推荐替代VuexTS友好HTTP请求Axios配合拦截器统一处理token和错误码部署Nginx jar包前后端分离的标准部署模式3. 数据结构设计5张核心表把业务规则焊死在字段上3.1 表结构如何规划做管理系统表结构设计一定要走在代码前面。我把这个项目最核心的表拆成5张覆盖主体业务用户表userid、username、passwordBCrypt加密存储、nickname、phone、roleUSER/ADMIN。这算是标准款需要单独提取的是角色字段建议用tinyint存数字比如0-普通用户、1-管理员方便扩展。场地表venueid、name、type篮球/羽毛球/乒乓球等、location、description、cover_url、status1-开放0-停用、open_time如08:00、close_time如22:00。很多初学者会漏了开放时间字段导致用户在晚上十点后还能预约早八的场地逻辑上奇葩。场地必须有自己的可用时间段。预约表reservation这是全表里最核心的一张。id、venue_id、user_id、reserve_date预约日期、start_time开始时刻、end_time结束时刻、status、remark、create_time。这里的status建议用int0-待确认1-已确认2-已取消3-已使用4-已完成5-爽约。场地场次表schedule可选但推荐id、venue_id、date、start_time、end_time、status。为什么不直接查预约表因为有些场地需要管理员预先放开某些时段比如工作日晚上才开放灯光球场单独一张场次表能承载管理员预先排期这个动作。通知公告表noticeid、title、content、create_time。用于管理员发布场馆维护、节假日闭馆等信息。3.2 时间字段怎么存一个容易被问倒的细节这个细节我单独拎出来讲因为十个人做预约系统有八个人会在时间处理上出问题。预约表里有一个预约日期和开始时间/结束时间。常见的错误做法是用一个字符串datetime存成2025-06-20 18:00:00然后前端传什么、后端存什么之间一点类型转换逻辑都不做结果就是用户选了跨天的晚间时段22:00到次日01:00后端按字符串排序把日期搞乱了或者查询当天预约时因时区问题前后偏差8小时。正确的做法是分两层区分语义reserve_date只存日期用DATE类型表示预约发生在哪一天。start_time和end_time存时间用TIME类型表示当天从几点到几点。后端统一用LocalDate、LocalTime、LocalDateTime出参一律格式化ISO标准字符串如2025-06-20T10:00:00不让数据库时区参与业务计算。前端Vue3里的Element Plus时间选择器可以直接绑定HH:mm格式的字符串日期选择器绑定YYYY-MM-DD交互层不用做太多额外的转换。这个设计答辩的时候会被面试官高看一眼——因为这证明你真的想过预约日期和时间点是两种粒度不同的数据。4. 预约冲突检测核心算法就一行但并发问题能让系统报废4.1 冲突判断的数学逻辑预约冲突的本质是判断两个时间段是否重叠。一个预约[newStart, newEnd]和已存在的有效预约[existStart, existEnd]冲突等价于newStart existEnd newEnd existStart举个例子已有预约是10:00到12:00如果新预约从09:00到11:00那么newStart(09:00) existEnd(12:00)成立newEnd(11:00) existStart(10:00)成立判断为冲突——两个时段确实在10:00-11:00重叠了。如果新预约是13:00到14:00第一条件13:00 12:00不成立不冲突。这个判断逻辑可以翻译成MyBatis-Plus的条件查询// 伪代码逻辑查同一场地、同一日期、状态为有效预约且时间段重叠 ListReservation conflictList reservationMapper.selectList( new LambdaQueryWrapperReservation() .eq(Reservation::getVenueId, venueId) .eq(Reservation::getReserveDate, date) .in(Reservation::getStatus, 0, 1) // 只查待确认和已确认 .apply(start_time {0} AND end_time {1}, endTime, startTime) ); if (CollUtil.isNotEmpty(conflictList)) { throw new BizException(该时段已被预约请选择其他时间); }apply里的参数直接绑定到SQL不是字符串拼接MyBatis-Plus会自动做预编译这点别偷懒写成start_time endTime ——SQL注入风险在预约系统里照样存在。4.2 并发超卖单机版系统的隐形杀手上面的查询单次执行没问题但两个用户同时提交同一个场地同一时段时就可能出现两人都查询无冲突同时写入成功。原因很简单查询和插入不是原子操作。解决这个问题的标准做法有两个层级第一层数据库唯一约束或者悲观锁。可以在reservation表上为(venue_id, reserve_date, start_time, status)做一个联合唯一索引——但如果一个场地允许多个流水号比如三个羽毛球场地可以同时有人预约这个索引就不适用。更通用的是在提交预约时锁住该场地当天的记录// 利用数据库的悲观锁锁住场地当日记录 VenueDayLock lock venueDayLockMapper.selectByIdForUpdate(venueId, date);这个select ... for update会锁住行另一个并发预约只能等着等锁释放后重新执行冲突校验。第二层把冲突校验和新增预约放进同一个事务方法里。这才是最关键的一步。很多新手把校验和新增分成两个Service方法调用结果加了锁也白搭——因为锁早就在校验完释放了。正确写法是在同一事务内先锁、再查、再插入Transactional(rollbackFor Exception.class) public void createReservation(ReservationCreateDTO dto) { // 1. 悲观锁或者使用selectForUpdate // 2. 冲突校验上一小节的逻辑 // 3. 插入预约记录 }关于锁校验插入必须在一个事务层级的说法我做项目时真见过有人把校验写在Controller插入写在Service结果并发量大一点预约单直接超卖。串联动作归属同一事务方法是这个系统最重要的一个工程约束。4.3 状态流转不容易取消、完成、爽约的边界处理预约状态流转设计得好不好直接决定管理员的日常体验。我按实际业务经验列出流转规则用户提交预约 → 状态0待确认或者直接1已确认无需审核的场地。已确认后在开始时间前用户可取消 → 状态2。开始时间到了用户未到场又未取消 → 超时记为爽约状态5。开场后用户可以核销使用或者管理员扫码验证 → 状态3使用中结束时间后自动变4已完成。已取消/爽约的记录不参与冲突检测。这里有个隐蔽问题取消后的时段需要重新开放给其他用户。所以取消操作也要做一次释放动作——要么物理删除预约记录要么把状态置为2同时冲突检测时只查状态0/1。推荐用状态2保留记录方便审计查询时过滤掉即可。5. 后端关键实现SpringBoot的配置与通用能力别在基础处翻车5.1 统一响应体与异常处理预约系统的接口数量保守估计在30个以上。如果每个接口返回格式不一样前端封装Axios会非常痛苦。我会在项目里定义统一返回结构Data public class RT { private Integer code; // 200成功4xx业务失败5xx系统异常 private String msg; private T data; public static T RT ok(T data) { ... } public static T RT fail(String msg) { ... } }配合全局异常处理器把参数校验异常、业务冲突异常、系统异常分别映射到统一响应前端只需要在Axios响应拦截器里判断code即可。这样接口层代码量大幅度减少联调时也更顺畅。5.2 时间传输序列化格式统一是关键SpringBoot默认使用Jackson序列化LocalDateTime时输出的是数组格式比如[2025, 6, 20, 10, 0]前端非常难处理。需要在配置文件里指定格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8同时对于LocalDate和LocalTime分别设置格式。前端Vue3侧使用Axios发送请求时body统一用JSON字符串传递2025-06-20T10:00:00后端DTO里用JsonFormat(pattern yyyy-MM-dd HH:mm:ss)接收。这个配合如果不提前统一联调阶段百分之百会出现前端传了日期后端解析失败的灵异现象。5.3 JWT登录与拦截器小白最容易漏掉的白名单配置登录方案建议用JWT用户登录成功后签发一个token前端存到localStorageAxios请求头带上Authorization: Bearer token。后端加一个拦截器验证token并把用户信息解析出来放入ThreadLocal方便Service层拿当前用户ID。但这里有一个特别容易踩的坑白名单配置不当。比如你拦截了所有请求但登录接口、注册接口、场地列表查询未登录也要能看吧、静态资源路径没有放行结果前端一打开页面全报401。正确做法是把不需要登录的接口路径纳入排除列表registry.addInterceptor(jwtInterceptor) .addPathPatterns(/**) .excludePathPatterns( /api/auth/login, /api/auth/register, /api/venue/list, /error, /doc.html // 如果集成了knife4j );另一个坑是token过期时间。JWT的过期时间建议设置成2小时前端拦截到401时需要跳转登录页并清理本地缓存。这块每次做联调都能碰到属于必踩的项目。5.4 文件上传选做场地图片的管理可以做简单方案场地列表没图面感很差但做一套完整的OSS又太重。实用做法是后端提供上传接口把图片存到服务器本地磁盘访问时通过Nginx映射为静态资源URL。PostMapping(/api/file/upload) public RString upload(RequestParam(file) MultipartFile file) { String filename UUID.randomUUID() . StringUtils.getFilenameExtension(file.getOriginalFilename()); file.transferTo(new File(uploadDir filename)); return R.ok(/uploads/ filename); }Nginx里配置location /uploads/ { alias /data/uploads/; }前端直接引用这个URL就行。这个方案虽然不高级但胜在简单完全够个人项目用。6. Vue3前端落地预约页面做得顺不顺决定归属感强不强6.1 组合式API组织代码的思路Vue3相比Vue2最核心的变化就是组合式APIComposition API。做预约系统这种以表单和状态为主的业务用script setup语法写起来一目了然。我把前端按页面组件化 组合式函数组织页面级组件场地列表页、场地详情页、预约表单页、我的预约页、后台管理页。组合式函数useReservation.ts把预约相关的状态、校验、提交逻辑抽出来多个页面复用避免在组件里堆大招。// useReservation.ts 的核心示意 export function useReservation() { const date ref(new Date()) const startTime ref(18:00) const endTime ref(19:00) const submitting ref(false) const submitReservation async () { submitting.value true try { await api.createReservation({ venueId, reserveDate: formatDate(date.value), startTime: startTime.value, endTime: endTime.value }) ElMessage.success(预约成功) } catch (e) { ElMessage.error(e.response?.data?.msg || 预约失败) } finally { submitting.value false } } return { date, startTime, endTime, submitting, submitReservation } }这里体现出组合式API对代码组织的好处同一业务逻辑内聚在一起而不是像选项式API那样把data、methods、computed分散在三个选项块里维护时来回跳跃。面试时如果有人问Vue3和Vue2的区别拿这个例子讲比背概念强一百倍。6.2 预约页面核心交互日期选择器 场地卡片 时间冲突提示预约页面的交互是前端的灵魂。常见布局左侧根据日期展示场地列表右侧是某个场地的具体时段格子。时段格子可以根据场地开放时间自动生成比如开放时间是08:00-22:00按每30分钟或60分钟一个格子生成已被预约的格子置灰。Element Plus里的el-calendar和el-time-picker可以组合出这个效果用户先选日期页面根据日期加载该场地当日所有schedule记录再渲染成时间轴。已被预约的格子禁用用户只能点选空闲格子提交预约。这里有个交互细节值得留意选好日期后切换场地时已选的时间要清空或重新校验完整性。我见过很多系统在场地切换后仍保留之前选的时间用户不细看一提交后端提示该时段已被预约体验很糟糕。处理方式是监听场地变化时重置时间选择。6.3 路由、Pinia存储与Axios封装后台管理页面做成独立Layout使用Vue Router的嵌套路由通过路由元信息requiresAuth控制是否需登录再配合全局前置守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })用户信息存储在Pinia里登录成功后调用store.setUser()保存避免每个页面都去解析token。Axios实例需要做两层拦截请求拦截器统一添加Authorization头响应拦截器根据code统一提示错误、401跳转登录。7. 前后端联调与部署的实测教训7.1 跨域是个老问题但解法必须写成配套本地开发时前端跑Vite的5173端口后端跑8080端口天然跨域。解决方式有两种后端CorsFilter全局允许或者前端Vite的server.proxy配置代理。我推荐后者// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/...都走Vite代理转发到后端浏览器无感知又能避免把后端的CORS配置暴露到生产环境。部署到Nginx时再配置一层反向代理规则把/api/转发到后端端口。注意生产环境下不要再靠后端CORS兜底统一由反向代理完成减少配置面和安全隐患。7.2 打包部署一次成功的标准流程这块我给一个跑通的完整操作序列前端执行npm run build产物在dist/目录。后端执行mvn clean package -DskipTests生成可执行jar包。创建部署目录比如/data/venue-front和/data/venue-back。前端dist内容拷贝到venue-frontjar包拷贝到venue-back。后端启动命令nohup java -jar venue-system.jar --server.port8080 app.log 21 Nginx配置前端静态资源与API代理反向server { listen 80; server_name venue.example.com; root /data/venue-front; index index.html; location / { try_files $uri $uri/ /index.html; } 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 ... /index.html尤其重要否则前端路由使用History模式时刷新页面会404。7.3 上线后我学到的三件事第一件服务器时间一定要设置成Asia/Shanghai。我遇到过服务器时区是UTC后端取当前日期做LocalDate.now()时比国内慢8小时导致预约记录穿越到前一天排查了很久才发现是时区问题。第二件数据库备份策略提前定。预约数据虽然不大但都是真实使用记录丢了没法找回。建议加一个每天凌晨的mysqldump定时任务哪怕只是导出到服务器本地。第三件不要过度设计。有同学总想加消息通知、微信小程序端、在线支付最后一个人做不完主体业务反而没做好。先把预约主体跑得又稳又流畅再考虑扩展这才是务实路径。我个人的习惯是每做完一个系统都会把过程中踩过的坑整理成一篇文档这个项目里最值得往后借鉴的就是并发下的预约冲突控制和前后端时间格式统一这两件事——代码层面看似不起眼却是决定系统能不能上线跑的关键。如果你也在做类似的预约类系统建议先把这两块测试用例写全再谈功能堆叠。祝开发顺利。