SpringBoot+Vue选课排课系统:并发选课与冲突检测实践
简介:这份zip压缩包面向计算机专业毕业生和Java全栈学习者,提供一套基于SpringBoot与Vue.js的选课/排课管理系统毕业设计材料,包含完整源码、SQL脚本、项目文档和演示PPT,可用于课程管理类课题的参考、复现与二次开发。系统围绕用户与权限、课程信息、学生选课、教师排课及选课数据统计等模块展开,后端采用SpringBoot构建RESTful接口并连接MySQL,前端以Vue组件化方式实现交互,适合用来理解前后端分离项目的接口设计、数据库表结构与业务逻辑组织。压缩包约17.55MB,文件总数与类型明细上游暂未提供,从资源说明可知主要包含源码、数据库脚本、文档和PPT等材料。目前已有561人学习,文档可帮助梳理架构设计与技术选型,PPT便于答辩展示,源码则适合对照调试和扩展功能。
1. 选课与排课系统到底难在哪:从一次课程冲突说起
一套 springboot+vue 的选课与排课管理系统,真正难的不是把页面画出来,而是让“选课不超卖、排课不撞车”这两件事在数据库里站得住。很多 Java 毕业设计 做到后期,功能列表很长,演示时一选课就出现余量负数,一排课就出现教师同一节上两门课,答辩老师追问一句“并发怎么处理”就卡住。这份源码、SQL 脚本、文档和 PPT 围绕的正是这条主线:学生选课、教师查课表、教务排课与审核,三类角色共用一套时间片与容量规则。适合正在做 Java 选课、SpringBoot 排课 方向毕业设计的人,也适合需要一套能改造成教务工具的项目参考。
2. 需求与数据模型:六张核心表把选课、排课、课表串起来
选课与排课系统最容易犯的错,是一上来就画十几个页面,结果课程、教学班、排课记录各存一套时间,到了课表展示环节对不上。比较稳妥的做法是先收边界:只做“基础数据维护—排课—选课—课表”这一条闭环,把冲突检测和容量控制做扎实,再考虑调课申请、消息通知这类扩展。数据模型一旦定错,后面补多少代码都像在填坑,这也是我见过最多的血泪经验。
2.1 角色与用例拆解:学生、教师、教务三种视角
学生端的核心用例只有四个:登录、浏览可选教学班、选课/退课、查看个人课表。教师端更轻:查看自己的排课、查看选课名单、提交调课申请。教务端承担基础数据维护和规则裁决:课程库、教师库、教室库、行政班、学期时间片,以及教学班开设、排课、选课开关、名单导出。三端共用同一套教学班数据,但看到的字段不同,学生看到余量和教师,教师看到上课时间和教室,教务看到容量、已选人数和冲突状态。
这里要提前定一条原则:课程和教学班必须分开。课程是“数据结构”这门课本身,教学班是“2024-2025-1 学期,某教师,面向某行政班,容量 60 人”的一次开设。很多 Java 毕业设计 把课程、教师、上课时间塞进一张表,导致同一门课换一个教师就要复制整行,排课冲突查询也变成字符串比较。拆开之后,选课选的是教学班,排课排的也是教学班,课表按教学班聚合,三端的数据口径自然一致。
用例边界还要明确“谁可以改什么”。学生不能改选课记录的状态,只能新增或删除自己的记录;教师不能直接改排课,只能提交申请;教务才能写排课表和教学班容量。权限控制放在后端接口层,前端菜单只负责隐藏入口,不能把“学生看不到按钮”当成权限。这个边界定清楚,后面的接口和表结构才不会互相打架。
2.2 核心表结构与字段取舍:课程、教学班、时间片、教室
六张业务核心表分别是课程、教学班、排课记录、选课记录、教室、时间片。身份相关的用户、学生、教师表单独放,不跟业务表混在一起。下面三张表是排课链路的骨架,字段取舍直接影响冲突检测怎么写。
-- 课程主表:只放课程本身属性,不绑定教师和学期 create table course ( id bigint primary key auto_increment, course_code varchar(32) not null comment '课程编号', course_name varchar(128) not null comment '课程名称', credit decimal(3,1) not null default 0 comment '学分', hours int not null default 0 comment '总学时', course_type tinyint not null default 1 comment '1必修 2选修', unique key uk_course_code (course_code) ) engine=InnoDB default charset=utf8mb4 comment='课程主表'; -- 教学班:一门课在某学期由某教师面向某班级开设 create table teaching_class ( id bigint primary key auto_increment, course_id bigint not null, teacher_id bigint not null, class_id bigint not null comment '行政班', semester varchar(20) not null comment '如2024-2025-1', capacity int not null default 0 comment '容量', selected_count int not null default 0 comment '已选人数', status tinyint not null default 1 comment '1开放 0关闭', unique key uk_class_semester (course_id, teacher_id, class_id, semester) ) engine=InnoDB default charset=utf8mb4 comment='教学班'; -- 排课记录:一条记录对应一个教学班的一次上课安排 create table course_schedule ( id bigint primary key auto_increment, teaching_class_id bigint not null, teacher_id bigint not null comment '冗余,便于冲突查询', class_id bigint not null comment '冗余,便于行政班冲突查询', classroom_id bigint not null, day_of_week tinyint not null comment '1-7', start_section tinyint not null comment '1-12', end_section tinyint not null, week_start tinyint not null, week_end tinyint not null, key idx_conflict (day_of_week, start_section, end_section), key idx_teacher (teacher_id, day_of_week), key idx_room (classroom_id, day_of_week) ) engine=InnoDB default charset=utf8mb4 comment='排课记录';排课记录里冗余了teacher_id和class_id,原因是冲突查询要同时按教师、教室、行政班三个维度过滤,每次 join 教学班和课程表会让 SQL 变长,索引也不好走。冗余字段在排课写入时一次性填好,后续教师或班级变更属于低频操作,由教务端统一修正。day_of_week用 1 到 7,start_section和end_section用 1 到 12,不要存“周一第 1-2 节”这种字符串,否则范围重叠判断只能靠拆分或正则,查一次冲突要扫全表。
时间片和教室单独建字典表。时间片表约束每天可选节次,比如上午 1 到 4 节、下午 5 到 8 节、晚上 9 到 12 节;教室表存教学楼、房间号、容量和类型。排课记录不直接存时间片 id,而是拆成星期、开始节次、结束节次、起始周、结束周,范围查询更直接。如果学校节次经常变,再在时间片表里维护一份展示用的节次名称,排课记录仍然保留拆开的字段。
2.3 选课容量与排课约束怎么落到表里
选课记录表的关键不是字段多,而是约束放在哪里。很多项目在 Service 里先select count(*)再判断是否小于容量,最后插入,单线程演示没问题,两个浏览器同时点就会超卖。正确做法是把“容量未满”做成一条带条件的更新,把“不能重复选”做成唯一索引,数据库层面兜底。
create table enrollment ( id bigint primary key auto_increment, student_id bigint not null, teaching_class_id bigint not null, status tinyint not null default 1 comment '1已选 0已退', create_time datetime not null default current_timestamp, unique key uk_student_class (student_id, teaching_class_id), key idx_class (teaching_class_id) ) engine=InnoDB default charset=utf8mb4 comment='选课记录';uk_student_class保证同一学生同一教学班只有一条记录。退课如果保留状态字段,唯一索引会让“退课后重新选”插入失败,所以通常的做法是退课直接物理删除选课记录,另建一张选课日志表记录退课动作;或者选课时先查旧记录,存在则把status改回 1,并同步扣减容量。两种做法都能跑,毕业设计里更推荐物理删除加日志表,逻辑简单,答辩时也容易讲清楚。
教学班表的selected_count是冗余计数,用一条原子更新维护,不要每次选课都去count(enrollment)。冗余计数会带来一个经典问题:退课时忘记减回去,导致余量越用越少。解决办法是把选课和退课都收在同一个 Service 里,退课先删记录,再执行selected_count = selected_count - 1 where id=? and selected_count > 0,两条语句放在同一个事务里。排课约束则落在course_schedule的三个索引上:教师、教室、班级分别建索引,冲突查询用范围重叠条件,后面第 3 章会展开。
3. 后端实现:SpringBoot 分层、鉴权与两个关键算法
后端部分不需要堆太多框架,SpringBoot 加 MyBatis-Plus 足够把选课和排课讲清楚。真正要花心思的是三件事:接口边界怎么切、登录态怎么传、选课和排课的并发与冲突怎么在数据库里收口。下面按工程分层、鉴权、选课、排课四个点展开,代码只留关键片段,参数和失败路径会写清楚。
3.1 工程分层与接口边界:Controller 不写业务,Service 不碰 Web 对象
包结构建议按职责分,不要按页面分。controller只做参数接收和结果返回,service放业务规则,mapper只做 SQL,entity对应表,dto放入参,vo放出参,config放拦截器和跨域,common放统一返回和异常。这样分的好处是排课冲突检测可以单独测,不用启动整个 Web 容器。
接口命名按资源走:/api/course、/api/teaching-class、/api/schedule、/api/enrollment。选课接口是POST /api/enrollment,退课是DELETE /api/enrollment/{id},学生课表是GET /api/enrollment/timetable?semester=...。统一返回结构用code、msg、data三个字段,业务异常抛BizException,全局异常处理器转成code=500或自定义错误码,前端只判断code,不要把 HTTP 状态码和业务码混在一起。
Controller 里不要出现HttpServletRequest取用户 id 后到处传,登录拦截器把userId和role放进请求属性,Controller 用@RequestAttribute取。Service 方法参数只保留业务字段,比如enroll(Long studentId, Long classId),不要把HttpServletRequest传进 Service。这样选课逻辑在单元测试里可以直接调用,排课冲突检测也能脱离登录态跑用例。
3.2 登录鉴权与角色路由:JWT + 拦截器的落地方式
登录成功后签发 JWT,payload 里放userId和role,不要放密码和敏感字段。前端把 token 放在Authorization: Bearer xxx请求头,后端拦截器统一解析,白名单只放登录和验证码接口。角色校验可以再加一层注解,比如@RequireRole("admin"),拦截器解析到方法上的注解后比对角色,避免每个 Controller 手写 if。
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 预检请求直接放行,否则跨域时浏览器拿不到响应 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { writeJson(response, 401, "未登录"); return false; } Claims claims; try { claims = JwtUtil.parse(token.substring(7)); } catch (Exception e) { writeJson(response, 401, "登录已过期"); return false; } // 用户信息放进请求属性,Controller 不再重复解析 token request.setAttribute("userId", Long.valueOf(claims.getSubject())); request.setAttribute("role", claims.get("role", String.class)); return true; } }JwtUtil.parse负责验签和过期判断,writeJson统一输出 JSON,避免拦截器返回空响应导致前端报跨域错误。白名单在WebMvcConfig里用excludePathPatterns("/api/auth/**")配置,不要在图省事在拦截器里写一堆if判断 URL。密码存 BCrypt 哈希,登录时用matches比对,不要明文存库,也不要在日志里打印密码。
角色路由分两层:前端按角色过滤菜单,后端按角色校验接口。学生不能调教务的排课接口,教师不能调选课接口,这些规则写在拦截器或方法注解里。前端隐藏菜单只是体验,不能当作权限。答辩时老师很爱问“我把前端代码改了是不是就能排课”,答案必须是否定的,因为后端接口还有角色校验。
3.3 选课并发控制:容量扣减、唯一索引与 Redis 预校验
选课接口的核心是两条约束:教学班余量不能小于 0,同一学生不能重复选同一教学班。前者用带条件的原子更新,后者用唯一索引。顺序通常是先扣减容量,再插入选课记录;插入失败抛异常,事务回滚,扣减自动恢复。这样比“先查余量再扣减”少一次查询,也避免了检查与更新之间的窗口。
@Transactional(rollbackFor = Exception.class) public void enroll(Long studentId, Long classId) { // 1. 原子扣减:只有未满且开放时才加 1,返回受影响行数 int rows = teachingClassMapper.increaseSelected(classId); if (rows == 0) { throw new BizException("余量不足或教学班未开放"); } // 2. 写选课记录;唯一索引 uk_student_class 兜底重复选课 try { enrollmentMapper.insert(new Enrollment(studentId, classId)); } catch (DuplicateKeyException e) { // 事务回滚,上面扣减的容量会自动恢复 throw new BizException("已经选过这门课"); } }对应的 Mapper 更新语句只有一条,条件里必须带status = 1和selected_count < capacity:
<update id="increaseSelected"> update teaching_class set selected_count = selected_count + 1 where id = #{classId} and status = 1 and selected_count < capacity </update>rows = 0有两种可能:教学班关闭或余量不足,前端提示可以合并成“该教学班不可选”,日志里再区分。DuplicateKeyException说明唯一索引拦住了重复选课,事务回滚后容量恢复,不会出现“没选上但余量少了”的情况。这里的事务隔离级别用默认的REPEATABLE READ就够,update会对教学班行加锁,并发请求排队执行,最后只有一个能扣减成功。
选课高峰时数据库行锁会成为瓶颈,可以加一层 Redis 预校验:把每个教学班的余量预热到 Redis,用 Lua 脚本判断并自增,返回 0 直接提示满员,返回 1 再走数据库。Redis 只做前置拦截,最终以数据库的原子扣减为准。Redis 计数可能因为事务回滚和缓存过期出现偏差,选课结束后按数据库selected_count校正一次,不要把它当成唯一数据源。
-- KEYS[1] = 教学班余量 key,ARGV[1] = 容量 local cap = tonumber(ARGV[1]) local current = tonumber(redis.call('get', KEYS[1]) or '0') if current >= cap then return 0 end redis.call('incr', KEYS[1]) return 1Lua 脚本的cap从教学班表读取后传入,key 的过期时间覆盖整个选课窗口。返回 0 时前端直接提示“已满”,不要再打数据库;返回 1 时继续走enroll方法,数据库仍可能因为余量变化返回失败,前端按最终结果刷新列表。这套组合在毕业设计里足够讲清楚“为什么要两层”,也不会把项目复杂度拉得过高。
3.4 排课冲突检测:时间片、教室、教师三维校验
排课冲突的本质是范围重叠:同一学期、同一天,两个排课记录的节次区间有交集,并且周次区间也有交集,同时教师、教室、行政班三个维度中任意一个相同,就算冲突。SQL 用start_section <= 新结束节次 and end_section >= 新开始节次判断节次重叠,周次同理。下面这条查询一次把三个维度都查出来,返回第一条冲突记录即可。
select id, teaching_class_id from course_schedule where day_of_week = #{dayOfWeek} and start_section <= #{endSection} and end_section >= #{startSection} and week_start <= #{weekEnd} and week_end >= #{weekStart} and (teacher_id = #{teacherId} or classroom_id = #{classroomId} or class_id = #{classId}) limit 1参数说明:dayOfWeek取 1 到 7,startSection和endSection是新课的节次区间,weekStart和weekEnd是周次区间,teacherId、classroomId、classId从教学班和排课表单里带过来。查询命中后不要只返回“冲突”,最好再查一次冲突记录的类型,告诉教务是教师冲突、教室冲突还是班级冲突,前端才能高亮对应行。排课保存时先查冲突再插入,批量排课放在同一个事务里,任意一条冲突整批回滚,避免出现“排了一半”的中间状态。
排课是低频操作,通常由教务端单线程触发,但也不能完全不管并发。两个教务同时排同一间教室,先查后插仍然可能都通过。稳妥的做法是在事务里对教师和教室加行锁,比如select id from classroom where id=? for update,再执行冲突查询和插入。锁的粒度按教师和教室分别加,不要锁全表。排课接口还要校验教学班是否存在、教室容量是否足够、周次是否在学期范围内,这些校验放在冲突查询之前,失败信息越具体,前端越容易定位。
4. 前端实现:Vue 课表渲染、角色菜单与选课交互
前端不只是把接口数据铺到页面上,选课和排课两个页面的交互细节会直接影响演示效果。课表要按星期和节次画成二维网格,选课页要把余量、冲突提示和按钮状态做清楚,排课页要允许调整但把最终裁决留给后端。下面四个点按路由、课表、选课、排课的顺序展开。
4.1 路由与菜单按角色动态生成
登录后拿到角色,前端按角色过滤路由表,菜单只展示当前角色能访问的页面。路由守卫里做一次兜底:目标路由不在当前角色允许列表里,直接跳到 403 页面。注意前端路由只负责展示,后端接口仍要校验角色,否则改一下 localStorage 就能进教务页面。
const roleRoutes = { student: ['/course/select', '/course/timetable'], teacher: ['/teacher/schedule', '/teacher/apply'], admin: ['/base/course', '/base/classroom', '/schedule/manage', '/course/audit'] } router.beforeEach((to, from, next) => { const role = localStorage.getItem('role') const allowed = roleRoutes[role] || [] if (to.meta.public || allowed.includes(to.path)) { next() } else { next('/403') } })roleRoutes只列一级路径,子路由通过meta.role再校验一次。菜单数据可以从后端返回,也可以前端按同一份配置生成,毕业设计里前端配置更直观,答辩时打开代码就能看到角色和页面的对应关系。token 过期时在响应拦截器里统一跳登录页,不要在每個请求里单独判断。
4.2 课表组件:二维网格怎么从课程列表映射出来
课表的数据源是排课记录列表,每条记录包含dayOfWeek、startSection、endSection、weekStart、weekEnd、课程名、教师、教室。渲染前先按当前周过滤,再把每条记录拆成“星期-节次”的映射。模板用 CSS Grid 画 7 列 12 行,每个单元格判断是否有课,有课且是起始节次时渲染一个跨行单元格。
const sections = Array.from({ length: 12 }, (_, i) => i + 1) const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] const grid = computed(() => { const map = {} schedules.value.forEach(item => { for (let s = item.startSection; s <= item.endSection; s++) { // 数据层节次从 1 开始,渲染数组下标从 0 开始,这里统一减 1 map[`${item.dayOfWeek}-${s}`] = { ...item, rowSpan: item.endSection - item.startSection + 1, isFirst: s === item.startSection } } }) return map })模板里用grid[${day}-${section}]取单元格,只有isFirst为 true 时才渲染内容,并设置rowSpan。颜色按courseId取模选一组预设色,同一门课在不同周次颜色一致,答辩演示时更容易看清。跨周课程要按当前周过滤,不能把 1 到 16 周的课全部铺在同一张表上,否则课表会重复。节次表头固定 1 到 12,星期表头固定周一到周日,数据层不要出现 0 或 8 这种越界值,后端插入时就要校验。
4.3 选课页面的交互细节:余量、冲突提示与结果反馈
选课列表每行展示课程名、教师、上课时间、教室、容量、已选、余量。余量为 0 时按钮置灰,已选时按钮变成“退课”,这两个状态只是体验,服务端仍要再校验。点击选课按钮后加 loading,防止连点;失败后根据后端返回的msg区分“已满”“已选”“时间冲突”,并刷新当前列表,避免本地余量和数据库不一致。
async function handleEnroll(row) { if (row.selectedCount >= row.capacity) return if (row.selected) return const { data } = await enrollApi(row.id) if (data.code === 200) { row.selected = true row.selectedCount += 1 ElMessage.success('选课成功') } else { ElMessage.warning(data.msg) await loadList() } }enrollApi里统一处理 token 和错误码,loadList重新拉取当前页数据。退课成功后同样刷新列表,不要只把按钮改回去。选课页还可以加一个“已选课程”Tab,按学期查询自己的选课记录,和课表页共用同一份数据。时间冲突提示可以在前端先做一次粗判:拿已选课程的星期和节次区间比对,命中就提示“与已选课程冲突”,但最终仍以服务端为准,前端判断只是减少无效请求。
4.4 排课端的拖拽与批量调整:前端只做展示,后端做裁判
排课页通常是一个可拖拽的周网格,教务把教学班拖到某个星期和节次上,前端更新临时排课列表,点击保存时整批提交。拖拽过程中不要实时写库,否则拖错一次就产生一条脏数据。保存接口接收一个数组,后端逐条校验冲突,返回每条的结果,前端根据结果把失败的条目标红,成功的保留。
批量接口的返回结构建议用successCount、failCount、failList,failList里带教学班 id、冲突类型和冲突描述。前端不要看到code=200就认为全部成功,要判断failCount。排课保存放在一个事务里时,任意一条失败整批回滚,前端需要把整个列表恢复成保存前的状态;如果业务允许部分成功,就不要用一个大事务,改成逐条事务并在返回里说明。毕业设计里更推荐整批事务,逻辑简单,答辩时也容易讲“要么全排,要么全不排”。
拖拽组件只负责视觉位置,星期和节次从放置的单元格里取,不要从鼠标坐标反算。放置后校验教学班是否已经排过、教室容量是否匹配,明显不合法的直接在前端拦下,减少后端压力。但教师冲突、教室冲突、班级冲突仍然交给后端 SQL 判断,前端不做最终裁决。
5. 避坑与排查:选课排课项目里最容易翻车的五件事
选课排课项目的 bug 有个特点:单线程点一遍都能过,一到演示就出问题。下面五条是这类项目里最常见的翻车现场,每条按“现象—原因—解决”写,照着排查能省不少时间。
5.1 时间片不统一导致“看起来没冲突”
现象:排课页面提示冲突,但数据库里查不到冲突记录;或者两门课明明撞了,系统却允许保存。原因通常是两套时间表示并存:排课表存day_of_week和节次,教学班表却存“周一第 1-2 节”字符串,冲突查询只查了其中一套,另一套没参与比较。还有的项目把周次存成“1-16周”字符串,范围重叠判断只能靠like,边界条件一多就漏。
解决:全链路统一成数值字段。排课记录只存day_of_week、start_section、end_section、week_start、week_end,展示层再拼成“周一第 1-2 节,1-16 周”。教学班如果也需要展示上课时间,从排课记录聚合,不要另存一份字符串。导入历史数据时写一个转换脚本,把“周一第 1-2 节”拆成数值后再入库,转换完抽查几条边界数据,比如“周日第 11-12 节”和“第 16 周”。
5.2 并发选课超卖:先查余量再扣减的顺序错了
现象:教学班容量设为 1,两个学生同时点选课,两个人都显示成功,selected_count变成 2。原因是在 Service 里先select余量,判断小于容量,再update加 1,两个请求都通过了判断,更新时没有条件限制。这是典型的 check-then-act,单线程测不出来,一并发就现形。
解决:把判断放进update的where里,用受影响行数判断是否成功。update teaching_class set selected_count = selected_count + 1 where id=? and status=1 and selected_count < capacity,返回 0 就抛业务异常。再加唯一索引uk_student_class防止同一学生重复选。事务里先扣减再插入,插入失败回滚,容量自动恢复。测试时用两个浏览器或无头脚本同时请求,看数据库最终记录数和selected_count是否一致。
5.3 排课冲突只查教师不查教室
现象:教师时间不冲突,两门课却排进了同一间教室;或者同一个行政班在同一节被排了两门课。原因是冲突查询只加了teacher_id条件,漏了classroom_id和class_id。有的项目虽然查了教室,但只查“教室 id 相同”,没有判断节次区间重叠,相邻节次也被误判。
解决:冲突 SQL 用or把教师、教室、班级三个维度串起来,节次和周次都用范围重叠条件。命中后不要只返回“冲突”,再查一次冲突记录,告诉用户是教师冲突、教室冲突还是班级冲突。保存前把三个维度分别查一遍,任何一条命中就拒绝,批量保存放在事务里,避免排一半。测试用例至少覆盖:同教师同节次、同教室同节次、同班级同节次、节次部分重叠、周次部分重叠。
5.4 前端课表渲染错位:从 0 开始还是从 1 开始
现象:周一第一节课显示在周日,或者所有课整体错开一格。原因是数据层节次从 1 开始,前端数组下标从 0 开始,渲染时直接拿section当下标用。还有的项目星期用 0 到 6,后端用 1 到 7,两边没对齐,周日变成周一。
解决:约定数据层星期 1 到 7、节次 1 到 12,前端渲染时统一减 1 再映射到数组或网格。课表组件的grid用“星期-节次”字符串做 key,模板里按 key 取,不要用二维数组下标硬算。写一个最小用例:只排一条周一第 1-2 节的课,看它是否落在第一列第一行;再排一条周日第 11-12 节,看是否落在最后一列最后一行。两个边界过了,中间基本不会错。
5.5 SQL 脚本导入失败与字符集问题
现象:导入 SQL 脚本时报错,或者导入成功但中文全是乱码,日期默认值报错
本文还有配套的精品资源,点击获取