简介:这是一套面向计算机相关专业学生与Java学习者的健身房管理系统毕业设计项目,采用SpringBoot与Vue前后端分离架构,后端整合MyBatis,前端基于Vue.js构建,可直接用于毕设、课程设计或期末大作业。项目已通过导师指导并高分通过,配套源码、数据库脚本、开发说明文档、部署与代码讲解视频及全套软件,环境基于JDK1.8、MySQL5.7、Maven3.3,支持Eclipse或IDEA开发。压缩包共767个文件,约22.29MB,涵盖197个Java后端源码、139个Vue前端组件、51个JavaScript脚本、25个XML配置、15个CSS样式及SQL脚本、说明文档与构建批处理文件等,结构完整、层次清晰。目前已有58人学习下载。读者可据此快速搭建运行环境,理解前后端分离的接口设计与业务实现,掌握会员、课程、器材等模块的开发思路,并借助讲解视频与文档完成调试与二次开发,为答辩与项目实战提供完整参考。
1. 健身房管理系统:从会员办卡到私教排课的完整业务闭环
很多同学做毕业设计时,一上来就急着搭环境、拉框架,结果写到一半发现业务逻辑根本串不起来,最后只能堆一堆增删改查凑字数。健身房管理系统这个题目看着简单,但它其实是一个典型的「多角色 + 多状态 + 时间维度」业务场景:前台要办卡续卡,会员要预约团课,私教要排自己的时间表,财务要看到账流水,店长要看月底报表。如果你只把它当成几张表的 CRUD,答辩时老师随便问一句「会员卡过期后预约记录怎么处理」就能把你问住。
这篇笔记面向正在做 Java 毕业设计、选了 SpringBoot + Vue 方向的同学,也适合想拿一个完整业务系统练手的中级开发者。我会按真实落地的顺序,把技术选型理由、数据库设计、后端接口分层、前端页面组织、联调排错这几个环节拆开讲,中间给到可以直接抄的代码和配置。读完你应该能独立跑通一个具备会员管理、课程预约、私教排课、订单支付模拟四大模块的系统,并且知道哪些地方最容易翻车。
2. 技术选型与工程骨架:为什么是 SpringBoot + Vue 而不是别的
2.1 后端选 SpringBoot 的三个现实理由
毕业设计的时间窗口通常只有 6 到 10 周,其中还要留出写论文和准备答辩的时间。后端框架的选择标准不是「哪个最先进」,而是「哪个能让我在两周内把接口全部跑通」。SpringBoot 在这个场景下的优势非常具体:
第一,自动配置省掉了大量 XML。传统 SSM 要配 web.xml、spring-mvc.xml、mybatis-config.xml,光是让一个接口返回 JSON 就要折腾半天。SpringBoot 引入spring-boot-starter-web之后,一个@RestController就能直接对外提供服务。
第二,起步依赖把版本冲突问题压到最低。毕业设计最怕的就是 jar 包打架,SpringBoot 的 parent POM 统一管理了常用库的版本,你只需要在pom.xml里声明spring-boot-starter-web、spring-boot-starter-jdbc、mybatis-spring-boot-starter这几个坐标,Maven 会自动拉取兼容版本。
第三,内嵌 Tomcat 让部署变成一条命令。写完直接java -jar就能跑,不需要单独装 Tomcat 再配 war 包。对于要在自己笔记本上演示的毕业设计来说,这一点非常实用。
注意:SpringBoot 3.x 要求 JDK 17 起步,如果你学校机房还在用 JDK 8,建议锁死在 SpringBoot 2.7.x 版本,避免答辩现场环境跑不起来。
2.2 前端选 Vue 而不是 Thymeleaf 的考量
很多同学会问:后端渲染用 Thymeleaf 不是更简单吗?为什么还要前后端分离?这里有一个很实际的判断标准——你的系统有没有大量局部刷新和交互反馈。健身房管理系统里,会员列表要支持搜索、分页、状态筛选,课程预约要实时显示剩余名额,私教排课要用日历控件拖拽。这些用 Thymeleaf 做会非常别扭,每次操作都要整页刷新,体验很差。
Vue 3 配合 Element Plus 组件库,表格、分页、日期选择器、弹窗表单都是现成的。你只需要关注数据怎么从后端拿、怎么绑定到组件上。而且前后端分离之后,前端可以独立部署,答辩时你可以先把前端跑在localhost:5173,后端跑在localhost:8080,通过 Vite 的代理解决跨域,整个演示流程很清晰。
2.3 项目骨架搭建的具体命令
下面是从零创建工程的步骤。后端用 Maven 构建,前端用 Vite 初始化。
# 后端:创建 SpringBoot 工程(也可以用 start.spring.io 生成后导入) mkdir gym-backend && cd gym-backend # 假设你已经装好 Maven 和 JDK 8/17 # 手动创建 pom.xml 后执行依赖拉取 mvn clean install -DskipTests # 前端:用 Vite 创建 Vue 3 项目 npm create vite@latest gym-frontend -- --template vue cd gym-frontend npm install npm install element-plus axios vue-router pinia npm run dev后端pom.xml的核心依赖如下,注意版本号根据你的 JDK 调整:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <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.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency> </dependencies>spring-boot-starter-web提供 MVC 和 Tomcat,mybatis-spring-boot-starter让 MyBatis 的 SqlSessionFactory 自动注入,pagehelper用来做分页查询,避免手写limit偏移量。MySQL 驱动版本要和你的数据库服务端匹配,8.0 的驱动连 5.7 的库需要加useSSL=false&serverTimezone=Asia/Shanghai参数。
前端vite.config.js里配置代理,解决开发阶段的跨域:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })这段配置的意思是:前端发往/api/xxx的请求会被转发到http://localhost:8080/xxx。changeOrigin设为 true 是为了让后端收到的 Host 头是目标地址,避免某些安全校验拦截。这样你在前端代码里写axios.get('/api/member/list')就能直接命中后端接口,不需要在每个请求里写完整域名。
3. 数据库设计与核心表结构:会员卡状态机是灵魂
3.1 五张核心表及其关系
健身房管理系统的表不用多,但字段要想清楚。我一般会建这几张核心表:member(会员)、membership_card(会员卡)、course(课程)、course_schedule(排课)、booking(预约记录)。另外加一张coach(私教)和order(订单)表。
会员和会员卡是一对多关系,一个会员可以办多张卡(年卡、季卡、私教课包)。课程和排课是一对多,一门课可以排多个时间段。预约记录关联会员和排课,同时要记录预约状态(已预约、已取消、已完成)。
建表时有一个关键决策:会员卡的有效期是存在卡表里还是会员表里?我的做法是存在卡表里,因为会员可能同时持有不同类型的卡,每张卡的有效期独立计算。会员表只存基本信息,卡表存start_date、end_date、card_type、status。
CREATE TABLE `member` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL, `phone` VARCHAR(20) UNIQUE NOT NULL, `gender` TINYINT DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE `membership_card` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `member_id` BIGINT NOT NULL, `card_type` VARCHAR(20) NOT NULL COMMENT '年卡/季卡/次卡', `start_date` DATE NOT NULL, `end_date` DATE NOT NULL, `status` TINYINT DEFAULT 1 COMMENT '1有效 0过期 2冻结', `remaining_times` INT DEFAULT 0 COMMENT '次卡剩余次数', INDEX `idx_member` (`member_id`) ); CREATE TABLE `course_schedule` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `course_id` BIGINT NOT NULL, `coach_id` BIGINT NOT NULL, `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `capacity` INT DEFAULT 20, `booked_count` INT DEFAULT 0, INDEX `idx_time` (`start_time`) ); 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已完成', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_member_schedule` (`member_id`, `schedule_id`) );booking表上的唯一索引uk_member_schedule很重要,它从数据库层面防止同一个会员重复预约同一节课。course_schedule里的booked_count是冗余字段,用来快速判断是否满员,避免每次预约都去 count 一遍 booking 表。
3.2 会员卡状态流转与定时任务
会员卡的状态不是静态的。一张年卡从办卡那天起就是「有效」,到了end_date之后应该自动变成「过期」。如果你只在查询时用WHERE end_date > NOW()来判断,那卡表里的status字段就永远不准,后续做统计报表时会出问题。
常见做法是加一个 Spring 的定时任务,每天凌晨跑一次,把过期的卡状态刷一遍:
@Component public class CardStatusJob { @Autowired private MembershipCardMapper cardMapper; // 每天凌晨2点执行 @Scheduled(cron = "0 0 2 * * ?") public void refreshExpiredCards() { int updated = cardMapper.updateExpiredCards(); System.out.println("过期卡刷新完成,影响行数:" + updated); } }对应的 Mapper XML:
<update id="updateExpiredCards"> UPDATE membership_card SET status = 0 WHERE status = 1 AND end_date < CURDATE() </update>这里用CURDATE()而不是NOW(),因为卡的有效期是按天算的,当天办卡当天有效,到end_date当天 23:59:59 之前都算有效。status = 1的条件避免重复更新已经过期的记录。定时任务需要在启动类上加@EnableScheduling注解才会生效。
提示:如果你不想引入定时任务,也可以在每次查询会员卡时动态计算状态,但这样做的代价是统计报表的 SQL 会变得很复杂,而且无法处理「冻结」这种需要人工干预的状态。
4. 后端接口分层与核心业务实现:预约和排课的并发问题
4.1 Controller-Service-Mapper 三层职责划分
SpringBoot 项目里最常见的分层是 Controller 接参、Service 写逻辑、Mapper 做持久化。但很多同学写着写着就把业务逻辑塞进 Controller,导致 Service 层变成空壳。一个判断标准是:如果这段代码涉及多个表的写操作,或者需要事务控制,它就应该在 Service 层。
以课程预约为例子。Controller 只负责接收前端传来的memberId和scheduleId,然后调用 Service:
@RestController @RequestMapping("/booking") public class BookingController { @Autowired private BookingService bookingService; @PostMapping("/create") public Result create(@RequestBody BookingDTO dto) { // 参数校验交给 Service,Controller 只做转发 return bookingService.book(dto.getMemberId(), dto.getScheduleId()); } }Service 层要处理的事情包括:检查会员卡是否有效、检查排课是否还有名额、检查是否重复预约、扣减名额、写入预约记录。这些操作必须在同一个事务里完成。
@Service public class BookingService { @Autowired private MembershipCardMapper cardMapper; @Autowired private CourseScheduleMapper scheduleMapper; @Autowired private BookingMapper bookingMapper; @Transactional(rollbackFor = Exception.class) public Result book(Long memberId, Long scheduleId) { // 1. 校验会员卡 MembershipCard card = cardMapper.findValidCardByMember(memberId); if (card == null) { return Result.fail("会员卡已过期或不存在"); } // 2. 校验排课名额(用行锁避免超卖) CourseSchedule schedule = scheduleMapper.selectForUpdate(scheduleId); if (schedule == null) { return Result.fail("排课不存在"); } if (schedule.getBookedCount() >= schedule.getCapacity()) { return Result.fail("该课程已满员"); } // 3. 检查重复预约 int exists = bookingMapper.countByMemberAndSchedule(memberId, scheduleId); if (exists > 0) { return Result.fail("您已预约过该课程"); } // 4. 扣减名额并写入预约 scheduleMapper.incrementBookedCount(scheduleId); bookingMapper.insert(memberId, scheduleId); return Result.success("预约成功"); } }selectForUpdate对应的 SQL 是SELECT * FROM course_schedule WHERE id = ? FOR UPDATE,它会在事务期间锁住这一行,防止两个请求同时读到相同的booked_count然后都执行加一操作。这是解决超卖问题的经典做法。
4.2 私教排课的时间冲突检测
私教排课比会员预约更容易出问题,因为同一个教练在同一时间段不能出现在两个地方。排课接口需要检查时间重叠:
public boolean hasTimeConflict(Long coachId, Date start, Date end) { // 查询该教练在指定时间段内是否已有排课 int count = scheduleMapper.countByCoachAndTime(coachId, start, end); return count > 0; }对应的 SQL 用start_time < #{end} AND end_time > #{start}来判断重叠。这个条件的意思是:已有排课的结束时间晚于新排课的开始时间,且已有排课的开始时间早于新排课的结束时间。两个区间只要有交集就会命中。
注意:时间冲突检测必须在插入排课之前执行,而且最好和插入操作放在同一个事务里。否则两个管理员同时给同一个教练排课,可能都通过了检测然后都插入成功。
4.3 分页查询与 PageHelper 的正确用法
会员列表、课程列表、预约记录都需要分页。PageHelper 的用法很简单,但有一个坑:PageHelper.startPage()必须紧跟在查询语句之前,中间不能插入其他数据库操作。
public PageInfo<MemberVO> listMembers(int pageNum, int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); List<MemberVO> list = memberMapper.selectByKeyword(keyword); return new PageInfo<>(list); }PageInfo会自动计算总页数、总记录数、当前页数据。前端拿到之后直接绑定到 Element Plus 的el-pagination组件上。keyword参数在 Mapper XML 里用LIKE CONCAT('%', #{keyword}, '%')处理,注意不要用字符串拼接,防止 SQL 注入。
5. 前端页面组织与前后端联调:从登录到预约的完整链路
5.1 路由设计与权限控制
Vue 项目里,路由不只是页面跳转,还要承担权限过滤。健身房管理系统至少有两种角色:管理员和普通会员。管理员能看到所有会员信息、排课管理、财务报表;会员只能看到自己的卡、预约记录和可预约课程。
路由表里给每个路由加meta.role标记:
const routes = [ { path: '/login', component: Login }, { path: '/admin', component: AdminLayout, meta: { role: 'admin' }, children: [ { path: 'members', component: MemberList }, { path: 'schedules', component: ScheduleManage } ] }, { path: '/member', component: MemberLayout, meta: { role: 'member' }, children: [ { path: 'my-card', component: MyCard }, { path: 'booking', component: BookingList } ] } ]然后在全局前置守卫里判断当前登录用户的角色,如果和目标路由的meta.role不匹配就重定向到登录页或提示无权限。用户角色存在 Pinia 或 localStorage 里,登录成功后写入。
5.2 Axios 封装与统一错误处理
前端每个页面都写一遍axios.get会很冗余,而且错误处理不统一。我一般会封装一个 request 工具:
import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res.data }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } ) export default request这段代码做了两件事:一是把后端返回的{code, msg, data}结构拆开,业务代码直接拿data;二是统一弹出错误提示,避免每个页面都写一遍catch。baseURL设为/api之后,配合 Vite 的代理配置,开发阶段不需要写完整后端地址。
5.3 预约页面的交互细节
预约页面看起来简单,但有几个交互细节会影响答辩印象。第一,已满员的课程要置灰按钮,不能等用户点了才提示失败。第二,预约成功后要立即刷新剩余名额,不能等用户手动刷新页面。第三,取消预约要有二次确认,防止误操作。
这些用 Element Plus 的el-button的disabled属性和ElMessageBox.confirm就能实现。数据刷新用await fetchData()重新拉取列表,Vue 的响应式会自动更新视图。
6. 避坑与排查:那些答辩前夜让你睡不着的问题
6.1 跨域配置在开发环境生效但打包后失效
现象:本地开发时前端 5173 端口请求后端 8080 一切正常,npm run build之后部署到 Nginx 或者直接打开 HTML 文件,所有接口报 404 或跨域错误。
原因:Vite 的server.proxy只在开发服务器生效,打包后的静态文件是直接由浏览器发请求,没有代理层。如果后端没有配置全局 CORS,浏览器会拦截。
解决:在后端加一个全局 CORS 配置类,允许前端域名访问。如果前端和后端部署在同一个域名下(比如都用 Nginx 转发),则不需要 CORS,但要确保 Nginx 的location /api正确转发到后端端口。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true) .maxAge(3600); } }6.2 事务不生效导致预约名额扣减后回滚失败
现象:预约接口里先扣减booked_count,再插入 booking 记录。如果插入失败,理论上应该回滚,但实际发现booked_count还是被扣了。
原因:@Transactional默认只对RuntimeException回滚,如果 Mapper 抛的是SQLException(受检异常),事务不会回滚。另外,如果 Service 类没有被 Spring 代理(比如自己new出来的),事务注解也不会生效。
解决:在@Transactional里加rollbackFor = Exception.class,确保所有异常都触发回滚。同时检查 Service 是否通过@Autowired注入,而不是手动实例化。
6.3 前端日期格式与后端不一致导致查询为空
现象:前端用el-date-picker选了一个日期范围,传给后端的格式是2024-01-01T00:00:00.000Z,后端用DATE类型字段去匹配,查不到任何数据。
原因:Element Plus 默认返回 ISO 8601 格式的日期字符串,带时区和毫秒。MySQL 的DATE或DATETIME字段无法直接匹配这种格式。
解决:在el-date-picker上加value-format="YYYY-MM-DD"属性,让前端直接输出2024-01-01这种格式。如果后端字段是DATETIME,则用value-format="YYYY-MM-DD HH:mm:ss"。
6.4 MyBatis 驼峰映射未开启导致字段为 null
现象:数据库字段是created_at,实体类属性是createdAt,查询结果里createdAt始终是 null。
原因:MyBatis 默认不开启驼峰命名映射,created_at和createdAt对不上。
解决:在application.yml里加配置mybatis.configuration.map-underscore-to-camel-case: true。或者在 Mapper XML 里手动写AS createdAt别名。推荐用全局配置,一劳永逸。
6.5 打包后静态资源 404
现象:npm run build生成的dist目录部署到 SpringBoot 的static文件夹下,访问首页白屏,控制台报 JS 和 CSS 文件 404。
原因:Vite 默认的base是/,打包后的 HTML 里引用资源用的是绝对路径/assets/xxx.js。如果应用不是部署在根路径下,就会找不到。
解决:在vite.config.js里设置base: './',让资源引用变成相对路径。或者确保 SpringBoot 的静态资源映射正确,dist里的文件放在src/main/resources/static下,访问时用http://localhost:8080/index.html。
7. 进阶技巧:用 AOP 做操作日志和接口耗时监控
毕业设计如果只完成增删改查,答辩时很难拿到高分。加一个操作日志功能,既能体现技术深度,又不会增加太多工作量。思路是用 Spring AOP 拦截所有 Controller 的写操作,把操作人、操作类型、请求参数、执行耗时记到数据库里。
先定义一个自定义注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OpLog { String value() default ""; // 操作描述 }然后在需要记录的方法上标注:
@OpLog("新增会员") @PostMapping("/member/add") public Result addMember(@RequestBody Member member) { return memberService.add(member); }切面类里环绕通知,计算方法执行时间并异步写入日志表:
@Aspect @Component public class OpLogAspect { @Autowired private OpLogMapper opLogMapper; @Around("@annotation(opLog)") public Object around(ProceedingJoinPoint joinPoint, OpLog opLog) throws Throwable { long start = System.currentTimeMillis(); Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; // 异步写入,避免影响主流程 new Thread(() -> { OpLogEntity entity = new OpLogEntity(); entity.setAction(opLog.value()); entity.setMethod(joinPoint.getSignature().toShortString()); entity.setCostMs(cost); entity.setCreatedAt(new Date()); opLogMapper.insert(entity); }).start(); return result; } }这段代码里joinPoint.proceed()执行原方法,前后取时间戳算耗时。日志写入放在新线程里,避免因为写日志拖慢接口响应。实际项目中更推荐用@Async注解配合线程池,但毕业设计里用new Thread也能说明你理解异步的思想。
日志表建好之后,前端加一个「系统日志」页面,管理员可以查看最近的操作记录和耗时。答辩时你可以现场演示:新增一个会员,然后刷新日志页面,看到刚才的操作被记录,耗时多少毫秒。这个演示效果比单纯展示 CRUD 页面要好得多。
还有一个可以加分的点是接口耗时统计。在切面里把耗时超过 500ms 的请求单独标红,或者用ConcurrentHashMap在内存里累计每个接口的调用次数和平均耗时,提供一个/monitor/stats接口给前端展示。这些都不复杂,但能让老师看到你在「完成功能」之外还考虑了「系统可观测性」。
我自己做这类系统时有一个习惯:每写完一个模块,先不急着写前端页面,而是用 Postman 或者 curl 把接口全部跑一遍,确认参数校验、异常返回、事务回滚都符合预期。这样等前端联调时,问题基本都出在跨域或参数格式上,不会出现「后端逻辑本身有 bug 但被前端掩盖」的情况。血泪经验就是,答辩前三天才发现事务不回滚,比前端样式错位要难修得多。希望帮到你。
本文还有配套的精品资源,点击获取