做健身俱乐部管理系统,本质上就是管好「课程、教练、会员」这三条主线,再挂上预约报名、公告发布、评价反馈这些副线。我最近在整理一套基于 Java SpringBoot + Vue3 + MyBatis + MySQL 的前后端分离版健身俱乐部网站系统源码,花了不少时间做代码梳理和技术选型。如果你正想找一个实战项目来练 SpringBoot 全家桶,想学习 Vue3 工程化开发,或者手头正好需要一套开源系统做二次改造,那这篇文章值得你认真看完。
这套系统的业务场景很典型:前台给访客和会员浏览课程、查看教练、查看公告、在线预约;后台给管理员做课程、教练、会员、预约订单和评价内容的增删改查。技术栈选择了目前国内企业开发最流行的 SpringBoot + Vue3 + MyBatis + MySQL 组合,前后端完全分离。这篇文章不打算像开源项目 README 那样只讲「怎么跑起来」,我会把设计思路、关键实现、踩过的坑全部揉碎讲清楚,你照着做也能写出一套完整系统。
1. 项目整体设计与需求拆解
1.1 用户角色与核心业务模块
我先从业务角度拆系统。健身俱乐部网站不是普通的「展示型官网」,它有真实业务流转,至少存在两类用户:前台访客/会员,后台管理员。实操中我还会加一个教练角色,方便课程和上课记录的管理。角色划分直接影响表设计和接口设计,千万不要一开始就堆功能。
从模块来说,这套系统我拆成了四个大块:
- 前台门户:首页轮播图、课程展示、教练风采、公告资讯、在线预约入口。需要做到无需登录就能看到大部分内容,只有预约操作才强制认证。
- 会员中心:注册登录、个人信息维护、我的预约记录、取消预约、我的评价。
- 教练端(可选但强烈推荐):查看被预约的课程列表、维护自己的课程时间安排。这个模块能显著提升系统的业务完成度。
- 后台管理:仪表盘统计(会员数量、今日预约量)、课程管理、教练管理、会员管理、预约管理、评价管理、公告管理。权限上需要区分超级管理员和普通管理员,我只保留了简化版本的 admin 单角色。
一个初学者常犯的错是把「后台管理」和「前台展示」混在一套页面里做,搞得代码里全是 if 判断。前后端分离项目里,我推荐做两套前端页面:一套面向普通用户,一套面向管理员。这样目录清晰、代码容易维护,部署时也能分域名访问。虽然工作量会多一点儿,但长远看绝对值得。
1.2 为什么选择前后端分离 + SpringBoot + Vue3
选择这套技术组合,不是我拍脑袋决定的,是结合实际开发场景和招聘市场需求来的。SpringBoot 简化了 Spring 的配置,内嵌 Tomcat,一条命令就能启动;MyBatis 轻量、灵活,SQL 自己掌控,适合中小型项目也适合老团队迁移;Vue3 配合 Vite 构建极快,组合式 API 写业务代码比 Vue2 的 Options API 更清爽;MySQL 是大家最熟悉的数据库,部署简单,生态完善。
从开发模式上看,前后端分离意味着前端和后端可以并行开发,只需要事先约定好接口格式。我在项目里使用的统一返回结构是{ code, message, data },前端根据 code 判断业务状态,这样即使后端某个接口临时调整字段,前端也只需要改对应 api 文件。相比传统的 Thymeleaf 模板渲染,这种分离模式对团队协作和后期多端复用(比如以后要做微信小程序)都更友好。
很多人担心 SpringBoot + Vue3 的组合会带来版本兼容问题。我实测下来,只要 SpringBoot 选 2.7.x 或 3.x,Vue3 用官方脚手架创建,别乱升级依赖,整体是非常顺利的。后面我会专门讲几个容易踩坑的地方,比如 SpringBoot 3.x javax 改 jakarta 这类问题。
1.3 数据库设计与 MySQL 选型
MySQL 是这个系统的核心存储。设计表之前先理清关系:一个教练可以带多门课程,一个课程可以有多个时间段(排课),一个会员可以预约多个课程,一个课程可以被多个会员预约。这就形成了教练表、课程表、时间表、会员表、预约表的五核心表结构。
实际建表我建议按照最小可用原则来,先把用户角色打通,再逐步增加业务表。下面是我在建表时最终保留的核心表结构,可以直接参考:
| 表名 | 核心字段 | 用途 |
|---|---|---|
| sys_user | id, username, password, role, nickname, avatar, phone, create_time | 统一用户表,角色区分管理员和会员 |
| coach | id, name, specialty, phone, photo, intro, user_id | 教练信息,关联用户表 |
| course | id, name, type, difficulty, cover, price, coach_id, intro | 课程信息,关联教练 |
| course_schedule | id, course_id, start_time, end_time, max_people, booked_count | 课程排期,记录具体上课时间 |
| booking | id, member_id, schedule_id, status, create_time | 预约记录,status 表示已预约/已取消/已完成 |
| announcement | id, title, content, cover, publish_time | 公告资讯 |
| evaluation | id, member_id, course_id, score, content, create_time | 会员对课程的评价 |
我强烈建议把会员信息也放在 sys_user 表里,用 role 字段区分管理员和会员,避免多套用户名密码体系。如果你想更规范一点,可以单独建 member_profile 表存身高体重等会员扩展字段,但在我们这套系统里,sys_user 扩展几个字段就够了,别过度设计。
2. 核心细节解析与实操要点
2.1 后端基础架构与分层设计
后端项目我采用了标准的分层架构:Controller(接口层)→ Service(业务逻辑层)→ Mapper(数据访问层)→ Entity(实体类)。同时用 DTO(数据传输对象)来做参数校验,避免直接把 Entity 返回给前端,防止密码等敏感字段泄露。
这里分享几点我在项目里坚持的原则:
- Controller 只负责接收参数、调用 Service、返回 Result,不在 Controller 里写业务逻辑。
- Service 层写事务逻辑,比如预约课程时要同时更新预约表和 course_schedule 的 booked_count 字段,必须加
@Transactional保证原子性。 - Mapper 层只做数据读写,SQL 尽量写严谨,多表查询可以用 @Select 注解甚至 XML,但项目跑通后建议统一放到 XML 里便于维护。
以预约课程为例,Service 层的核心伪代码如下:
@Service public class BookingService { @Transactional public Result createBooking(BookingDTO dto) { // 1. 校验会员是否存在 SysUser member = userMapper.selectById(dto.getMemberId()); if (member == null) { return Result.error("会员不存在"); } // 2. 校验排期是否还有名额 CourseSchedule schedule = scheduleMapper.selectById(dto.getScheduleId()); if (schedule.getBookedCount() >= schedule.getMaxPeople()) { return Result.error("名额已满"); } // 3. 防止重复预约 int count = bookingMapper.checkExist(dto.getMemberId(), dto.getScheduleId()); if (count > 0) { return Result.error("请勿重复预约"); } // 4. 插入预约记录,并原子更新已预约人数 bookingMapper.insert(dto); scheduleMapper.incrBookedCount(dto.getScheduleId()); return Result.success(); } }注意:这里的「防止重复预约」用一个 select + insert 并不是绝对安全的,并发时可能出现超卖。进阶方案是给 booking 表加唯一约束(member_id, schedule_id),或者在 update 时判断结果影响行数。我在小系统里先用最简单的方式,然后在常见问题部分讲了并发扩展思路,真上线时需要再补一层 Redis 锁或者数据库乐观锁。
2.2 接口设计与权限控制
接口设计要遵循 Restful 风格,比如:
POST /api/auth/login登录GET /api/course/list课程列表POST /api/booking新增预约DELETE /api/booking/{id}取消预约GET /api/admin/course/list后台课程列表
权限控制我选用 JWT + Spring Security,实现思路是:登录成功后签发 token,前端把 token 存到 localStorage,每次请求在 header 里带上Authorization: Bearer <token>,后端通过拦截器解析 token 并确定用户身份和角色。
JWT 比传统的 Session 更适合前后端分离,因为后端不存状态,服务任意扩展。但我得提醒一句:JWT 无法主动失效,如果用户注销登录或密码修改,之前的 token 依然有效,除非你再做一层 Redis 黑名单。我这个项目里把 token 有效期设短一点(比如 2 小时),并配合前端路由守卫在 token 过期后跳转登录页。
下面是一个简单的 Spring Security 配置类的关键点:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeRequests() .antMatchers("/api/auth/**", "/api/course/**", "/api/coach/**").permitAll() .anyRequest().authenticated(); return http.build(); } }不过在实际业务中只靠 Spring Security 的路径拦截还不够。比如管理员删除课程,必须判断当前登录用户的角色是不是 admin。这个判断我放在自定义注解@PreAuthorize("hasRole('ADMIN')")里,直接在 Controller 方法上标注,比写一堆 if 要优雅得多。前端也要配合做按钮权限,否则用户直接在控制台发请求照样能越权,这一点务必记住。
2.3 前端 Vue3 项目搭建与组件化
前端部分我选择 Vite 构建,Vue3 使用<script setup>组合式 API,状态管理直接用 Pinia,路由用 Vue Router,UI 组件库用 Element Plus。这套组合是目前最顺手的,没有之一。
前端目录结构我这样划分:
src/ |-- api/ # axios接口封装 |-- assets/ # 静态资源 |-- components/ # 公共组件 |-- router/ # 路由配置 |-- store/ # pinia状态管理 |-- views/ | |-- front/ # 前台门户页面 | |-- admin/ # 后台管理页面 |-- App.vue |-- main.jsaxios 封装是每个 Vue3 项目必须做的基础工作,我在项目里建了一个request.js,统一处理 baseURL、token 注入、错误码拦截:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:注入token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一处理code request.interceptors.response.use( response => { const res = response.data if (res.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject(new Error('未登录')) } return res }, error => { ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } ) export default request组件化开发上,前台页面我把课程卡片、教练卡片、公告列表都抽取成了组件,因为这些内容在首页、列表页、详情页都会复用。后台页面我把搜索表单和表格封装在一起,比如 MemberTable、CourseTable,只传入列表接口和操作事件。这套组件化思路可以大幅减少复制粘贴,后期有需求变更只需要改组件内部逻辑。
3. 实操过程与核心环节实现
3.1 从零搭建 SpringBoot 后端并集成 MyBatis
后端搭建我强烈建议直接去 start.spring.io 初始化项目,而不是自己手动建 Maven 工程。选好 Group、Artifact,依赖勾选 Spring Web、MySQL Driver、MyBatis Framework、Lombok、Spring Security、JWT 相关库可以后面手动加。
maven 依赖核心部分如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>注意 MyBatis 和 SpringBoot 的兼容版本。如果 SpringBoot 是 3.x,建议mybatis-spring-boot-starter用 3.0.3 以上,否则会启动报错。如果 SpringBoot 2.7.x,用 2.3.2 没问题。
application.yml配置数据源和 MyBatis:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/fitness_club?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl mapper-locations: classpath:mapper/*.xmlmap-underscore-to-camel-case绝对是必开配置,否则数据库字段create_time无法自动映射到createTime,你会被一堆 null 字段逼疯。log-impl建议在开发环境打开,方便查看 SQL 语句。
编写一个最简单的用户查询接口测试:实体类 SysUser、Mapper 接口、XML 文件,然后写 Controller 调用。启动项目后访问localhost:8080/api/auth/login能看到 JSON 返回,这说明后端基本跑通了。然后你再慢慢往里面加课程、教练、预约接口即可。
3.2 前端页面构建与交互
前端构建从 Vite 初始化开始。命令行执行npm create vite@latest front-end -- --template vue,然后按需安装依赖:
npm install npm install vue-router@4 npm install pinia npm install element-plus npm install axios这里注意 Element Plus 有两种引入方式:全量引入和按需自动引入。小项目推荐全量引入省心,大项目追求打包体积建议用 unplugin-vue-components 按需引入。我为了演示方便,直接全量引入:
import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' app.use(ElementPlus)Vue Router 配置时,需要区分前台和后台。前台页面在/下的子路由,比如/course、/coach、/booking;后台页面统一挂在/admin下面。通过路由守卫判断是否需要登录:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })前台首页调后端接口展示课程列表,只需要在 onMounted 中调用 api 函数:
<script setup> import { ref, onMounted } from 'vue' import { getCourseList } from '@/api/course' const courseList = ref([]) onMounted(async () => { const res = await getCourseList({ page: 1, limit: 8 }) if (res.code === 200) { courseList.value = res.data.records } }) </script>这里需要注意接口返回的结构约定。我后端统一返回{ code, message, data },前端判断code === 200代表成功。Data 里如果是分页数据,我用 PageResult 包装成{ total, records }。这个结构要在前后端联调之前就定好,不然两边各改各的,最后过程会很痛苦。
预约功能是前端交互的重头戏。课程排期卡片上显示时间、剩余名额,点击预约按钮后如果未登录就跳转登录页;已登录则直接提交预约请求,成功后刷新剩余名额。为了体验更好,我给按钮加了 loading 状态和禁用逻辑,这些交互细节是区分「能跑」和「好用」的关键。
3.3 前后端联调、跨域与部署
联调阶段第一关是跨域。开发环境下,Vite 默认跑在 5173 端口,SpringBoot 跑在 8080 端口,直接请求会被浏览器拦截 CORS。解决办法不是在后端加@CrossOrigin,而是在 Vite 配置 proxy 代理:
// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }配置完成后,前端请求/api/course/list实际会被代理转发到localhost:8080/api/course/list,从浏览器视角来看是同源的,完美绕过跨域问题。千万要记住,开发环境用 proxy 是为了模拟生产环境,生产环境应该用 Nginx 做反向代理实现同样的效果,而不是在前端代码里写死后端地址。
生产部署我的习惯是把后端打 jar 包,前端npm run build生成 dist 目录,然后用 Nginx 同时托管前端静态文件和转发后端接口:
server { listen 80; server_name fitness-club.example.com; location / { root /opt/fitness/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files这一行很重要,因为 Vue3 是 history 路由模式,刷新页面时 Nginx 需要把所有路径都回退到 index.html,否则就会出现 404。我见过太多项目部署后点击刷新白屏,基本都是这个配置没写。
数据库初始化脚本我是用spring.sql.init.mode=always配合schema.sql自动建表,但生产环境不建议这么干,应该手动执行 SQL 脚本。模拟数据可以写一个data.sql,开发环境自动注入一些课程和教练信息,方便前端调试。
4. 常见问题与排查技巧实录
4.1 MySQL 连接报错与时区问题
用 MySQL 8.x 的同学经常会遇到连接报错,最常见的两个:
Public Key Retrieval is not allowed:需要在 JDBC url 后面加allowPublicKeyRetrieval=true。The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized:这是因为 MySQL 时区没正确设置,url 上加serverTimezone=Asia/Shanghai就能解决。
我遇到过的另一个坑是驱动类问题。MySQL 8 以上驱动类是com.mysql.cj.jdbc.Driver,而不是旧版的com.mysql.jdbc.Driver。如果你用旧驱动,启动就会直接报错。建议统一用mysql-connector-j。
4.2 MyBatis 字段映射失败与 SQL 错误
如果你发现查询返回的对象有些字段是 null,但数据库里明明有值,第一检查是否开启了 map-underscore-to-camel-case。第二检查 XML 文件里的 resultMap 是否正确。我习惯的做法是不要手写 resultMap,直接用resultType配合驼峰自动映射,省心又不容易错。
还有一个高频问题:MyBatis 的<符号在 XML 里会被当成标签开始,比如select * from course where price < 100会解析报错。解决办法是使用<,或者把整段 SQL 用<![CDATA[ ]]>包住。这是一个新手必踩的坑。
4.3 Vue3 项目请求失败与跨域踩坑
前端列表加载不出来,打开控制台看到 CORS 报错,说明代理没生效。最常见原因是请求地址里写死了http://localhost:8080,而不是写/api开头走代理。记住:开发环境所有请求都要走相对路径/api,由 Vite 代理,不要在 axios 里写死 target 地址。
另一个坑是 axios 封装后响应的结构变了。我见过有人把response当 data 用,结果一直取不到字段值,然后又开始怀疑后端。我的建议是在响应拦截器里直接返回response.data,这样前端拿到的是{ code, message, data }而不是 AxiosResponse 对象,逻辑更干净。
4.4 SpringSecurity JWT 失效与 token 过期处理
前端登录后跳转页面,但一会儿后请求开始报 401。检查原因大概率是 token 过期了,因为我刚才设置的 JWT 有效期只有 2 小时。解决方案有两个:一是前端在响应拦截器遇到 401 时清除 token 并跳转登录页;二是实现 refresh_token 刷新机制。
如果只是学习项目,做个简单的 401 跳转就够了。真正的企业项目会引入 refresh_token,该 token 的有效期更长,比如 7 天,access_token 过期后自动用 refresh_token 换取新的。这个机制涉及前后端两个端点的配合,建议后续单独安排一次优化迭代。
最后一个我踩过的坑是:登录用户有权限,但访问后台接口仍返回 403。排查发现是因为 Spring Security 的user.getAuthorities()没有添加ROLE_ADMIN前缀,而hasRole('ADMIN')内部会检查ROLE_ADMIN。所以在构建 UserDetails 时,简单句用"ROLE_" + role来拼接,这个问题就能解决。
我在实际梳理这套源码时最大的体会是:技术栈本身没有太多高级技巧,真正的难点在于把业务模块拆得不重不漏、把接口约定定好、把权限控制做严谨,以及把前端组件边界划清楚。如果你能顺着这篇文章的思路把课程、教练、会员、预约、公告几个模块完整做出来,那你对 SpringBoot + Vue3 的工程化理解绝对会上一个台阶。后续如果你想给这个系统加个微信小程序端,或者接个支付功能做在线购课,本文的前后端分离架构也能让你很轻松地扩展。