1. 项目概述与需求拆解
1.1 为什么高校排课系统在2025年依然值得认真做
高校排课这件事,看起来只是“把课程、教师、教室、时间四个维度组合一下”,但真正接触过教务工作的人都知道,它本质是一个多约束组合优化问题。一个中等规模的二级学院,每学期光课程就有200多门,涉及教师100多位、教室几十间,还要避开教师个人时间冲突、班级合班拆分、课程间隔合理性、教室容量匹配、实验课对机房和实验室的特殊要求……这些条件叠加起来,靠人工在Excel里排,往往要折腾两三周,排出来的结果还经常被老师以各种理由要求调整。
我选择做这个项目,不单是为了完成一个毕业设计或者课程设计,而是想通过SSM框架把“排课”这件脏活累活,真正用代码打通一遍。整套系统的核心价值可以概括成三句话:用规则引擎做冲突检测,用回溯算法做自动排布,用SSM框架做标准化的Web交付。对于正在选课题的同学来说,这个题目覆盖了Java Web开发的完整链路——从数据库建模到后端业务逻辑,再到前端数据可视化,做完一个项目,基本就把SSM开发流程吃透了。
1.2 功能需求梳理:不只是一个简单的CRUD
很多人在设计排课系统时,容易把重心放在“课程管理、教师管理、教室管理”这三个基础CRUD上,结果做出来的东西本质上是个信息管理系统,离“排课”两个字差得很远。真正的排课系统,核心能力应该在“排”字上。
我的需求梳理分成了五个层次:
- 基础数据管理:教师、课程、教室、班级、学期等基础信息的增删改查。
- 智能排课引擎:输入学期、课程安排、教师可用时间、教室类型要求,自动生成无冲突的课表。
- 冲突检测机制:排课结果必须满足硬性约束(教师同一时间只能上一门课、教室同一时间只能被一个班级使用、班级同一时间不能上两门课),同时尽量满足软性约束(课程时间分布均匀、上午尽量排主课)。
- 可视化课表展示:按教师、按班级、按教室三个维度查看课表,周视图和列表视图切换。
- 手动调课与审批:自动排完的结果允许手工拖拽调整,调整时立即重新做冲突检测,有冲突直接提示。
这套需求下来,项目的工作量适中,既不会像纯管理系统那样空洞,也不会像图像识别、推荐算法那样在算法上失控。对毕业设计答辩来说,既能讲清楚工程实现,也能展示算法理解和业务思考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与核心设计思路
2.1 为什么选SSM而不是Spring Boot
现在Spring Boot已经非常普及,新项目直接用Spring Boot确实更省事。但在高校的课程设计和毕业设计中,SSM(Spring + SpringMVC + MyBatis)依然是教学体系里的主流,原因有几点:
- 教学惯性:很多高校的Java Web课程仍以SSM为蓝本,从配置文件逐行理解Spring IoC、SpringMVC请求流转、MyBatis映射机制,能建立更扎实的框架认知。
- 手动配置的价值:SSM把核心配置暴露在你面前,比如
applicationContext.xml、spring-mvc.xml、mybatis-config.xml,每一个配置都对应一个框架机制。调试一遍配置,远比Spring Boot自动配置更能理解底层原理。 - 答辩表现力:答辩时老师非常喜欢问“你了解Bean的生命周期吗”“SpringMVC的HandlerMapping是怎么工作的”“MyBatis的一二级缓存区别是什么”,SSM项目对这些问题的支撑是最直接的。
当然我也承认,这套系统如果用Spring Boot写,开发效率至少提升30%,尤其是不用费劲处理各种XML配置。但从学习角度考虑,我会建议基础薄弱的同学先按SSM做一遍,再迁移到Spring Boot就是体力活。
2.2 系统整体架构设计
系统采用经典的三层架构,严格遵循分层开发:
**表现层(SpringMVC)**负责接收请求、参数校验、返回视图或JSON数据。视图中课表的表格渲染用BootStrap + Thymeleaf模板引擎配合,没有强行上Vue,原因很简单:这是一套以传统页面交互为主的后台管理系统,数据量不大,不需要前后端分离的复杂度。
**业务层(Spring Service)**是系统的核心承重墙,专门拆出了排课引擎模块和服务接口。
**持久层(MyBatis + MySQL)**负责数据映射,采用Mapper接口加XML映射文件的组合方式,对复杂查询(尤其是课表多维查询、冲突检测的专项SQL)支持很好。
系统模块划分上,我做了六个核心包:controller(控制层)、service(业务逻辑)、dao/mapper(持久层)、entity(实体类)、algorithm(排课算法相关)、util(公共工具类)。其中algorithm包是这套系统的灵魂,专门负责约束满足和排课演算。
2.3 数据库设计:把约束建模做在前面
数据库设计我是花时间最多的地方,因为这个系统的一切判断,最终都会落到数据关系上。我的核心实体设计为六张基础表和一张排课结果表:
| 表名 | 核心字段 | 说明 |
|---|---|---|
| teacher | id, name, title, department, max_hours_per_week | 教师表,加入每周最大课时数用于软约束 |
| course | id, name, credit, hours, week_count, course_type, requires_lab | 课程表,requires_lab标识是否必须用实验室 |
| classroom | id, name, capacity, type, campus | 教室表,type区分普通教室/多媒体/机房/实验室 |
| class_info | id, name, grade, student_count, department | 班级表,class_info避免class关键字 |
| teaching_task | id, course_id, teacher_id, class_ids, semester | 教学任务表,class_ids支持合班,多班级逗号分隔 |
| time_slot | id, day_of_week, section_number, section_time | 时间片表,预处理好的周一到周五、1-8节 |
| schedule_result | id, task_id, classroom_id, time_slot_id, semester, status | 排课结果表,唯一键不直接建,由算法保证 |
时间片表的处理是我自认为做得比较聪明的一个地方。很多人做排课直接在前端生成时间段字符串,但数据库层面用一张time_slot表把“星期几+第几节”映射成时间片ID,后面做冲突检测就非常优雅——直接判断同一个teacher_id/time_slot_id或classroom_id/time_slot_id是否重复,SQL简洁且走索引效率高。
对于schedule_result表,我没有在数据库层面建联合唯一约束,我明确知道为什么:算法层面的冲突检测已经保证了不重复,数据库再加约束反而会影响后期手动调课时临时产生的中间状态保存。这是一个实际开发中容易踩的坑,后面在“常见问题”里我会再展开讲。
3. 排课算法设计:核心中的核心
3.1 先理解问题:排课本质上是约束满足问题
排课问题在计算机科学中被定义为CSP(Constraint Satisfaction Problem,约束满足问题),属于典型的NP完全问题。这听起来很吓人,但对一个学期100多门课的学院来说,远不需要去解一个大规模NP完全问题——搜索空间虽大,但约束剪枝效果显著,用基础的回溯算法结合启发式规则,就能在秒级到分钟级得到满意解。
我的算法设计目标很明确:不是数学最优解,而是工程上的满意解。数学最优解意味着要让所有教师的上课时间都完美避开个人偏好、所有教室利用率全局最大化,这需要跑混合整数规划或者遗传算法进化几百代,对于课排系统来说属于过度设计。
3.2 冲突检测规则:硬约束与软约束分离
我把约束分成两个层级,代码里用独立的工具类管理,这样后续规则调整非常方便。
硬约束(必须满足,不满足则排课失败):
- 同一教师同一时间只能安排一门课程。
- 同一教室同一时间只能安排一门课程。
- 同一班级同一时间只能安排一门课程。
- 教室容量必须大于等于班级人数(或合班总人数)。
- 课程类型必须匹配教室类型(实验课不能排到普通教室)。
软约束(尽量满足,不满足则降低评分):
- 同一门课一周内的多次授课尽量间隔分布,不要连排。
- 主修课尽量安排上午1-2节或3-4节的思维活跃时段。
- 教师每天的课尽量不超过4节,避免过度疲劳。
- 尽量不把课程排在周五7-8节。
硬约束的冲突检测我写成了独立的ConflictChecker类,核心方法是检查教师冲突、教室冲突、班级冲突三大类,每一类都提供checkByTaskAndSlot()方法供算法和手动调课共用。这样自动排课和手动调课走的是同一套检测逻辑,保证了系统行为的一致性。
3.3 回溯算法实现:按时段填充 + 启发式排序
自动排课的核心算法逻辑如下:
先对教学任务列表按照冲突风险从高到低排序(带实验课的任务优先、合班任务优先、周课时多的任务优先),然后逐一对每个任务寻找所有可用的时间片和教室的组合,通过冲突检测后递归进入下一个任务的排布;如果某个任务的候选位置全部被试探完仍然无解,则回溯到上一个任务,更换时间教室组合重新搜索。
其中的关键设计是按时间段填充法。我一开始尝试的是“按课程逐门搜索”,很快遭遇了性能问题:排到后半段时大量课程已经找不到无冲突的时段,频繁回溯导致搜索时间暴涨。改成“按时间段从前往后填充”之后,每周的1-8节、周一到周五共40个时间段,为每个时间段调度匹配的课程和教室,相当于把一个三维排列问题简化成了二维匹配问题,搜索效率显著提升。
代码的核心结构大致如下(节选核心逻辑,完整版工程代码比较长):
java复制public class BacktrackingScheduler {
public List<ScheduleResult> schedule(List<TeachingTask> tasks) {
List<TeachingTask> sortedTasks = sortByPriority(tasks);
List<ScheduleResult> result = new ArrayList<>();
backtrack(sortedTasks, 0, result);
return result;
}
private boolean backtrack(List<TeachingTask> tasks, int index, List<ScheduleResult> result) {
if (index == tasks.size()) return true;
TeachingTask task = tasks.get(index);
// 获取候选时段与教室组合,按时段优先级排序
List<SlotAndRoom> candidates = getCandidates(task);
for (SlotAndRoom candidate : candidates) {
if (conflictChecker.isHardConflict(task, candidate)) {
continue;
}
// 建立临时排布
ScheduleResult temp = buildResult(task, candidate);
result.add(temp);
if (backtrack(tasks, index + 1, result)) {
return true;
}
// 回溯
result.remove(temp);
}
return false;
}
}
这里必须提一个非常容易踩的坑:getCandidates()里的排序规则直接决定算法的成败。如果候选时段是无序的,算法排完课表经常会发现部分课程被挤到周五7-8节,或者一天内某个教师被连排了6节课。我后来加的启发式是:候选时段先按“该时段已被占用数量”升序,再按教师当天已有课时数升序,最后按星期几偏好排——这样能最大化地均匀分散课程。
3.4 手动调课模块:给算法兜底
任何自动排课系统都不可能完全替代教务员的经验判断。比如有些老教授明确要求“周一上午不要排课”,这种个性化需求其实适合归入软约束,但真正操作时会出现算法无法理解的情况,比如“这两个班不能挨着考试”。
所以我还做了一个手动调课界面,列表展示某教师或某班级的当前课表,点击任意课次可以更换教室和时间。调课时,前端先请求后端的一个checkAllConflicts接口做实时校验,校验通过才允许保存。这样既展现了排课系统的开放性,也提高了实际场景中的可用性。
4. 核心模块实现与SSM整合细节
4.1 SSM框架整合中的关键配置
SSM整合的滋味,真正跑起来的人才知道——配置报错排查的时间往往比写业务代码还长。我把自己搭好的关键配置结构分享在这里,新人照着做能省掉大量折腾时间。
pom.xml中的依赖版本搭配是最容易出现冲突的地方。我测试通过的一组稳定版本组合:Spring 5.1.8.RELEASE、SpringMVC 5.1.8.RELEASE、MyBatis 3.5.1、mybatis-spring 2.0.1、MySQL Connector/J 8.0.16、Druid连接池 1.1.20。Spring和SpringMVC版本必须保持一致,这点很多人会忽略,我一开始把Spring配成4.3.x、SpringMVC用5.x,结果项目启动直接抛NoSuchMethodError,排查了半个多小时才找到版本不一致的问题。
spring-mvc.xml中的关键配置:
xml复制<context:component-scan base-package="com.course.controller"/>
<mvc:annotation-driven/>
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/views/"/>
<property name="suffix" value=".jsp"/>
</bean>
注意:
component-scan这里要格外小心。如果扫描范围写成了com.course,会把@Service、@Repository等核心业务注解也扫进来,加上spring-context.xml也扫描了一遍,就会造成Bean定义重复。我的做法是Spring和SpringMVC各司其职:Spring容器扫描service、dao、algorithm,SpringMVC容器只扫描controller。
mybatis-config.xml中比较重要的是驼峰映射设置:
xml复制<configuration>
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
</settings>
</configuration>
数据库字段设计的是class_id、teacher_name这种下划线风格,Java实体类用的是classId、teacherName驼峰风格。不开启驼峰映射的话,MyBatis查询结果会有一半字段为null,而且是那种“查得到但封装不上”的隐性bug,新人排查起来特别容易怀疑人生。
4.2 课表可视化模块的实现思路
课表的展示是排课系统最直观的成果输出。我设计了一个专门用于展示的ScheduleViewService,从schedule_result表关联time_slot、teaching_task、course、teacher、classroom、class_info表,用一条多表连接SQL查询出某教师(或某班级)的全部排课记录,然后组装成一个8行(节次)× 7列(工作日)的二维数组。
二维数组的组装逻辑用Java做比较容易理解:
java复制public String[][] buildWeeklyGrid(List<ScheduleResult> results, String dimension) {
String[][] grid = new String[8][7];
for (ScheduleResult r : results) {
int row = r.getTimeSlot().getSectionNumber() - 1;
int col = r.getTimeSlot().getDayOfWeek() - 1;
String cellContent = r.getCourse().getName() + "\n" + r.getTeacher().getName()
+ "\n" + r.getClassroom().getName();
grid[row][col] = cellContent;
}
return grid;
}
前端用JSP + JSTL遍历二维数组,渲染成<table>。每个单元格默认显示课程名、教师、教室三行信息。点击单元格,弹出一个模态框,显示该课程的所有详细信息,同时提供“调课”按钮,方便进入手动调课流程。
这个模块我做了一个交互上的增强:对“同一门课一周多次课”的单元格使用统一的背景色块标识,比如周一3-4节和周三1-2节如果都是《数据结构》,会用同一个浅蓝色标注。这样视觉上一眼就能看出课程一周的分布情况,教务员查看课表时体验提升很明显。
4.3 权限控制与登录模块的落地
系统角色我设计了三种:管理员、教师、教务员。管理员管基础数据和系统配置,教务员跑自动排课和手工调课,教师只能查看自己的课表。没有接入Spring Security,而是用拦截器(Interceptor)加Session实现了轻量级权限控制,原因很实在——这个项目权限模型足够简单,引入Spring Security会大大增加学习和部署成本。
登录成功后,Session中保存currentUser对象,包含id、roleType等字段。拦截器AuthInterceptor在SpringMVC配置中注册,拦截所有/admin、/scheduler开头的请求。
java复制public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
User user = (User) request.getSession().getAttribute("currentUser");
if (user == null) {
response.sendRedirect(request.getContextPath() + "/login");
return false;
}
String uri = request.getRequestURI();
if (uri.contains("/admin/") && !"ADMIN".equals(user.getRoleType())) {
response.setStatus(403);
return false;
}
return true;
}
}
一个小细节值得分享:拦截器里判断权限时不要用!uri.startsWith("/admin"),而是用uri.contains("/admin/")。因为项目部署时如果不加context-path,startsWith("/admin")会把/administrator这种路径也拦进来,容易误伤。
5. 常见问题与排查技巧实录
5.1 自动排课结果有冲突,算法是不是有bug
现象:排课完成提示成功,但教务员在课表里发现某教师同一时间出现在两个教室。
排查过程:我第二次遇到这个问题时差点怀疑人生,因为ConflictChecker明明把检测逻辑写得没有漏洞。后来打印排课过程中的中间数据才发现,问题出在TeachingTask对象上的class_ids字段——一个合班教学任务被拆成了“按班级排布”还是“按任务排布”的问题。算法按任务排布时,只检测了任务级别的冲突,而合班中的两个班级,如果分别被其他课程引用,就会造成“班级A在上计算机原理,同时又被排了数学课”的隐蔽冲突。
解决方案:在getCandidates()和backtrack()中,对合班任务做“班级级”的拆解检查。即对于一个包含多个班级的教学任务,必须遍历每个班级,检查该班级在目标时段是否已被其他任务占用。排查逻辑单独抽成一个checkClassCollision()方法,并补充了自动化测试用例,专门覆盖合班场景。
5.2 MyBatis查询结果为null,且不报错
现象:查课表时,courseName和teacherName有值,但classroomName始终为null,数据库里明明有数据。
排查过程:这是新手必踩的经典坑。我排查时先看SQL,直接在Navicat里执行,结果正常;再看实体类字段名,也都是驼峰。最后才想到查看映射文件——classroom_name这个字段在SQL中用别名cr.classroom_name返回,但对应的映射文件里<result column="classroom_name" property="classroomName"/>少写了一个r。字段别名对不上,正好驼峰映射没有作用于classroomName,所以其他字段都正常,就它为空。
解决方案:统一规范——所有SQL查询都显式写别名并和resultMap的column一一对应,不依赖MyBatis自动映射。虽然多写几行,但排错成本大幅下降。
5.3 排课算法运行时间过长,达到分钟级
现象:导入60个教学任务时算法能稳定运行,但加到120个任务后耗时变得非常夸张,甚至卡顿。
排查过程:加日志后发现,回溯深度到了100层以后频繁出现“无解重试”,单次回溯尝试的候选时段组合数量太大。原因有两个:一是getCandidates()对候选时段没有做优先级排序,导致大量无效尝试;二是教室类型匹配做了全遍历,没有先通过type字段做索引过滤。
优化方案:第一,候选时段排序加启发式权重(前面提过的占用数+教师当日课时数+星期偏好);第二,给classroom表的type字段加普通索引,查询候选教室时先排除类型不匹配的;第三,在递归前做一个预处理:如果剩余任务中某个教师的课程总课时数已经超过其每周最大容量,直接返回失败,提前剪枝。三项优化后,120个任务在5秒内完成,完全可接受。
5.4 典型问题排查速查表
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 页面加载报404,但Controller存在 | SpringMVC扫描包路径配置错误 | 检查<context:component-scan>包名与Controller包名是否一致 |
| MyBatis查询返回结果为空但不报错 | 实体类与表字段映射不一致 | 开启驼峰映射或显式配置resultMap |
| 自动排课结果有隐蔽冲突 | 合班任务未做班级级冲突检测 | 对合班任务逐班级校验冲突 |
| 排课耗时过长 | 候选时段无优先级、扫描全表 | 添加启发式排序、加索引、添加剪枝 |
Druid连接池报GetConnectionTimeoutException |
连接池最大连接数配置过低 | 调大maxActive,建议设置为20-50 |
| Maven打包后配置文件丢失 | 资源目录未包含xml/properties | 在pom.xml的build中配置resources目录 |
5.5 从项目实战中总结的几个教训
第一,不要过早优化算法,先让数据结构和业务逻辑正确。我最早花了很多时间研究遗传算法和模拟退火,最后发现对于高校排课场景,一个良好的回溯算法加上启发式剪枝已经完全够用。先跑起来,再谈优化,这个次序不能反。
第二,数据库设计时预留扩展位,但不要太泛化。第三张表起初我设计了extra_field之类的万能扩展字段,后来发现代码里根本没人用,反而干扰阅读。真正需要灵活的是time_slot表和软约束评分表,把该固化的规则固化下来就够了。
第三,配置文件的版本锁定必须从一开始就做好。SSM的依赖版本组合问题是非常典型的“配错一个全盘崩”问题,建议第一次搭环境时就把能用的版本记录下来,方便以后复用。我自己这次整理的稳定版本组合已经分享在4.1节,能帮大家少走很多弯路。
6. 项目部署与答辩展示经验
6.1 从源码到可运行系统的部署流程
这个系统我用的是传统方式部署:打包成WAR包,丢到Tomcat的webapps目录下。跟现在流行的Spring Boot内嵌Tomcat相比,这种方式在高校机房环境中更稳妥,不用额外装IDE,教务处的Windows机器只要能跑浏览器和Tomcat就够了。
部署步骤记录一下:
- 在项目的
application.properties中修改数据库连接地址和用户名密码。 - 执行
mvn clean package -DskipTests,在target目录下生成WAR包。 - 将WAR包复制到Tomcat的
webapps目录,启动Tomcat自动解压部署。 - 浏览器访问
http://localhost:8080/course-scheduler/login,进入登录页。 - 首次运行时系统会自动执行
init_db.sql初始化表结构和基础数据。
有一个部署的小坑要提醒你:Tomcat的JDK版本和项目编译版本必须一致。如果项目用JDK 1.8编译,Tomcat却跑在JDK 11上,有时能正常跑,有时会抛UnsupportedClassVersionError。我的策略是统一用JDK 1.8 + Tomcat 8.5,这套组合很稳。
6.2 答辩展示时的核心亮点表达
如果你的项目是毕业设计,答辩时要把评委的目光引导到系统的难点和亮点上。我在答辩时的展示顺序是:
- 先展示自动排课的一键操作:选择学期,点击“开始排课”,几秒后生成课表。
- 再展示多维冲突检测:故意选一个原有数据已经存在冲突的场景,演示系统在手动调课时的即时拦截效果。
- 最后展示算法的技术深度:说明回溯搜索的剪枝策略,以及如何平衡硬约束和软约束。
关于技术选择的问题答辩时一定要准备一个答案:“为什么不用现成的商业排课软件”。我当时是这样回答的:一方面商业软件封闭、难以根据学校个性化规则做调整,另一方面这个系统的价值在于展示从需求分析到算法设计再到工程实现的完整能力,同时实现了一套可扩展的规则引擎,未来可以按学校的特殊需求做定制。这样的回答既有高度又务实。
6.3 系统的后续扩展思路
如果你打算把这个项目再升级一版,或者想在里面加一些体现个人能力的东西,我建议从这三个方向选一个深入:
- 引入遗传算法作为高级排课模式:现在已经有了回溯算法的基线版本,可以在算法模块里加一个“遗传算法模式”,用基因编码表示课表,定义适应度函数来评价软约束满足度,用选择、交叉、变异算子迭代搜索。这个扩展能在答辩中展示你对最优化算法的掌握。
- 加入Excel导入导出:现在的大部分基础数据还是手工录入,加一个POI或EasyExcel组件,支持按照教务系统的Excel模板导入课程、教师、教室数据,导出的课表格式尽量对齐学校教务处的打印格式。这一项对实际使用价值提升很大。
- 多人协同与调课审批流程:教务员调整课表后,系统通知相关教师;教师可以提交调课申请,由教务员审批通过后生效。这个功能引入了完整的状态流转,能展示你对业务流程的理解。
我个人做完这套系统后最深刻的体会是:排课系统真正的复杂度不在框架代码,而在那堆看起来琐碎的业务规则和边界情况里。数据库设计时多想一层“合班怎么处理”,算法实现时多写几个日志输出,部署上线前把常见冲突场景逐一遍历测试——这些实操积累,才是做个项目和写完作业之间的本质区别。
