健身房管理系统从课设到毕设:核心业务闭环与并发避坑指南 简介这份健身房管理系统项目源码包面向计算机相关专业的毕业设计、课程设计及大作业场景尤其适合人工智能、计算机科学与技术等方向的学生使用。系统围绕健身房日常运营设计覆盖会员、前台管理员、教练与管理者等多角色包含会员注册、信息管理、课程安排、设备使用跟踪、财务报表与预约管理等模块可帮助读者理解完整业务流程与软件工程实践。压缩包共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 个现象是热门课程预约人数超过容量。原因是「查已约人数 → 判断 → 插入」这三步不是原子的。解决方法是把容量判断放进更新 SQLUPDATE 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() { // 查询三天内到期的正常会员 ListMember 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、事务、并发控制、定时任务这几个核心概念就有了实操层面的理解。别小看它简单能把状态流转和并发边界处理干净比堆一堆花哨功能更能体现工程能力。希望帮到你。本文还有配套的精品资源点击获取