SpringBoot+Vue实战:体育馆管理系统设计与实现
2026/9/24 21:47:58 网站建设 项目流程

每年七八月,海滨城市的体育馆基本是满负荷运转,前台电话响个不停,场地预订靠一张Excel传来传去,会员卡余额要人工核对,到了月底对账更是头疼。我之前帮一个体育馆做过一套管理系统,用的就是SpringBoot+Vue这套组合,数据库走MySQL,持久层用MyBatis,整套项目从零搭起来到上线运营,前后折腾了不少时间,也踩了不少坑。这篇文章把我整个设计与实现过程梳理一遍,从需求拆解、数据库设计到后端接口和前端页面,再到联调阶段容易翻车的细节,全部拿出来聊聊。不管是做毕业设计,还是接外包项目,或者单纯想系统走一遍Java全栈流程,这篇文章都可以当作一份参考资料。

我先把这套系统的最终形态说清楚:管理员可以在后台维护场地、会员、教练和课程,普通用户可以登录后在线预约场地、查看自己的订单和余额流水,教练可以看课表和学员信息,收银台在手机上就能完成开卡、充值和退款操作。数据大屏会实时展示今天的人流量、场地使用率和营收情况。所有功能都是浏览器访问,管理员用电脑,前台和教练用手机也能操作,这一点在部署时非常实用。

1. 体育馆管理的业务到底长什么样,为什么要用SpringBoot+Vue这套组合

很多人一上来就开始写代码,结果写到一半发现权限关系理不清、场地和订单状态对应不上,返工成本特别高。我建议先花半天时间把业务模型想明白,再动手建工程。

1.1 放前台场景里一琢磨,系统其实要管六件大事

体育馆的业务看着简单,实际拆开之后涉及的角色和流程比想象中复杂。我给这个系统划分了六个核心模块,分别对应体育馆日常运营中最常接触的六类事务:

  • 场地管理:羽毛球、篮球、网球、游泳馆等不同场馆,需要维护场地编号、类型、可容纳人数、按时段计费的价格策略,还要支持租赁状态和定期维护状态的标记。
  • 会员管理:实体卡和电子会员并存,需要区分散客、次卡会员、年卡会员、VIP会员四档,每一档的折扣率和权限都不一样。
  • 在线预约:用户选场地、选时间段、提交订单、在线支付或余额扣款,整个流程要处理场地冲突检测、爽约处理、退款和改签。
  • 教练排课:每位教练可以带多个私教班和团课班,需要管理课程时间、学员名单、课程消耗的课时数。
  • 收银与财务管理:充值、退款、消费流水、发票记录,这块必须做到每一笔钱都能追溯。
  • 统计报表:各球馆的时段利用率、会员消费排行、课程收入趋势,管理层要看这些数据来做运营决策。

模块划分清楚之后,整个系统的表结构和接口设计就有了底盘,后面写代码基本不会被业务绕晕。

1.2 技术选型不是赶时髦,是卡着业务需求来的

技术栈选型我见过太多翻车案例,不是用了太重的东西把自己拖死,就是选太冷门的框架出了问题找不到人问。SpringBoot + Vue + MySQL + MyBatis这套组合,胜在稳,而且每个环节都有大量案例可参考。

我给这套系统设计的技术组件如下:

技术组件用途为什么选它
SpringBoot 2.7.x后端基础框架自动配置机制能省掉大量XML配置,内嵌Tomcat让部署变成一个jar包的事
MyBatis持久层框架SQL由自己掌控,复杂动态查询好优化,比全自动ORM更可控
MySQL 8.x数据库稳定、免费、文档多,体育场馆这类中小型业务量完全够用
Vue 2.7 + Element UI前端框架和组件库渐进式上手快,Element UI的表格和表单组件能覆盖80%的后台管理页面
AxiosHTTP请求库封装简单,拦截器能统一处理token刷新和错误弹窗
ECharts数据可视化做场馆利用率、营收趋势图轻量又高效

后端分层用标准的 Controller-Service-Mapper 三段式,前端就是组件化的页面。有人可能会问为什么不用MyBatis-Plus,我个人的看法是:这类系统中原生MyBatis的灵活性和可控性更好,尤其是统计类SQL,原生写法调整起来不绕弯子。如果不想手写太多基础CRUD,引入MyBatis-Plus也完全没问题,核心业务表的增删改查能省不少代码量。

2. 需求拆解:前台、会员、教练和管理员眼里各自需要什么

一套系统能不能落地,关键看每个角色打开页面后能不能快速完成自己的事。我实际去体育馆蹲了两天,跟前台、店长和教练分别聊过,需求可以归纳成四类主视角。

2.1 会员端:预约要快,记录要准

会员端是普通用户在手机上使用的功能,这个是整个系统里最能提升体验感的部分。会员打开小程序或H5页面后,最常用的功能是:

  • 场地实时查看:按场馆类型查看某个日期、某个时段的空闲状态,我用颜色来区分空闲、已订、维护三种状态。
  • 在线预约下单:选好场次后,系统自动计算价格,会员可选择余额支付或绑定在线支付。
  • 订单记录:查看已预约场地的订单详情,支持开场前4小时免费取消,弥补因临时有事导致的损失。
  • 余额与课时管理:显示当前余额、次卡剩余次数、年卡到期日期,每次消费都有流水明细。

2.2 前台运营端:收银快,订单改起来要顺手

前台操作页面我设计得比较"朴素",核心诉求就一个:减少操作步骤。前台需要处理办卡、充值、退款、预约改签、散客登记,还要能快速查到某位会员的资料。比如会员报出手机号,输入框直接模糊搜索,匹配到后自动带出会员等级和余额,然后直接选业务操作,整个过程不超过三秒。

前台还有一个高频操作是场地状态的人工修正。比如顾客订了晚上七点到九点的羽毛球场,结果下大雨没来也没提前取消,前台需要一键把订单标记为"爽约",同时释放场地。

2.3 教练端:看课表,管学员

教练端的权限比较窄,只需要看三样东西:我的课表、我的学员、课时消耗记录。私教课的时长扣减逻辑要注意,如果学员购买的是12节私教课包,每上完一节系统自动扣减一次,还有剩余次数提醒。

2.4 管理端:对外是数据驾驶舱,对内是规则配置中心

管理端是权限最高的一侧,包含系统全部数据模块。除了常规的场地、会员、订单维护,有两个设计比较容易被忽略,我在这里重点提一下:

  • 计费规则配置:不同时段的价格策略不能写死在代码里,要支持管理员可视化配置。例如工作日的18:00-22:00是黄金时段,价格上浮50%,周末全天按节假日价格计算,这些都要能随时调整。
  • 操作日志:关键操作必须留痕,谁在什么时间修改了价格、给哪位会员退了款,都要有记录。这个需求在验收时经常被提到,所以设计表结构时我就预留了操作日志表。

角色和权限的整体设计,我采用RBAC模型,用五张表实现:用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。后端在拦截器里校验角色编码,前端根据角色动态渲染菜单,这样无论加多少种新角色,都不会改动核心代码。

3. 数据库设计:会员、场地、订单三张核心表这样设计才不打架

数据库设计是整个系统最重要的地基。表关系理不顺,后面写SQL和做统计的时候会非常痛苦。我先画整体表结构,再逐个讲解核心表的设计理由。

3.1 核心表清单与设计意图

数据表表名核心作用
场地信息表venue记录所有场馆和场地,区分类型、位置、容量、状态
场地时段价格表venue_period_price按星期、按时段、按场地类型存价格策略
会员表member存储会员资料、卡类型、余额、累计消费
会员卡类型表member_card_type次卡、年卡、VIP等卡种的规则定义
订单表orders场地预约订单主表
订单明细表order_item订单按场次拆分明细
课程表course私教和团课的基本信息
教练表coach教练基本信息和带课能力
充值流水表recharge_record每笔充值和退款的资金流水
消费流水表consume_record每次余额扣款或课时扣减的流水
操作日志表sys_operation_log关键操作留痕
用户权限表sys_user, sys_role, sys_menu 等后台登录用户和权限

3.2 会员表的设计细节

会员表除了基础联系方式和卡信息之外,有四个字段很容易忽略但很关键:

  • member_no:会员编号,用年月日加序号生成,例如HY202507160001,既是业务标识,也方便前台快速查找。
  • card_type_id:关联卡类型表,而不是直接存字符串。因为卡类型可能会改名称,直接存ID,改名称只动子表。
  • balance:余额字段用DECIMAL(10,2),不要用FLOAT。浮点数在精度上会出问题,金额相关的字段一律用定点数。
  • status:状态字段,我用0正常、1冻结、2注销,而不是直接删记录。会员的充值记录和订单记录需要永久保留,物理删除会造成审计链断裂。

我踩过一个教训:最初会员表的手机号直接建了普通索引,后来数据量到十万级之后查询变慢,改成唯一索引并配合前端格式校验,问题就解决了。

3.3 订单和场地时段的锁冲突处理

场地预约最核心的逻辑是防止同一个场地、同一个时间段被两个人同时下单。我用两种策略共同保证:

  • 数据库唯一约束:场地明细表中对(venue_id, booking_date, time_slot)建唯一索引。这是最后一道防线,不管代码怎么写都不会超卖。
  • 下单前置检查:在事务内执行SELECT ... FOR UPDATE锁住订单明细中的场地行,然后再判断是否可订。

注意,FOR UPDATE一定要放在事务里,并且查询条件最好带上索引字段,否则会锁全表,造成严重的并发性能问题。

3.4 时间字段的设计经验

时间字段我用MySQL的DATETIME,顺便踩过时区相关的坑。如果应用服务器和数据库服务器不在同一时区,Java的LocalDateTime和MySQL的DATETIME之间转换会出现小时偏移。建议连接串强制带serverTimezone=Asia/Shanghai,并且实体类统一使用LocalDateTime,不要混用java.util.DateLocalDateTime,否则序列化格式会乱成一团。

数据库初始化脚本我建议手写,不要全靠ORM的自动建表。建表语句要写明ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci,尤其是utf8mb4这个字符集不能为了省事用默认的utf8,否则会员昵称里带个生僻字或表情符号,直接给你报Incorrect string value错误。

4. 后端核心实现:SpringBoot项目的骨架与关键模块开发

后端是整套系统的中枢,重点不是写CRUD,而是把工程结构理清楚,把几个容易出问题的公共机制配置好。

4.1 工程结构与统一响应体

我习惯按功能模块分包,而不是按技术层次分包。按技术分层会导致一个业务改动要跨好几个包,按功能模块分包则天然形成了内聚:

com.gym.admin ├── common // 统一返回体、异常处理、工具类 ├── config // 配置类,如拦截器、跨域、MyBatis ├── modules │ ├── venue // 场地模块 │ ├── member // 会员模块 │ ├── booking // 订单预约模块 │ ├── course // 课程教练模块 │ └── report // 统计报表模块 ├── security // 登录认证与权限拦截

统一响应体我封装了一个Result<T>,包含codemessagedata三个字段,业务接口全部返回这个结构。配合全局异常处理器,业务逻辑里只需要丢出BizException,前端就能收到带错误码和提示信息的JSON,clean。

4.2 MyBatis配置与Mapper开发要点

MyBatis的Mapper接口和XML映射文件写起来有讲究。我做了三个约定:

第一,所有动态SQL都用XML写,不在接口上写注解SQL。注解适合简单查询,但一旦涉及动态条件、批量更新、多表联查,注解读起来非常痛苦,XML的<where><if><foreach>标签表达力强得多。

第二,配置驼峰映射map-underscore-to-camel-case: true。数据库字段member_no自动映射到Java属性memberNo,省去一大堆resultMap手写映射。

第三,自定义拦截器不要和分页拦截器混着乱写。MyBatis的插件机制用@Intercepts注解,我自己写过一个操作日志插件,差点跟分页插件在Executor层面冲突,后来学乖了,日志记录直接放在Service层用AOP处理,不碰MyBatis的Executor。

4.3 分页插件配置和使用

分页是后台管理系统的高频场景。我使用的分页插件是PageHelper,配置和用法如下:

<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>

配置一行就够:

pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true

reasonable: true的作用是当页码大于总页数时,自动回退到最后一页,不至于前端翻页翻出白屏。

使用上有一个非常重要的约定:PageHelper.startPage()后面必须紧跟第一条SQL查询语句,中间不能查其他东西。我曾经在startPage之后先查了一波会员对象再查订单,结果分页失效,数据全量返回,排查了很久才发现是页面上一个多余的赋值操作插在了中间。

4.4 场地预定的并发事务实现

发布场地预订的核心Service方法时,我用注解式事务@Transactional(rollbackFor = Exception.class)包住整个操作。关键逻辑如下:

  1. 查询场地时段记录,使用FOR UPDATE锁定
  2. 判断该时段是否已被占用,被占用则抛异常
  3. 扣减会员余额(先查余额,再更新余额,这一步如果用乐观锁,需要在会员表加版本号字段)
  4. 生成主订单和订单明细
  5. 写入消费流水

注意,@Transactional默认只在RuntimeException下回滚,如果业务代码里捕获了异常又没有重新抛出,事务不会回滚,就会出现"钱扣了订单没生成"的情况。所以异常处理要么不做catch让Spring统一管理,要么catch之后重新抛出BizException

4.5 登录认证与权限拦截

登录认证在这个项目里用的是JWT方案,流程是:用户登录成功后,后端生成一个带有用户ID和角色编码的Token返回给前端,前端每次请求在Header里带Authorization: Bearer xxx,后端写一个拦截器解析Token并放行。

关键点是拦截器只校验Token有效性,角色的功能权限在具体的Service或注解里校验。我写了一个@RequireRole("admin")注解配合AOP,被注解标记的接口只允许指定角色访问。这样做的好处是权限规则跟着方法走,看代码时一目了然。

5. 前端Vue实现:从项目初始化到核心页面开发

前端这部分是很多人容易卡住的地方,尤其是没接触过Vue的后端同学。我尽量把搭建过程和页面实现逻辑讲细一点。

5.1 工程初始化与依赖安装

我用Vue CLI创建项目,命令如下:

vue create gym-admin-frontend

在配置选择时选了Router和Vuex,脚手架自己会装好依赖并生成基础结构。然后手动安装Element UI和Axios:

npm install element-ui axios echarts

这里有个新手容易踩的坑:国内网络环境下npm install的速度不稳定,经常超时失败。建议在项目根目录创建.npmrc配一下国内镜像,速度能快一个量级。安装完依赖之后,在main.js里注册Element UI:

import Vue from 'vue' import ElementUI from 'element-ui' import 'element-ui/lib/theme-chalk/index.css' import App from './App.vue' import router from './router' import store from './store' Vue.use(ElementUI) new Vue({ router, store, render: h => h(App) }).$mount('#app')

注册Element UI之后,基础的表格、表单、弹窗、日期选择器这些组件就都可以直接用了,开发效率提升非常明显。如果觉得Element UI太重,只按需引入需要的组件也行,不过后台管理系统我个人建议全量引入,省心。

5.2 路由设计与登录守卫

路由设计上,我把页面分成两类:一类是公共页面,比如登录页和场地展示页;另一类是登录后才能访问的管理页面。管理页面统一挂在名为Layout的父路由下面,这样左侧菜单栏和顶部导航只需要写一次,子页面自动继承布局。

登录守卫用Vue Router的beforeEach,实现起来很简洁:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next('/login') } else { next() } })

页面内的按钮级权限,我通过自定义指令v-permission实现。管理员和普通员工的菜单本来就不一样,前端初始化时根据后端返回的菜单列表动态注册路由,这是一种比较优雅的做法,避免把一份完整的菜单塞给所有人再看一遍删掉。

5.3 Axios封装与拦截器

Axios的封装质量直接决定前后端联调的效率。我的封装思路是:新建一个request.js,实例化Axios对象,设置baseURL和请求超时时间,然后在请求拦截器里加Token,在响应拦截器里统一处理错误码。

import axios from 'axios' import { Message } from 'element-ui' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { router.push('/login') } else { Message.error('网络异常,请稍后重试') } return Promise.reject(error) } ) export default service

注意baseURL设置成/api,这只是给开发环境的Proxy代理用的标识,生产环境会用Nginx把/api前缀转发到后端服务,这样前后端部署时就不需要改前端代码了。

5.4 场地预约页面的核心逻辑

场地预约页是整个系统前端最复杂的页面,核心是场地状态日历和时段选择。我用ECharts先渲染一个"场地状态总览",然后用Element UI的日历组件展示每天各时段的预订热度。

时段选择区是一个动态生成的表格,行是场地编号,列是时间片,每个格子有三种状态:

  • 绿色:空闲可订
  • 灰色:已经被别人订走
  • 黄色:当前选中待提交

用户点击黄色格子后,组件把选中的场地和时间片集合传给后端,后端统一做可用性校验。选完触发价格计算,单价从venue_period_price读取,会员折扣从卡类型表读取,前端直接展示折后价格。这块用到Vue的双向绑定和计算属性比较多,建议把选中集合维护在Vuex里,避免跨组件传参传得晕头转向。

5.5 数据大屏和图表展示

管理端的首页我放了一个"今日概览",用ECharts画了三个图表:主屏是一个24小时场地利用率曲线图,下方两个辅助区域分别是各场馆营收占比的饼图和本周客流量的柱状图。

数据获取走一个聚合接口/report/overview,后端一次返回所有图表所需的数据结构。前端图表在mounted里请求数据后初始化ECharts实例,窗口大小变化时调用chart.resize()。注意组件销毁时要执行chart.dispose(),否则多标签页切换会导致内存泄漏。

6. 联调与部署阶段必踩的坑,我一个个给你排掉

前后端独立开发完,联调阶段才是真正检验系统质量的时刻。这个阶段暴露的问题五花八门,我挑几个出现频率最高、最容易让人卡半天的分享出来。

6.1 跨域配置的正确姿势

前端跑在http://localhost:8080,后端跑在http://localhost:8081,直接请求必然有跨域问题。开发环境我推荐用Vue CLI的Proxy代理解决,配置在vue.config.js

module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }

这样前端请求/api/venue/list时,开发服务器会把请求转发到http://localhost:8081/venue/list,前端代码里不存在跨域问题。

生产环境则由Nginx统一处理:

location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

后端也可以配置CORS,但要注意一个隐蔽问题:如果允许携带Cookie,allowedOrigins不能写*,必须写确切的前端地址。我之前图省事写了*,结果前端每次登录都拿不到Set-Cookie,排查半天才发现是CORS规范对凭据的限制。

6.2 前后端时间格式不统一

后端序列化LocalDateTime返回给前端,默认格式是类似2025-07-16T14:30:00带T的ISO格式,前端控件直接显示这一串字符,非常不友好。我在后端加了一个统一的Jackson配置:

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

时间从后端到前端的问题解决之后,反向还有一个坑:前端日期选择器默认给的是yyyy-MM-dd,而后端实体类的LocalDateTime要求带时分秒,直接传字符串会被Spring的格式转换器拒绝。给实体类的日期字段统一加上@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),并且前端在传参时拼上00:00:00作为默认时间,整个链路就顺畅了。

6.3 上传文件时的URL编码上传问题

系统里有一个功能是上传场地实拍图片,我最初直接在Controller里写MultipartFile接收参数并保存到本地磁盘,通过http://ip:port/static/...访问。但后来发现上传文件名带中文或空格时,前端展示图片会404,原因是URL没有做编码。

解决方式是自定义一个静态资源映射配置,同时规定上传文件重命名为UUID文件名,彻底避免编码问题。

6.4 MyBatis分页插件与统计SQL的冲突

统计报表模块有一个按月营收分组的SQL,里面用到了临时表和嵌套子查询。分页插件对这类复杂SQL的判断有时会不准,导致统计结果被拦腰截断。我的做法是:统计类SQL不走PageHelper.startPage(),直接封装成List返回,前端拿到全部数据后再自行分页展示。统计报表的查询条件本来就少,数据量也不会爆炸,后端全量返回是合理选择。

6.5 逻辑删除字段与唯一索引的冲突

会员手机号我建了唯一索引,同时会员表还有逻辑删除字段。问题来了:如果一个会员注销了,手机号还在表里,下次同手机号注册新会员时,唯一索引会阻止插入。

解决办法是唯一索引改成组合索引:(phone, deleted),这样同一个手机号只要deleted不同就可以共存。物理删除和逻辑删除混用会产生很多类似的边界问题,设计索引时一定要先想清楚删除策略。

6.6 后端接口的并发校验不能省

场地预约是高并发场景,前端做了场地状态展示之后,后端下单接口在保存订单前必须重新校验一次场地可用性。不能完全信任前端传过来的"可预订"状态。我在Service层用分布式锁的思路做了限制:以场地加时间段生成Redis锁key,加锁成功才继续处理订单,加锁失败直接拒绝并提示"该时段刚被其他用户预订"。数据库唯一索引是最底层的兜底,Redis锁是性能和体验的优化,两者不冲突。

7. 系统的扩展方向与我的最终感受

标准的版本做到这一步,该有的功能都有了,但上线运营一段时间后,总会有新的需求冒出来。我把自己经历过的一些扩展点列出来,给大家做个参考。

第一个是对接微信小程序。目前前台会员是通过H5页面访问的,体验尚可,但如果想让用户更便捷地预订场地,小程序是必然方向。后端接口设计时我把返回体结构做成统一的数据格式,小程序端实际上可以直接复用这套接口,只需要把Axios换成小程序的wx.request即可。

第二个是短信通知与消息推送。用户预约成功、课程开始前、余额变动时,都应该有通知机制。最初没有接短信网关,预约成功只是页面弹窗,很多用户过了几天就忘约了。后来接了一个短信服务,开场前两小时自动发提醒短信,爽约率明显下降。

第三个是自动退款和优惠券系统。目前退款需要前台人工操作,逻辑复杂的地方在于退款金额和余额回滚要放在同一个事务里。下一步可以做成超时自动退款、优惠券系统配合拉新活动,这些都是在现有表结构上增加关联表就能实现的功能。

最后再分享一个小技巧。这套系统的后端接口我统一用Postman维护了一套完整的接口文档,每一步开发联调都按照文档来。等整个项目交付之后,这套文档的价值甚至超过了源码本身,因为后面所有新增功能和对接第三方系统,都得先回去翻接口文档。我建议不管项目多紧,接口文档不要省,哪怕只是把请求和响应示例贴进去,都能省下后面几倍的时间。

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

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

立即咨询