☰
SpringBoot+Vue3健身俱乐部管理系统:前后端分离实战解析
2026/10/2 2:54:22 网站建设 项目流程

做健身俱乐部管理系统,本质上就是管好「课程、教练、会员」这三条主线,再挂上预约报名、公告发布、评价反馈这些副线。我最近在整理一套基于 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_userid, username, password, role, nickname, avatar, phone, create_time统一用户表,角色区分管理员和会员
coachid, name, specialty, phone, photo, intro, user_id教练信息,关联用户表
courseid, name, type, difficulty, cover, price, coach_id, intro课程信息,关联教练
course_scheduleid, course_id, start_time, end_time, max_people, booked_count课程排期,记录具体上课时间
bookingid, member_id, schedule_id, status, create_time预约记录,status 表示已预约/已取消/已完成
announcementid, title, content, cover, publish_time公告资讯
evaluationid, 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.js

axios 封装是每个 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/*.xml

map-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会解析报错。解决办法是使用&lt;,或者把整段 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 的工程化理解绝对会上一个台阶。后续如果你想给这个系统加个微信小程序端,或者接个支付功能做在线购课,本文的前后端分离架构也能让你很轻松地扩展。

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

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

立即咨询