简介:面向高校毕业设计与课程实践场景,这套 SpringBoot+Vue 选课/排课管理系统提供可直接运行的完整工程,适合需要完成选题、答辩演示或学习前后端分离开发的学生与初级开发者。后端基于 SpringBoot 构建 RESTful 接口,结合 MySQL 完成课程、学生、教师与排课等数据增删改查,并涉及 JWT 身份验证、MyBatis 或 JPA 持久化思路;前端采用 Vue.js 的组件化与响应式绑定,配合 Vuex 状态管理和 Axios 请求,实现用户管理、课程管理、学生选课、教师排课时段与教室冲突规避、选课数据统计等模块。交付内容包含源码、SQL 脚本、设计文档与答辩 PPT,文档覆盖架构设计、功能模块、数据库设计与技术选型,PPT 便于梳理演示思路;压缩包约 17.55MB,文件总数与类型明细暂未随页面提供。已有 561 人学习,可作为理解 SpringBoot 与 Vue 协作方式、对照数据库脚本和项目结构的实践样本。
1. 从一份跑不起来的毕设说起:选课排课系统到底该交付什么
做 java毕业设计 的同学里,有一批人卡在同一个环节:压缩包解压了,文档也翻过了,IDE 一打开,数据库连不上、SQL 脚本报错、前端 404,最后只留下一个能登录的登录页。这套基于 springboot选课 与 springboot排课 双模式的管理系统,交付的不是一个"能跑就行"的空壳,而是从数据模型、接口分层到冲突校验的完整链路,任何一段都能拿出来单独讲清楚。它适合两类人:一类是要在规定时间内交出一份能答辩的 java毕业设计,另一类是带课程设计、想把系统改造成自己学校或培训机构场景的开发者。选课和排课看起来只是两条业务分支,但它们的难点完全不同——选课的难点在并发和名额,排课的难点在时间与资源的组合约束,把这两条线分开设计,才不会在后期被"改一个功能全线崩"拖住。
2. 需求与角色拆解:三类用户、两条业务主线和一份能扩展的数据模型
2.1 三类角色与两条业务主线怎么切
选课排课系统的角色划分不复杂,管理员、教师、学生三类就够撑起全部用例,复杂的是权限边界。管理员管的是全局配置,包括学期、开课计划、教室、时间段和账号;教师管的是自己的教学班,包括查看选课名单、录入成绩、申请调课;学生管的是自己的课表,包括选课、退课、查学分。这三类人的入口是同一套系统,但能看到的菜单、能调的接口必须彻底分开,否则一个学生账号能调教师接口,答辩时随便点两下就露馅。
业务主线有两条,而且它们的生命周期不同步。选课主线是"教学班发布 → 开放选课 → 学生提交 → 名额扣减 → 截止统计",节点集中在几周内,压力集中在开放选课的头几分钟。排课主线是"课程计划 → 教师与教室分配 → 时间段编排 → 冲突检测 → 课表发布",节点在学期开始前,压力集中在配置阶段。把两条线塞进同一个 Controller 是最常见的偷懒做法,后果是选课业务需要的高并发处理被排课业务的复杂查询拖慢,后期想给选课加个缓存都无处下手。通常的做法是拆成course和schedule两个业务包,各自有独立的 Service 与数据表,只在"教学班"这个实体上做关联。
2.2 数据模型:八张核心表怎么摆
数据表是整个系统里最不该省的部分。毕设答辩时导师最爱问的就是"你的表怎么设计的",答不上来基本就废了。这套模型的思路是把"课程"和"教学班"拆开:course是课程本身的属性,比如课程名称、学分、课程类型;course_class是某学期实际开出来的一个班,带教师、容量、时间、教室。一门课可以开多个班,学生选的是班而不是课,这个区分不做,后面所有统计都会错。
-- 课程主表:只描述课程本身,不涉及学期 CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(32) NOT NULL UNIQUE COMMENT '课程编号,如 CS1001', course_name VARCHAR(128) NOT NULL COMMENT '课程名称', credit DECIMAL(3,1) NOT NULL DEFAULT 0 COMMENT '学分,0.5 的粒度', course_type TINYINT NOT NULL DEFAULT 1 COMMENT '1必修 2选修 3公选', pre_course_id BIGINT NULL COMMENT '先修课程 id,可为空', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT '课程基础信息'; -- 教学班:真正被选的对象,容量、教师、学期都挂在这里 CREATE TABLE course_class ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT '关联 course.id', teacher_id BIGINT NOT NULL COMMENT '授课教师', term_id BIGINT NOT NULL COMMENT '学期 id', class_no VARCHAR(32) NOT NULL COMMENT '教学班号,如 2024-1-CS1001-01', capacity INT NOT NULL DEFAULT 60 COMMENT '容量上限', selected_num INT NOT NULL DEFAULT 0 COMMENT '已选人数,与选课表冗余但便于扣减', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已发布 2选课中 3已截止', UNIQUE KEY uk_class_no (class_no), KEY idx_term_teacher (term_id, teacher_id) ) COMMENT '教学班,选课的最小单位'; -- 选课记录:唯一索引是防重复选的最后一道闸 CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, class_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1已选 2已退 3候补', select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_class (student_id, class_id, status), KEY idx_class (class_id) ) COMMENT '学生选课记录';上面三张表是核心,另外还需要student、teacher、classroom、time_slot、schedule五张表。time_slot单独建表而不是直接在排课表里存"周一第 1-2 节"这种字符串,原因是冲突检测要做区间比较,字符串没法比大小。把时间段固化成slot_index(第几节课,从 1 开始)加start_time/end_time,冲突判断就退化成整数区间重叠,一条 SQL 就能查完。
selected_num这个冗余字段要不要留,是新手最容易纠结的点。留的理由是扣减名额时不必每次COUNT(*)全表扫,而且能做乐观锁;代价是必须和course_selection强一致。我一般的处理是在选课 Service 里把两个更新放在同一事务内,并额外加一个每天晚上跑的校对任务,把selected_num重算一遍写日志,出现偏差能第一时间发现。
提示:如果课程设计要求做"志愿抽签"式选课(比如热门公选课先申请再抽签),
course_selection的status要扩成 4 个状态,并把唯一索引改成只对"有效记录"生效。这个改动要在建模阶段就想清楚,后期加会动到一半的查询。
2.3 状态机与业务规则清单
教学班的状态流转必须固定下来,不能允许任意跳转。0 草稿 → 1 已发布 → 2 选课中 → 3 已截止 → 4 已结课,只允许顺序推进和有限的回退(草稿可删,已发布未选课时可回退到草稿)。很多毕设的翻车点就在这:学生能选到一个"已截止"的教学班,或者管理员把已选满的班删掉外层报外键错误。把状态判断写在 Service 层而不是前端,前端只做置灰,后端才是真正的守门人。
业务规则清单建议直接写成代码注释或配置,答辩时拿出来讲很有说服力:
| 规则 | 校验位置 | 违反时的处理 |
|---|---|---|
| 教学班容量未满 | Service + 乐观锁 | 返回"名额已满",提示可加入候补 |
| 课程时间不冲突 | 选课时对已选课表做区间比较 | 返回冲突的具体课程与节次 |
| 学分上限(如 30 学分/学期) | 选课前累计已选学分 | 返回当前已选学分与剩余额度 |
| 先修课程已通过 | 选课前查成绩表 | 返回缺失的先修课程名 |
| 同一课程不重复选 | 数据库唯一索引兜底 | 捕获唯一键冲突转成友好提示 |
这五条规则里,前三项在前端也要做一次提示,但孙后端必须再校验一遍。前端校验是体验,后端校验是底线,这个关系想反了,答辩时演示一个"用 Postman 直接调接口绕过去"的场景就很难看。
3. 技术架构与工程骨架:SpringBoot 分层、Vue 路由与鉴权链路
3.1 后端分层与包结构
后端用 SpringBoot 做主框架,好处不是"流行",而是它把毕设需要的那些东西——拦截器、事务、参数校验、定时任务——都收在一个容器里,不用自己拼。分层坚持 Controller / Service / Mapper 三层,不要为了炫技加一堆中间层。Controller 只做参数接收与响应包装,Service 承载业务规则和事务边界,Mapper 只写 SQL,任何一层越界都会让后期维护变成灾难。
包结构按业务域划分而不是按技术类型划分,这是我这几年踩过坑之后固定下来的做法:
com.example.course ├── common/ # 统一响应体、异常、常量、工具 ├── config/ # 拦截器、跨域、MyBatis、Swagger 配置 ├── auth/ # 登录、JWT 生成与校验 ├── course/ # 课程与教学班:controller/service/mapper/entity ├── schedule/ # 排课:时间段、教室、冲突检测 ├── selection/ # 选课:选、退、候补、统计 └── system/ # 用户、角色、菜单、日志按技术类型划分(所有 Controller 一个包、所有 Service 一个包)在文件少的时候看着整齐,一旦业务到十来个模块,改一个需求要在四个包里来回跳,这是纯粹的自找麻烦。
3.2 鉴权链路:JWT + 拦截器 + 前端路由守卫
登录成功后签发 JWT,token 里只放用户 id、角色和过期时间,不要把权限列表塞进去,否则权限一改就得等 token 过期。后端写一个HandlerInterceptor,在preHandle里解析 token、校验签名和过期时间,再把用户信息放进ThreadLocal,Service 层取当前用户就不必层层传参。
@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 预检请求直接放行,否则浏览器跨域会先挂掉 if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { writeJson(response, 401, "未登录或登录已过期"); return false; } try { // 解析失败会抛异常,统一在这里转成 401 LoginUser user = jwtUtil.parse(token.substring(7)); UserContext.set(user); // ThreadLocal 存当前用户 return true; } catch (Exception e) { writeJson(response, 401, "登录状态无效,请重新登录"); return false; } } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { // 必须清理,否则线程池复用时会串用户,这是最隐蔽的一类线上事故 UserContext.clear(); } }afterCompletion里的清理是重点。Tomcat 的线程是复用的,ThreadLocal不清理,下一个请求可能读到上一个用户的身份,本地小流量测不出来,答辩演示多账号切换时也不一定暴露,但这是一个真实存在的数据越权隐患。参数上,Authorization头统一用Bearer前缀,前端封装请求时自动带上,避免每个页面手写。
前端用 Vue 配套的路由守卫做第二道拦截:路由 meta 里标roles: ['ADMIN'],全局beforeEach里判断本地存的角色是否匹配,不匹配就跳回首页。这道拦截只为体验服务,真正的权限判定在后端接口层,两边都做才算完整。
3.3 前端工程结构与请求封装
前端按页面维度组织,views下分admin、teacher、student三个目录,各自的路由懒加载。所有接口调用集中放在api目录,一个业务模块一个文件,页面里不出现裸的axios.get。这样接口路径变更时只改一处,也方便后面写 mock。
// src/utils/request.js —— 统一请求封装 import axios from 'axios' import router from '@/router' const request = axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 从环境变量读,方便换环境 timeout: 15000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) request.interceptors.response.use( res => { const body = res.data // 后端统一返回 {code, msg, data},code 非 200 一律当业务失败 if (body.code !== 200) { return Promise.reject(new Error(body.msg || '请求失败')) } return body.data }, err => { // 401 单独处理:清 token 并回登录页,避免死循环刷新 if (err.response && err.response.status === 401) { localStorage.removeItem('token') router.replace('/login') } return Promise.reject(err) } ) export default request这段封装做了三件事:统一注入 token、统一剥壳返回data、统一处理 401。timeout给 15 秒是经验值,排课冲突检测这种接口如果超过 15 秒还没返回,基本是 SQL 写得有问题,早报错比让用户干等强。页面里调用就变成const list = await getClassList(params),拿到的直接是数据,不用每处都写res.data.data。
4. 核心模块实现:选课并发控制、排课冲突检测与课表生成
4.1 选课:三条防超卖的路线怎么选
选课是这套系统里唯一存在真实并发压力的地方。开放选课的那几分钟,几十上百个学生同时点同一个热门班,如果代码写成"先查人数、再判断、再插入",两次查询之间被别人插队,就会出现超卖——容量 60 的班选进 63 个人。这个问题不解决,答辩时导师问一句"你怎么保证不超卖",场面会很尴尬。
三条常见路线,我一般按项目规模选:
第一条是数据库唯一索引 + 计数校验,最简单,适合并发不高的课程设计。靠uk_student_class防重复选,靠UPDATE ... WHERE selected_num < capacity的受影响行数判断是否抢到名额,一行 SQL 天然原子。
第二条是乐观锁,在course_class加version字段,更新时带版本号,失败重试一到两次。适合读多写少、冲突概率中等的场景。
第三条是 Redis 预扣库存,适合真要做高并发的演示。代价是引入额外组件,还要处理缓存与数据库的一致性,毕设里如果不是特别要求,不建议上,讲清楚原理即可。
实际落地时我通常把第一条和第二条合起来用:
@Service public class SelectionServiceImpl implements SelectionService { @Autowired private CourseClassMapper classMapper; @Autowired private CourseSelectionMapper selectionMapper; @Override @Transactional(rollbackFor = Exception.class) public void select(Long studentId, Long classId) { // 第 1 步:原子扣减名额,受影响行数为 0 说明已满或被并发抢走 int affected = classMapper.decreaseStock(classId); if (affected == 0) { throw new BizException("该教学班名额已满,可选择候补"); } try { // 第 2 步:插入选课记录,唯一索引兜底防重复 selectionMapper.insert(studentId, classId); } catch (DuplicateKeyException e) { // 重复选课会让事务回滚,第 1 步的扣减一并撤销 throw new BizException("你已经选过这门课了"); } } }对应的 Mapper SQL 是UPDATE course_class SET selected_num = selected_num + 1 WHERE id = #{classId} AND selected_num < capacity AND status = 2。三个条件缺一不可:selected_num < capacity保证不超卖,status = 2保证只在选课中的班可被选。@Transactional的rollbackFor = Exception.class也别省,Spring 默认只回滚运行时异常,漏了这行,受检异常会导致名额扣了但记录没插进去。
这里有一个容易被忽略的细节:两条语句的顺序必须是"先扣名额、后插记录"。反过来先插记录再扣名额,扣减失败时虽然事务也能回滚,但唯一索引冲突和名额不足会撞在一起,异常信息很难区分,排查起来就是黑匣子。顺序固定下来,出错时能一眼看出是哪一步挂的。
4.2 排课冲突检测:把时间变成可比较的区间
排课的难点不在写代码,在把规则说清楚。冲突分三类:教师冲突(同一教师同一时间上两个班)、教室冲突(同一教室同一时间排两门课)、班级冲突(同一个教学班被排到两个时间段)。三类冲突的本质都是"同一资源在同一时间区间内被占用两次",所以只要把时间统一映射成(week_start, week_end, day_of_week, slot_start, slot_end)五元组,判断就退化成区间重叠。
public class ScheduleValidator { /** * 判断两个排课时间段是否冲突 * @param a 待排时间段 * @param b 已存在的时间段 * @return true 表示冲突 */ public static boolean isConflict(TimeRange a, TimeRange b) { // 周次不重叠,直接放行(如前 8 周和后 8 周) if (a.getWeekEnd() < b.getWeekStart() || b.getWeekEnd() < a.getWeekStart()) { return false; } // 星期不同,不算冲突 if (!a.getDayOfWeek().equals(b.getDayOfWeek())) { return false; } // 同一天,看节次区间是否重叠:a.start <= b.end && b.start <= a.end return a.getSlotStart() <= b.getSlotEnd() && b.getSlotStart() <= a.getSlotEnd(); } }区间重叠的判定条件a.start <= b.end && b.start <= a.end是这套逻辑的核心,写反了或者漏了等号,就会出现"第 1-2 节和第 2-3 节被判为不冲突"这种玄学 bug。周次判断放在最前面是因为它的剪枝效果最好,大部分候选方案在第一步就被排除,后面两次比较几乎不花时间。
实际查询时不必把所有排课记录捞到内存里跑循环,直接在 SQL 里过滤更快:
-- 查询某教师在指定周次、星期、节次范围内是否已有排课 SELECT COUNT(1) FROM schedule WHERE teacher_id = #{teacherId} AND day_of_week = #{dayOfWeek} AND week_start <= #{weekEnd} AND week_end >= #{weekStart} AND slot_start <= #{slotEnd} AND slot_end >= #{slotStart} AND status != 9; -- 9 表示已取消,不参与冲突判断教师、教室、教学班三个维度各跑一次,任意一个返回大于 0 就拒绝。三次查询放在同一事务里,中间不要穿插写操作,避免刚查完就被别人插入的竞态。真要做严谨,schedule表上按(teacher_id, day_of_week)和(classroom_id, day_of_week)建联合索引,查询能压到毫秒级。
4.3 课表生成与导出
课表不是存出来的,是算出来的。数据库里存的是排课记录(哪门课、哪个时间、哪个教室),学生课表是按学生已选的教学班把这些记录拼成一张二维表:行是节次,列是星期一到星期日。前端拿到的结构通常是一个{ "1-1": {...}, "1-2": {...} }的 map,key 是"星期-节次",value 是课程信息,渲染时按格子填。
导出这块,毕设里常要求 Excel。做法是用 EasyExcel 或 POI,把二维表按 7 列 × N 行的格式写出去,第一节合并单元格写"第 1-2 节"。这里有个小坑:合并单元格的区域要用CellRangeAddress显式声明,只写值不合并,导出的表看起来会像错位。另外中文文件名在响应头里要 URL 编码,否则部分浏览器下载下来是乱码,这个问题在答辩现场演示时最容易出洋相。
5. 避坑与排查:环境、事务、联调三类最容易翻车的地方
5.1 环境与启动:换台电脑就跑不起来
现象:源码在自己电脑上正常,换到同学机器或者答辩机上一启动就报Access denied for user或Unknown database。
原因:配置文件里的数据库账号密码、端口、库名是写死的,SQL 脚本也没按顺序执行。这类问题占了"跑不起来"故障的一大半。
解决:把application.yml拆成application-dev.yml和application-prod.yml,账号密码走环境变量或本地覆盖,交付时附一份README写清楚执行顺序:先建库、再跑建表脚本、最后跑初始化数据脚本。初始化数据里至少要有一个管理员账号,否则登录页进不去,很多人卡在这一步以为是代码问题。
注意:SQL 脚本里不要用
DROP DATABASE,用CREATE DATABASE IF NOT EXISTS加DROP TABLE IF EXISTS。前者在某些环境下会因为权限不足直接中断,后者可控得多。
5.2 事务不生效:名额扣了但记录没进去
现象:选课接口偶尔出现教学班人数涨了,但学生课表里没有这门课。
原因:三种情况最常见——@Transactional加在 private 方法上;同类内部方法自调用(this.xxx())绕过代理;异常被catch住没往外抛,事务认为一切正常于是提交了。
解决:注解只加在public方法上;跨方法调用走注入的 Bean 而不是this;catch里要么重新抛出,要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。这三条我都吃过亏,其中自调用那条最隐蔽,代码看起来完全正常,跑起来就是不回滚。
5.3 联调与数据口径:前端显示的人数对不上
现象:列表页显示"已选 60/60",点进去还能选;或者统计报表里的选课总人次和教学班人数之和对不上。
原因:列表查的是course_class.selected_num,详情查的是course_selection的COUNT(*),两个数据源没同步;退课逻辑只改了status没减selected_num。
解决:把"有效选课数"的定义固定在一处,要么统一用冗余字段,要么统一用实时统计,不要混用。退课接口里,改状态和减人数必须在同一事务。再加一个定时校对任务,每天凌晨重算一次selected_num并打印差异日志,跑上一周就能发现所有口径不一致的地方。
5.4 时间与格式:导出乱码、日期差一天
现象:Excel 导出的中文文件名变成乱码,或者课表里的日期比数据库里少一天。
原因:响应头没做 URL 编码;MySQL 的DATETIME和 Java 的LocalDateTime在序列化时区没对齐,或者前端new Date()解析字符串时用了本地时区。
解决:文件名URLEncoder.encode(name, "UTF-8")并把+替换成%20;全局配置 Jackson 的时区和格式spring.jackson.time-zone=GMT+8与spring.jackson.date-format=yyyy-MM-dd HH:mm:ss,前后端统一用yyyy-MM-dd这种带前导零的格式。这类问题不影响功能,但演示时特别扎眼。
6. 验收与答辩:用一张自检清单把系统讲成自己的东西
验收不是把功能点一遍,而是按"角色—流程—边界"三个层次走一遍。角色层验证权限隔离:用学生账号直接访问管理员接口,必须返回 403;流程层验证主链路:发布教学班 → 学生选课 → 名额扣减 → 退课 → 名额回滚,每一步都查数据库确认状态对得上;边界层验证极端情况:容量为 1 的班两个账号同时抢、选课截止后再选、排课时故意填一个冲突时间段。三层走完,系统能不能经得起问基本就有数了。
答辩前建议按下面这张表自查一遍,每一项都能对应到具体的页面或接口:
| 检查项 | 验证方法 | 期望结果 |
|---|---|---|
| 登录与鉴权 | 不带 token 调任意业务接口 | 返回 401,不返回业务数据 |
| 权限隔离 | 学生 token 调教师接口 | 返回 403,日志有记录 |
| 选课防超卖 | 容量 1 的班两账号并发选 | 仅一人成功,另一人提示名额已满 |
| 重复选课 | 同一账号连点两次选课 | 第二次提示已选过,数据只有一条 |
| 排课冲突 | 同教师同时间排两个班 | 第二次保存被拒绝并提示冲突对象 |
| 退课一致性 | 退课后查教学班人数 | 人数减 1,课表同步消失 |
| 数据导出 | 导出学生课表 Excel | 中文不乱码,节次合并正确 |
答辩时最容易被追问的三个点,提前准备好答案:一是并发怎么处理的,答"数据库原子更新加唯一索引兜底,事务保证两步一致";二是冲突检测的判定条件是什么,把a.start <= b.end && b.start <= a.end这个区间重叠公式写出来;三是权限怎么防越权,答"后端拦截器加 Service 层角色校验,前端守卫只做体验"。这三个问题答得利索,基本就过关了。
还有一个细节很加分:演示前把数据库重置成初始状态,准备两三个不同角色的账号,用手写的时间线把流程串一遍,别临场找菜单。我见过太多人系统做得不错,演示时手忙脚乱点错页面,导师的第一印象直接打折。
血泪经验是:每次交付前,我都会在一台干净的机器上从零走一遍"建库 → 导数据 → 启动后端 → 启动前端 → 登录 → 跑主流程",中间任何一步需要额外操作,就补进 README。从那以后我每次交东西都强制走一遍这套流程,再没出现过"在我电脑上是好的"这种尴尬。希望帮到你。
本文还有配套的精品资源,点击获取