Java教练培训排课系统源码拆解:从数据模型到自动排课算法

📅 发布时间:2026/9/9 21:33:17
Java教练培训排课系统源码拆解:从数据模型到自动排课算法
做教练培训这一行最头疼的往往不是教学本身而是排课。我带过的几个线下机构早期全靠Excel排课学员一多教练时间撞车、场地重复预约、临时调课通知不到位各种混乱接踵而来。后来我花了两周时间用Java从零写了一套排课系统把教练、学员、课程和场地统一管起来才算是把这块硬骨头啃了下来。这篇文章我就把整套系统源码的核心设计、表结构、算法思路和实操中的坑全部拆开讲希望能帮到正在做同类项目的朋友。这套排课系统的核心价值在于把“谁在什么时候、在哪个场地、跟哪个教练、上什么课”这条业务主线彻底数字化同时支持自动检测时间冲突、批量排课、状态流转和消息提醒。它不像大型ERP那么重但覆盖了教练培训场景下90%的排课痛点。无论你是驾校、健身工作室、游泳培训还是球类培训的机构管理者或是接手了类似需求的技术开发这份源码拆解都能给你一个可以直接落地复用的方案。1. 排课系统的业务痛点与核心设计思路1.1 教练培训场景下的排课为什么难很多人以为排课就是往表格里填时间段真正做过才知道这里面水有多深。教练培训的排课有几个显著特点第一一节课同时占用多维资源——教练、场地、设备比如驾校的车、健身房的器械任何一个资源冲突这节课就排不下去第二临时变动极其频繁学员请假、教练调休、天气影响户外项目都会导致已排课程需要快速调整第三存在复杂的业务规则比如教练每天最大课时数限制、学员连续上课的间隔要求、场地消毒时间预留等等这些规则如果靠人肉记忆迟早出问题。我用一个驾校的实例来说明。驾校有8个教练、10辆车、2块训练场学员200多人。人工排课时教务老师每天要花2到3个小时协调时间还经常出现同一个教练被安排在两个场地同时上课的情况。学员投诉多教练意见也大。后来上线排课系统后每天排课时间压缩到10分钟以内冲突概率降到了接近零。这就是系统化排课的价值所在。从技术角度看排课系统本质上是一个带约束的资源调度问题。我们把教练、场地、设备都抽象成“资源”把一节课抽象成“资源占用时间段”排课就是在一堆可用时间段里找到满足约束条件的组合。理解了这一层编码实现就顺理成章了。1.2 系统模块划分与整体架构选型这套系统的技术栈选型比较务实没有引入高深框架走的是稳健路线Spring Boot 2.7 MyBatis-Plus MySQL 8.0 Redis可选前端用 Vue 3 Element Plus。后端整体按模块划分包括教练管理、学员管理、课程模板管理、排课管理、上课签到、消息通知六大模块其中排课管理是核心中的核心。为什么选这套组合我给你说下背后的考虑。Java生态在中小型管理系统里依然是最稳的选择Spring Boot的自动配置极大降低了搭建成本MyBatis-Plus提供了强大的CRUD封装写业务代码时不用重复造轮子MySQL做主存储绝大多数培训机构的数据量根本碰不到性能瓶颈选它成本最低。至于Redis我只在“热门时间段并发选课”的场景用到了分布式锁如果你机构规模不大纯MySQL也能跑得动可以暂时不引入。数据库设计上我遵循一个原则凡是需要追踪变化的业务数据都要有独立的状态字段和创建、更新时间。这听起来基础但很多人做排课系统时会把状态写到业务备注里后期统计和排查问题会非常痛苦。我的做法是每张业务表都统一带status、create_time、update_time字段状态一律用数字编码例如0表示禁用、1表示启用排课记录再单独用一组状态码表达不同阶段后面会详细展开。2. 核心数据模型设计一次排课背后的表结构2.1 教练、学员、课程与排课记录的四张核心表凡是做管理系统表结构设计得踏实后面写代码就顺畅表结构一开始偷懒后面重构能让你怀疑人生。排课系统最少需要四张核心表教练表、学员表、课程表、排课记录表再加两张辅助表场地表和排课明细表。我把核心表的字段逻辑拆开讲一下。教练表除了基本姓名、手机号、教学项目这些字段外我特别加了两个字段max_daily_lessons每日最大课时数和skill_tags擅长项目用逗号分隔。这两个字段是后面自动排课算法的重要输入约束。比如一个教练一天最多上6节课两个月下来连续排7节的情况就不会再出现。很多系统的排课功能做得不专业就是因为没有把这类业务约束落到数据模型里。学员表设计相对简单但有两点值得注意一是study_progress字段记录学员当前学习阶段二是preferred_time字段存学员偏好的上课时间段JSON格式比如{weekday: [1,3,5], time_range: 09:00-11:00}。排课时优先匹配学员偏好体验会好很多。这个字段存在学员表里看似有点违反范式但从查询效率角度看很合理避免排课的时候还得去另一个表取偏好配置。课程表我设计成“课程模板”的概念不直接绑定具体时间。它描述的是机构提供了哪些课程比如“科目二基础训练”“科目三道路驾驶”每个课程模板包含课程名称、时长分钟、所需场地类型、默认教练级别。这样设计的好处是课程被复用到不同时间段时属性不会重复配置。比如“科目二基础训练”是60分钟、需要小型训练场、建议3年经验以上的教练这些信息在课程模板里定义一次后续排课只需要引用模板ID。排课记录表是业务主表字段设计如下字段名类型说明idbigint主键schedule_novarchar(32)排课单号业务可读coach_idbigint教练IDstudent_idbigint学员IDcourse_idbigint课程模板IDsite_idbigint场地IDlesson_datedate上课日期start_timetime开始时间end_timetime结束时间statustinyint状态1待上课 2已完成 3已取消 4待补课sourcetinyint来源1手动排课 2自动排课 3学员端预约remarkvarchar(255)备注这张表的查询频率最高所以索引策略要跟上。我的做法是(coach_id, lesson_date)联合索引、(student_id, lesson_date)联合索引、(site_id, lesson_date, start_time)联合索引。这样按教练查日程、按学员查课表、按场地查占用都很快。索引不是建得越多越好联合索引的顺序也有讲究必须把选择性高的字段放前面实战中(coach_id, lesson_date)的查询效率明显优于单独建coach_id索引。2.2 时间冲突检测的原理与实现思路排课系统最核心的技术点是冲突检测。两节课冲突的定义是同一资源教练、学员或场地在时间上有重叠。判断两个时间段是否重叠有一个经典公式boolean isOverlap !(newStart oldEnd || newEnd oldStart);这个公式的意思是如果不满足“新开始时间晚于或等于旧结束时间”且不满足“新结束时间早于或等于旧开始时间”那么两个时间段必然重叠。反过来理解两个时间段不重叠只有两种可能一种是一个完全在另一个前面一个完全在后面。具体到代码实现查询冲突时要根据资源类型分别判断。比如检查教练冲突时需要查询该教练在同一天、同一时间段是否已有排课记录并且状态要排除“已取消”的记录SELECT COUNT(*) FROM schedule_record WHERE coach_id #{coachId} AND lesson_date #{lessonDate} AND status ! 3 AND NOT (end_time #{startTime} OR start_time #{endTime})这里有个容易踩的坑状态过滤条件不能漏。如果不排除已取消的排课记录整条时间轴会被一堆已取消的课程占满明明可以排课却提示冲突。我早期就犯过这个错后来在查询条件里统一加上status ! 3才解决。另一个坑是时区问题。MySQL连接串上一定要加serverTimezoneAsia/Shanghai否则时间字段的读写可能出现8小时的偏差导致排课时间整体偏移。这个问题在本地测试时不容易发现部署到云服务器上就暴露了。2.3 状态机预约、上课、取消、补课的状态流转排课记录的状态不能随意乱跳我定义了一套明确的状态机用代码强制约束流转路径。这个状态机看起来简单但它是系统健壮性的基石避免了业务人员误操作导致的数据错乱。状态定义如下1-待上课排课已确认尚未到上课时间。所有新建的排课记录都从该状态开始。2-已完成课程按时结束学员完成签到记录终结。3-已取消课程在开始前被取消学员请假、教练调休等记录终结。4-待补课原排课因故取消且双方约定补课时间。此时原记录转为待补课状态系统会生成一条新的待确认排课记录。状态流转的约束在Service层实现我用一个Map来定义允许的流转路径private static final MapInteger, SetInteger ALLOWED_TRANSITIONS; static { ALLOWED_TRANSITIONS new HashMap(); ALLOWED_TRANSITIONS.put(1, new HashSet(Arrays.asList(2, 3, 4))); // 待上课 - 已完成/已取消/待补课 ALLOWED_TRANSITIONS.put(4, new HashSet(Arrays.asList(1, 2, 3))); // 待补课 - 待上课/已完成/已取消 } public void changeStatus(Long scheduleId, Integer newStatus) { ScheduleRecord record getById(scheduleId); SetInteger allowed ALLOWED_TRANSITIONS.get(record.getStatus()); if (allowed null || !allowed.contains(newStatus)) { throw new BizException(非法的状态流转: record.getStatus() - newStatus); } record.setStatus(newStatus); updateById(record); }为什么“待补课”可以流转回“待上课”因为补课时间确定后重新安排的课程会另起一条正式排课记录原记录则终止。如果学员和教练协商后补课时间变动可能再次生成新记录所以原记录从待补课流转到已取消也是合理的。这个状态机的设计原则是每条记录的状态必须能回答“这节课现在处于什么阶段”而不是把多个含义塞到备注里。做管理系统的朋友看到这里应该深有体会状态字段定义得清楚后面的统计报表、待办提醒、异常数据排查全都省心。3. 核心功能模块的源码级拆解3.1 自动排课算法的实现与优先级策略自动排课是这个系统的灵魂功能。教务老师一键点击系统自动为一批学员生成可行的课程时间表。这个功能实现之前我花了很多时间梳理业务规则最后把规则分为硬约束和软约束两类。硬约束是必须满足的比如教练同一时间不能上两门课、场地不能同时被两个班使用软约束是尽量满足的比如优先匹配学员的偏好时间段、教练的每日课时数不要超过上限。自动排课算法我采用的是贪心策略加优先级排序不搞复杂的遗传算法或约束求解器。原因很简单教练培训机构的排课规模通常不大几十个教练、几百个学员贪心策略结合合理的初始排序已经能获得非常好的效果而且代码逻辑清晰、容易维护。如果引入复杂的优化算法业务人员无法理解排课结果为什么是这样反而引发信任问题。算法流程是这样的获取学员的课程需求列表按“偏好时间段匹配度”降序排序——偏好匹配度高的学员先安排因为这些需求更容易满足先排可以减少后续的“无解”情况。对每个学员需求遍历“可用教练 可用场地”的组合过滤掉硬约束冲突时间重叠、教练休息日、场地维护中。对剩余组合按软约束评分评分项包括学员偏好时间匹配权重最高、教练课时量剩余空间权重次之、场地距离学员位置远近如果机构有多个校区。取评分最高的组合创建排课记录初始状态为待上课。重复以上过程直到所有需求处理完毕。核心评分代码片段大致如下public int scoreScheduleOption(ScheduleOption option, StudentDemand demand) { int score 0; // 学员偏好时间匹配是最重要的软约束权重设为50 if (isPreferredTime(option.getStartTime(), demand.getPreferredTime())) { score 50; } // 教练剩余课时越充裕越优先安排权重设为30 int remaining coach.getMaxDailyLessons() - coach.getScheduledLessons(option.getLessonDate()); score Math.min(30, remaining * 5); // 场地距离权重设为20同一校区则直接加满分 if (demand.getPreferredSiteId() ! null demand.getPreferredSiteId().equals(option.getSiteId())) { score 20; } return score; }这套算法上线后驾校原来一天需要2小时的人工排课时间压缩到了10分钟而且生成的课表基本没有需要手工调整的情况。如果你的机构规模更大、约束更多可以升级为基于决策树或整数规划的方案但对于教练培训这个体量贪心策略足够又优雅。3.2 冲突检测与可视化时间轴的实现除了自动排课手动排课页面的可视化时间轴是我用得最多也最受好评的功能。管理端排课页面提供了一个周视图时间轴横轴是周一到周日纵轴是每天的时间段每个排课记录以彩色块呈现拖动可以调整课程时间松开会自动触发冲突检测。时间轴页面初期用原生JavaScript实现后来发现维护成本高就换成Vue 3 Element Plus的Calendar组件改造。但底层的时间冲突检测逻辑是纯Java服务端的不依赖前端框架。前端调整课程时间后会请求后端接口后端进行完整校验后决定是否允许调整Transactional(rollbackFor Exception.class) public void reschedule(Long scheduleId, LocalDate newDate, LocalTime newStartTime) { ScheduleRecord record getById(scheduleId); // 计算新的结束时间使用课程模板配置的时长 CourseTemplate course courseMapper.selectById(record.getCourseId()); LocalTime newEndTime newStartTime.plusMinutes(course.getDurationMinutes()); // 冲突检测教练、学员、场地三类资源逐一检查 checkCoachAvailable(record.getCoachId(), newDate, newStartTime, newEndTime, scheduleId); checkStudentAvailable(record.getStudentId(), newDate, newStartTime, newEndTime, scheduleId); checkSiteAvailable(record.getSiteId(), newDate, newStartTime, newEndTime, scheduleId); // 更新排课记录 record.setLessonDate(newDate); record.setStartTime(newStartTime); record.setEndTime(newEndTime); updateById(record); }这里有几个细节值得分享。一是冲突检测时要排除自身记录否则修改课程时间时一定会检测到自己永远无法保存。二是事务注解不能少如果检测通过后更新记录的过程中出现异常事务要能回滚避免出现脏数据。三是rollbackFor Exception.class这个参数必须显式指定因为Spring默认只在RuntimeException下回滚而自定义的业务异常往往是继承Exception的不指定的话更新失败时数据不会回滚这是非常隐蔽的坑。3.3 提醒通知与定时任务的实现排课系统如果不会主动提醒就解决不了“学员忘记上课时间”这个痛点。我实现了两套提醒机制一种是定时任务每天晚8点扫描第二天所有待上课的排课记录批量发送短信或公众号模板消息另一种是实时提醒当排课调整发生时立即触发消息推送。两种机制互补前者兜底后者及时。定时任务我用Spring自带的Scheduled注解实现没有引入Quartz或XXL-Job原因还是体量问题。具体思路是写一个定时任务类每天固定时间执行一次扫描Component public class RemindScheduler { Scheduled(cron 0 0 20 * * ?) public void remindNextDayLessons() { ListScheduleRecord records scheduleRecordMapper.selectList( new LambdaQueryWrapperScheduleRecord() .eq(ScheduleRecord::getLessonDate, LocalDate.now().plusDays(1)) .eq(ScheduleRecord::getStatus, 1) ); for (ScheduleRecord record : records) { sendRemindMessage(record); } } }这里有个部署层面的坑如果在多实例部署时同一个定时任务会在每个节点都执行一遍导致学员收到重复提醒。解决办法有两种一是使用分布式锁基于Redis的setnx让同一时刻只有一个实例能执行二是把定时任务单独拆成一个服务只跑一个实例。我倾向于后者简单直接不会因为Redis挂掉影响提醒发送。消息推送渠道我封装了一个消息工厂接口根据机构的实际情况可以对接短信通道、微信公众号模板消息或企业微信通知。驾校的学员年龄跨度大有些年纪大的学员不怎么看微信短信是刚需所以我把短信设为默认渠道微信公众号作为补充。接口设计上定义MessageSender接口短信和微信各实现一个通过简单的工厂根据配置动态选择后续要加推送渠道也容易扩展。3.4 并发预约与超卖问题处理当系统开放学员端自主预约课时会面临高并发下的超卖问题。比如热门教练的黄金时间段放出来10个名额瞬间有20个学员同时抢课如果不加控制就可能出现同一个时间段被多名学员预约成功的情况。这个问题本质上是“资源竞争”解决方案是使用数据库的乐观锁或悲观锁。我采用的是MyBatis-Plus的乐观锁实现在排课记录表里加入版本号字段version更新时带上条件Update(UPDATE schedule_record SET student_id #{studentId}, status 1, version version 1 WHERE id #{scheduleId} AND version #{version} AND student_id IS NULL) int occupySchedule(Param(scheduleId) Long scheduleId, Param(studentId) Long studentId, Param(version) Integer version);执行后检查返回的更新行数如果是0说明有人已经抢先预约或者版本号变更此时提示用户“名额已被抢完”。这种方案比纯synchronized锁好在它是数据库层面的原子操作天然支持多实例部署不需要额外维护分布式锁组件。实现乐观锁时有个细节排课记录表刚生成时student_id为空代表名额未被占用学员抢到的操作就是把student_id从空值填成自己的ID。更新条件里的student_id IS NULL相当于一个轻量级条件守卫即使版本号机制出现极端情况这个条件也能兜底防止同一个名额被多次占用。这种“业务条件 版本号”的双重约束实战下来非常稳。4. 从源码到上线环境搭建、部署与常见问题排查4.1 环境搭建与启动步骤我把整个项目做成了前后端分离结构后端是标准Maven工程前端是Vue3工程。部署环境要求不高一台2核4G的云服务器就能跑得很顺畅这大大降低了小型培训机构的上线门槛。下面给出后端启动的完整步骤照着操作就能跑起来。第一步准备基础环境安装JDK 1.8以上、MySQL 8.0、Maven 3.6以上。JDK我推荐用开源的OpenJDK发行版安装后执行java -version验证配置是否成功。MySQL安装完成后需要创建业务数据库并导入项目提供的init.sql脚本这个脚本会建库建表并写入一批基础演示数据包括教练、学员、课程模板和几条排课记录方便界面快速展示效果。第二步修改后端配置文件。项目的application.yml里需要调整三处数据库地址、数据库账号密码、短信通道的密钥如果暂时没有短信服务可以先把消息发送实现类替换为日志打印避免启动报错。数据库连接串示例spring: datasource: url: jdbc:mysql://127.0.0.1:3306/coach_schedule?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: yourpassword这里建议把useSSLfalse加上否则本地MySQL如果没配置SSL证书连接阶段会大量告警日志影响排查问题。serverTimezone务必按我前面说的设置成Asia/Shanghai。第三步启动后端服务。在项目根目录执行mvn spring-boot:run或先mvn clean package打包成jar再运行java -jar target/coach-schedule.jar。看到Started Application in x.xxx seconds的日志说明启动成功默认端口是8080可以通过http://localhost:8080/api/health检查服务健康状态。第四步启动前端工程。安装Node.js 16以上在frontend目录执行npm install安装依赖再执行npm run dev启动开发模式默认端口是5173浏览器访问http://localhost:5173即可进入系统首页。开发模式支持热更新改代码页面会自动刷新排障效率高。正式上线时前端执行npm run build打包出dist目录用Nginx托管同时把/api路径反向代理到后端8080端口就完成了标准的前后端分离部署。不过如果你是第一次部署建议先在本机用开发模式跑通全流程再考虑服务器部署。4.2 常见问题与排查技巧实录做排课系统过程中我前前后后整理了一堆问题排查记录挑几个最典型的拿出来说这些绝大部分是源码自带的“坑”不看文档直接踩进去是要花时间爬出来的。第一个是时区导致的日期偏移问题。现象是数据库里存的日期时间比实际时间少了8小时排课记录显示的上课时间整体错位。排查方法是先查MySQL的time_zone变量再检查JDBC连接串是否带了serverTimezone参数。解决方法是统一使用serverTimezoneAsia/Shanghai并且在Java代码中所有日期时间类型都用LocalDate、LocalTime、LocalDateTime不要用java.util.Date因为后者在序列化和时区转换时很容易出幺蛾子。第二个是索引失效导致的查询缓慢。排课系统的核心查询条件是coach_id lesson_date如果建索引时字段顺序反了比如把lesson_date放前面那么按教练查日程时走不了索引全表扫描导致页面响应要好几秒。我的排查方法是先用MySQL的EXPLAIN命令查看执行计划观察key字段是否为预期的联合索引。如果是NULL或走了全表扫描就要检查SQL条件字段顺序跟索引是否匹配。第三个是前面提到的乐观锁更新失败问题。现象是并发预约场景下明明数据量不大却经常提示“抢课失败”。排查方向是查看version字段是否在每次更新后正确递增更新SQL的where条件是否包含了版本号。我试过把版本号更新遗漏结果同一时刻两个请求都能读同一版本号导致超卖。解决办法就是按源码里那样在Mapper语句中显式写version version 1和WHERE version #{version}。第四个是事务回滚失效问题。表现是排课服务中前面调用了更新排课记录的方法后面抛出业务异常数据却没有回滚。原因99%是事务方法被同类内部调用Spring的AOP代理失效事务注解形同虚设。解决办法是把需要事务的方法拆到独立的Service类中或者注入自身的代理对象来调用。第五个是定时任务重复执行。如果在多实例部署时没有加锁每天提醒消息会发两遍。排查时先查看应用实例数量再检查任务调度配置。我的建议是一个机构规模下没必要搞多实例单实例部署最省心实在要做高可用再把任务调度独立出来。我把这些常见问题整理成一张速查表供快速定位问题现象根因分析解决方案上课时间整体偏移8小时JDBC连接串缺时区参数连接串加serverTimezoneAsia/Shanghai排课查询响应缓慢联合索引字段顺序不当调整索引为(coach_id, lesson_date)同时抢课人数多时数据错乱缺少并发控制引入乐观锁或数据库行锁业务异常后数据未回滚事务方法同类自调用拆分Service或自注入调用提醒消息重复发送多实例同时执行定时任务任务集中单实例执行或加分布式锁4.3 从教练排课到通用预约系统的扩展思路这套排课系统的代码结构虽然是为教练培训场景设计的但核心架构其实非常通用。把“教练/学员/课程”替换成“医生/患者/科室”、“会议室/参会人/会议”、“设备/使用人/用途”系统的排课逻辑完全可以直接复用。这也是我当时刻意做的设计业务表拆得细、状态机定义得严谨、冲突检测独立成公共模块这些设计天然支持领域复用。如果你要做通用预约系统扩展重点在三个方面。一是资源模型的抽象把教练、场地、设备抽象成统一的“资源”接口排课记录改成同时关联多个资源ID这样一节课可以同时占用教练和场地。二是预约规则的配置化把“每日最大课时”“最小提前预约时间”“最长连续预约天数”等等全部挪到配置表里而不是硬编码在代码中。三是消息通知的渠道扩展对接邮件、短信、钉钉、企业微信等不同渠道时维护一套统一发送接口插件化扩展即可。我尝试过把这个系统改造成运动场馆的预约系统只需新增场地类型、调整课程模板规则、重写前端页面文案后端核心的排课、冲突检测、状态机、定时提醒全部原样保留前后只花了不到三天时间。这说明当初抽象层次划分合理没有把业务规则和场景数据强耦合在一起。还有一点补充排课数据是典型的时序数据如果要支持历史回溯、教练考勤、课程收入统计等分析需求建议在一开始就引入简单的分月表或归档策略。我现在的做法是每月自动把超过三个月的已完成排课记录归档到历史表保证主表的查询性能始终稳定。写在最后的实操体会我个人在这套排课系统上线后最大的体会是排课系统真正的复杂度不在于算法而在于把业务规则梳理清楚并落实到数据结构和状态流转里。如果你所在机构连“教练每天最多上几节课”这种规则都没定清楚再好的代码也救不了排课混乱的现实。所以做这类系统一定要先跟教务老师深聊几个下午把所有的异常场景请假、天气停课、临时换人都聊透再动手写代码。最后再分享一个小技巧排课系统上线初期一定要做好权限控制尤其是“取消排课”和“调整课程时间”这两个操作最好只有管理员角色能执行并且所有操作都记录操作日志。否则一旦有权限过大的普通员工误操作整个班的课表都可能被打乱后续恢复成本极高。这套系统运行了大半年最值钱的设计就是那条覆盖关键操作的操作日志出了问题也能快速定位责任和恢复数据。如果你正准备用Java开发排课系统或者正在为教练培训机构做信息化方案希望这篇源码级别的拆解对你有所启发。遇到具体问题欢迎多交流踩过的坑我基本都帮你提前排掉了。