简介:这份健身房管理系统项目源码包面向计算机相关专业的毕业设计、课程设计及大作业场景,尤其适合人工智能、计算机科学与技术等方向的学生使用。系统围绕健身房日常运营设计,覆盖会员、前台管理员、教练与管理者等多角色,包含会员注册、信息管理、课程安排、设备使用跟踪、财务报表与预约管理等模块,可帮助读者理解完整业务流程与软件工程实践。压缩包共1336个文件,约38.44MB,以js、css、html、jsp等前后端页面脚本,java源码与class、jar编译产物,以及png、jpg、gif等界面素材为主,另含md说明、xml与properties配置,结构完整便于按模块查阅。目前已有55人学习下载。源码经过测试可正常运行,读者可据此快速搭建环境、梳理数据库与前后端交互逻辑,并参考README获取使用说明,作为学习交流与项目实践的有效参考。
1. 健身房管理系统:从课设到毕设,一套能跑通的最小闭环
很多同学拿到「健身房管理系统」这个题目时,第一反应是去搜一套现成源码,改改界面、换个配色就交差。但真正做过答辩的人都知道,老师问的从来不是「你用了什么框架」,而是「会员到期后系统怎么处理」「一节课同时来了两个教练签到怎么办」。这套系统的核心不在界面多花哨,而在于把会员、课程、教练、签到、计费这几条业务线串成一个自洽的闭环。它适合计算机相关专业的课设或毕设,也适合想练手 CRUD 加业务状态机的开发者。我见过太多人卡在「表建好了但业务跑不通」,这篇就把从建表到跑通签到扣课的完整路径拆开讲,让你少走弯路。
2. 需求拆解与技术选型:别一上来就堆微服务
2.1 先画业务闭环,再决定表结构
健身房管理系统的业务其实不复杂,但新手容易把它想成「会员管理 + 课程管理」两个独立模块。真正跑起来你会发现,核心链路是:会员办卡 → 买课/约课 → 教练排课 → 到店签到 → 扣课时 → 到期提醒。这条链路里,会员和课程不是并列关系,而是通过「订单」和「签到记录」关联的。
我一般会先画一张状态流转图(纸上画就行,不用工具),确认几个关键状态:
- 会员卡状态:待激活、正常、已过期、已冻结
- 课程状态:待开课、进行中、已结束、已取消
- 签到状态:已预约、已签到、爽约、已消课
把这几个状态定清楚,表结构基本就出来了。很多同学的表里只有一个status字段用 0/1 表示,结果做到一半发现「过期」和「冻结」要区分处理,只能加字段重构,血泪经验。
2.2 技术栈选型:课设和毕设的边界在哪
选型要看你的时间预算和答辩要求。课设通常两周到一个月,毕设三到四个月。我的建议是:
| 场景 | 推荐栈 | 理由 |
|---|---|---|
| 课设,只求跑通 | Spring Boot + MyBatis + MySQL + 原生 HTML | 上手快,调试直观 |
| 课设,想拿高分 | Spring Boot + Vue + Element UI | 前后端分离,演示效果好 |
| 毕设,要求有亮点 | 上面基础上加 Redis 缓存 + 定时任务 | 能讲出技术深度 |
| 毕设,偏数据分析 | Python Flask + ECharts | 做会员活跃度分析方便 |
不要为了「显得高级」硬上微服务。我见过一个课设拆了五个服务,结果本地跑不起来,答辩时现场启动失败,翻车得很彻底。单体应用先把业务跑通,比什么都强。
2.3 数据库表设计:六张核心表就够
不用建十几张表,核心六张就能撑起整个系统:
-- 会员表:存基本信息,卡状态单独用字段控制 CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, phone VARCHAR(16) UNIQUE NOT NULL, card_type TINYINT DEFAULT 1 COMMENT '1月卡 2季卡 3年卡', card_status TINYINT DEFAULT 0 COMMENT '0待激活 1正常 2过期 3冻结', expire_time DATETIME COMMENT '到期时间,激活时计算', balance DECIMAL(10,2) DEFAULT 0 COMMENT '账户余额', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 教练表 CREATE TABLE coach ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, phone VARCHAR(16) UNIQUE, specialty VARCHAR(64) COMMENT '擅长方向', status TINYINT DEFAULT 1 COMMENT '1在职 0离职' ); -- 课程表:定义课程模板,不绑定具体时间 CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, coach_id BIGINT NOT NULL, capacity INT DEFAULT 10 COMMENT '最大人数', duration INT DEFAULT 60 COMMENT '时长分钟', price DECIMAL(8,2) COMMENT '单次价格' ); -- 排课表:课程 + 具体时间 = 一次可约的场次 CREATE TABLE 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, booked INT DEFAULT 0 COMMENT '已预约人数', status TINYINT DEFAULT 0 COMMENT '0待开课 1进行中 2已结束 3已取消' ); -- 预约记录表:会员和排课的关联 CREATE TABLE booking ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0已预约 1已签到 2爽约 3已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_member_schedule (member_id, schedule_id) ); -- 签到消课记录表:实际扣课时的凭证 CREATE TABLE checkin ( id BIGINT PRIMARY KEY AUTO_INCREMENT, booking_id BIGINT NOT NULL, checkin_time DATETIME DEFAULT CURRENT_TIMESTAMP, consume_amount DECIMAL(8,2) COMMENT '本次扣费金额', operator VARCHAR(32) COMMENT '操作人' );这里有几个设计点值得说。booking表加了member_id + schedule_id的唯一索引,防止同一会员重复预约同一场次,这是最容易被忽略的约束。schedule表冗余了coach_id,因为排课时教练可能和课程默认教练不一致,查询时不用再关联课程表。checkin表单独存扣费金额,是因为后续可能调价,历史记录不能跟着变。
2.4 后端接口分层:Controller 别写业务逻辑
常见做法是 Controller 只做参数校验和响应封装,Service 层处理业务,Mapper 层管数据库。我一般会这样组织:
// Controller 只负责接参和返回 @PostMapping("/booking") public Result bookCourse(@RequestBody BookingDTO dto) { // 参数校验交给注解,业务异常统一捕获 bookingService.book(dto.getMemberId(), dto.getScheduleId()); return Result.success(); } // Service 层处理核心逻辑 @Service public class BookingService { @Transactional public void book(Long memberId, Long scheduleId) { // 1. 校验会员状态 Member member = memberMapper.selectById(memberId); if (member.getCardStatus() != 1) { throw new BizException("会员卡状态异常,无法预约"); } // 2. 校验排课是否可约 Schedule schedule = scheduleMapper.selectById(scheduleId); if (schedule.getStatus() != 0) { throw new BizException("该场次已不可预约"); } if (schedule.getBooked() >= schedule.getCapacity()) { throw new BizException("预约人数已满"); } // 3. 插入预约记录,唯一索引兜底防重 try { bookingMapper.insert(new Booking(memberId, scheduleId)); } catch (DuplicateKeyException e) { throw new BizException("请勿重复预约"); } // 4. 更新已预约人数 scheduleMapper.incrBooked(scheduleId); } }这段代码的关键在于第 3 步用数据库唯一索引兜底,而不是先查再插。先查再插在并发下必然出问题,两个请求同时查到「没预约过」,然后都插入,唯一索引就是最后一道防线。第 4 步的incrBooked要用UPDATE schedule SET booked = booked + 1 WHERE id = ? AND booked < capacity,把容量判断也放进 SQL,避免超卖。
3. 核心功能落地:签到扣课与到期处理
3.1 签到消课:一次事务里做完三件事
签到是整个系统里最容易出 bug 的地方,因为它要同时改三张表:预约记录状态、排课状态、会员余额或课时。我一般把这三步放在一个事务里:
@Transactional(rollbackFor = Exception.class) public void checkin(Long bookingId, String operator) { // 1. 查询预约记录,加行锁防止并发签到 Booking booking = bookingMapper.selectForUpdate(bookingId); if (booking == null || booking.getStatus() != 0) { throw new BizException("预约记录状态异常"); } // 2. 查询排课和课程,计算扣费金额 Schedule schedule = scheduleMapper.selectById(booking.getScheduleId()); Course course = courseMapper.selectById(schedule.getCourseId()); BigDecimal amount = course.getPrice(); // 3. 扣会员余额,余额不足直接抛异常回滚 int rows = memberMapper.deductBalance(booking.getMemberId(), amount); if (rows == 0) { throw new BizException("余额不足,请先充值"); } // 4. 更新预约状态为已签到 bookingMapper.updateStatus(bookingId, 1); // 5. 写入签到记录 checkinMapper.insert(new Checkin(bookingId, amount, operator)); }selectForUpdate是行锁,保证同一预约不会被两个终端同时签到。第 3 步的deductBalance要写成UPDATE member SET balance = balance - ? WHERE id = ? AND balance >= ?,把余额判断放进 SQL,返回影响行数为 0 就说明余额不够。这样即使并发也不会扣成负数。
3.2 会员卡到期:定时任务加惰性判断双保险
到期处理有两种做法:定时任务扫描,或者每次访问时惰性判断。我建议两个都做,因为定时任务可能因为服务重启漏跑,惰性判断能兜底。
// 定时任务:每天凌晨两点扫描过期会员 @Scheduled(cron = "0 0 2 * * ?") public void expireMemberCard() { // 把到期时间已过且状态正常的会员改为过期 memberMapper.expireCards(new Date()); } // 惰性判断:查询会员时顺带检查 public Member getMember(Long id) { Member member = memberMapper.selectById(id); if (member.getCardStatus() == 1 && member.getExpireTime().before(new Date())) { // 顺手更新状态,避免下次再判断 memberMapper.updateStatus(id, 2); member.setCardStatus(2); } return member; }定时任务的 cron 表达式0 0 2 * * ?表示每天凌晨两点执行,这个时间点业务量最小。惰性判断放在查询方法里,虽然多了一次判断,但能保证任何入口拿到的会员状态都是准的。注意expireCards的 SQL 要加card_status = 1条件,别把冻结的卡也改成过期了。
3.3 排课冲突检测:教练和时间两个维度都要查
排课时最容易出的问题是同一个教练同一时间段被排了两节课。检测逻辑要覆盖两个维度:
-- 查询该教练在指定时间段是否已有排课 SELECT COUNT(*) FROM schedule WHERE coach_id = #{coachId} AND status != 3 AND start_time < #{endTime} AND end_time > #{startTime}这个 SQL 的判断条件是「开始时间早于新排课的结束时间,且结束时间晚于新排课的开始时间」,两个区间只要有重叠就会被查出来。注意status != 3排除了已取消的场次,否则取消过的课会一直占着时间段。如果还要检测场地冲突,再加一个room_id条件即可。
3.4 前端页面:三个页面撑起演示
课设答辩演示时间通常只有五到十分钟,不需要做十几个页面。我一般聚焦三个:
- 会员列表页:展示会员卡状态、到期时间,带搜索和状态筛选
- 排课日历页:按周展示课程,点击可预约,已满的场次置灰
- 签到操作页:输入手机号或预约号,显示待签到记录,一键签到
排课日历用现成的日历组件,把每天的排课数据按时间渲染成色块就行。签到页要做得简单,答辩时老师可能亲自操作,输入手机号能查到预约、点签到能扣费,这个流程顺畅比界面精美重要得多。
4. 避坑与排查:那些答辩前夜才发现的坑
4.1 坑一:会员卡到期时间算错,月卡变成 30 天整
现象是会员 1 月 31 日办月卡,系统算出 3 月 2 日到期。原因是用了new Date(System.currentTimeMillis() + 30 * 24 * 3600 * 1000)这种加毫秒的写法,跨月时天数不对。正确做法是用Calendar或LocalDateTime加月份:
// 错误写法:加固定天数,跨月会错 // Date expire = new Date(now.getTime() + 30L * 24 * 3600 * 1000); // 正确写法:加一个月,自动处理月末 LocalDateTime expire = LocalDateTime.now().plusMonths(1);月卡加一个月、季卡加三个月、年卡加一年,用plusMonths而不是plusDays,这样 1 月 31 日办卡到期就是 2 月 28 日(或 29 日),符合业务直觉。
4.2 坑二:签到扣费金额为 null,事务回滚但记录已写
现象是签到时报空指针,但签到记录已经插进去了。原因是先插了checkin记录,再去查课程价格,查出来是 null 导致异常。解决方法是调整顺序,先查价格再插记录,或者给checkin表的consume_amount加非空约束。我一般会在 Service 方法开头就把所有需要的数据查完,确认无误再执行写操作,避免写到一半失败。
4.3 坑三:并发预约导致超卖,容量 10 人约进 12 个
现象是热门课程预约人数超过容量。原因是「查已约人数 → 判断 → 插入」这三步不是原子的。解决方法是把容量判断放进更新 SQL:
UPDATE schedule SET booked = booked + 1 WHERE id = #{id} AND booked < capacity返回影响行数为 0 就说明已满,直接抛异常。这样即使 100 个请求同时来,也只有前 10 个能成功。这个坑在答辩演示时如果被问到并发,能答上来是加分项。
4.4 坑四:删除会员时外键约束报错,数据删不掉
现象是想删一个测试会员,报外键约束错误。原因是booking表里有该会员的预约记录。解决方法是做逻辑删除,给member表加deleted字段,删除时只改标记不删数据。物理删除在业务系统里基本不用,因为历史记录要保留。如果非要物理删除,得先删关联的预约和签到记录,但这样会丢失业务数据,不推荐。
4.5 坑五:时间字段用字符串存,排序和比较全乱套
现象是排课列表按时间排序,结果 10 点的课排在 9 点前面。原因是数据库里start_time用了VARCHAR存"2024-1-5 9:00"这种格式,字符串排序按字符逐位比较,"10"排在"9"前面。解决方法是时间字段一律用DATETIME类型,前端传参用标准格式yyyy-MM-dd HH:mm:ss,让数据库来处理排序和比较。
5. 进阶技巧:让系统在答辩时多拿十分
5.1 用定时任务做会员到期提醒
在到期前三天给会员发提醒,这个功能实现简单但演示效果好。加一张notification表,定时任务每天扫描即将到期的会员,插入提醒记录,前端登录时弹窗展示。
@Scheduled(cron = "0 0 9 * * ?") public void remindExpiring() { // 查询三天内到期的正常会员 List<Member> list = memberMapper.selectExpiring(3); for (Member m : list) { notificationMapper.insert(new Notification( m.getId(), "您的会员卡将于" + m.getExpireTime() + "到期,请及时续费" )); } }selectExpiring的 SQL 用expire_time BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 3 DAY),注意要加card_status = 1条件,只提醒正常状态的卡。这个功能在答辩时能体现你对业务完整性的考虑,比单纯增删改查有说服力。
5.2 加一个简单的数据看板
用 ECharts 做一个会员活跃度看板,展示近七天签到次数、课程预约率、会员卡类型分布。数据用一条 SQL 聚合出来:
-- 近七天每日签到次数 SELECT DATE(checkin_time) AS day, COUNT(*) AS cnt FROM checkin WHERE checkin_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY DATE(checkin_time) ORDER BY day前端拿到数据直接塞给 ECharts 的折线图。这个看板不需要实时刷新,每次打开页面查一次就行。答辩时老师看到有数据可视化,印象分通常会给得高一些。
5.3 接口幂等:防止重复提交的通用做法
签到、预约这类写操作要防重复提交。除了数据库唯一索引,还可以在前端加一个提交锁,或者后端用 token 机制。我一般用最简单的方案:前端按钮点击后置灰,后端靠唯一索引兜底。如果要求更严格,可以引入 Redis 存一个booking:memberId:scheduleId的 key,设置几秒过期,重复请求直接拒绝。课设阶段前端置灰加唯一索引就够了,不用过度设计。
5.4 答辩演示的准备工作
演示前一定要准备一份种子数据,包括几个不同状态的会员、几节排好的课、几条待签到的预约。数据要真实一点,别用「张三」「李四」,用「王会员」「刘教练」这种。演示流程按「登录 → 查看会员 → 预约课程 → 签到扣费 → 查看到期提醒」走一遍,控制在五分钟内。提前把数据库备份一份,万一演示时改乱了能快速恢复。我一般会在演示前把整个流程自己走三遍,确保每一步都不会卡住。
这套系统做完,你对 CRUD、事务、并发控制、定时任务这几个核心概念就有了实操层面的理解。别小看它简单,能把状态流转和并发边界处理干净,比堆一堆花哨功能更能体现工程能力。希望帮到你。
本文还有配套的精品资源,点击获取