ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue选课排课系统:超卖防控与冲突检测

SpringBoot+Vue选课排课系统:超卖防控与冲突检测

简介:一套面向高校毕业设计与课程实践场景的 SpringBoot + Vue 选课/排课管理系统,提供完整源码、SQL 脚本、配套文档与答辩 PPT。后端以 SpringBoot 为核心,涵盖依赖注入、自动配置与 RESTful API,并通过 JPA 或 MyBatis 与 MySQL 交互;前端采用 Vue.js,结合组件化、Vuex 状态管理与 Axios 异步请求,实现前后端分离。功能覆盖用户注册登录与权限控制、课程信息维护、学生选课与已选课程查询、教师时段与教室排课冲突校验,以及选课人数和课程热度统计等模块,可帮助读者理解教务管理业务从数据库设计到接口联调的完整链路。压缩包约 17.55MB,以 zip 格式分发,包内文件数量与类型明细上游暂未提供。已有 561 人学习,适合需要参照成熟项目结构完成毕设、课程设计或技术栈练手的学生与初中级开发者,文档与 PPT 也可用于梳理架构说明和答辩演示。

1. 选课开放十分钟,系统要先扛住什么

每学期选课开放那十分钟,学生群里必然炸锅:热门课秒没、课表撞车、退课后名额不释放。这套 SpringBoot + Vue 的选课与排课管理系统,处理的正是这几件事——Java 后端负责名额扣减和时间冲突判定,Vue 前端拆成学生、教师、教务三套工作台,学生点选课、教师看排课、教务审课程,各走各的入口。它属于典型的课程设计/毕设级完整工程:源码、SQL 脚本、文档和答辩 PPT 都在包里,拿下来改改字段就能当成自己的题目。适合两类人:一是要把 java 毕业设计跑通并且讲得清架构的同学,二是想找一个前后端分离、带真实业务规则(超卖、冲突、状态流转)的练手项目的中级开发。下面按“能干什么 → 怎么跑 → 怎么改”往下拆。

2. 技术选型与工程结构:一套前后端分离工程怎么落地

2.1 技术栈定格:谁管业务规则,谁管交互

这类 java 选课系统最常见的组合是 SpringBoot 2.x + MyBatis-Plus + MySQL 8 + JWT,前端 Vue 2 + Element UI + axios + vue-router,图表统计再挂一个 ECharts。选型谈不上新潮,但胜在每一步都能查到资料,答辩时也讲得明白。真正决定这套代码好不好改的,是职责边界怎么划:后端不返回“页面需要什么就给什么”的拼接数据,前端也不做任何“看起来像规则”的判断。名额够不够、时间冲不冲突、这门课能不能退,全部压在 Service 层,前端只负责把Result里的 code 翻译成提示语。

我一般会把规则分成三层。第一层是硬约束,落在数据库上:唯一索引挡重复选课,非空和外键挡脏数据,条件更新挡超卖。第二层是业务规则,落在 Service:退课时间窗口、先修课校验、教师同一天最多几节课。第三层才是交互提示,落在 Vue:按钮置灰、弹窗二次确认。很多人把这三次层搞反了,先在前端写if (course.selected >= course.capacity) return,结果接口直接拿 Postman 一打就穿了——这套项目的价值点也在这,它的 Service 里确实做了原子扣减,不是靠前端拦。

JWT 那部分是另一条主线。登录接口签发 token,里面塞userId和role(学生/教师/教务),拦截器解析后放进ThreadLocal,Service 里直接取当前用户,不用每个方法都传studentId。常见做法是拦截器只做“有没有登录”,细粒度的角色鉴权用注解@RequireRole("teacher")挂在 Controller 方法上,前后端各管一半,路由守卫管页面跳转,注解管接口调用,两边都拦才安全。

2.2 目录结构对照:后端一个 controller 对应前端一个 api 文件

拿到源码包先别急着点启动,先把目录看一遍,知道哪块改哪里。

course-system/ ├── course-server/ # SpringBoot 后端,JDK8 就能跑 │ ├── src/main/java/com/example/course/ │ │ ├── controller/ # 只做参数校验 + 返回包装,不写业务 │ │ ├── service/impl/ # 选课、排课、审核的规则全在这一层 │ │ ├── mapper/ # MyBatis-Plus 接口 │ │ ├── entity/ # 与表一一对应,字段名走驼峰映射 │ │ ├── config/ # 拦截器、跨域、分页插件、Knife4j │ │ └── common/ # Result、全局异常、常量枚举 │ └── src/main/resources/ │ ├── application.yml # 数据源、端口、时区,先改这里 │ ├── mapper/ # 复杂 SQL 放 XML,别全塞注解 │ └── static/upload/ # 课件、头像这类静态文件 ├── course-web/ # Vue 前端 │ ├── src/api/ # 一个业务模块一个 js,和 controller 对齐 │ ├── src/views/student/ # 学生端:选课、我的课表、成绩 │ ├── src/views/teacher/ # 教师端:我的教学班、调课申请 │ ├── src/views/admin/ # 教务端:课程库、排课、审核 │ ├── src/router/index.js # 路由守卫在这里按 role 分流 │ └── vue.config.js # devServer.proxy 指向后端 8080 └── sql/ ├── course_schema.sql # 建表 + 索引 + 外键 └── course_data.sql # 初始账号、字典、示例课程

这个对应关系很重要:后端CourseSelectionController大概率对应前端src/api/selection.js,改接口时两边一起动,别只改一边然后对着 404 发呆。views按角色分目录而不是按页面类型分,是因为三端的菜单和权限完全不同,混在一起后面加角色会非常痛苦。

2.3 从零跑起来:三步启动,先通再改

第一步导库。用 Navicat 或命令行都行,顺序不能反,先 schema 后 data,否则外键和初始数据会打架。

# 建库 + 建表,字符集一定写 utf8mb4,别用默认 latin1 mysql -uroot -p123456 -e "CREATE DATABASE course_system DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p123456 course_system < sql/course_schema.sql mysql -uroot -p123456 course_system < sql/course_data.sql

第二步改配置。数据源、账号密码、端口三处必须和本机一致,时区那一段别省。

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/course_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 # 少了这行,课表时间会整体差 8 小时 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true # 数据库 class_id -> 实体 classId

serverTimezone和jackson.time-zone要成对出现,这是后面“选课时间显示成昨天”的根因,先在这里堵住。map-underscore-to-camel-case打开后,实体类不用写一堆@TableField,减少一半样板代码。

第三步双端启动。后端跑CourseApplication主类,看到 Tomcat started on port 8080 就算过;前端npm install之后npm run serve,注意 Node 版本别太新,Vue 2 项目在 Node 18 以上容易报ERR_OSSL_EVP_UNSUPPORTED,装个 16.x 最稳。

cd course-web npm config set registry https://registry.npmmirror.com # 装依赖卡住就换源 npm install npm run serve # 默认 8081,代理转 8080

登录账号一般在course_data.sql里,学生、教师、教务各一条,密码多为加密后的固定值。第一次登录成功后先别急着点选课,去教务端把学期、时间片、教学班这三样数据核对一遍——排课和选课都依赖它们,数据不全后面全是空指针。

3. 数据库设计:六张表撑起选课与排课的全部规则

3.1 核心表结构:教学班才是选课的真正对象

新手最容易搞混的一点:学生选的不是“课程”,是“教学班”。同一门《高等数学》可能开五个班,不同教师、不同时间、不同教室,容量各算各的。所以表要拆成课程(course)和教学班(course_class)两层,选课记录挂在教学班上。这套项目的表基本是这么分的:

表名作用关键字段
sys_user登录账号与角色username、password、role
student/teacher学生、教师档案student_no、major_id、title
course课程库course_code、credit、hours
course_class教学班(开课实例)course_id、teacher_id、capacity、selected_count
class_schedule排课结果class_id、time_slot_id、classroom_id、week_range
course_selection选课明细student_id、class_id、status、score

course_class里同时存capacity和selected_count,是典型的冗余设计:每次选课都去count(*)一遍course_selection当然更“准”,但几百人同时点的时候这张表会被扫穿。用计数字段 + 条件更新,把并发压力收敛到一行记录上,是这类系统的标准解法。

3.2 名额扣减:一条带条件的 UPDATE 顶掉所有锁

超卖是选课系统的头号事故。先select查余量、判断、再update,这个写法在并发下必翻车——两个线程都读到 59/60,都判断通过,最后变成 61/60。正确的做法是让数据库自己判断:

-- 只有“还没满”且“班级状态正常”时才 +1,一行 SQL 完成判断和写入 UPDATE course_class SET selected_count = selected_count + 1 WHERE id = #{classId} AND selected_count < capacity AND status = 1;

调用方拿到的是受影响行数:返回 1 说明抢到了,返回 0 就是满了或已停开,直接抛业务异常回滚。这里不需要synchronized,也不需要分布式锁,InnoDB 的行锁会把同一行的并发串起来,代价最小。

再补一道保险,防重复提交和重复选课:

-- 同一个学生同一门教学班只能有一条记录,接口重复点也不会写脏 ALTER TABLE course_selection ADD UNIQUE KEY uk_student_class (student_id, class_id);

注意这个唯一索引和上面的条件更新要一起用。只加索引不加条件更新,第二个请求会撞唯一键报 500,用户看到的是“系统错误”而不是“已选过”;只加条件更新不加索引,疯狂点按钮能把selected_count加到一个离谱的数字,而明细表里只有一条记录,对账时根本对不上。

3.3 排课冲突检测:教师、班级、教室三线并查

排课比选课更容易出玄学问题,因为它有三类冲突同时存在:一个教师同一时间不能上两门课,一个班级同一时间不能上两门课,一间教室同一时间不能排两门课。三者只要有任意一条命中,这个时间片就不能用。所以time_slot_id必须是一个统一的字典,比如“周一第 3-4 节”对应 id=7,全库共用一套,绝不能一边存“周一3-4节”这种字符串一边存数字。

-- 排课前校验:同一个学期 + 同一个时间片,三类资源任意一个被占用就算冲突 SELECT COUNT(1) FROM class_schedule WHERE term = #{term} AND time_slot_id = #{timeSlotId} AND (teacher_id = #{teacherId} OR class_id = #{classId} OR classroom_id = #{classroomId}) AND id <> IFNULL(#{id}, 0); -- 编辑排课时排除自己,否则永远冲突

id <> IFNULL(#{id}, 0)这一句是编辑场景的关键:新增时id传 null,IFNULL兜成 0,条件恒真;修改时把当前记录排除掉,不然保存自己都保存不了。这个小坑我在三个类似项目里都见过。

另外week_range(起止周)要不要参与冲突判断,取决于业务复杂度。严格做法是把周次也拆成位图比较,比如weeks字段存1111111000000000这样的 16 位串,两个教学班只要有任意一位同时为 1 才算冲突。这套项目用的是简化版——整学期占用,先保证主流程跑通,二次开发时再升级成位图比较,是很合理的取舍。

4. 核心模块拆解:一次选课从点击到落库的完整链路

4.1 选课接口:Controller 薄,Service 厚

先看 Controller,它的活只有三件:收参数、取当前登录人、把结果包成统一格式。不要把任何判断写在这里,否则后面加移动端接口就得复制一遍逻辑。

@RestController @RequestMapping("/api/selection") public class CourseSelectionController { @Autowired private ICourseSelectionService selectionService; /** * 学生选课 * @param classId 教学班主键,前端从课程列表里带过来 */ @PostMapping("/select/{classId}") public Result select(@PathVariable Long classId) { // 当前登录人由拦截器写入 ThreadLocal,Controller 里不传 studentId Long studentId = UserContext.getUserId(); return selectionService.select(studentId, classId); } }

真正的规则在 Service 里,顺序不能乱:先幂等校验,再扣容量,最后写明细。三步都在同一个事务里,任何一步失败整体回滚,selected_count也就跟着回退。

@Service public class CourseSelectionServiceImpl implements ICourseSelectionService { @Autowired private CourseClassMapper classMapper; @Autowired private CourseSelectionMapper selectionMapper; @Override @Transactional(rollbackFor = Exception.class) public Result select(Long studentId, Long classId) { // 1. 幂等前置校验:挡住连点,唯一索引做最终兜底 if (selectionMapper.countByStudentAndClass(studentId, classId) > 0) { return Result.fail("你已经选过这门课了"); } // 2. 条件更新扣容量,受影响行数为 0 = 满员或已停开 int affected = classMapper.tryIncreaseSelected(classId); if (affected == 0) { return Result.fail("名额已满或该教学班已停开"); } // 3. 写选课明细,status=1 表示已选,退课后改 2 而不是删记录 CourseSelection record = new CourseSelection(); record.setStudentId(studentId); record.setClassId(classId); record.setStatus(1); record.setCreateTime(new Date()); selectionMapper.insert(record); return Result.ok("选课成功"); } }

tryIncreaseSelected对应的 Mapper 就一行注解 SQL,返回值是int,千万别写成void——行数是唯一的成功信号。

public interface CourseClassMapper extends BaseMapper<CourseClass> { @Update("UPDATE course_class SET selected_count = selected_count + 1 " + "WHERE id = #{classId} AND selected_count < capacity AND status = 1") int tryIncreaseSelected(@Param("classId") Long classId); }

参数classId是教学班主键,不是课程主键,传错会加到别人班上,这种低级错误排查起来特别费时间,建议 Controller 层加个@NotNull之外再核一次师生归属。

4.2 退课与状态流转:为什么不能直接 delete

退课如果直接DELETE明细,会丢两样东西:一是学生的选课历史(毕业审核要看),二是成绩(退课不等于没上过)。所以状态字段才是正解:1 已选、2 已退、3 已录成绩。

退课实现就是选课的反向操作,同样要事务,同样要更新计数:

@Override @Transactional(rollbackFor = Exception.class) public Result drop(Long studentId, Long classId) { // 只允许退“已选”状态,已录成绩的课退不掉 int rows = selectionMapper.updateStatus(studentId, classId, 2); if (rows == 0) { return Result.fail("没有可退的选课记录,或已录入成绩"); } // 容量 -1,注意加 selected_count > 0 防负数 classMapper.decreaseSelected(classId); return Result.ok("退课成功"); }

decreaseSelected的 SQL 要带AND selected_count > 0,否则一旦有人手动改过数据,计数会被减成 -1,前端显示“剩余 -1 个名额”,学生截图发群里就很难解释了。

候补队列是这套项目的加分项,实现不难:容量满的时候不报错,而是往course_selection插一条status=0的候补记录,退课时按create_time升序找最早的一条候补,改成status=1并占用释放出来的名额。这样用户体验从“名额已满”变成“已进入候补第 3 位”,答辩时也是能展开讲的设计点。

4.3 排课:贪心打底,回溯兜底

排课本质是约束满足问题。教学班数量不多(一个学期几百个),用贪心 + 回溯完全够,不必上遗传算法那种重武器。思路是:每个教学班预先算出一批可用时间片(教师空闲、教室空闲、班级空闲),然后按“候选时间片少的优先”排序,逐个尝试分配,卡住了就回退上一步换一个时间片。

/** * 递归排课 * @param index 当前处理到第几个教学班(已按候选数升序排好) * @param tasks 待排任务列表 * @param ctx 占用表,记录每个时间片被哪些教师/班级/教室占了 */ private boolean arrange(int index, List<CourseTask> tasks, ScheduleContext ctx) { if (index == tasks.size()) { return true; // 全部排完,成功返回 } CourseTask task = tasks.get(index); for (Long slotId : task.getCandidateSlots()) { if (ctx.hasConflict(task, slotId)) { continue; // 教师/班级/教室任一被占,换下一个时间片 } ctx.occupy(task, slotId); // 占位:三类资源同时打标记 if (arrange(index + 1, tasks, ctx)) { return true; } ctx.release(task, slotId); // 回溯:把这一格还回去,继续试下一个 } return false; // 所有候选都试完还不行,交给上一层换 }

hasConflict里就是三个Set的contains,occupy对应三个add,release对应三个remove,用teacherId + "#" + slotId这种拼接 key 最省事。候选时间片排序那一步很关键:先排候选最少的(比如某位教师只有周三下午有空),能大幅减少回溯次数。如果不排序,教室多的班级会把搜索树撑得很大,几百个教学班跑起来要几十秒。

排完课要落库,同时把结果同步到class_schedule,并在教学班详情里回显“周一第 3-4 节 / 教三-401”。前端课表用 Element UI 的el-table按节次渲染行、按星期渲染列,把time_slot_id反解成行列坐标就行,不需要额外算法。

5. 避坑与排查:五个我确实踩过的坑

5.1 后端起来了,前端所有请求 404 或 CORS 报错

现象:浏览器控制台一片红,Access-Control-Allow-Origin缺失,或者请求打到http://localhost:8081/api/...直接 404。

原因:跨域配了两遍还配冲突了。后端写了WebMvcConfigurer.addCorsMappings,同时某个 Controller 上又挂了@CrossOrigin,再加上前端vue.config.js里还有一层proxy,三层叠在一起,预检请求的响应头被覆盖成不完整的那一份。

解决:只留一层。开发期统一走前端代理,后端跨域配置直接注释掉;生产环境用 Nginx 同源部署,压根不需要跨域。代理这么写:

// vue.config.js module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', // 后端地址,改端口只改这里 changeOrigin: true // 后端接口本身带 /api 前缀,就不要写 pathRewrite,否则会变成 /api/api } } } }

5.2 登录成功,但刷新页面就跳回登录页

现象:登录后能进首页,F5 一刷新就退回登录,或者所有接口 401。

原因有两个常见版本。一是 token 存在 Vuex 里,刷新即丢,路由守卫读不到就判定未登录;二是前端请求头写的是Authorization: token值,后端拦截器却按Bearer前缀截取,截出来带空格,解析失败。

解决:token 落localStorage,路由守卫每次从存储里读;请求头统一用config.headers['Authorization'] = 'Bearer ' + token,后端substring(7)之前先startsWith("Bearer ")判一下。另外拦截器的放行列表别忘了/api/login、/api/captcha,漏一个就是“登录接口本身要登录”的死循环。

5.3 后台明明显示剩 1 个名额,两个人同时选都成功了

现象:压测或选课高峰期,selected_count超过capacity,明细表条数也对不上。

原因:Service 里写成了“先查询余量 → if 判断 → update”,判断和写入之间有窗口期,并发下必然穿透。

解决:换成第 3 章那条带selected_count < capacity的条件更新,用受影响行数判断成败,再补(student_id, class_id)唯一索引。改完拿 JMeter 开 200 线程打同一个classId,容量设 50,最后selected_count必须正好是 50,多一条都算没改对。

5.4 排课结果里出现教师同一时间上两门课

现象:冲突校验写了,但排完课一查还是有重叠。

原因:校验 SQL 只判了教室,漏了教师和班级;或者time_slot_id没统一——教师端维护的是“周一 1-2 节”,教务端导入的是“周一第1、2节”,字符串不同但语义相同,SQL 里=永远不相等。

解决:时间片做成独立字典表,全库只认数字 id;冲突查询里三个OR一个都不能少;排课保存前再跑一次校验,别只依赖算法内部的Set,算法内存状态和数据库可能不同步(比如别人刚在另一个页面手工调了课)。

5.5 课表时间整体差 8 小时,或者中文变成问号

现象:数据库里存的是2024-09-01 08:00:00,页面显示2024-09-01 00:00:00;或者课程名全是???。

原因:JDBC URL 少了serverTimezone=Asia/Shanghai,驱动按 UTC 解析;字符集用了utf8而不是utf8mb4,某些生僻字和 emoji 直接丢。

解决:URL 里三个参数一次配齐useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,application.yml的jackson.time-zone设GMT+8,建库语句显式写DEFAULT CHARSET utf8mb4。导入 SQL 脚本时也要确认文件本身是 UTF-8 无 BOM,用 Windows 记事本存过的脚本经常带 BOM,导进去第一行就报语法错误。

6. 二次开发:把这套“够用”改成“能讲”

这套代码的功能闭环是完整的,但离生产还有距离,正好是二次开发的空间。我的建议是挑两三个点做深,别贪多,答辩或面试时能讲透一个比罗列十个强。

改造点动手位置验证方式
选课接口加 Redis 缓存 + 限流CourseSelectionServiceImpl入口同一账号 1 秒点 20 次,超出部分返回友好提示
候补队列自动化drop()里补一段按时间取候补容量 1、三人候补,退课后第一位自动转正
权限细化到按钮级sys_user加permission字段 + 前端v-has指令用教师账号访问教务菜单,接口返回 403 而非 500
时间片升级为周次位图class_schedule.weeks改varchar(32)单双周课程不再互相冲突

拿限流举例,别一上来就上 Sentinel,先用 Redis 做个简单的计数器就够讲清楚:

// 同一学生 10 秒内最多提交 5 次选课,key 带日期防跨天残留 String key = "select:limit:" + studentId + ":" + LocalDate.now(); Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1) { redisTemplate.expire(key, Duration.ofSeconds(10)); // 第一次写入时设过期 } if (count != null && count > 5) { return Result.fail("操作太频繁,请 10 秒后再试"); }

increment是原子操作,expire只在count == 1时设,避免每次都重置过期时间导致 key 永不过期。这套逻辑放在 Service 最前面,比拦截器更贴近业务,也方便按接口单独调阈值。

验证方法上,我给个笨但有效的习惯:改完并发相关的代码,一定开两个浏览器(一个正常窗口一个无痕),登录两个学生账号,对着同一个教学班同时点,看selected_count和明细条数是否严丝合缝;再写一个 JUnit 用CountDownLatch起 50 个线程打同一接口,断言最终计数等于容量。这两步花不了二十分钟,能挡掉九成的超卖问题。

还有个容易被忽略的点:文档和 PPT 别照抄源码包里的原版。改完字段和接口后,把数据库表结构图、接口清单同步更新一遍,把“我加了什么、为什么加、怎么验证的”写进设计章节。答辩老师问的从来不是“这个功能怎么实现的”,而是“你为什么这么设计”。

从那以后我每次拿到这类选课排课项目,都强制先跑一遍上面那个 50 线程的压测脚本再动别的代码——先把数据一致性钉死,再谈界面好不好看,顺序反了后面全是返工。这套源码加 SQL 加文档的组合,最省事的地方就在于它已经把表关系和主流程铺好了,你要做的是在上面加自己的思考和验证。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进