简介:这份资源是面向高校计算机相关专业毕业设计场景的选课与排课管理系统完整方案,基于SpringBoot与Vue.js前后端分离架构实现,适合正在准备毕设或希望实践Java全栈开发的学习者。压缩包为zip格式,整体约17.55MB,内含源码、SQL脚本、项目文档与答辩PPT等主要文件类型,分别对应系统实现、数据库建表与初始化、架构与功能说明以及汇报演示等用途。系统覆盖用户权限管理、课程信息维护、学生选课、教师排课时段与教室安排、选课数据统计等核心模块,后端采用SpringBoot提供RESTful接口并集成JWT身份验证,前端借助Vue的响应式绑定与组件化完成交互,数据层通过MySQL完成增删改查。目前已有559人学习下载,读者可据此快速理解前后端分离项目的目录组织与接口设计,并在此基础上按需修改扩展,作为毕设起步模板或全栈练手案例均较为实用。
1. 选课排课系统为什么总在毕业设计里翻车
每年到了毕设季,总有一批同学在选课排课管理系统这个题目上栽跟头。表面上看,这就是个增删改查的管理系统,能有多难?但真正动手做过的人都知道,选课和排课这两个业务场景,藏着大量容易被忽略的约束条件。选课要考虑课程容量、时间冲突、先修课依赖;排课要处理教室资源、教师时间、班级课表不重叠。很多同学做到一半才发现,自己写的系统只能跑通“一个学生选一门课”的理想情况,一旦并发上来或者约束变多,逻辑就全乱了。
这篇文章面向的是正在做这类毕业设计的同学,也适合想拿它当 SpringBoot + Vue 全栈练手项目的开发者。我会从技术选型、数据库设计、核心业务实现、前后端联调、部署交付这几个环节,把一套能跑通、能答辩、能复现的方案讲清楚。标题里提到的源码、SQL 脚本、文档和 PPT,本质上是一套完整的交付物,但比交付物更重要的是,你得理解每个模块为什么这么设计。下面我按实际开发顺序,把关键决策和踩过的坑一个个拆开说。
2. 技术选型与项目骨架:为什么用 SpringBoot + Vue 而不是别的
2.1 后端选 SpringBoot 的三个现实理由
毕业设计的时间窗口通常只有两三个月,后端框架的选型直接决定了你能不能在截止日期前跑通核心业务。SpringBoot 最大的优势是自动配置和起步依赖,你不需要像传统 SSM 那样写一堆 XML 配置文件。一个spring-boot-starter-web就能把 Tomcat、SpringMVC、Jackson 全部拉进来,对于选课排课这种以 RESTful 接口为主的系统来说,开发效率是最高的。
另一个容易被忽视的点是生态成熟度。选课排课系统绕不开权限控制、事务管理、分页查询这几个需求,SpringBoot 配合 Spring Security 或者 Shiro 做认证授权,配合 MyBatis-Plus 做数据访问,几乎不需要自己造轮子。我在实际开发中一般会选 MyBatis-Plus 而不是 JPA,原因是排课查询经常需要多表联查和动态条件拼接,MyBatis-Plus 的 QueryWrapper 写起来更直观,生成的 SQL 也更容易调试。
第三个理由是社区资料丰富。毕业设计最怕遇到问题搜不到答案,SpringBoot 的中文资料量级足够大,你遇到的 90% 的报错都能找到现成的解决方案。但要注意,不要盲目复制网上的代码,尤其是版本不一致的时候,SpringBoot 2.x 和 3.x 的依赖坐标差异很大,选定了版本就要保持一致。
2.2 前端选 Vue 而不是 React 或 Thymeleaf 的考量
前端选 Vue 的核心原因是学习曲线平缓。对于大多数只学过 HTML、CSS、JavaScript 的同学来说,Vue 的模板语法和指令系统几乎可以无缝上手。选课排课系统的前端交互主要集中在表格展示、表单提交、弹窗确认这几个场景,Vue 配合 Element Plus 组件库,能快速搭出可用的管理界面。
不选 Thymeleaf 这类服务端渲染方案的原因是前后端分离更符合当前企业开发的实际情况。毕业设计如果只写个 Thymeleaf 页面,答辩时容易被问到“前后端怎么交互”这种问题,而前后端分离的项目天然就有清晰的接口边界,答辩时也更好展示技术栈的完整性。
Vue 3 和 Vue 2 的选择上,我建议用 Vue 3 + Vite 的组合。Vite 的冷启动速度比 Webpack 快很多,开发体验更好。但要注意,Element Plus 是配合 Vue 3 的,Element UI 是配合 Vue 2 的,不要混用。下面是一个最小化的项目结构参考:
# 后端项目结构(SpringBoot) course-scheduling-backend/ ├── src/main/java/com/example/scheduling/ │ ├── controller/ # 接口层 │ ├── service/ # 业务逻辑层 │ ├── mapper/ # 数据访问层 │ ├── entity/ # 数据库实体 │ ├── config/ # 配置类(跨域、拦截器) │ └── common/ # 统一返回结果、异常处理 ├── src/main/resources/ │ ├── application.yml # 主配置文件 │ └── mapper/ # MyBatis XML(如果不用注解) └── pom.xml # 前端项目结构(Vue 3 + Vite) course-scheduling-frontend/ ├── src/ │ ├── api/ # 接口封装 │ ├── views/ # 页面组件 │ ├── router/ # 路由配置 │ ├── store/ # 状态管理(Pinia) │ └── utils/ # 请求拦截器、工具函数 ├── vite.config.js └── package.json这个结构不是唯一的,但它的好处是职责清晰。controller 只负责接收参数和返回结果,service 写业务逻辑,mapper 只管数据库操作。很多同学把业务逻辑写在 controller 里,后期改需求时牵一发而动全身,这个坑一定要避开。
2.3 数据库选 MySQL 8 的版本注意点
MySQL 8 和 MySQL 5.7 在选课排课系统里最明显的差异是窗口函数和 CTE(公用表表达式)。排课算法中经常需要按教师、班级、教室分组后取最大时间戳或者计算排名,MySQL 8 的ROW_NUMBER()和RANK()能省掉很多子查询。另外 MySQL 8 默认字符集是 utf8mb4,支持 emoji 存储,虽然选课系统不一定用得上,但避免乱码问题总是好的。
建库时要注意排序规则用utf8mb4_general_ci还是utf8mb4_0900_ai_ci。后者是 MySQL 8 的默认值,对中文排序更准确,但如果你从 5.7 迁移过来,可能会遇到排序结果不一致的情况。毕业设计里我一般直接用默认值,减少不必要的麻烦。
3. 数据库设计:选课排课系统的表结构与约束怎么定
3.1 核心表清单与字段设计
选课排课系统的数据库设计是整个项目的地基,表结构没定好,后面写业务逻辑会非常痛苦。下面这张表列出了最核心的几张表及其关键字段,实际开发中可以根据需求增减。
| 表名 | 作用 | 关键字段 | 约束说明 |
|---|---|---|---|
| student | 学生信息 | id, student_no, name, class_id | student_no 唯一索引 |
| teacher | 教师信息 | id, teacher_no, name | teacher_no 唯一索引 |
| course | 课程信息 | id, course_no, name, credit, capacity, teacher_id | course_no 唯一索引,capacity 默认 0 |
| classroom | 教室信息 | id, room_no, capacity, building | room_no 唯一索引 |
| course_schedule | 排课记录 | id, course_id, classroom_id, day_of_week, start_section, end_section | 联合唯一索引防冲突 |
| course_selection | 选课记录 | id, student_id, course_id, select_time, status | student_id + course_id 联合唯一 |
| class_info | 班级信息 | id, class_name, grade | class_name 唯一索引 |
这张表里最容易被忽略的是course_schedule的联合唯一索引。排课的核心约束是“同一教室、同一天、同一时间段不能有两门课”,这个约束如果不在数据库层面加唯一索引,只靠代码判断,并发情况下一定会出问题。我一般会建一个UNIQUE KEY uk_room_time (classroom_id, day_of_week, start_section, end_section),让数据库来兜底。
course_selection表的status字段用来标记选课状态,比如 1 表示已选、2 表示已退、3 表示候补。很多同学只用一个布尔字段表示是否选中,后期想加候补功能时就得改表结构,不如一开始就留好扩展空间。
3.2 时间冲突检测的 SQL 实现
选课和排课都绕不开时间冲突检测。学生选课时要判断新选的课是否和已选课程时间重叠,排课时要判断教室和教师是否被占用。下面这段 SQL 是检测某个学生在指定时间段内是否已有课程:
-- 检测学生是否在指定时间段已有课程 -- 参数:studentId, dayOfWeek, startSection, endSection SELECT COUNT(1) FROM course_selection cs JOIN course_schedule sch ON cs.course_id = sch.course_id WHERE cs.student_id = #{studentId} AND cs.status = 1 AND sch.day_of_week = #{dayOfWeek} AND sch.start_section <= #{endSection} AND sch.end_section >= #{startSection};这段 SQL 的逻辑是:两段时间重叠的条件是“新课程开始节次小于等于已有课程结束节次,且新课程结束节次大于等于已有课程开始节次”。如果返回的 COUNT 大于 0,说明存在冲突。参数说明:studentId是学生 ID,dayOfWeek是星期几(1-7),startSection和endSection是节次范围。注意status = 1只统计已选中的课程,退选的课程不参与冲突检测。
排课时的教室冲突检测逻辑类似,只需要把student_id换成classroom_id,并且去掉course_selection的关联,直接查course_schedule表即可。这里有个细节:如果排课记录允许修改,更新时要把当前记录排除掉,否则会自己和自己冲突。
3.3 外键约束要不要加
毕业设计里关于外键的争论一直存在。我的建议是:不加物理外键,用代码保证引用完整性。原因是物理外键在批量插入和删除时会带来额外的锁开销,而且很多同学在测试阶段手动改数据,外键约束会导致操作失败,增加调试难度。但要在文档里写清楚表之间的逻辑关系,答辩时老师如果问到,你能说出“逻辑外键”这个概念就行。
如果一定要加外键,注意级联删除的配置。比如删除课程时,关联的排课记录和选课记录应该怎么处理?ON DELETE CASCADE会直接删掉关联数据,ON DELETE RESTRICT会阻止删除。选课系统里我倾向于用 RESTRICT,因为课程删除应该是一个软删除操作,直接物理删除会导致历史数据丢失。
4. 核心业务实现:选课并发与排课算法的代码落地
4.1 选课接口的并发控制方案
选课系统最典型的场景是:一门课容量 50 人,开放选课的一瞬间有 200 个请求同时进来。如果不做并发控制,数据库里会出现选课人数超过容量的情况。常见的解决方案有三种:数据库悲观锁、Redis 分布式锁、乐观锁版本号。毕业设计里我推荐用乐观锁 + 数据库唯一索引的组合,实现简单且不容易出错。
乐观锁的实现方式是在course表加一个version字段,每次更新容量时检查版本号:
// CourseServiceImpl.java 选课核心逻辑 @Transactional(rollbackFor = Exception.class) public Result<?> selectCourse(Long studentId, Long courseId) { // 1. 查询课程信息(带版本号) Course course = courseMapper.selectById(courseId); if (course == null) { return Result.fail("课程不存在"); } // 2. 检查容量 if (course.getSelectedCount() >= course.getCapacity()) { return Result.fail("课程已满"); } // 3. 检查时间冲突(调用 3.2 节的 SQL) if (hasTimeConflict(studentId, courseId)) { return Result.fail("时间冲突"); } // 4. 乐观锁更新已选人数 int updated = courseMapper.increaseSelectedCount(courseId, course.getVersion()); if (updated == 0) { return Result.fail("选课失败,请重试"); } // 5. 插入选课记录(依赖唯一索引兜底) try { courseSelectionMapper.insert(new CourseSelection(studentId, courseId)); } catch (DuplicateKeyException e) { throw new BusinessException("请勿重复选课"); } return Result.success("选课成功"); }对应的 Mapper 方法:
<!-- CourseMapper.xml 乐观锁更新 --> <update id="increaseSelectedCount"> UPDATE course SET selected_count = selected_count + 1, version = version + 1 WHERE id = #{courseId} AND version = #{version} AND selected_count < capacity </update>这段代码的关键点有三个:第一,WHERE条件里带了version和selected_count < capacity,保证并发时只有一个请求能更新成功;第二,course_selection表的student_id + course_id联合唯一索引防止重复选课;第三,整个方法加了@Transactional注解,任何一步失败都会回滚。参数说明:studentId和courseId由前端传入,version从查询结果中获取。如果更新返回 0,说明版本号已被其他请求修改,提示用户重试即可。
4.2 排课算法的简化实现
排课算法是这类系统的难点,完整的排课需要考虑教师时间、教室容量、班级课表、课程优先级等多个维度。毕业设计里不需要做到工业级排课,但至少要能自动生成一份无冲突的课表。我一般用贪心算法 + 回溯的思路:按课程优先级排序,依次为每门课寻找可用的时间段和教室,如果找不到就回溯调整。
下面是一个简化版的排课核心逻辑:
// ScheduleServiceImpl.java 自动排课 public void autoSchedule(Long semesterId) { // 1. 获取本学期所有待排课程,按优先级排序 List<Course> courses = courseMapper.selectPendingCourses(semesterId); courses.sort(Comparator.comparingInt(Course::getPriority).reversed()); // 2. 获取所有可用时间段(周一至周五,每天 5 个时间段) List<TimeSlot> timeSlots = generateTimeSlots(); // 3. 获取所有教室 List<Classroom> classrooms = classroomMapper.selectAll(); // 4. 贪心分配 for (Course course : courses) { boolean assigned = false; for (TimeSlot slot : timeSlots) { for (Classroom room : classrooms) { if (room.getCapacity() < course.getCapacity()) { continue; // 教室容量不足 } if (isRoomAvailable(room.getId(), slot) && isTeacherAvailable(course.getTeacherId(), slot) && isClassAvailable(course.getClassId(), slot)) { // 插入排课记录 scheduleMapper.insert(new CourseSchedule( course.getId(), room.getId(), slot.getDayOfWeek(), slot.getStartSection(), slot.getEndSection() )); assigned = true; break; } } if (assigned) break; } if (!assigned) { // 记录排课失败的课程,后续人工干预 log.warn("课程 {} 排课失败,需要手动调整", course.getName()); } } }这段代码的逻辑说明:generateTimeSlots()生成所有可选时间段,比如周一第 1-2 节、周一第 3-4 节等。isRoomAvailable、isTeacherAvailable、isClassAvailable分别检查教室、教师、班级在该时间段是否已被占用。参数说明:semesterId是学期 ID,priority是课程优先级(数值越大越优先),capacity是课程容量。这个算法不保证全局最优,但能生成一份可用的课表,剩下的冲突由人工在界面上调整。
4.3 前端选课页面的交互细节
前端选课页面有两个容易翻车的地方:一是选课按钮的防重复点击,二是选课成功后的数据刷新。防重复点击可以用loading状态控制:
// SelectCourse.vue 选课按钮逻辑 const handleSelect = async (course) => { if (course.loading) return; // 防止重复点击 course.loading = true; try { const res = await selectCourseApi({ courseId: course.id }); if (res.code === 200) { ElMessage.success('选课成功'); await fetchCourseList(); // 刷新列表 } else { ElMessage.error(res.message); } } finally { course.loading = false; } };参数说明:course.loading是每个课程项独立的加载状态,不要用全局的 loading,否则一个课程在请求中会导致所有按钮都禁用。fetchCourseList()重新拉取课程列表,保证已选人数是最新的。这里有个细节:如果选课请求返回“课程已满”,前端应该把该课程的按钮置灰,而不是等用户再点一次才提示。
5. 避坑与排查:选课排课系统最常见的五个问题
5.1 跨域问题导致前端请求全部 404
现象:前端启动后调用后端接口,浏览器控制台报Access-Control-Allow-Origin错误,Network 面板显示请求被拦截。
原因:前后端分离项目里,前端运行在localhost:5173,后端运行在localhost:8080,端口不同就构成跨域。SpringBoot 默认不允许跨域请求。
解决:在后端加一个全局跨域配置类,不要在每个 Controller 上单独加@CrossOrigin,那样容易漏掉。配置如下:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") // 允许所有来源 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns和allowedOrigins的区别:当allowCredentials(true)时,不能用allowedOrigins("*"),必须用allowedOriginPatterns,否则 SpringBoot 会报错。
5.2 选课人数超卖但数据库没有报错
现象:课程容量 50,但选课结束后发现已选人数是 53,数据库里也没有唯一索引冲突的报错。
原因:并发请求同时读到selected_count = 49,都判断“未满”,然后都执行了更新。如果更新语句没有加selected_count < capacity条件,就会超卖。
解决:检查increaseSelectedCount的 SQL,确保WHERE子句里有selected_count < capacity。另外,course_selection表的联合唯一索引必须存在,防止同一个学生重复选同一门课。如果用的是 MyBatis-Plus 的updateById,它不会带容量条件,必须手写 SQL。
5.3 排课结果出现教师时间冲突
现象:自动排课完成后,某个教师在周一第 1-2 节同时有两门课。
原因:isTeacherAvailable方法在判断时只查了course_schedule表,但排课过程中新插入的记录可能还没有提交事务,导致查询不到。
解决:把自动排课方法加上@Transactional注解,保证插入和查询在同一个事务里。另外,isTeacherAvailable的查询条件要包含teacher_id、day_of_week、start_section、end_section四个字段,缺一不可。如果还是冲突,检查一下course_schedule表有没有脏数据,先清空再重新排。
5.4 前端表格分页参数传错导致数据重复
现象:课程列表第一页和第二页显示的数据有重复,或者翻页后数据不变。
原因:Element Plus 的el-pagination组件默认页码从 1 开始,但后端 MyBatis-Plus 的Page对象当前页也是从 1 开始,如果前端传了pageNum = 0,就会导致查询结果异常。
解决:在前端请求拦截器里统一处理分页参数,确保pageNum最小值为 1。后端接收参数时用@RequestParam(defaultValue = "1")设置默认值。另外,分页查询的 SQL 要加ORDER BY id DESC,否则 MySQL 返回的顺序不稳定,翻页时会出现数据跳动。
5.5 部署到服务器后接口 500 但本地正常
现象:本地开发环境一切正常,打包部署到服务器后,部分接口返回 500,日志显示Table 'xxx' doesn't exist。
原因:服务器上的数据库没有导入完整的 SQL 脚本,或者数据库名和application.yml里配置的不一致。还有一种可能是 MySQL 版本不同,本地是 8.0,服务器是 5.7,某些语法不兼容。
解决:部署前先确认服务器 MySQL 版本,用SELECT VERSION();查看。导入 SQL 脚本时注意字符集,用source /path/to/script.sql命令导入,不要直接复制粘贴。application.yml里的url要加上useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,避免时区和编码问题。
6. 交付物整理与答辩演示的实用技巧
6.1 源码、SQL 脚本、文档、PPT 的整理规范
毕业设计的交付物不只是代码能跑就行,文档和 PPT 的质量直接影响答辩分数。源码目录里建议加一个README.md,写清楚项目启动步骤、依赖版本、数据库配置。SQL 脚本要分成两个文件:schema.sql建表语句和data.sql初始数据。初始数据里至少要有管理员账号、几个测试学生和教师、几门示例课程,方便答辩时直接演示。
文档部分,我一般会准备三份:需求分析文档、数据库设计文档、接口文档。接口文档可以用 Swagger 自动生成,省去手写的麻烦。PPT 控制在 15 页以内,重点讲清楚技术选型理由、数据库设计思路、核心业务实现、遇到的难点和解决方案。不要堆砌代码截图,用流程图和表格代替。
6.2 答辩演示的流程设计
答辩演示最怕现场翻车,所以提前走一遍完整流程很有必要。我一般会按这个顺序演示:先登录管理员账号,展示课程管理、教师管理、教室管理;然后切换到学生账号,演示选课流程,重点展示时间冲突检测和容量已满的提示;最后回到管理员账号,执行自动排课,展示排课结果和手动调整功能。
演示前要准备一个“后悔药”方案:如果自动排课结果不理想,提前手动排好一份课表,演示时直接展示手动调整后的结果。另外,数据库里的测试数据要提前准备好,不要现场录入,避免手忙脚乱。
6.3 一个容易被忽略的细节:接口返回的统一格式
很多同学写接口时,有的返回{ code: 200, data: ... },有的直接返回对象,前端处理起来很麻烦。建议定义一个统一的返回结果类:
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> fail(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }前端在 axios 响应拦截器里统一判断code,如果是 200 就返回data,否则弹出错误提示。这样业务代码里就不用每次都写if (res.code === 200),减少重复代码。
6.4 最后说一个血泪教训
做毕业设计最忌讳的是前期不着急,后期通宵赶。选课排课系统的业务逻辑比看起来复杂,尤其是并发控制和排课算法,调试起来很花时间。我的习惯是先把数据库设计和接口定义好,用 Postman 把后端接口全部测通,再写前端页面。这样前后端联调时,问题基本都出在前端,排查范围小很多。
另外,版本控制一定要用 Git,每天提交一次。我见过太多同学因为改错一个文件导致整个项目跑不起来,又没有备份,最后只能重写。Git 的commit信息写清楚改了什么,比如“完成选课接口并发控制”比“修改代码”有用得多。
希望帮到你。
本文还有配套的精品资源,点击获取