☰
基于SpringBoot+Vue健身房管理系统毕设:核心业务与避坑指南
2026/10/29 18:02:04 网站建设 项目流程

简介:这是一套面向计算机相关专业毕业设计与课程设计场景的健身房管理系统完整项目,采用SpringBoot+MyBatis后端与Vue前端的前后端分离架构,适合正在准备毕设的Java学习者以及需要项目实战练习的开发者,可直接作为期末大作业或毕设选题使用。压缩包共767个文件,约22.29MB,其中197个Java文件承载后端业务逻辑,139个Vue文件构成前端页面,另有51个JS脚本、25个XML配置、15个CSS样式及SQL脚本、说明文档、部署与讲解视频等配套内容,覆盖会员管理、课程预约、器材与教练管理等典型模块。项目已通过严格调试,确保可运行,并附有开发说明文档与代码讲解视频,便于理解整体结构与实现思路。目前已有58人学习关注,适合希望快速搭建可运行系统、对照源码梳理业务逻辑并完成毕设撰写的学习者参考。

1. 健身房管理系统这个选题,答辩时老师到底在看什么

很多同学拿到「Java基于SpringBoot+vue的健身房管理系统」这个题目,第一反应是去搜一套现成源码改改交差。我带过几届毕设答辩,见过太多这样的项目:功能列表写得满满当当,一问会员续卡逻辑怎么算、私教排课冲突怎么判,就答不上来了。老师真正想看的,不是你有多少个增删改查页面,而是你有没有把健身房这个业务场景里最核心的那几条数据流想清楚。

这个系统本质上解决三件事:会员从入会到续卡到过期提醒的生命周期管理、私教和团课两类课程的排期与预约、以及围绕会员卡和课程产生的收费记录。适合谁做?适合已经学过 Java 基础、SpringBoot 能跑起来一个 Hello World、Vue 能照着文档搭出页面的同学。如果你连 Maven 依赖冲突都没排查过,建议先把 SpringBoot 项目构建方法过一遍再动手。下面我按实际落地的顺序,把这个系统从建表到联调讲透。

2. 技术选型与工程骨架:为什么是 SpringBoot + Vue 而不是别的

2.1 前后端分离在毕设场景下的真实收益

很多同学纠结要不要用 JSP 或者 Thymeleaf 做服务端渲染,觉得那样简单。但健身房管理系统有个典型特征:前台会员端和后台管理端界面差异极大,会员端要展示课程表、预约按钮,管理端要处理表格、统计图表。如果用服务端渲染,两套页面的模板逻辑会搅在一起,改一处崩一处。

前后端分离之后,Vue 负责所有界面渲染和路由跳转,SpringBoot 只提供 JSON 接口。这样做还有个隐性好处:答辩演示时你可以同时开两个终端,一个跑后端 8080,一个跑前端 5173,老师一眼就能看出你理解分层架构。常见做法是用 axios 做请求封装,统一处理 token 和错误提示。

选型上,SpringBoot 版本不要追新。我一般会选 2.7.x 这个长期维护版本,因为网上大部分教程和依赖都验证过。热词里提到的「springboot版本太高」是真实痛点——3.x 之后 Jakarta EE 包名从 javax 变成 jakarta,很多老教程里的代码直接编译不过。Vue 这边用 Vue 3 + Vite 构建,比 Vue 2 + webpack 快得多,npm run dev基本秒起。

2.2 用 Maven 搭出可运行的后端骨架

先建一个标准的 Maven 项目,pom.xml 里锁定关键依赖版本。下面是我常用的最小依赖集:

<!-- pom.xml 关键依赖,版本锁定避免冲突 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <!-- 不要用 3.x,javax 包名会坑你 --> </parent> <dependencies> <!-- Web 接口层 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus 简化单表 CRUD,比原生 MyBatis 少写 XML --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- JWT 做登录态,比 Session 更适合前后端分离 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> </dependencies>

这段配置的逻辑是:parent 统一管理 Spring 生态版本,避免自己一个个填版本号填出冲突。MyBatis-Plus 的价值在于单表查询不用写 SQL,但涉及多表关联的统计(比如某教练本月课时费)还是得手写 XML,这点后面会讲。JWT 的 0.9.1 版本虽然老,但 API 简单稳定,适合毕设。

参数说明:spring-boot-starter-parent的版本决定了整个 Spring 生态的版本基线,改它等于改全局。MyBatis-Plus 的3.5.3.1和 SpringBoot 2.7.x 兼容性经过验证,不要随意升到 3.5.5 以上,有分页插件行为变更。

2.3 Vue 端工程初始化与路由规划

前端用 Vite 创建项目,然后装 axios、vue-router、element-plus 三个核心库:

# 创建 Vue3 项目,选择 Vue + JavaScript 模板 npm create vite@latest gym-frontend -- --template vue cd gym-frontend npm install # 路由和请求库 npm install vue-router@4 axios # UI 组件库,表格和表单直接用现成的 npm install element-plus

装完之后在src/router/index.js里规划路由。健身房系统的路由分两组:会员端(/member/courses、/member/my-bookings)和管理端(/admin/members、/admin/coaches)。用路由守卫判断 token 是否存在,没有就跳登录页。这里有个细节:Vue 动态路由可以在登录后根据角色(会员/管理员)动态添加路由表,避免会员手动输管理端地址就能进去。热词里的「vue动态路由」用在这里正合适。

提示:Vite 默认端口 5173,后端 8080,开发时在vite.config.js里配 proxy 把/api转发到后端,避免跨域报错。上线时用npm run build打包,把 dist 目录扔进 SpringBoot 的static文件夹,这就是热词里说的「vue打包放进springboot中」。

3. 数据库设计:会员卡、课程、预约三张核心表的字段怎么定

3.1 会员表与会员卡表的拆分逻辑

新手最容易犯的错是把会员信息和卡信息塞一张表。结果就是:一个会员续了三次卡,要么加三个字段,要么存三条重复的姓名电话。正确做法是拆成member(人)和member_card(卡)一对多。

-- 会员基本信息表 CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL COMMENT '姓名', phone VARCHAR(16) NOT NULL UNIQUE COMMENT '手机号,登录账号', password VARCHAR(128) NOT NULL COMMENT 'BCrypt 加密后的密码', gender TINYINT DEFAULT 0 COMMENT '0未知 1男 2女', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '会员表'; -- 会员卡表,一个会员可以有多张卡(续卡记录) CREATE TABLE member_card ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL COMMENT '关联 member.id', card_type TINYINT NOT NULL COMMENT '1月卡 2季卡 3年卡 4次卡', start_date DATE NOT NULL COMMENT '生效日期', end_date DATE NOT NULL COMMENT '到期日期', remaining_times INT DEFAULT 0 COMMENT '次卡剩余次数,期限卡为0', status TINYINT DEFAULT 1 COMMENT '1有效 0过期 2冻结', INDEX idx_member (member_id), INDEX idx_end_date (end_date) -- 用于定时任务扫描即将过期的卡 ) COMMENT '会员卡表';

拆表之后,判断会员当前是否有效,就是查member_card里status=1 AND end_date >= CURDATE()的记录。续卡就是插一条新记录,历史记录天然保留。idx_end_date索引是给定时任务用的——每天凌晨扫一遍三天内到期的卡,发提醒。这个细节答辩时提一句,老师会觉得你考虑到了实际运营。

参数说明:card_type用 TINYINT 而不是 VARCHAR 存「月卡」这种中文,是为了查询效率,前端用字典映射显示。remaining_times只对次卡有意义,期限卡填 0,逻辑上在 Service 层根据 card_type 分支处理。

3.2 课程表与预约表的冲突检测

课程分两类:团课(多人,有容量上限)和私教(一对一)。用一张course表加course_type区分,排课信息单独放course_schedule。

-- 课程定义表 CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT '课程名,如动感单车', course_type TINYINT NOT NULL COMMENT '1团课 2私教', coach_id BIGINT NOT NULL COMMENT '授课教练', duration INT DEFAULT 60 COMMENT '时长(分钟)', capacity INT DEFAULT 1 COMMENT '容量,私教为1' ) COMMENT '课程表'; -- 排课表,具体到某天某时段 CREATE TABLE course_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, schedule_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, booked_count INT DEFAULT 0 COMMENT '已预约人数', UNIQUE KEY uk_coach_time (course_id, schedule_date, start_time) ) COMMENT '排课表'; -- 预约记录表 CREATE TABLE booking ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, status TINYINT DEFAULT 1 COMMENT '1已约 0已取消 2已完成', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_member_schedule (member_id, schedule_id) -- 防止重复预约 ) COMMENT '预约表';

这里的关键设计是uk_member_schedule唯一索引,从数据库层面杜绝同一个会员对同一节课重复点预约按钮。很多同学只在代码里if (已存在) return,并发下照样插进去两条。加唯一索引后,插入冲突会抛异常,Service 层捕获转成友好提示即可。

排课冲突检测靠uk_coach_time,同一个教练同一时段只能排一节课。但注意这个唯一键是course_id + date + start_time,如果两个不同课程恰好同一教练同一时间,course_id 不同就绕过了。更严谨的做法是在 Service 层查:SELECT COUNT(*) FROM course_schedule cs JOIN course c ON cs.course_id=c.id WHERE c.coach_id=? AND cs.schedule_date=? AND (cs.start_time < ? AND cs.end_time > ?),用时间区间重叠判断。

3.3 教练表与收费记录表的关联

教练表存基本信息和擅长领域,收费记录表记录每一笔会员付款,关联到会员和卡或课程。

CREATE TABLE coach ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, phone VARCHAR(16), specialty VARCHAR(128) COMMENT '擅长领域,逗号分隔', hourly_rate DECIMAL(10,2) COMMENT '私教课时费' ) COMMENT '教练表'; CREATE TABLE payment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_type TINYINT COMMENT '1办卡 2续卡 3私教课', related_id BIGINT COMMENT '关联的卡ID或排课ID', pay_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '收费记录表';

related_id是个多态外键,根据pay_type指向不同表。这种设计在毕设里够用,但要在文档里说明它没有数据库级外键约束,靠应用层保证一致性。答辩时如果老师问起,就答「为了灵活性和避免级联删除的复杂性」。

4. 后端接口实现:登录鉴权、预约、到期提醒三条主线

4.1 JWT 登录与角色拦截器

登录接口接收手机号和密码,校验通过后签发 token,payload 里放 memberId 和 role。

// JwtUtil.java 核心方法 public class JwtUtil { private static final String SECRET = "gym-system-secret-key-2024"; // 实际项目放配置文件 private static final long EXPIRE = 7 * 24 * 3600 * 1000L; // 7天 public static String generateToken(Long userId, String role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) // "MEMBER" 或 "ADMIN" .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }

逻辑说明:generateToken把用户 ID 和角色写进 token,前端每次请求在 header 里带Authorization: Bearer xxx。后端写一个HandlerInterceptor,在preHandle里解析 token,把 Claims 塞进ThreadLocal,Controller 里直接取当前用户。参数上,EXPIRE设 7 天是因为毕设演示周期短,实际生产会短很多并配刷新机制。

拦截器注册时要注意放行登录接口和静态资源:

// WebConfig.java @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login", "/api/auth/register"); }

4.2 预约接口的并发控制

预约是毕设里最能体现技术含量的点。核心逻辑:检查卡是否有效、检查是否重复预约、检查课程是否满员、扣减名额、插入记录。这五步必须在一个事务里,且名额扣减要用乐观锁或数据库行锁。

@Service public class BookingService { @Autowired private CourseScheduleMapper scheduleMapper; @Autowired private BookingMapper bookingMapper; @Autowired private MemberCardMapper cardMapper; @Transactional(rollbackFor = Exception.class) public void book(Long memberId, Long scheduleId) { // 1. 校验会员卡有效 MemberCard card = cardMapper.selectValidCard(memberId); if (card == null) throw new BizException("会员卡已过期,请先续卡"); // 2. 扣减名额,用 SQL 的 where 条件保证不超卖 int rows = scheduleMapper.decreaseIfAvailable(scheduleId); if (rows == 0) throw new BizException("该课程已满员"); // 3. 插入预约,唯一索引兜底防重复 try { Booking b = new Booking(); b.setMemberId(memberId); b.setScheduleId(scheduleId); bookingMapper.insert(b); } catch (DuplicateKeyException e) { throw new BizException("你已经预约过这节课了"); } } }

对应的 Mapper SQL:

<!-- decreaseIfAvailable:只有 booked_count < capacity 时才更新 --> <update id="decreaseIfAvailable"> UPDATE course_schedule SET booked_count = booked_count + 1 WHERE id = #{scheduleId} AND booked_count &lt; capacity </update>

这个WHERE booked_count < capacity就是乐观锁的精髓——把判断和更新合成一条原子 SQL,并发下不会超卖。如果返回影响行数为 0,说明满了。参数scheduleId是排课记录主键。注意@Transactional要配rollbackFor = Exception.class,否则 BizException 是运行时异常默认回滚,但受检异常不会,养成习惯写上。

4.3 会员卡到期提醒的定时任务

用 Spring 的@Scheduled每天凌晨扫一次三天内到期的卡,往notification表插提醒记录,会员登录后弹窗展示。

@Component public class CardExpireTask { @Autowired private MemberCardMapper cardMapper; @Autowired private NotificationMapper notificationMapper; // 每天凌晨2点执行 @Scheduled(cron = "0 0 2 * * ?") public void checkExpiringCards() { // 查3天内到期且状态有效的卡 List<MemberCard> cards = cardMapper.selectExpiringWithin(3); for (MemberCard card : cards) { Notification n = new Notification(); n.setMemberId(card.getMemberId()); n.setContent("您的会员卡将于" + card.getEndDate() + "到期,请及时续卡"); notificationMapper.insert(n); } } }

逻辑说明:selectExpiringWithin(3)对应 SQL 是WHERE end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 3 DAY) AND status = 1。cron 表达式0 0 2 * * ?表示每天 2:00:00。启动类上要加@EnableScheduling注解,否则任务不生效——这是新手最常漏的一步。参数 3 可以改成配置项@Value("${gym.expire-notify-days:3}"),方便调整。

5. 前后端联调与部署:从本地跑通到打包成一个 jar

5.1 跨域配置与 axios 封装

开发阶段前端 5173 调后端 8080,浏览器会拦跨域。两种解法:后端加@CrossOrigin,或者前端配 proxy。我一般两个都做,后端配全局 CORS 以防万一。

// CorsConfig.java @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") // 开发阶段放开,上线改具体域名 .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true) .maxAge(3600); } }

前端 axios 封装统一加 token 和错误处理:

// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' 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 }) // 响应拦截:统一处理业务错误 request.interceptors.response.use( res => res.data, err => { ElMessage.error(err.response?.data?.msg || '请求失败') return Promise.reject(err) } ) export default request

参数说明:baseURL: '/api'配合 Vite proxy,开发时转发到 8080,上线后因为前端打包进 SpringBoot,同源不需要代理。timeout: 10000是 10 秒,导出报表这类慢接口可以单独调大。

5.2 打包部署:把 Vue 塞进 SpringBoot

前端npm run build生成 dist 目录,把里面的文件复制到后端src/main/resources/static/下。然后mvn clean package打成一个 jar,java -jar xxx.jar就能跑。

# 前端打包 cd gym-frontend npm run build # 复制产物到后端静态目录 cp -r dist/* ../gym-backend/src/main/resources/static/ # 后端打包 cd ../gym-backend mvn clean package -DskipTests # 运行 java -jar target/gym-backend-1.0.jar

注意 Vue Router 要用 hash 模式(createWebHashHistory),否则刷新页面会 404,因为 SpringBoot 找不到对应的后端路由。这是「vue打包放进springboot中」最容易翻车的地方。如果非要用 history 模式,得在后端配一个 fallback 到 index.html 的控制器。

5.3 接口联调时用 Swagger 省时间

装springfox-boot-starter或knife4j,启动后访问/doc.html就能看到所有接口,直接在上面填参数测试,不用开 Postman。Controller 上加@ApiOperation("会员预约课程")这类注解,答辩演示时打开文档页,老师能直观看到你设计了哪些接口。

6. 避坑与排查:毕设答辩前最容易翻车的五个点

6.1 现象:登录后刷新页面就退出登录

原因:token 存在 Vuex/Pinia 里,刷新后内存清空。解决:登录成功后同时写localStorage,应用初始化时从localStorage读回并恢复状态。别只存内存。

6.2 现象:预约时提示成功但名额没减

原因:@Transactional没生效,常见于同类内部方法调用(this 调用不走代理)。解决:把扣减逻辑抽到另一个 Service,或者注入自身代理。检查启动类是否有@EnableTransactionManagement(SpringBoot 自动配了,但自定义数据源时可能丢)。

6.3 现象:MyBatis-Plus 分页查出来 total 为 0

原因:没配分页插件。解决:加一个配置类注册MybatisPlusInterceptor并添加PaginationInnerInterceptor。这是热词里「springboot配置」的高频坑,不配插件分页 SQL 不会自动加 limit。

6.4 现象:Vue 打包后接口 404

原因:axios 的 baseURL 写死了http://localhost:8080,上线后域名变了。解决:baseURL 用相对路径/api,靠同源或 nginx 转发。同时确认后端接口路径前缀和前端一致。

6.5 现象:日期字段返回一串时间戳

原因:Jackson 默认把Date序列化成毫秒数。解决:application.yml里配spring.jackson.date-format: yyyy-MM-dd HH:mm:ss和time-zone: GMT+8,或者在字段上加@JsonFormat注解。前端 element-plus 的日期组件绑定值也要注意格式转换。

7. 让答辩加分的一个技巧:用统计接口把数据讲成故事

功能做完只是及格,想让老师记住你,得有一个能体现业务理解的统计接口。我一般会加一个「教练课时统计」:给定月份,返回每个教练的团课节数、私教节数、总课时费。这个接口涉及三表关联加分组,能展示你会写复杂 SQL。

<!-- CoachStatMapper.xml --> <select id="statByMonth" resultType="CoachStatVO"> SELECT c.id AS coachId, c.name AS coachName, COUNT(CASE WHEN co.course_type = 1 THEN 1 END) AS groupCount, COUNT(CASE WHEN co.course_type = 2 THEN 1 END) AS privateCount, SUM(CASE WHEN co.course_type = 2 THEN c.hourly_rate ELSE 0 END) AS totalFee FROM coach c LEFT JOIN course co ON co.coach_id = c.id LEFT JOIN course_schedule cs ON cs.course_id = co.id AND DATE_FORMAT(cs.schedule_date, '%Y-%m') = #{month} GROUP BY c.id, c.name </select>

逻辑说明:LEFT JOIN保证没排课的教练也出现在结果里,课时数为 0。CASE WHEN在聚合里做条件计数,比子查询高效。#{month}传2024-06这种格式。参数month由前端日期选择器传入。

前端用 ECharts 画个柱状图,答辩时点一下「本月统计」,柱子上直接显示每个教练的课时费。老师问「这个数据怎么来的」,你就把三表关联的逻辑讲一遍,比干巴巴说「我做了增删改查」强太多。

我自己做这类系统时有个习惯:每写完一个模块,先不急着写下一个,而是把数据库里手动插几条脏数据——比如过期卡、满员课、重复预约——看系统会不会崩。这个习惯帮我提前发现了至少三个边界 bug。毕设时间紧,但花半天做边界测试,比答辩时被问倒强。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询