
1. 项目背景与核心需求拆解做教练培训排课系统这件事最初是因为一个做驾校培训的朋友找我吐槽教练和学员的约课全靠微信群接龙场地、车辆、教练的时间撞车是家常便饭碰到寒暑假高峰期一天光协调课程就得搭进去俩小时。后来我去了解了一圈发现不光驾校健身私教、职业技能培训、企业内训凡是涉及人带人、场地有限、时间又不能乱的场景都绕不开排课这个老大难。于是就有了这个Java实现的教练培训高效排课系统源码项目。它的核心目标很明确在给定教练、学员、场地、课程四类资源的前提下自动生成不冲突且利用率高的课程表同时支持人工调整、微信通知等配套功能。排课这个词听起来简单真正落地要处理的约束条件其实不少——教练同时间段只能上一节课一个场地同一时间不能同时容纳两个班学员不能同时约两门课还得考虑教练的工作时段、休息日、课程难度与教练等级的匹配关系。这个系统适合谁来参考如果你是在校学生想找一个能写进毕业设计或简历里的Java全栈项目它比网上千篇一律的图书管理系统有区分度得多如果你是在职开发想了解排课类业务本质上是一个带约束的资源调度问题如何建模和落地这里的冲突检测算法和数据库设计可以直接迁移到会议室预约、考试编排、实验室排课等场景。整个项目基于Spring Boot MyBatis Plus Vue这套主流技术栈前置要求不高能跑通Spring Boot增删改查基本就能看懂。我在动手之前把这类系统在网上翻了个遍发现大多数开源排课系统有两个极端要么业务模型太弱只能做到教室号时间段的简单分配换到培训场景就水土不服要么算法堆得很重上来就是遗传算法、模拟退火但工程上根本没法维护改一个参数要重启半天。这个项目走的是一条务实的中间路线用常规的Java编程解决90%的排课需求剩下的10%留给人工微调兜底这也是实际业务里真正能落地的做法。2. 排课系统的总体设计与技术选型2.1 功能模块怎么划分才合理拿到这类业务第一步一定不是写代码而是把角色和流程理清楚。教练培训排课系统里我划分了六个核心模块分别是教练管理、学员管理、课程管理、排课中心、课时统计、系统配置。教练管理模块管的是教练档案包括姓名、手机号、教练等级初级/中级/高级、擅长科目、可授课时间周几的哪个时段还有本月已排课时数这个字段是为了后面做负载均衡用的。学员管理管的是学员信息和预约记录一个学员可以关联多个课程订单。课程管理则是维护课程库比如科目二场地训练、科目三路考训练、体能私教课、急救员培训课等每门课要绑定一个默认课时长度它是排课的最小时间单位。排课中心是整个系统的核心提供自动排课和手动排课两种模式。自动排课要完成三件事给未排课的学员课程找到满足所有约束的教练、场地、时间段组合或者明确告诉运营人员某门课因为教练资源不足无法排入本周手动排课则是运营人员通过日历界面拖拽课程到具体时间格子里拖动时系统要实时校验冲突并给出提示。课时统计负责按周、按月汇总教练的课时量也可以通过图表展示。如果你在paper里看过类似的预约系统你会发现我刻意没有做学员在线自主约课这个功能。原因在于教练培训的业务决策方是运营人员课程价格、教练档期、学员程度都可能影响最终排课结果直接开放给学员自主选择容易造成资源分配不均。当然这个模块作为扩展版本后续是可以加的我留下了一个排课规则表的扩展位。2.2 数据库设计的几个关键点数据库设计是排课系统最容易想当然、也最容易后期返工的地方。我的原则是业务对象表尽量少关系表和规则表单独拆最终核心表控制在十张以内这里挑几张重点讲。首先是教练表和学员表这两张是基础资料表字段没什么特别的。比较有讲究的是课程实例表course_instance它记录的是哪个学员在什么时间、由哪个教练、在哪个场地、上哪门课这是排课操作最终的落点每个字段都是外键但查询频率极高。我把这块设计成一张宽表关联查询时直接join教练名、学员名、课程名而不是拆成一张排课关系表再加一张课程实例表实际测试中列表页的响应速度提升明显代码的复杂度和记忆成本也低得多。其次是一张容易被忽略的场地表。很多初学排课系统的人会把场地当成课程的一个属性字段直接写死科目二→场地A这么做在真实培训场景里行不通。驾校的场地分倒车入库区、侧方停车区、坡道起步区健身房的场地分力量区、操房、单车房不同课程适用的场地不同而且同一块场地可以按时间段碎片化地分配给不同课程。所以我用场地表保存场地名称、类型、可容纳人数再用排课记录去引用场地ID这样冲突检测才能做到同一场地同一时间段只能有一节课。第三张是排课规则表它是系统灵活性的关键。表中保存规则的维度教练等级限制、场地类型限制、最大连续课时数等和规则值。比如某个高级教练只带高级班这个限制如果写死在业务代码里后面的运营人员想调整必须找开发改代码放进规则表之后通过后台配置就能完成。不过也要提醒一句规则表不要设计成万能的否则会出现一个排课请求要遍历五十条规则的性能灾难。我这里只放了三条最核心的规则即教练等级匹配、场地类型匹配、每日最大课时数。建表的SQL不算复杂但有几个细节值得记录。时间字段我统一用datetime类型不使用时间戳因为在MySQL里直接对datetime加索引做范围查询可读性和性能都更好表中凡是会被作为查询条件的字段比如教练ID、场地ID、课程开始时间一定要建联合索引。之前我见过一个半成品项目排课查询慢得离谱后来一查发现是没加任何索引全表扫描几十万条记录加完索引直接快了一个数量级。2.3 技术栈选型的理由技术栈是Spring Boot 2.7 MyBatis Plus MySQL 8.0 Vue 3 Element Plus前端用若依框架做基础二次开发。选择这套组合没有太花哨的理由Spring Boot是目前Java后端的事实标准生态最成熟出了问题随便一搜就是解决方案MyBatis Plus在单表操作上能省掉大量重复的XML配置适合这类业务逻辑集中在Service层的管理后台Vue 3 Element Plus做管理端UI效率很高日历组件、表格组件都是现成的。有一点我想特别说一下为什么不推荐用微服务架构。很多人一设计系统就想着拆用户服务、排课服务、通知服务但对这样一个用户量在几百到几千级别的内部管理系统来说微服务完全是负资产。部署成本高、调试链路长、事务一致性难保证。单应用加模块化包结构就够了后续如果并发真的大了优先考虑加缓存和读写分离而不是拆分服务。3. 核心排课算法的设计与实现3.1 把排课问题抽象成数学模型从计算机的角度来看教练培训排课是一个典型的多约束资源调度问题。它有三个核心资源教练C、场地R、时间段T一个排课动作就是把课程实例分配给一个三元组CRT同时满足一系列硬约束和软约束。硬约束是无论如何不能打破的包括以下几类同一时段教练只能出现在一个场地同一时段场地只能承载一门课程同一时段学员只能参加一门课程教练等级必须不低于课程要求等级场地类型必须匹配课程类型教练和场地的排课时间必须在其可用时间片内。软约束是尽量满足但不强制的要求包括教练每周总课时尽量均匀避免有人累死有人闲死每个学员的课程尽量连续安排黄金时间段的资源优先分配给老学员或高价值课程。这个数学模型的价值在于它能帮你理清一个关键问题自动排课的算法应该解决到什么程度。如果全部用约束规划或者启发式算法来求解理论上能找到全局最优解但代码复杂度呈指数级上升而且需求一变化就需要重新建模。我的处理方式是把问题拆成筛选可用资源和择优分配两步走筛选用简单的规则匹配择优用带权重的贪心策略。这对大多数培训场景已经足够只能覆盖95%的需求剩下的人工兜底。3.2 时间冲突检测的核心逻辑排课系统的地基是冲突检测。不管是自动排课还是手动拖拽排课都闪不开这个功能。冲突检测要判断的是给定一个时间段startTime到endTime和一组资源教练、场地、学员是否已经存在占用。这里最容易犯的错误是把时间段判断写成这样// 错误写法只判断了包含关系漏掉了部分重叠 if (existingStart newStart existingEnd newEnd) { // 冲突 }这种写法只覆盖了已有课程被新课程完全包含的情况而实际的重叠方式千奇百怪新课程在已有课程中间、新课程从已有课程中间开始、新课程恰好接壤等。正确且健壮的区间重叠判断只有一种写法// 正确写法两个区间有交集等价于一个区间起点不晚于另一个区间终点 boolean isConflict existingStart newEnd existingEnd newStart;这个公式的逻辑很简单两个区间不重叠的充要条件是前一个区间的结束时间不晚于后一个区间的开始时间或者反之。所以重叠的否定就是上面这个条件。把这个判断放到教练、场地、学员三个维度各跑一遍任何一个维度返回true就说明存在冲突。在具体实现中我会把三个维度的冲突检测合并到一次数据库查询里而不是查三次减少数据库压力。有一个边界情况要特别留意课程的结束时间恰好等于另一门课的开始时间比如上午10:00结束和10:00开始的课这两者不构成冲突。所以判断条件里用的是严格小于和严格大于而不是小于等于。如果你用的是SQL的BETWEEN要注意BETWEEN是闭区间容易把恰好接壤的也算成冲突我建议直接用开区间比较条件。3.3 自动排课的贪心策略与权重设计自动排课的流程是这样的先把所有待排课程按优先级排序比如学员课程到期时间越近优先级越高然后逐门课寻找可用的教练、场地、时间段组合。搜索顺序我设计成先锁定时间段再筛教练再筛场地。时间段的生成逻辑是把运营配置的工作时间切片。假设工作日是上午8点到12点、下午14点到18点每节课标准时长90分钟那么上午可以切成8:00-9:30、9:30-11:00两段下午可以切成14:00-15:30、15:30-17:00两段。切片后的结果是候选时间段列表每个时间段再通过冲突检测查询当前是否空闲。教练筛选时的择优逻辑是排课质量的关键。我设计了三个权重指标分别为教练负载率已排课时/月最大课时、教练匹配度等级和擅长科目是否完全匹配、历史被排课次数用于打破平局。综合得分最高的教练获得本课程的分配权。用公式表示就是// 综合评分weight1 weight2 weight3 1 double score weight1 * (教练负载率) weight2 * (匹配度系数) weight3 * (历史排课次数归一化值);这里有个容易被忽略的坑如果直接按教练负载率从小到大排序会导致每次自动排课都优先选择最空闲的教练表面上很公平但实际上忽略了匹配度。举例来说一个擅长科目二的教练被安排去带科目三虽然空闲但教学效果差。所以权重设计上匹配度系数必须是最高的我实际使用中weight2设到0.5负载率0.3历史次数0.2这样排出来的结果满意度最高。搜索完所有候选后如果某门课找不到任何一个无冲突的教练场地时间段组合就把它标记为排课失败并在结果列表里给出失败原因比如周四下午无可用教练或场地A已被占用运营人员可以据此调整规则或手动排课。4. 实操过程与核心环节实现4.1 排课核心Service的代码骨架直接抛一段我实际项目中的核心代码这是自动排课的入口方法从待排课程列表到生成排课实例的完整流程。为了控制篇幅我做了简化但核心结构保留了。Service public class ScheduleService { Autowired private CourseInstanceMapper courseInstanceMapper; Autowired private CoachMapper coachMapper; Autowired private VenueMapper venueMapper; Autowired private ScheduleRuleMapper ruleMapper; Transactional(rollbackFor Exception.class) public ScheduleResult autoSchedule(ListCourseRequirement requirements) { // 1. 获取排课规则 ScheduleRule rule ruleMapper.selectActiveRule(); // 2. 生成候选时间段 ListTimeSlot slots generateTimeSlots(rule.getWorkStartTime(), rule.getWorkEndTime(), rule.getCourseDuration()); // 3. 倒序排列越紧急的课程越先排 requirements.sort((r1, r2) - r2.getDeadline().compareTo(r1.getDeadline())); ScheduleResult result new ScheduleResult(); for (CourseRequirement req : requirements) { // 4. 找到最优排课方案 ScheduleDecision decision findBestDecision(req, slots, rule); if (decision null) { result.addFailure(req, 无可用资源组合); continue; } // 5. 插入课程实例并更新教练负载 CourseInstance instance buildInstance(req, decision); courseInstanceMapper.insert(instance); coachMapper.increaseScheduledCount(decision.getCoachId()); result.addSuccess(instance); } return result; } private ScheduleDecision findBestDecision(CourseRequirement req, ListTimeSlot slots, ScheduleRule rule) { ScheduleDecision best null; double bestScore Double.MIN_VALUE; for (TimeSlot slot : slots) { // 过滤掉学员已有课程的时间段 if (isStudentBusy(req.getStudentId(), slot)) continue; ListCoach candidates findAvailableCoaches(req, slot, rule); for (Coach coach : candidates) { Venue venue findAvailableVenue(req.getCourseType(), slot); if (venue null) continue; double score evaluateScore(req, coach, venue, rule); if (score bestScore) { bestScore score; best new ScheduleDecision(coach, venue, slot, score); } } } return best; } }这段代码有几个设计点想解释一下。第一Transactional注解必不可少因为一次自动排课可能插入几十条课程实例记录中途一旦出现异常必须回滚所有记录否则会出现排了一半、数据库里残留脏数据的情况。第二findAvailableCoaches和findAvailableVenue两个方法内部都做了冲突检测它们查询的是已有的课程实例表判断待排时间段是否与已有记录区间重叠。第三evaluateScore实现了上面说的三权重评分分数最高的决策胜出。4.2 冲突检测SQL怎么写得高效这段代码看起来简单但冲突检测的SQL实现是有讲究的。最直接的写法是用循环遍历查库但这样性能很差。我推荐的是区间条件资源ID的一条SQL例如查询某教练在某个时间段是否已有课程SELECT COUNT(*) FROM course_instance WHERE coach_id #{coachId} AND start_time #{newEndTime} AND end_time #{newStartTime} AND deleted 0返回0表示无冲突。这个SQL的好处是能利用到coach_idstart_time的联合索引查询效率极高。如果一次要查询多位教练也可以用IN语句批量处理但要注意IN的数量不要超过几百否则会拖慢查询。实际测试中在一个约2万条排课记录的数据库里单个冲突检测的耗时在毫秒级完全满足需求。这里我还有一个优化技巧把时间段条件改成时间范围的左闭右开问题即把记录的start_time和end_time做成半开区间[start, end)这样相邻课程前课后课无缝衔接不会互相冲突而且计算总占用时长会更直观。这个看似小的约定实际使用中能减少很多莫名其妙的冲突误报。4.3 前端排课日历的实现要点前端核心是一个月历视图用户点击某一天右侧显示当天的课程时间线课程以卡片形式展示。我基于Vue 3和Element Plus的Calendar组件做了二次开发用v-for渲染每天的时间线格子课程的拖拽移动用的SortableJS库。拖拽排课是最符合运营人员使用习惯的交互但实现时要处理两个关键问题。第一是拖拽目标位置的定位即从鼠标落点计算出目标时间段我用的方案是给每个时间格子绑定一个data-slot属性例如>public ListCoachMonthlyStat monthlyStat(int year, int month) { LocalDateTime start LocalDateTime.of(year, month, 1, 0, 0); LocalDateTime end start.plusMonths(1); ListCourseInstance list courseInstanceMapper.selectList( new LambdaQueryWrapperCourseInstance() .between(CourseInstance::getStartTime, start, end) .eq(CourseInstance::getDeleted, 0) ); MapLong, Long coachToMinutes list.stream() .collect(Collectors.groupingBy( CourseInstance::getCoachId, Collectors.summingLong(x - Duration.between(x.getStartTime(), x.getEndTime()).toMinutes()) )); coachToMinutes.forEach((coachId, minutes) - { // 组装报表数据保存或返回给前端 }); }这段代码里用了一个GroupingBy和SummingLong的组合Java 8的Stream API在处理这种内存汇总时非常顺手代码量少且可读性高。在统计的时候需要把删除标记deleted 0过滤掉这是一条我在项目里踩过坑的经验很多系统的统计数据出错根源就是逻辑删除的数据被算进去了。5. 常见问题与排查技巧实录5.1 明明时间不冲突为什么检测结果还是冲突这是刚写完冲突检测代码后最容易遇到的问题。我排查过类似的问题最终发现十有八九是时间格式转换的锅。前端传过来的是字符串2025-01-15 09:30后端解析成LocalDateTime时如果格式不匹配或者时区问题解析出来的时间会和预期差几个小时。建议统一在全局配置一个Jackson的时间格式化规则并用2025-01-15 09:30这种无歧义格式避免使用yyyy/MM/dd或带T的ISO格式。还有一种隐蔽情况课程实例表里的时间字段存的是带时区的类型比如timestamp类型而你在代码里用的LocalDateTime不带时区数据库在存储时会自动做一次时区转换如果你的MySQL连接串没有指定serverTimezoneAsia/Shanghai查询出来的时间可能比真实时间早8小时直接导致冲突检测误判。这个问题查起来很费时间最好的办法是在jdbc连接串里显式加上serverTimezoneAsia/Shanghai。5.2 自动排课偶尔出现并发重复排课在实际运营中两个运营人员可能同时打开排课页面同时触发自动排课导致同一个教练在同一时间段被安排了两门课。数据库层的唯一索引是最终防线我在课程实例表上建了start_timeend_timecoach_id的联合唯一索引这样即使并发执行第二个插入也会因为唯一键冲突而失败。当然仅仅靠数据库索引还不够因为服务层可能已经做了一些复杂的计算。我还在Service层加了一个基于Redis的分布式锁以schedule:lock:{date}为key通过setIfAbsent加锁排课完成或异常后释放。用Redis而不是数据库悲观锁的原因很简单响应速度快而且不会长时间占用数据库连接。如果项目里还没引入Redis用数据库的SELECT ... FOR UPDATE也能凑合但要注意它在高并发下会有锁等待问题。5.3 排课结果不合理教练忙闲不均自动排课跑完之后运营人员经常会反映A教练一周排了25节课B教练只排了8节课。这是因为我在权重设计中的负载率指标没有足够的影响力。解决方法是两步第一步把教练的月最大课时数从固定值改为可配置项结合教练的等级和签约类型设定这样负载率的分母更合理第二步在排课之前增加一次预分配即先给所有待排课程做一次粗粒度的教练分配确保每节课至少有一个候选教练再执行精细的评分排课。这个预分配机制能明显改善忙闲不均的现象。5.4 课程时长不统一切片逻辑怎么处理培训课程的标准时长不一定是固定的90分钟有的课程是45分钟有的是120分钟。我在生成时间段切片时不是简单地把工作时间按固定长度均分而是用可用时间窗口 课程时长动态生成起始时间点。比如上午8点到12点45分钟的课程可以安排在8:00、8:45、9:30、10:15、11:00这些时间点120分钟的课程则只能安排在8:00、10:00。这个逻辑我封装在generateStartTimes方法里根据课程ID读取对应的默认时长再计算所有可用的起始时间点。还有一个优化选项是允许同一场地在同一天里按不同时长交错排课也就是一个120分钟的课程后面紧跟一个45分钟的课程只要总时长不超过场地的可用时段即可。这个需求在实战中经常出现我的做法是砍掉场地连续占用检测的复杂度直接用时间段重叠判断加一天的课程总时长限制。6. 源码使用建议与扩展方向6.1 拿到源码后从哪里入手看代码源码拿到手不要急着跑起来先花半个小时把模块结构过一遍。我的建议阅读顺序是先从数据库SQL脚本看起把表结构和字段含义弄明白因为它是整个系统的地基然后看排课核心Service也就是前面展示的ScheduleService理解自动排课的主流程接着看Controller层了解前端调用的接口最后再看前端代码的排课日历组件。在本地跑的时候需要注意把application.yml里的数据库连接信息改成你自己的MySQL的root密码也一并改掉。前端项目如果是前后端分离的需要先启动后端再启动前端开发服务器并且把前端的代理配置指向后端端口。6.2 如何扩展成学员自主约课系统这是一个高频的扩展需求。实现思路是在现有的课程实例表上增加一个状态字段未开放/可预约/已约满由运营人员先批量生成一周的可预约课程模板学员通过小程序或APP查看并进行预约。预约相当于将一个可预约状态的课程实例绑定到具体学员ID上预约时也要做学员维度的冲突检测防止一个学员同时报了两门冲突的课。这个扩展的难点在于预约的公平性控制热门课程可能一放出来就被秒光。简单的方案是用Redis做库存扣减课程模板刚开始预约时把可预约名额作为key存进Redis学员预约时用synchronized Redis decrement做原子扣减扣减成功才落库。这样能承接几千人的瞬时预约请求对这个量级的系统来说完全够用。6.3 排课系统还能用到哪些场景最后聊一个有意思的事。这个系统的核心抽象是资源时间冲突检测因此它能复用的场景远超教练培训。会议室预约系统、考试考场编排、实验室设备借用、咨询师排班、直播课程安排本质上都是同一类问题。如果后续要复用这套源码只需要把教练表换成会议室表、场地表换成楼层表、课程实例表换成预约记录表排课算法和冲突检测逻辑可以直接平移。我在写这个项目时最大的体会是排课系统看似是一个CRUD管理后台但真正拉开差距的是对约束条件的建模能力和冲突检测的严谨程度。很多项目跑不起来或者排出的结果没法用往往不是代码写得差而是从一开始就没想清楚有哪些约束、哪些约束是硬性的哪些是软性的。把这个想透了系统的骨架和核心逻辑就定下来了剩下的事情只是填充代码而已。