做健身俱乐部管理系统本质上就是管好「课程、教练、会员」这三条主线再挂上预约报名、公告发布、评价反馈这些副线。我最近在整理一套基于 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.xVue3 用官方脚手架创建别乱升级依赖整体是非常顺利的。后面我会专门讲几个容易踩坑的地方比如 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 RouterUI 组件库用 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency注意 MyBatis 和 SpringBoot 的兼容版本。如果 SpringBoot 是 3.x建议mybatis-spring-boot-starter用 3.0.3 以上否则会启动报错。如果 SpringBoot 2.7.x用 2.3.2 没问题。application.yml配置数据源和 MyBatisspring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/fitness_club?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 vitelatest front-end -- --template vue然后按需安装依赖npm install npm install vue-router4 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.modealways配合schema.sql自动建表但生产环境不建议这么干应该手动执行 SQL 脚本。模拟数据可以写一个data.sql开发环境自动注入一些课程和教练信息方便前端调试。4. 常见问题与排查技巧实录4.1 MySQL 连接报错与时区问题用 MySQL 8.x 的同学经常会遇到连接报错最常见的两个Public Key Retrieval is not allowed需要在 JDBC url 后面加allowPublicKeyRetrievaltrue。The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这是因为 MySQL 时区没正确设置url 上加serverTimezoneAsia/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 的工程化理解绝对会上一个台阶。后续如果你想给这个系统加个微信小程序端或者接个支付功能做在线购课本文的前后端分离架构也能让你很轻松地扩展。