做这套海滨体育馆管理系统,最初的原因其实很朴素:一个体育馆的前台每天要接几十通电话,有人问羽毛球馆今晚还有没有场,有人查游泳次卡还剩几次,还有人打电话来改预约时间。电话记录、纸质台账、Excel 表格来回倒腾,遇上高峰时段,漏约、重约、超卖的情况屡见不鲜。所以当运营方提出想上一套信息化系统时,我第一个想法就是:先把"场地预约"这件事做成一个确定性的流程,再逐步覆盖会员、课程、器材这些周边业务。
这套系统最终采用 SpringBoot + Vue + MyBatis + MySQL 的前后端分离架构,后端专注业务逻辑和数据接口,前端做页面交互,数据库统一沉淀核心数据。整套源码加部署教程整理完之后,它既是一个可以直接改造成商用的小型场馆信息化方案,也是一套非常适合用来打通前后端技能树的实战项目。不管你是正在学 SpringBoot 和 Vue 的开发者,还是想接中小型场馆外包项目的从业者,这篇文章里讲的模块拆分、数据库设计、接口实现和部署细节,都能直接拿去参考。
1. 需求盘底:海滨体育馆运营里那些"不得不数字化"的场景
1.1 运营方最痛的四个业务场景
做系统之前,我花了差不多一周时间蹲在场馆里看他们怎么干活,最后把痛点收敛成四块,后续所有设计都围绕这四块展开。
第一是场地预约。海滨场馆通常同时包含羽毛球馆、篮球场、游泳馆、网球场、乒乓球室等多种场地,每种场地又有不同的计价方式:工作日和周末价格不一样,会员和非会员价格不一样,有的按小时租,有的按固定场次租。预约还要考虑到场地维护、闭馆时段、节假日的特殊安排。这个场景是整个系统的核心,也是数据一致性要求最高的地方。
第二是会员管理。场馆的主要营收来源其实是会员卡:次卡、月卡、年卡、储值余额,各种类型混着来。次卡要记录剩余次数,储值卡要记录余额和每一笔消费,会员在预约时还能享受折扣。这里最容易出问题的地方是账目对不上,所以我把充值和消费全部做成流水记录,任何人任何时候都能追溯。
第三是课程培训。游泳培训班、少儿篮球班、网球私教课是很多海滨场馆的引流项目。课程涉及教练排课、学员报名、固定班级、请假补课,如果没有系统支撑,光靠排课表和微信群非常容易乱。
第四是公告与运营通知。临时闭馆、设备维修、节假日营业时间调整,这些信息必须能触达所有用户。最基础的做法是系统内公告 + 前端首页横幅,再配合短信或公众号通知,就能覆盖大多数场景。
1.2 角色划分与权限边界
根据实际使用的人,我把系统角色分成四类:系统管理员、运营前台、教练、普通用户(会员)。管理员拥有全部权限,包括场地配置、角色分配、数据统计;运营前台负责日常接单、场地排期、会员办卡、处理取消和退款;教练只看得到自己的课程表和上课记录;用户在微信端或网页端完成预约、充值、查看订单、取消预约。
权限设计上没有做特别复杂的 RBAC 表,而是用一个 user_role 字段 + 后端接口注解校验解决。前期角色少,这种方案维护成本最低。如果未来要扩展,再改成标准的角色-权限两张表也不迟,接口层不需要大动。
1.3 边界取舍:哪些功能第一版先不做
这是很多项目翻车的根源——什么都想要,结果什么都做不透。我在第一版明确砍掉了三块:不接真实第三方支付网关,只做余额扣款和模拟支付回调,避免资质和商户号问题;不做闸机联动和硬件对接,那需要单独的物联网方案;不做实时视频监控,那是另一个量级的产品。把这些边界提前讲清楚,开发周期能从三个月压缩到一个月,交付后也不会被需求淹没。
2. 技术选型复盘:前后端分离是趋势,但这套组合更适配
2.1 前后端分离的边界到底划在哪
很多人一上来就分两个工程,但说不清边界。我的判断标准很简单:凡是涉及"数据和钱"的都不能交给前端,价格计算、库存校验、权限判断、订单状态流转必须全部在后端完成;前端只负责页面展示、表单校验和交互动作。后端以 RESTful API 的形式输出 JSON,前端通过 HTTP 调用,互不干扰。
这样做的直接好处有两个:一是前端可以独立开发,后端接口 mock 好之后两边并行推进;二是未来如果想出小程序端或者 App 端,后端接口完全不用重写,直接复用同一套 API。
2.2 四个核心组件各自在解决什么问题
先说说 SpringBoot。选择它不是因为"大家都在用",而是因为它确实适合这种业务系统:内置 Tomcat,打包成 Jar 就能跑,依赖管理省心,生态里要什么有什么。对于一个小团队维护的体量,SpringBoot 的开发和运维成本是最低的。
MyBatis 则是冲着 SQL 可控性去的。场馆预约系统的查询条件非常灵活——按场地类型、按日期、按时间段、按价格区间,各种条件组合,用 MyBatis 动态 SQL 能精确控制每一句查询,也好做 SQL 级别的性能优化。相比之下,JPA 在复杂查询时生成的 SQL 不可控,反而让人心里没底。
Vue 选它是因为后台管理系统是它的主场。组件化开发、响应式数据绑定、配合 Element Plus 这类现成的 UI 组件库,一个功能完整的运营后台写起来速度飞快。我记得光靠表单组件和表格组件,就节省了至少三分之一的页面开发时间。
MySQL 就不用多说了,稳定性好、成本低、运维资料多。以这家体育馆的规模——日预约量几百单、会员几千人——单机 MySQL 完全够用,连分库分表的边都不用摸。
2.3 为什么没上 Redis 和微服务,是因为它们此刻不必要
可能有人会问:预约系统并发冲突,不是应该上 Redis 分布式锁吗?我的回答是:要看量级。一个海滨体育馆的瞬时并发很难超过几十,MySQL 的事务隔离 + 唯一索引 + 乐观锁完全能扛住,引入 Redis 反而会给部署和运维增加一个中间件,对教学项目和中小型项目来说都是多余的复杂度。
微服务更是没必要。单体能解决的问题,拆成微服务只会让开发、部署、排查都变得痛苦。前后端分离的"单体应用"是这个项目规模下效率最高的形态,没有之一。真正的技术选型不是越复杂越好,而是在满足业务的前提下做到最简单。
3. 数据库设计:一套经得起运营折腾的表结构
3.1 核心域:场地、场次、预约三张表联动
预约系统的地基是三张表:场地表、场次表、预约订单表。场地表存每个场地的名称、类型、容纳人数、工作日价格、周末价格、状态;场次表把每个场地按固定的时间粒度(比如每半小时或一小时)预生成未来一段时间的可用场次;预约订单表记录谁在什么时间占了哪个场地的哪个场次。
这里有一个设计上的关键决策:到底是"预生成场次记录"还是"按时间区间动态判断"。我最终采用的是预生成 + 按日占用的混合方案。管理员配置好场地和营业时间后,系统每天定时生成未来七天的场次数据,状态默认"可约"。用户下单时把对应场次状态改为"已占",同时写一条预约订单。这样查询"某个场地今天还有哪些空场"就是一条简单的按状态查列表,前端展示也直观。
预约订单表我加了状态、金额、折扣金额、实付金额、创建时间这几个核心字段。金额全部用 DECIMAL(10,2),杜绝浮点精度问题。
3.2 会员域:余额、卡次与消费流水如何保证对得上账
会员这块的账目设计我吃过亏,所以这次特意做得保守。会员主表只存基本信息、卡类型、会员等级和当前余额/剩余次数;所有资金变动都拆成独立的流水表——充值流水和消费流水。充值加余额、预约扣款、取消退款,每一笔都写流水,并且带操作时间和操作人。
这样的设计看起来多写了几行代码,但对账的时候极其有用。运营人员只需要把流水的累加值和会员表余额一对比,哪里不对马上能定位到具体某笔操作。我见过太多项目把金额直接写在订单表里就完事,月底一对账全是糊涂账,返工成本远高于当初多写的那几行。
3.3 业务域:教练、课程、器材的关联设计
课程培训模块由四张表组成:教练表、课程表、课程排期表、报名表。教练表存基本信息和擅长项目;课程表定义课程的名称、时长、价格、学员上限;课程排期表把课程和具体时间、教练绑定;报名表记录学员报了哪一期课程。请假和补课我直接用报名表上的状态字段处理,避免无限加表。
器材模块更简单:器材表 + 借用记录表。器材表维护名称、库存、状态,借用记录表记录谁在什么时间借了什么、归还时间。因为这套系统不涉及硬件,器材的"借出/归还"完全靠前台在界面上操作,数据层面就是两条状态更新。
3.4 冗余字段与索引:写起来难看,查起来真香
我数了一下,整个系统一共 21 张表。在设计上我刻意做了一些"不规范"的冗余:例如预约订单表里冗余了场地名称、用户昵称,而不是每次都去 join 场地表和用户表。这样做的原因是查询列表页时少几次联表,响应速度快,代码也更直白。数据冗余带来的更新一致性问题,在这个量级下靠后端在同一个事务里同步更新就能解决,不需要引入额外机制。
索引方面,预约订单表的 (venue_id, use_date, start_time) 联合索引、会员表的 user_id 索引、流水表的 member_id + created_at 索引是必备的。尤其是场地时间冲突校验,没有联合索引的话,并发一高就可能出现慢查询。
4. 后端落地:SpringBoot + MyBatis 的接口实践与状态机
4.1 JWT 鉴权与接口权限控制
后端所有接口统一走 REST 风格,项目按模块分包:controller、service、mapper、entity、common。鉴权用的是 JWT,登录成功后签发 token,前端在请求头里带 Authorization,后端用一个 HandlerInterceptor 统一拦截。
拦截器里做三件事:解析 token 并校验有效期;把用户信息放入当前请求上下文;根据接口上的自定义注解 @RequireRole 判断角色是否匹配。核心代码大致是这样:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equalsIgnoreCase("OPTIONS")) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { throw new BusinessException(401, "未登录"); } // 解析并校验 token LoginUser user = JwtUtil.parseToken(token); // 角色校验 if (handler instanceof HandlerMethod) { RequireRole requireRole = ((HandlerMethod) handler).getMethodAnnotation(RequireRole.class); if (requireRole != null && !hasRole(user, requireRole.value())) { throw new BusinessException(403, "无权限"); } } UserContext.set(user); return true; } }角色校验这步一定要放在后端,不能只靠前端隐藏按钮。接口层的校验哪怕重复,也是安全底线。
4.2 预约订单的状态机设计
预约订单我定义了四个状态:待支付、已预约、已完成、已取消。用户创建预约后先生成订单并锁定场次,状态是"待支付";支付或余额扣款成功后变成"已预约";场馆确认使用完成或系统按时间自动流转为"已完成";取消则进入"已取消",同时释放场次、回滚余额。
状态机看着简单,但有几个细节必须注意。取消操作只允许在"待支付"和"已预约"两个状态执行,已完成和历史订单不能取消;退款金额要按当时的折扣比例计算,不能用当前会员等级套算,否则账目会乱;所有状态变更都必须在后端 Service 层完成,前端只能发起动作,不能直接改状态字段。
我把状态变更封装成独立方法,每个方法开头先校验当前状态是否允许变更,这就是一个最朴素的状态机。好处是以后新增"已过期"等状态时,只需要在方法里加分支,不会把逻辑散落在各处。
4.3 MyBatis 动态 SQL 在复杂筛选中的价值
场馆列表和订单列表都有复杂的筛选条件。MyBatis 的<if>标签在这里非常好用,比如查询可预约场次时,要按场地类型、日期、时间段、价格上限动态拼接条件,一段核心 SQL 如下:
<select id="selectAvailableSchedules" resultType="ScheduleVO"> SELECT s.*, v.name AS venue_name, v.type AS venue_type FROM schedule s JOIN venue v ON s.venue_id = v.id <where> <if test="venueType != null and venueType != ''"> AND v.type = #{venueType} </if> <if test="useDate != null"> AND s.use_date = #{useDate} </if> <if test="maxPrice != null"> AND (v.price_weekday <![CDATA[ <= ]]> #{maxPrice} OR v.price_weekend <![CDATA[ <= ]]> #{maxPrice}) </if> <if test="sportType != null"> AND v.sport_type = #{sportType} </if> AND s.status = 0 </where> ORDER BY s.use_date, s.start_time </select>这里有个小坑要注意:<和>在 XML 里必须用<![CDATA[]]>包起来,否则会被当成 XML 标签解析报错。我第一次写就栽在这里,整整排查了一个小时。
4.4 并发下防止场地超订的兜底方案
防止同一场次被两个人同时抢到,这是整个系统最关键的技术点。很多教程只讲"在代码里先查状态再插入",但并发场景下这样根本不够——两个请求同时查到"空闲",同时插入,就超订了。
我的方案是三层兜底。第一层是数据库唯一索引,在预约订单表上建立 (venue_id, schedule_id) 联合唯一索引,从数据库层面保证同一场次最多一条有效订单;第二层是事务内先更新场次状态再插入订单,利用行锁串行化操作;第三层是捕获 DuplicateKeyException,捕获到就明确向用户返回"该场次已被抢订"。
这三层不是理论,是我在生产环境真实遇到过超订事故后总结出来的。只看任何一层都有漏洞,叠起来才能做到真正的可靠。
5. 前端搭建:Vue3 工程化、权限路由与预约交互
5.1 基于角色的动态路由
前端我用的 Vue3 + Vite + Element Plus + Pinia。登录成功后,后端返回用户信息和角色,前端根据角色动态注册路由。管理员能看到全部管理页面,前台看到订单处理和会员管理,教练只看到课程表,普通用户只看到预约和我的订单。
这里的关键是路由守卫。所有页面跳转前都要检查本地是否有 token,没有就跳登录页。但由于路由是动态添加的,刷新页面时会出现路由还没注册好就跳转的时序问题。我的解决办法是:在路由守卫里先判断 Pinia 里有没有用户信息,没有就调一次获取用户信息的接口,拿到后再继续导航,而不是直接依赖本地缓存。
router.beforeEach(async (to, from, next) => { const token = localStorage.getItem('token') if (!token) { if (to.path === '/login') return next() return next('/login') } if (!userStore.userInfo) { await userStore.fetchUserInfo() // 根据角色添加动态路由 addDynamicRoutes(userStore.role) return next({ ...to, replace: true }) } next() })这种方式确实会让页面初始化多一次请求,但换来的是刷新不白屏、权限不乱跳,值得。
5.2 场地面板与时间冲突校验
预约页面是用户用得最多的界面。我把它做成一个"场地 × 时间段"的面板,横向是当天各场地,纵向是时间点,格子颜色表示可约、已约、维护中。用户点可选格子,系统弹出确认框,显示场地、时间、原价、会员折扣价和余额,确认后生成订单。
前端在点击时也会做一层简单的冲突校验:如果格子已经被置灰就不允许点击。同时我会在用户选择的同一时间跨多个场地时做提示,避免用户误以为一次能订多块场地。但必须强调,前端校验纯粹是提升体验,最终的冲突判定仍然以后端唯一索引和事务为准,前端永远不能被当作安全边界。
5.3 数据可视化:让运营方愿意用起来的统计页
管理系统如果只有增删改查,运营方用两周就没兴趣了。我额外加了一个数据统计页,用 ECharts 展示近七日预约量趋势、各场地使用率排行、会员增长曲线、收入结构饼图。数据全部来自后端聚合接口,前端只负责渲染。
别小看这个页面,它直接决定了系统能不能在运营方那里活下来。当负责人能一眼看到"网球场周末使用率只有 30%"的时候,他会主动去调整价格和排期,这个系统才算真正产生了价值。
6. 部署教程:一台 Linux 服务器从零跑通前后端分离项目
6.1 服务器与基础环境准备
部署我建议直接准备一台 2核4G 的云服务器,这个配置跑当前系统加 Nginx 非常轻松。系统盘用主流 Linux 发行版就行,比如 CentOS、Ubuntu 均可。需要安装的东西列出来其实只有几个:
| 软件 | 版本建议 | 用途 |
|---|---|---|
| JDK | 1.8 或 17 | 运行 SpringBoot 服务 |
| Maven | 3.6+ | 后端项目构建打包 |
| MySQL | 8.0 | 数据库 |
| Nginx | 1.2 以上 | 前端静态资源与反向代理 |
| Node.js | 16 以上 | 前端构建 |
后端项目打包前要确认 Maven 仓库能拉到依赖,建议先把本地构建跑通一次,再到服务器上操作,避免在服务器上反复排错。
6.2 后端 Jar 包的打包与启动
后端打包很简单,在项目根目录执行:
mvn clean package -DskipTests打完之后 target 目录下会生成一个 Jar 包。生产环境我推荐把它放到独立目录,比如/opt/stadium,然后用nohup启动:
nohup java -jar stadium-system.jar --spring.profiles.active=prod > /opt/stadium/logs/stadium.log 2>&1 &--spring.profiles.active=prod用来加载生产配置文件,里面是真实的数据库地址、账号密码。用 nohup 启动的好处是关闭 SSH 后服务不会退出,但服务器重启后服务不会自动拉起。更稳妥的做法是写一个 systemd 服务文件,让系统守护进程管理,开机自动启动,崩溃自动重启。服务文件核心内容如下:
[Unit] Description=Stadium System Service After=network.target mysql.service [Service] Type=simple User=root ExecStart=/usr/bin/java -jar /opt/stadium/stadium-system.jar --spring.profiles.active=prod Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target把文件放到/etc/systemd/system/stadium.service,然后systemctl enable stadium && systemctl start stadium,之后所有管理都通过 systemctl 完成,比 nohup 靠谱得多。
6.3 前端构建与 Nginx 反向代理
前端本地先执行:
npm install npm run build构建完成后 dist 目录就是纯静态文件。把它上传到服务器的/var/www/stadium目录。Nginx 配置里要做两件事:一是把根路径指向静态目录并配置 history 路由回退,二是把/api开头的请求反向代理到后端 8080 端口。
server { listen 80; server_name _; root /var/www/stadium; index 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }配好之后nginx -t检查语法,再nginx -s reload生效。这里最容易漏的是最后一行try_files,没有它,前端路由在刷新页面时会直接 404。
6.4 数据库初始化与备份策略
数据库部分,先在服务器上建库,再把初始化 SQL 导入:
mysql -uroot -p < stadium_init.sql生产库的用户名密码不要写在项目公共配置里,放在 prod 配置文件中单独维护。备份是我强烈建议一开始就做好的事。写一个简单的脚本,用 mysqldump 每天凌晨备份,加上 crontab 定时任务:
0 2 * * * mysqldump -uroot -p密码 stadium > /backup/stadium_$(date +\%F).sql保留最近三十天的备份就够了,数据量不大,全量备份比增量简单也更可靠。
7. 踩坑实录与排错链路:从启动失败到并发超订
7.1 MyBatis 日期参数比较导致的查询偏差
第一次做"按日期查可约场次"功能时,前端把2024-09-15这样的字符串传给后端,后端直接用字符串和数据库datetime字段比较。结果当天没问题,但第二天写死的日期也可能被查出来,因为 MySQL 隐式类型转换把字符串转成了当天的 00:00:00,导致范围边界计算错误。
排查链路是这样的:先在前端看到"查 9 月 15 日却返回了 9 月 16 日的记录",怀疑 SQL 条件写错;打印 SQL 日志后,看到use_date = '2024-09-15'确实生成了,数据库里这条记录的 use_date 也是 9 月 15 日,问题诡异;最后翻到字段类型是datetime而不是date,才明白是类型转换的锅。解决方案是后端统一把入参解析成LocalDate,比较时精确到日期,再配合<![CDATA[ ]]>处理符号问题,彻底根治。
7.2 跨域配置没问题,前端却一直报 CORS 错误
前后端分离开发时跨域是必经之路。我一开始在后端加了 CorsFilter,配置看起来完全标准,但前端请求就是被拦截,报 CORS 错误。
排了半天发现,问题不在 CORS 配置本身,而在于我给所有接口加了认证拦截器。浏览器预检请求是 OPTIONS 方法,没有带 token,被拦截器拦下直接返回 401,预检失败导致浏览器认为跨域不通过。解决方法是两行代码的事:拦截器放行 OPTIONS 请求。这个坑特别隐蔽,但遇到一次之后再也不会忘。
7.3 前端部署后刷新任意页面都是 404
本地开发一切正常,一上服务器,用户从首页点进预约页也正常,但只要按 F5 刷新,就白屏 404。
原因很清晰:Vue Router 用的是 history 模式,跳转路径是/reservation这样的真实 URL,刷新时浏览器向服务器请求这个路径,而服务器上根本没有对应的文件。最开始 Nginx 配置只有静态文件指向,没有加try_files回退,自然 404。加了try_files $uri $uri/ /index.html;之后,所有未知路径都回到 index.html,由前端路由接管,问题立刻消失。
7.4 同一场次被两个人同时抢到的并发事故
这是上线后唯一一次真正让我睡不着的故障。预约功能稳定运行了两周,突然有一天运营反馈一次性出现了两笔相同场地相同时间的订单。
第一反应是查代码逻辑:先查场次状态再插入订单,逻辑上没问题。再用 jmeter 模拟并发 20 个请求抢同一个场次,发现大概每 8 组就会复现一次超订。问题源头逐渐清晰:两个事务并发执行时,都先 SELECT 查到场次状态是"空闲",随后都执行 UPDATE 和 INSERT,因为事务隔离级别下 Select 不加锁,两个请求都认为自己是唯一合法预约。
修复方案就是前面说的三层兜底:数据库唯一索引、事务内锁场次、捕获 DuplicateKeyException 友好提示。上线后再用同样并发脚本压测,超订消失,重复请求被正确拦截并提示。这个案例告诉我:并发问题不能靠"我以为不会发生",必须有数据库层面的硬约束。
整个项目做下来,我最大的体会是:一个系统能不能长久用下去,关键不在于功能多炫,而在于关键流程上的数据是否可靠、账目是否清晰、部署是否省心。对想上手这套系统的朋友,我的建议是先别急着抄代码,把预约状态机和表结构关系看懂,再去跑前后端联调,最后落到部署上。代码层面碰到问题时,优先看数据库约束和日志输出,大部分诡异问题都能从这两处找到答案。如果这套源码和部署流程能让你少走几个我走过的弯路,那就很值了。