简介一套面向高校毕业设计与课程实践场景的 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学生/教师/教务拦截器解析后放进ThreadLocalService 里直接取当前用户不用每个方法都传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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 # 少了这行课表时间会整体差 8 小时 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true # 数据库 class_id - 实体 classIdserverTimezone和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、rolestudent/teacher学生、教师档案student_no、major_id、titlecourse课程库course_code、credit、hourscourse_class教学班开课实例course_id、teacher_id、capacity、selected_countclass_schedule排课结果class_id、time_slot_id、classroom_id、week_rangecourse_selection选课明细student_id、class_id、status、scorecourse_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 节”对应 id7全库共用一套绝不能一边存“周一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传 nullIFNULL兜成 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) { // 当前登录人由拦截器写入 ThreadLocalController 里不传 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. 写选课明细status1 表示已选退课后改 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 BaseMapperCourseClass { 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插一条status0的候补记录退课时按create_time升序找最早的一条候补改成status1并占用释放出来的名额。这样用户体验从“名额已满”变成“已进入候补第 3 位”答辩时也是能展开讲的设计点。4.3 排课贪心打底回溯兜底排课本质是约束满足问题。教学班数量不多一个学期几百个用贪心 回溯完全够不必上遗传算法那种重武器。思路是每个教学班预先算出一批可用时间片教师空闲、教室空闲、班级空闲然后按“候选时间片少的优先”排序逐个尝试分配卡住了就回退上一步换一个时间片。/** * 递归排课 * param index 当前处理到第几个教学班已按候选数升序排好 * param tasks 待排任务列表 * param ctx 占用表记录每个时间片被哪些教师/班级/教室占了 */ private boolean arrange(int index, ListCourseTask 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的containsoccupy对应三个addrelease对应三个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 少了serverTimezoneAsia/Shanghai驱动按 UTC 解析字符集用了utf8而不是utf8mb4某些生僻字和 emoji 直接丢。解决URL 里三个参数一次配齐useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghaiapplication.yml的jackson.time-zone设GMT8建库语句显式写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 加文档的组合最省事的地方就在于它已经把表关系和主流程铺好了你要做的是在上面加自己的思考和验证。希望帮到你。本文还有配套的精品资源点击获取