☰
Spring Boot + Vue健身房管理系统实战:从数据库设计到部署上线
2026/10/8 2:30:26 网站建设 项目流程

开门见山说个事:前段时间帮朋友健身房做的那套管理系统,前后大概折腾了一个多月,从数据库设计到前后端联调再到部署上线,踩的坑比想象中多。这套系统的技术栈就是标题里写的Spring Boot + Vue,功能上覆盖了会员管理、课程预约、教练排班、签到统计这些健身房日常运营的核心场景。写这篇东西不是为了秀技术,就是想把这个项目从设计到落地的完整思路和实操过程记录下来,给准备做类似管理系统的朋友一个可以抄作业的参考。这篇文章适合正在做毕业设计、课程设计,或者想自己搞一套前后端分离项目练手的人读,不管你是后端为主还是前端为主,都能找到自己能复现的部分。

1. 项目概述与需求拆解

1.1 系统定位与核心功能

健身管理系统这名字听着大,但落到实际业务上,核心就是解决健身房日常运营里这些事:谁来上课、谁买了卡、卡什么时候到期、教练的时间怎么排、会员约了课没来怎么处理。说白了,这套系统的本质是业务管理系统,不是电商系统也不是社交平台,所有功能都围绕"人、卡、课、预约"四个核心对象展开。

我做的这套系统最终定了四个角色:管理员、前台、教练、会员。管理员负责全局配置和统计报表,前台负责会员办卡和签到操作,教练管理自己的课程和学员,会员则是通过前端页面完成注册、选课、预约、查看记录这些自助操作。功能模块上,我把系统拆成了这样几块:

  • 会员管理:会员信息的增删改查、会员卡类型(月卡、季卡、年卡)管理、卡到期提醒。
  • 课程管理:团操课程的发布、课程时间表、每节课的可预约人数、课程状态(可预约/已满/已结束)。
  • 教练管理:教练基本信息、教练负责的课程列表、教练排班。
  • 预约管理:会员预约课程、取消预约、签到记录、爽约标记。
  • 统计报表:每日/每周的预约量、签到率、热门课程排行。
  • 公告通知:运营公告的发布与展示。

这套系统做下来,最核心的业务闭环是:会员注册 → 查看课程表 → 预约课程 → 到店签到 → 教练核销。把这条链路跑通,系统的主干就算立住了。

1.2 为什么选Spring Boot + Vue这套组合

技术选型这个话题,每次都能吵半天。我直接说我的结论:Spring Boot + Vue是目前做前后端分离项目最稳的组合,没有之一。这不是因为它最先进,而是因为它最适合这类中小型业务系统的开发节奏。

后端用Spring Boot,理由很实在。第一,生态成熟,MyBatis Plus、Spring Security、Redis这些组件都有非常完善的中文文档和社区解决方案,踩了坑基本都能搜到答案。第二,开发效率高,Spring Boot的自动配置和starter机制把大量繁琐的配置工作省掉了,一个main方法就能把服务跑起来,对中小项目和单人开发尤其友好。第三,Java本身的类型系统和Spring的依赖注入让后期维护相对容易,一个项目写完之后半年再回来看,代码还是能读懂的。

前端用Vue,理由同样直接。Vue的学习曲线比React平缓,模板语法直观,组件化开发模式适合这种以表单和表格为主的管理系统页面。配合Element Plus组件库,后台管理类页面的开发速度非常快。Vue Router负责路由跳转,Pinia管状态,Axios发请求,这几件套组合起来,前端开发的工作量控制得很低。

不过这里要提醒一点:选Vue3还是Vue2,这得想清楚。如果是从零开始的新项目,直接上Vue3 + Vite + Element Plus。Vue2已经停止维护了,新项目没必要再用组合式API都别扭的老版本。我这套系统就是用Vue3写的,后面所有代码示例都是Vue3的写法。

2. 数据库设计与后端核心实现

2.1 数据库表结构设计思路

数据库是这个系统的地基,表设计的好坏直接决定后面写业务代码的体验。我设计表的时候遵循了一个原则:按业务对象拆表,用业务ID关联,不做冗余字段。核心表一共有六张。

会员表(member)的字段包括:id(主键)、username、password(BCrypt加密后存储)、real_name、phone、gender、card_type(月卡/季卡/年卡)、card_start_date、card_end_date、status(正常/冻结)、create_time。这里有个很重要的设计细节:会员状态和卡到期时间必须独立存字段,不要通过计算去判断,因为定时任务和查询条件都要用到这两个字段,冗余一点换来查询方便是值得的。

课程表(course)的字段包括:id、course_name、coach_id(关联教练表)、start_time、end_time、max_people(最大预约人数)、booked_count(已预约人数)、status(可预约/已满/已结束)、intro(课程简介)。预约人数的控制是这个表的关键逻辑,我后面在预约模块会详细讲到并发控制的问题。

预约表(booking)的字段包括:id、member_id、course_id、booking_time、status(已预约/已签到/已取消/爽约)、check_in_time。这张表是业务发生最频繁的表,所以索引要建好,member_id和course_id都要加索引。

教练表(coach)、公告表(announcement)、签到表(check_in)相对简单,不展开写了。另外提醒一句:如果你用的是MySQL,所有表的引擎都用InnoDB,字符集用utf8mb4,这样能避免很多中文乱码和事务支持的坑。

2.2 Spring Boot项目分层结构

项目结构上,我采用了最标准的四层架构:controller(接口层)→ service(业务层)→ mapper(数据访问层)→ entity(实体类)。这是Spring Boot项目最经典的分层方式,每一层只做自己该做的事,职责边界清晰,后面维护的时候找代码非常快。

一个值得参考的包结构是这样的:

com.example.fitness ├── controller/ # 接口层 ├── service/ # 业务层 │ ├── impl/ ├── mapper/ # MyBatis数据访问接口 ├── entity/ # 数据库实体 ├── dto/ # 请求/响应对象 ├── config/ # 配置类(跨域、拦截器、WebMvc等) ├── common/ # 统一返回结果、异常处理、工具类 ├── security/ # JWT鉴权相关 └── FitnessApplication.java

分层设计有个很容易被忽略的坑:很多人在service层直接返回entity实体,这是不对的。实体类是数据库的映射,不该直接把数据库字段暴露给前端。我习惯单独建一个dto包,接口的入参和出参都用dto对象,比如返回会员列表的时候用MemberDTO,把必要的字段拼装好再返回。这样做的最大好处是,数据库表结构变化时,接口的返回结构可以保持稳定,前端不会因为后端改了表结构就崩。

再说说统一返回结果。我建了一个Result类,固定结构是{code: 200, message: "success", data: {}},所有接口都返回这个格式。这样前端拦截器只需要判断code就知道请求是否成功,不用每个接口单独去解析。这个习惯看着简单,但实际用起来非常爽,尤其是前后端联调的时候,不会出现"这个接口为什么返回的字段名不一样"这种问题。

2.3 JWT登录鉴权的实现方式

管理系统里登录鉴权必须做,不做的话,任何人都能调用接口把会员数据删了。我用的方案是JWT(JSON Web Token)。这里说下整体设计流程。

用户登录成功后,后端生成一个有效期为2小时的token返回给前端。前端把token存在localStorage里,每次请求Axios拦截器把它加到请求头。后端写了一个拦截器,拦下所有非登录接口的请求,解析token并把用户信息放到ThreadLocal里,这样后面每次业务操作都能拿到当前登录人的信息。

生成token的核心代码大概是这样的:

String token = Jwts.builder() .setSubject(userId.toString()) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 7200000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();

拦截器里解析token的过程就不贴完整代码了,有一个经验值得分享:解析token异常要分类型处理。token过期和token被篡改返回的错误信息不应该一样,前者提示"登录已过期,请重新登录"并跳转登录页,后者提示"登录状态异常"并清理本地存储。这个细节直接影响用户体验,我见过很多项目在这块做得比较粗糙,用户被卡在一个没有明确提示的报错里。

权限控制方面,我用的是角色判断。接口注解上加一个自定义的@RequireRole("admin"),拦截器里判断当前登录人的角色是否有权限访问。不需要引入Spring Security这种重框架,因为系统角色只有四种,简单的角色判断足够用,引入重框架反而增加学习和调试成本。

3. 前端Vue页面设计与API对接

3.1 前端技术栈与目录结构

前端这边用的组合是:Vue3 + Vite + Vue Router + Pinia + Axios + Element Plus。没有用TypeScript,原因很实际:团队和后续接手的人都更熟JavaScript,而且这套系统的类型复杂度不高,TS的收益有限。不过如果你打算长期维护并且团队水平可以,上TS是加分项。

目录结构按功能划分,推荐这样组织:

src/ ├── api/ # 接口请求定义 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置 ├── store/ # 全局状态(Pinia) ├── utils/ # 工具函数(request封装等) ├── views/ # 页面组件 │ ├── member/ # 会员管理 │ ├── course/ # 课程管理 │ ├── booking/ # 预约管理 │ ├── dashboard/ # 统计报表 │ └── login.vue # 登录页 └── App.vue

api目录下每个模块一个文件,比如member.js、course.js、booking.js,每个文件导出对应模块的接口函数。这样做的好处是页面组件里不直接写请求URL,所有接口统一定义,后端接口路径变了只需要改一个文件。

3.2 Axios封装的关键点

Axios封装是前端项目的重中之重,代码量不大,但设计得好不好直接决定开发效率和联调体验。我的request工具类做了三件事。

第一,请求拦截器:从localStorage里取token,有就加到请求头的Authorization字段,没有就放行。第二,响应拦截器:解包统一返回结果里的code,code为200就返回data部分,业务代码里直接拿到业务数据,不用每个页面再解一层。code为401就清空本地存储、跳转登录页。第三,错误统一弹提示:网络错误、服务器错误统一用Element Plus的Message组件提示,页面里不做重复的错误处理逻辑。

// utils/request.js 核心结构 import axios from 'axios' import { ElMessage } from 'element-plus' 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) { return res.data } if (res.code === 401) { localStorage.clear() window.location.href = '/login' } ElMessage.error(res.message) return Promise.reject(new Error(res.message)) }, error => { ElMessage.error('网络请求失败,请稍后重试') return Promise.reject(error) } ) export default service

这样封装完后,页面里调接口就是非常简洁的写法,比如会员列表:

import { listMember } from '@/api/member' const getList = async () => { const data = await listMember({ page: currentPage.value, size: 10 }) tableData.value = data.records total.value = data.total }

3.3 路由守卫与权限控制

路由层面,我用Vue Router的全局前置守卫做登录拦截。逻辑很简单:访问任何一个页面之前,检查有没有token,没有就重定向到登录页。登录之后就放行。管理员和会员看到的页面不同这件事,我用了动态路由的思路处理:路由表分成两部分,公共路由直接注册,权限路由在登录后根据角色动态添加。

动态路由这个功能在管理系统里很实用,但实现起来有个坑:路由动态添加后退出登录再切换账号,之前添加的路由不会自动清除。解决方法是退出登录时调用router.options.routes重置路由,或者直接用重定向刷新页面让路由表重建。我踩过这个坑,用后者的方式解决了,简单可靠。

Pinia在这里主要存两类全局信息:当前登录用户的基本信息(用户名、角色、头像)和菜单的展开收起状态。用户信息在登录接口返回后存入store,退出登录时清空。整个项目用到的全局状态不多,Pinia的模块拆成user.js和app.js两个就够了。

4. 核心模块实操:课程预约全流程实现

4.1 业务规则与流程分析

课程预约是这套系统里业务逻辑最复杂的模块,也是并发压力最大的模块。如果预约功能做不好,整个系统在真实场景下是扛不住的。

预约的业务规则我梳理出了四条:

  • 会员必须登录且会员卡未到期才能预约。
  • 同一节课一个会员只能预约一次。
  • 课程状态为"可预约"才能被预约,已满或已结束不能预约。
  • 取消预约后,该会员可以重新预约同一节课(如果还有名额)。

整个流程文字描述是这样:会员进入课程列表页,看到所有发布日期在当天及之后的课程,每门课程卡片上显示剩余名额。点击"预约"按钮,前端弹出确认框,确认后请求后端预约接口。后端校验通过后写入预约记录,同时课程表的已预约人数加一。页面刷新后课程的剩余名额减少。

4.2 后端接口实现与并发控制

预约接口的URL是POST /api/booking,请求体参数是{courseId: 1, memberId: 5}。Service层的bookCourse方法逻辑分五步:查会员卡状态、查课程当前状态、查是否已预约、判断是否约满、插入预约记录并更新已约人数。

这里最大的坑是并发问题:两个会员同时点预约,恰好都查到了剩余名额1个,然后都执行插入,最后一节课超员了。解决办法我用了数据库层面的事务加悲观锁。查询课程记录时加上SELECT ... FOR UPDATE行级锁,锁住课程行,这样同一时间只有一个请求能执行后面的预约逻辑。

@Transactional public void bookCourse(Long memberId, Long courseId) { Member member = memberMapper.selectById(memberId); if (member == null || member.getStatus() != 1 || member.getCardEndDate().before(new Date())) { throw new BizException("会员卡状态异常,无法预约"); } Course course = courseMapper.selectByIdForUpdate(courseId); if (course == null || course.getStatus() != 0) { throw new BizException("该课程当前不可预约"); } int count = bookingMapper.countByMemberAndCourse(memberId, courseId, "已预约"); if (count > 0) { throw new BizException("您已预约该课程,请勿重复预约"); } if (course.getBookedCount() >= course.getMaxPeople()) { throw new BizException("该课程名额已满"); } Booking booking = new Booking(); booking.setMemberId(memberId); booking.setCourseId(courseId); booking.setStatus("已预约"); booking.setBookingTime(new Date()); bookingMapper.insert(booking); course.setBookedCount(course.getBookedCount() + 1); courseMapper.updateById(course); }

这段代码有三个要点。一是@Transactional必须加,事务保证插入预约记录和更新课程人数是原子操作,任何一个失败都会回滚。二是selectByIdForUpdate这个方法是自己在Mapper里写的,加了FOR UPDATE关键字,不是MyBatis Plus自动生成的。三是业务异常要主动抛出来让全局异常处理器统一包装成Result返回,不要在Service里自己try-catch吞掉。

4.3 前端页面交互实现

前端课程列表页我用了Element Plus的Card组件展示课程卡片,每张卡片显示课程名、教练、时间、剩余名额,底部是预约按钮。剩余名额为0时按钮禁用,已经预约过的课程按钮文案变成"已预约"且不可再点。

预约按钮的点击处理逻辑是:

const handleBook = async (courseId) => { try { await createBooking({ memberId: userStore.userInfo.id, courseId }) ElMessage.success('预约成功') await getCourseList() // 刷新列表 } catch (e) { // 错误提示已经在拦截器里统一处理了 } }

这里有个易踩的坑:预约成功后一定要刷新课程列表,但不要整页刷新,调用getCourseList重新拉数据就好。因为课程卡片上的剩余名额是列表数据里的字段,不重新拉数据的话,前端显示的剩余名额还是之前的,用户连续预约两节不同的课会看到名额不对。曾经为了省事直接用location.reload(),结果弹窗消失、页面闪烁,体验非常差。

4.4 联调阶段几个容易踩的坑

前后端联调是整个过程里最容易出问题的时候,我发现的问题多数集中在三个地方。

第一是日期格式。后端LocalDateTime默认序列化出来的格式是2024-11-20T14:30:00,带一个T,前端如果想显示成2024-11-20 14:30,要么后端统一配置Jackson的日期格式,要么前端做格式化处理。我建议后端直接配置,全局生效,省得前端每处都处理。

第二是字段命名风格。后端Java习惯用驼峰命名(createTime),数据库习惯用下划线(create_time),MyBatis Plus开了驼峰映射之后没问题。但前端联调时看返回的JSON字段是驼峰还是下划线必须前后端统一确认好,不然就会出现页面拿到的是undefined这种经典问题。

第三是跨域。前后端分离开发模式下,前端跑在5173端口,后端跑在8080端口,必然触发跨域。我在后端写了一个CorsConfig配置类,允许指定的前端源地址跨域。上线部署后跨域配置还不能删,因为Nginx反向代理的配置方式不同,这步我在后面部署章节专门讲。

5. 部署上线与常见问题排查

5.1 前后端打包与部署方案

系统开发完成后部署上线,这里分享一套我验证过很多次的方案。前端用Nginx做静态文件服务器和反向代理,后端的Spring Boot应用打成jar包直接跑。

前端打包执行:

npm run build

打包产物在dist目录。把dist目录上传到服务器任意位置,然后配置Nginx。

后端打包执行:

mvn clean package -DskipTests

生成jar包后,用nohup java -jar fitness-system.jar > logs/fitness.log 2>&1 &启动。生产环境建议用systemd管理进程,这样重启方便还能开机自启。

Nginx配置有两个关键点:一是前端history路由模式必须配置try_files,不然刷新页面会404;二是/api路径要反向代理到后端端口。

server { listen 80; server_name your-domain.com; root /www/fitness-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 $uri $uri/ /index.html这行是必须的。Vue Router默认使用history模式,URL是/member/list这种真实路径,刷新时浏览器会向Nginx发起这个路径的请求,Nginx找不到对应的静态文件就会404。try_files把所有未知路径回退到index.html,再由前端路由接管,页面就能正常显示了。

5.2 常见问题速查表

做这套系统的过程中我整理了一张问题排查表,都是实际遇到过的,按照从高频到低频排列。

问题现象可能原因解决方法
前端请求接口报403/跨域Nginx没配代理或后端跨域配置丢失检查Nginx的location /api配置,确认proxy_pass指向后端
刷新页面404路由history模式缺少try_files配置在Nginx location / 里加try_files $uri $uri/ /index.html
预约接口数据不一致并发场景下没有用悲观锁或事务确认查询课程用了FOR UPDATE锁,方法加了@Transactional
中文乱码数据库字符集不是utf8mb4ALTER TABLE xxx CONVERT TO CHARACTER SET utf8mb4
登录后马上失效token过期时间太短或时区问题检查JWT的expiration时间和服务器时间是否一致
打包后接口地址不对前端写死了localhost用环境变量配置接口baseURL,打包时注入正确的API地址
上传的图片显示不了静态资源路径配置问题把上传目录配置到Nginx静态资源路径,或用OSS存储

排查问题的思路也很重要。我习惯先把问题分成三类:前端问题、后端问题、部署环境问题。前端问题先看浏览器开发者工具的Network面板,接口请求没发出去就是前端问题,发出去没响应就是后端或代理问题。后端问题看日志,Spring Boot的日志会打印完整的异常栈和SQL语句,95%的问题看日志就能定位。部署环境问题基本集中在端口没开、路径不对、Nginx配置错误这三类。

5.3 安全加固的几个必做项

刚部署上线时系统安全性是没法用的,暴露在外网必须做几件事。密码存储必须用BCrypt,就算数据库被拖走,明文密码也不能泄露。接口层面要防SQL注入,MyBatis Plus的#{}预编译机制能挡住大部分注入攻击,但还是不要用拼接SQL的方式写复杂动态查询。

管理后台登录接口要加验证码。最初做系统时没加验证码,结果上线的第二天就被扫到登录接口,开始密码暴力尝试。后来我引入了Hutool的验证码工具库,用后端生成验证码图片,登录时校验验证码。这步虽然增加了一点用户体验成本,但在公网环境下是必须的。

运维层面也有经验:Spring Boot的actuator接口默认暴露了健康检查、环境信息等端点,如果你引入了这个依赖却没配置权限,等于把服务器的一部分信息免费送给别人看。生产环境配置里要显式关闭这些端点,只保留health一个就够了。

6. 写在最后的实操体会

整个健身管理系统做下来,我最深的体会是:这类管理系统的核心难点不在技术,而在业务逻辑的严谨性。预约模块的并发控制、会员卡到期时间的比较、状态流转的边界条件,这些看似不起眼的地方才是真正考验工程师功力的地方。技术栈Spring Boot和Vue只是工具,把业务想清楚才是根本。如果现在重新让我做一遍,我会先花更多时间画清楚状态流转图,而不是急着写代码。另外想提一个后续可以扩展的方向:这套系统目前只做了Web端,如果健身房需要,完全可以把前端换成微信小程序,后端接口只要稍微调整鉴权方式就能复用,基本不需要重写业务逻辑。这个项目本身的价值不在于代码量有多大,而在于把一套真实业务场景完整落地了,从需求到上线整个链路都走通了,这个经验比代码本身值钱。

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

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

立即咨询