☰
SpringBoot+Vue选课排课系统源码解析:冲突检测与并发选课实现
2026/10/14 1:44:34 网站建设 项目流程

简介:这份资源是面向高校计算机相关专业毕业设计场景的选课与排课管理系统完整方案,适合正在准备毕设、需要参考前后端分离项目实战的Java学习者。项目以SpringBoot为后端核心,结合Vue.js构建前端界面,涵盖用户权限、课程信息、学生选课、教师排课与数据统计等典型业务模块,并配套SQL脚本、设计文档与答辩PPT,便于理解整体架构与实现思路。压缩包为zip格式,大小约17.55MB,内含源码、数据库脚本、文档及演示文稿等主要文件类型,分别对应项目运行、数据初始化与设计说明等用途。目前已有559人学习下载,可作为毕设起步模板,帮助读者快速掌握SpringBoot与Vue.js的整合方式、RESTful接口设计及数据库交互流程,并在此基础上按需修改扩展。

1. 从一份能跑通的选课排课系统源码说起:它到底解决了谁的燃眉之急

每年到了毕设选题季,后台总有一批同学在“选课系统”和“排课系统”之间反复横跳。原因很直接:这两个题目看起来业务逻辑清晰,但真动手写起来,冲突检测、时间片分配、并发选课这些坑一个比一个深。最近拆了一份基于 SpringBoot + Vue 的选课排课管理系统完整源码包,里面包含源码、SQL 脚本、文档和 PPT,算是把这条链路上最磨人的部分都趟了一遍。它解决的不是“从零写一个系统”的问题,而是“让你在有限时间内交出一个能演示、能讲清逻辑、能扛住答辩追问”的成品。适合谁?正在做 Java 毕业设计、需要一套可运行项目参考的本科生,或者刚转 Java Web 想拿一个完整业务练手的初级开发者。这套东西的价值不在于代码有多高深,而在于它把选课和排课两条业务线的数据模型、冲突判定和前端交互都落到了具体实现上,你拿到手就能跑,跑起来就能改。

2. 拆开源码包先看什么:技术栈选型与数据库表结构设计

2.1 为什么是 SpringBoot + Vue 而不是别的组合

拿到一个 Java 毕设项目,第一件事不是急着mvn spring-boot:run,而是先看它的技术选型能不能让你在答辩时说得清楚。这套源码后端用的是 SpringBoot,前端是 Vue,数据库 MySQL,持久层 MyBatis-Plus。这个组合在毕设场景里属于“稳得一批”的选择——SpringBoot 把 Tomcat 内嵌了,不用单独配 Web 服务器;Vue 是渐进式框架,配合 Element UI 能把选课列表、课表展示这些页面快速搭出来;MyBatis-Plus 省掉了大量手写 CRUD 的功夫,让你能把精力放在排课冲突算法上。

常见做法是后端按controller、service、mapper、entity分层,前端按views、api、router组织。这套源码基本遵循了这个结构,你拿到后先看pom.xml里的依赖版本,重点确认 SpringBoot 版本和 MyBatis-Plus 版本是否匹配。如果 SpringBoot 是 2.7.x,MyBatis-Plus 用 3.5.x 比较稳;如果是 3.x 的 SpringBoot,那 MyBatis-Plus 也得跟着上 3.5.3 以上,否则启动时容易报Invalid bound statement的玄学错误。

2.2 数据库表结构:选课和排课的核心表有哪些

排课和选课系统的表设计直接决定了你后面写 SQL 的难度。这套源码的 SQL 脚本里,核心表大概有这么几张:

表名作用关键字段
student学生信息student_id、name、class_id
teacher教师信息teacher_id、name、title
course课程信息course_id、course_name、credit、teacher_id
classroom教室信息room_id、room_name、capacity
course_schedule排课记录schedule_id、course_id、room_id、week_day、section
course_selection选课记录selection_id、student_id、schedule_id、select_time

这里有个设计细节值得注意:course_schedule表里用week_day和section两个字段来定位时间片,而不是存一个完整的时间字符串。这样做的好处是冲突检测可以直接用 SQL 的WHERE week_day = ? AND section = ?来查,不用做字符串解析。你如果自己改,千万别把这两个字段合并成一个time_slot字符串,否则后面查冲突会写到怀疑人生。

2.3 导入 SQL 脚本时容易忽略的字符集问题

SQL 脚本导入不是source xxx.sql就完事了。这套源码的脚本里建表语句用的是utf8mb4,但如果你本地 MySQL 的my.ini里默认字符集是latin1,导入后中文课程名会变成乱码。稳妥的做法是在导入前先执行:

-- 查看当前字符集设置 SHOW VARIABLES LIKE 'character_set%'; -- 如果 character_set_database 不是 utf8mb4,先改数据库 ALTER DATABASE your_db_name CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

然后在连接字符串里加上useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai。serverTimezone这个参数不加的话,MySQL 8.x 驱动会报时区错误,这也是个经典翻车点。

3. 把项目跑起来:后端配置、前端联调与接口验证

3.1 后端启动前必须改的三个配置项

源码包里的application.yml通常留的是作者本地的配置,你直接跑大概率连不上数据库。需要改的地方集中在这三处:

spring: datasource: url: jdbc:mysql://localhost:3306/你的数据库名?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 servlet: multipart: max-file-size: 10MB max-request-size: 20MB server: port: 8080

url里的数据库名要和你导入 SQL 时创建的库名一致。username和password换成你自己的。server.port如果被占用了就改一个,比如 8081,但改完后面前端代理配置也得跟着动。

改完配置后,在项目根目录执行:

# 清理并编译,跳过测试类避免因为测试数据没准备好而报错 mvn clean package -DskipTests # 启动 SpringBoot 应用 java -jar target/xxx-0.0.1-SNAPSHOT.jar

看到控制台输出Started Application in x.x seconds就算后端起来了。如果卡在HikariPool或者报Access denied,八成是数据库账号密码不对;如果报Table 'xxx' doesn't exist,说明 SQL 脚本没导全,回去检查是不是漏了某张表。

3.2 前端 npm 安装与代理配置

前端部分一般是 Vue CLI 或 Vite 构建的。先看package.json里的scripts,如果是vue-cli-service serve就是 Vue CLI,如果是vite就是 Vite。安装依赖:

# 进入前端目录 cd frontend # 安装依赖,建议用 npm 而不是 cnpm,避免依赖版本不一致 npm install # 启动开发服务器 npm run serve

启动前检查vue.config.js或vite.config.js里的代理配置。常见写法是:

// vue.config.js 示例 module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', // 后端地址 changeOrigin: true, pathRewrite: { '^/api': '' } } } } }

这里port是前端启动端口,target是后端地址。如果你后端改了端口,这里必须同步改。pathRewrite的作用是去掉请求路径里的/api前缀,因为后端接口通常不带这个前缀。如果前端请求 404,先看浏览器 Network 面板里请求的 URL 是什么,再对照后端 Controller 的@RequestMapping路径,基本就能定位。

3.3 用 Postman 或浏览器验证核心接口

项目跑起来后,别急着点页面,先用接口验证后端逻辑是否正常。选课系统的核心接口一般有这几个:

  • GET /course/list:查询课程列表
  • POST /selection/select:学生选课
  • GET /schedule/check:检查排课冲突
  • POST /schedule/add:新增排课

以选课接口为例,用 Postman 发一个 POST 请求:

{ "studentId": 1001, "scheduleId": 2001 }

如果返回{"code": 200, "msg": "选课成功"},说明后端逻辑通了。如果返回{"code": 500, "msg": "课程已满"},那说明容量校验生效了。这一步的目的是把前后端问题隔离开——接口通了再调页面,接口不通就先查后端日志。

4. 排课冲突检测与选课并发:核心业务逻辑的代码实现

4.1 排课冲突检测的 SQL 与 Java 双层校验

排课最核心的逻辑就是冲突检测。这套源码里用了两层校验:SQL 层查数据库有没有冲突记录,Java 层再做一次逻辑判断。SQL 大概长这样:

-- 检查同一教室在同一时间片是否已被占用 SELECT COUNT(*) FROM course_schedule WHERE room_id = #{roomId} AND week_day = #{weekDay} AND section = #{section} AND schedule_id != #{scheduleId} -- 编辑时排除自身

Java 层在ScheduleServiceImpl里会先调这个 SQL,如果count > 0就直接返回冲突提示。但光有 SQL 不够,因为还有教师冲突——同一个老师不能在同一时间片上两门课。所以还需要再加一条:

-- 检查同一教师在同一时间片是否已有排课 SELECT COUNT(*) FROM course_schedule cs LEFT JOIN course c ON cs.course_id = c.course_id WHERE c.teacher_id = #{teacherId} AND cs.week_day = #{weekDay} AND cs.section = #{section} AND cs.schedule_id != #{scheduleId}

这两条 SQL 都返回 0 才能插入新排课记录。你如果自己扩展,还可以加上班级冲突检测,逻辑是一样的,只是把teacher_id换成class_id。

4.2 选课并发问题:为什么加了唯一索引还会超卖

选课场景下,多个学生同时点“选课”按钮,如果不做并发控制,就会出现课程容量明明只剩 1 个,却有两个学生都选上了的情况。这套源码里用了两种手段:数据库唯一索引 + 应用层锁。

唯一索引建在course_selection表的(student_id, schedule_id)上,防止同一个学生重复选同一门课。但防超卖还得靠另一招:

// 在选课方法上加同步锁,锁的粒度是 scheduleId synchronized (("schedule_" + scheduleId).intern()) { // 先查当前已选人数 int selectedCount = selectionMapper.countByScheduleId(scheduleId); // 再查课程容量 int capacity = scheduleMapper.getCapacityById(scheduleId); if (selectedCount >= capacity) { throw new BusinessException("课程已满"); } // 插入选课记录 selectionMapper.insert(selection); }

intern()的作用是确保相同scheduleId的字符串是同一个对象,这样锁才能生效。如果不加intern(),两个内容相同的字符串对象锁不住,并发问题依旧。当然,这种单机锁在分布式环境下会失效,但毕设场景单机部署够用了。如果你要上 Redis 分布式锁,那是另一个话题,先把单机跑通再说。

4.3 课表展示:前端如何把排课数据渲染成网格

排课结果最终要展示成一张课表。前端拿到后端返回的排课列表后,需要按week_day和section映射到表格的单元格里。常见做法是用一个二维数组:

// 假设后端返回的 scheduleList 是扁平的排课记录数组 const timetable = Array.from({ length: 7 }, () => Array(5).fill(null)); scheduleList.forEach(item => { // week_day 从 1 开始,数组索引从 0 开始,所以要减 1 const dayIndex = item.weekDay - 1; // section 从 1 开始,同样减 1 const sectionIndex = item.section - 1; timetable[dayIndex][sectionIndex] = { courseName: item.courseName, teacherName: item.teacherName, roomName: item.roomName }; });

然后在模板里用v-for渲染这个二维数组。这里有个坑:如果week_day或section的值超出了数组范围,会直接报undefined错误。稳妥的做法是在赋值前加一个边界判断,或者在后端就限制好取值范围。

5. 避坑与排查:从环境配置到业务逻辑的五个血泪教训

5.1 现象:前端请求全部 404,控制台报 CORS 错误

原因:跨域问题。前端跑在 8081,后端跑在 8080,浏览器同源策略拦截了请求。虽然vue.config.js里配了代理,但有时候代理没生效,或者后端没加@CrossOrigin注解。

解决:先确认前端请求的 URL 是不是以/api开头,再看vue.config.js的proxy配置有没有写错。如果代理配了还报 CORS,可以在后端 Controller 类上加@CrossOrigin(origins = "*")临时解决。但生产环境别这么干,用 Nginx 反向代理更稳。

5.2 现象:MyBatis-Plus 查询返回字段全是 null

原因:实体类字段名和数据库列名没对上。MyBatis-Plus 默认开启驼峰命名转换,比如studentName对应student_name。但如果数据库列名是sname这种缩写,就映射不上了。

解决:在实体类字段上加@TableField("sname")注解显式指定列名。或者干脆在application.yml里把map-underscore-to-camel-case设为false,然后所有字段都用@TableField手动映射。后者麻烦但不容易出错。

5.3 现象:选课成功后页面没刷新,要手动刷新才看到数据

原因:前端选课接口调用成功后没有重新拉取选课列表,或者拉取了但 Vue 的响应式没触发。

解决:在选课成功的回调里重新调用getSelectedList()方法。如果数据变了但视图没更新,检查是不是直接给数组赋值了,Vue 2 里要用this.$set或者splice才能触发响应式。Vue 3 的ref和reactive一般没这个问题,但如果你解构了reactive对象,响应式也会丢。

5.4 现象:排课冲突检测偶尔失效,明明冲突了却提示成功

原因:并发场景下,两个请求同时查冲突,都查到count = 0,然后都插入了。这是典型的“检查后写入”竞态条件。

解决:在冲突检测的 SQL 上加FOR UPDATE行锁,或者在 Java 层用synchronized锁住排课方法。更彻底的做法是在数据库层加唯一约束,比如(room_id, week_day, section)建唯一索引,插入冲突时直接抛异常,捕获后返回友好提示。

5.5 现象:项目打包成 jar 后运行报FileNotFoundException,但 IDE 里正常

原因:代码里用了相对路径读取文件,比如new File("src/main/resources/xxx.txt")。打成 jar 后,src目录不存在了,文件在 classpath 里。

解决:用ClassPathResource或getClass().getResourceAsStream()读取 classpath 下的文件。如果文件在 jar 包外,就用绝对路径或者通过启动参数传入路径。这个坑在需要读取 Excel 模板导入数据的场景里特别常见。

6. 进阶技巧:用 AOP 统一记录选课日志与接口耗时

项目能跑通之后,如果你想在答辩时多一个亮点,可以加一个 AOP 切面,统一记录选课和排课接口的调用日志和耗时。这样既能体现你对 Spring 生态的掌握,也能在演示时展示“系统有监控能力”。

先加依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>

然后写切面类:

@Aspect @Component public class LogAspect { private static final Logger log = LoggerFactory.getLogger(LogAspect.class); // 切到 service 包下所有选课和排课相关的方法 @Pointcut("execution(* com.example.service.SelectionService.*(..)) || " + "execution(* com.example.service.ScheduleService.*(..))") public void businessMethods() {} @Around("businessMethods()") public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); String methodName = joinPoint.getSignature().getName(); Object[] args = joinPoint.getArgs(); try { Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; log.info("方法 {} 执行成功,参数 {},耗时 {} ms", methodName, args, cost); return result; } catch (Throwable e) { long cost = System.currentTimeMillis() - start; log.error("方法 {} 执行异常,参数 {},耗时 {} ms,异常信息:{}", methodName, args, cost, e.getMessage()); throw e; } } }

这段代码的逻辑是:在目标方法执行前后各打一个时间戳,算出耗时;如果方法抛异常,记录异常信息后原样抛出,不影响业务逻辑。@Pointcut里的表达式可以根据你的包名调整,execution的第一个*表示任意返回值,第二个*表示任意方法名,(..)表示任意参数。

参数说明:joinPoint.getArgs()拿到的是方法入参数组,如果参数是复杂对象,日志里会打印对象的toString(),建议给实体类加上@Data注解或者重写toString(),否则日志里只能看到对象地址,排查问题时等于没打。

加完这个切面后,你每次调用选课接口,控制台都会输出类似这样的日志:

方法 selectCourse 执行成功,参数 [1001, 2001],耗时 45 ms

答辩时如果老师问“你怎么保证选课性能”,你就可以把这段日志翻出来,说“我加了 AOP 监控,正常选课耗时在 50ms 以内,并发高的时候会先查缓存”。这比干说“我用了 Redis”有说服力得多。

从那以后我每次拿到一个毕设项目,都强制先跑通接口再加监控,因为只有接口通了,你才知道业务逻辑到底有没有问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询