☰
教务系统源码实战:排课冲突、选课并发与数据对账
2026/10/30 2:51:06 网站建设 项目流程

简介:这是一套面向教育机构教务管理员、计算机专业学生与初级开发者的教务管理系统源码,围绕学生、课程、教师、课表、考勤与报表统计等日常场景,提供可直接运行和二次改造的简化实现。压缩包共171个文件、约4.18MB,以41个C#源码文件和5个项目工程文件为核心,辅以窗体资源、配置、图片及编译产物,目录结构较完整,便于顺着解决方案与项目分层理解模块组织。已有1995人学习,说明其在课程设计和源码研读场景中具备一定参考热度。读者可从中梳理学生信息、课程安排、成绩与考勤等业务的数据模型和界面交互,观察前后端/桌面端调用关系,并据此做功能扩展、界面调整或课程设计参考。

1. 教务系统源码不是增删改查合集:先扛住开学第一周的三件事

开学第一周,选课接口一开,余量数字跳得比心跳还快;排课表刚保存,教师端就说撞课;成绩录入到一半,Excel 导入把学号读成了科学计数法。教务管理系统源码值不值得下,不看登录页多漂亮,看它有没有把学期、开课、排课、选课、成绩这五件事串成一条能自洽的链路。这套源码覆盖学生、教师、班级、课程、开课、排课、选课、成绩和 RBAC 权限,后端是 Spring Boot 一类的主流结构,前端 Vue 加组件库,数据库脚本单独放。适合拿来做课程设计、二次开发,或者给内部小范围试用打底。下面按能跑、能改、能验收的顺序拆。

2. 把源码跑起来:目录、四个配置项和登录链路

拿到压缩包先别双击 IDE。先判断它是单模块还是多模块、前后端是否分离、sql目录是否完整,这三件事决定了后面是十分钟跑通还是一下午填坑。常见的教务源码目录结构大致如下,名字可能不同,但角色差不多:

目录/文件作用先看什么
server或backendSpring Boot 后端pom.xml、application.yml、启动类
web或frontendVue 前端package.json、.env、代理配置
sql建表与初始化数据字符集、引擎、外键、初始账号
docs部署说明/接口说明端口、默认密码、导入模板
upload附件与导入文件是否被版本管理排除

2.1 先认目录:业务代码集中在哪里

后端先找controller、service、mapper、entity四层,再看config和security。教务系统的业务重心通常不在登录页,而在service/schedule、service/enroll、service/score这几个包:排课冲突、选课并发、成绩批量导入,基本都藏在这里。前端先看views下的模块划分,再看router和权限守卫;如果菜单是后端返回的,找到/auth/me或/menu这类接口,登录链路就清楚一半了。

我一般会先搜三个关键字:term_id、selected_count、week_day。如果业务表里没有学期字段,后面按学期查课表、按学期统计成绩都会变成补丁;如果选课表没有唯一约束,重复选课迟早出现;如果星期字段前端传 0-6、后端按 1-7 算,排课能保存但课表会错一天。这三个词比任何架构图都更能判断一套教务源码是否靠谱。

2.2 四个配置项先对齐:数据库、缓存、密钥、上传目录

先建库、导脚本、编译后端、装前端依赖。命令不复杂,顺序别乱:

# 1. 建库,字符集用 utf8mb4,避免姓名和备注出现乱码 mysql -uroot -p -e "CREATE DATABASE edu_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 先结构后数据,别把 data.sql 导到空库里才发现表不存在 mysql -uroot -p edu_admin < sql/schema.sql mysql -uroot -p edu_admin < sql/data.sql # 3. 后端先跳过测试打包,确认依赖能拉齐 mvn -DskipTests clean package # 4. 前端装依赖并启动,有 lock 文件时优先用 npm ci npm install npm run dev

-DskipTests只跳过测试执行,测试代码仍会编译;如果编译报错,先把测试目录排除再判断是不是业务代码问题。npm install会改package-lock.json,团队协作时用npm ci更稳。导入脚本前先打开schema.sql看有没有CREATE DATABASE和USE,有的话别再手动建库,否则可能导到另一个库。

配置项集中在application.yml,重点看四处:

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/edu_admin?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: change_me redis: host: 127.0.0.1 port: 6379 database: 0 servlet: multipart: max-file-size: 10MB max-request-size: 20MB app: jwt: secret: replace-with-a-32-byte-random-string expire-minutes: 120 upload: dir: ./upload

serverTimezone要和服务器、数据库保持一致,排课和成绩里的日期跨天问题大多从这儿来。allowPublicKeyRetrieval=true只建议本地开发用,线上按数据库实际认证方式收紧。jwt.secret别用示例值,长度不够或每次重启随机生成,都会导致登录后立刻 401。multipart的两个值决定 Excel 导入能传多大文件,学生名单几千行时 10MB 通常够用,但别把max-request-size设得比max-file-size还小。upload.dir在本地可以写相对路径,部署时改成绝对路径,否则换个启动目录就找不到模板。

2.3 启动顺序与登录验证:别只看页面能不能开

先启后端,再启前端,最后看浏览器 Network。顺序反了前端会一直代理失败,容易误判成接口没写。Vue CLI 项目看vue.config.js,Vite 项目看vite.config.js,目的都是把/api转到后端:

// Vue CLI 开发代理:把 /api 转到后端,避免本地跨域 module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://127.0.0.1:8080', changeOrigin: true } } } }

changeOrigin会改请求头里的 Host,后端按域名做校验时有用;如果后端接口本身带/api前缀,代理这里不要再pathRewrite重复去掉。登录成功后重点看三件事:后续请求有没有带Authorization,菜单接口返回的权限码和前端路由是否对得上,刷新页面后 token 是否还在。初始化脚本里通常会内置一个管理员账号,密码看data.sql注释;第一次登录后立刻改密码,别把默认账号留在演示环境里。

3. 数据库不是配角:教务表关系、初始化 SQL 与三处索引

很多教务源码登录能跑,一到排课就崩,根因不是代码写得多差,而是表没设计学期维度。课程是基础数据,开课才是某个学期真正发生的事;选课挂在开课上,成绩挂在选课或开课上,排课再挂在开课上。这条链路断了,后面所有统计都会变成手工对账。

3.1 核心表关系:从学生、班级到开课、选课、成绩

表名作用关键字段关联
sys_user登录账号id、username、password、status与角色多对多
sys_role角色id、role_code、role_name管理员/教师/学生
sys_user_role用户角色user_id、role_id权限入口
edu_student学生档案id、user_id、student_no、class_id关联班级
edu_teacher教师档案id、user_id、teacher_no关联开课
edu_class行政班级id、class_name、major_id学生归属
edu_course课程基础数据id、course_code、credit被开课引用
edu_term学期id、term_name、start_date、end_date所有业务表的维度
edu_course_offer开课id、term_id、course_id、teacher_id、class_id、capacity排课/选课父表
edu_course_schedule排课id、offer_id、week_day、section_start、section_end、classroom_id挂开课
edu_enroll选课id、offer_id、student_id、status学生与开课
edu_score成绩id、offer_id、student_id、score选课结果

term_id是整套表的分水岭。查课表不带学期,会把上学期和这学期的课混在一起;统计选课人数不带学期,历史数据会把当前容量撑爆。开课表是课程、教师、班级三者的交点,排课表是开课表的时间地点展开,选课表是学生和开课表的交点。成绩表可以直接挂offer_id + student_id,也可以挂选课 ID,前者查询少一次 join,后者状态更清楚,课程设计里两种都常见。

3.2 初始化 SQL 怎么读、怎么改

先看开课表和选课表,这两张表最能看出作者有没有想过并发和重复选课:

-- 开课表:一门课在一个学期、由一个老师、在一个班级开一次 CREATE TABLE edu_course_offer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, term_id BIGINT NOT NULL COMMENT '学期ID', course_id BIGINT NOT NULL COMMENT '课程ID', teacher_id BIGINT NOT NULL COMMENT '授课教师', class_id BIGINT NOT NULL COMMENT '教学班', capacity INT NOT NULL DEFAULT 60 COMMENT '容量', selected_count INT NOT NULL DEFAULT 0 COMMENT '已选人数,展示用,扣减以条件更新为准', status TINYINT NOT NULL DEFAULT 1 COMMENT '1开放 0关闭', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_term_course_class (term_id, course_id, class_id), KEY idx_term_teacher (term_id, teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开课表'; -- 选课表:退课不删行,改状态,唯一索引兜底重复选课 CREATE TABLE edu_enroll ( id BIGINT PRIMARY KEY AUTO_INCREMENT, offer_id BIGINT NOT NULL, student_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0已选 1已退 2候补', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_offer_student (offer_id, student_id), KEY idx_offer_status (offer_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课记录';

开课表用term_id + course_id + class_id做唯一键,表示同一学期同一门课对同一个班级只开一次。如果业务允许同一门课拆成多个教学班,把class_id换成teaching_class_no之类的字段,别直接删唯一键。选课表的uk_offer_student是防重复选课的底线,退课改status而不是DELETE,否则唯一索引会挡住第二次选课。selected_count只做展示,真正扣减要用条件更新,后面会写。

如果源码里给开课表加了deleted逻辑删除,还把deleted塞进唯一索引,要特别小心:第一次删除后deleted=1,再删同键记录仍然冲突,除非你只允许删一次。教务系统里开课记录一般不建议物理删除,关课用status=0,历史数据还能查。

3.3 索引与约束:选课高峰前必须补的三处

索引不是越多越好,但这三处漏了,数据量一上来就能感觉到:

-- 排课冲突查询:按学期、教师、星期和节次过滤 ALTER TABLE edu_course_schedule ADD INDEX idx_term_teacher_time (term_id, teacher_id, week_day, section_start, section_end); -- 选课明细按开课和状态统计 ALTER TABLE edu_enroll ADD INDEX idx_offer_status (offer_id, status); -- 成绩按学期、班级、课程查 ALTER TABLE edu_score ADD INDEX idx_term_class_course (term_id, class_id, course_id);

联合索引的列顺序按“等值条件在前、范围条件在后”排。term_id、teacher_id、week_day都是等值过滤,section_start和section_end做范围比较,放后面合理。edu_enroll的idx_offer_status能覆盖“某开课已选人数”的统计,也能辅助候补队列。加完索引用EXPLAIN看执行计划,重点看type有没有从ALL变成ref或range,rows有没有明显下降;如果查询仍然扫全表,先检查条件字段有没有做函数运算或隐式类型转换。

4. 排课冲突与选课并发:两个最容易被演示糊弄过去的业务点

演示环境里一个管理员慢慢点,排课不会冲突,选课也不会超卖;一到真实场景,两个管理员同时保存、几十个学生同时点选课,问题才暴露。这两个点建议在改页面前先写对。

4.1 排课冲突检测:时间片模型与四类冲突

排课冲突本质是时间区间重叠。先统一模型:week_day用 1-7,section_start和section_end用节次整数,week_start和week_end用教学周。同一学期内,同一天、节次区间有交集、周次区间有交集,就算时间片重叠。再分别检查教师、班级、教室三类资源是否已被占用。

-- 统计与当前排课时间片重叠、且教师/班级/教室任一冲突的记录数 SELECT COUNT(1) FROM edu_course_schedule WHERE term_id = #{termId} AND week_day = #{weekDay} AND section_start <= #{sectionEnd} AND section_end >= #{sectionStart} AND week_start <= #{weekEnd} AND week_end >= #{weekStart} AND (teacher_id = #{teacherId} OR class_id = #{classId} OR classroom_id = #{classroomId})

section_start <= #{sectionEnd} AND section_end >= #{sectionStart}是闭区间重叠判断;如果你们的节次按“开始节次 + 持续节数”存,改成start < existing_end AND end > existing_start的半开区间写法。week_start和week_end也是同理。这个 SQL 只做保存前检查,不能替代数据库约束,因为时间区间没法直接建唯一索引。

public void saveSchedule(ScheduleDTO dto) { // 1. 先校验时间片本身合法,避免前端传 0 节或倒序节次 if (dto.getSectionStart() > dto.getSectionEnd()) { throw new BizException("开始节次不能大于结束节次"); } if (dto.getWeekStart() > dto.getWeekEnd()) { throw new BizException("开始周不能大于结束周"); } // 2. 同一学期、同一天、节次和周次重叠才需要查冲突 int conflict = scheduleMapper.countConflict(dto); if (conflict > 0) { throw new BizException("教师、班级或教室在该时间片已被占用"); } // 3. 插入后再回查一次,防止保存过程中被别人插队 scheduleMapper.insert(dto); if (scheduleMapper.countConflict(dto) > 1) { throw new BizException("排课冲突,请刷新后重试"); } }

countConflict的参数就是当前排课 DTO 里的学期、星期、节次、周次和资源 ID,少传一个都会漏判。最后回查只能降低概率,两个请求同时通过第一次检查仍然可能都插入;更稳的做法是按term_id + week_day + section_start加分布式锁,或者让排课提交串行化。课程设计里用单管理员入口也能接受,但要在文档里写清楚边界。

如果学校有单双周、调休、连排课,区间模型会不够用。常见做法是加一张edu_schedule_week表,把一次排课展开成schedule_id + week_no多行,冲突查询直接按周号做唯一性检查,逻辑更直白,代价是写入行数变多。

4.2 选课并发:别用“先查余量再 update”

下面这段是典型的反例,演示时没问题,并发时一定超卖:

// 反例:先查后改,两个请求同时进来会读到同一个 selected_count CourseOffer offer = offerMapper.selectById(offerId); if (offer.getSelectedCount() < offer.getCapacity()) { offer.setSelectedCount(offer.getSelectedCount() + 1); offerMapper.updateById(offer); } enrollMapper.insert(new Enroll(offerId, studentId, 0));

正确做法是把“判断余量”和“扣减”合成一条原子 SQL,再看受影响行数:

UPDATE edu_course_offer SET selected_count = selected_count + 1 WHERE id = #{offerId} AND status = 1 AND selected_count < capacity;
@Transactional(rollbackFor = Exception.class) public void enroll(Long offerId, Long studentId) { // 1. 条件更新:返回 0 表示已满、已关闭或课程不存在 int updated = offerMapper.increaseSelected(offerId); if (updated == 0) { throw new BizException("课程已满或未开放"); } // 2. 唯一索引兜底重复选课,异常会触发事务回滚 try { enrollMapper.insert(new Enroll(offerId, studentId, 0)); } catch (DuplicateKeyException e) { throw new BizException("请勿重复选课"); } }

@Transactional要加在公共方法上,rollbackFor = Exception.class别省,否则受检异常可能不回滚。increaseSelected返回的是受影响行数,不是选课人数;返回 0 就直接抛业务异常,让扣减回滚。DuplicateKeyException被捕获后重新抛业务异常,事务仍然会回滚,不会留下“人数加了但明细没插进去”的脏数据。退课用对称的条件更新:

UPDATE edu_course_offer SET selected_count = selected_count - 1 WHERE id = #{offerId} AND selected_count > 0;

如果源码里用了 Redis 预扣余量,先确认失败回补逻辑和数据库对账脚本有没有;没有的话,课程设计阶段建议直接用数据库条件更新,别为了“高并发”三个字把链路复杂化。

4.3 退课与候补:把状态机定死再写接口

选课状态别用布尔值,至少三态:0 已选、1 已退、2 候补。状态流转写清楚,接口就不会互相打架:

当前状态操作目标状态条件
0 已选退课1 已退开课仍在退课窗口内
2 候补候补转正0 已选有余量且轮到队首
1 已退重新选课0 已选有余量、未重复选

退课不要DELETE,否则唯一索引会让重新选课插入失败。候补转正按created_at取最早一条,更新时带状态条件,避免两个请求同时把同一个人转正:

-- 退课:只允许从“已选”变成“已退” UPDATE edu_enroll SET status = 1 WHERE offer_id = #{offerId} AND student_id = #{studentId} AND status = 0; -- 候补转正:按排队时间取队首,状态条件防止重复转正 UPDATE edu_enroll SET status = 0 WHERE id = ( SELECT id FROM ( SELECT id FROM edu_enroll WHERE offer_id = #{offerId} AND status = 2 ORDER BY created_at ASC LIMIT 1 ) t ) AND status = 2;

MySQL 里直接子查询同表更新会报错,所以用派生表包一层。退课和候补转正最好放在同一个事务里,先退再补,补失败就回滚。候补人数多的时候别在循环里一条条查,先取队首 ID,再按 ID 更新,减少锁竞争。

5. 常见问题排查:五个翻车现场的现象、原因和解决

5.1 登录后 401/403 循环

现象:登录接口返回成功,跳转首页后接口全是 401,刷新又回登录页;有时控制台还报跨域。

原因:JWT 过滤器顺序不对,安全链还没认到 token 就把请求拦了;前端 axios 没统一加Authorization;跨域预检OPTIONS被权限拦截;jwt.secret每次重启变化,旧 token 全部失效。

解决:先放行OPTIONS,再把 JWT 过滤器放到用户名密码过滤器之前;前端在请求拦截器统一加Bearer前缀,别在每个页面手写。检查secret是不是从配置文件读的固定值,长度是否够。权限码不一致也会 403,把后端@PreAuthorize里的权限码和菜单表里的perms对一遍,别只改前端隐藏菜单。

5.2 Excel 导入中文乱码、导出文件打不开

现象:导入学生名单后姓名变成问号;导出成绩 Excel 打开提示“文件格式与扩展名不匹配”;CSV 用 Excel 打开全是乱码。

原因:数据库或连接串字符集不是utf8mb4;CSV 没加 BOM,Excel 按本地编码读;导出时先调了getWriter()又调getOutputStream();POI 流没 flush 或没关闭,文件只写了一半。

解决:连接串加characterEncoding=utf8,建库用utf8mb4;CSV 开头写\uFEFF;Excel 用 XLSX 格式,别把 CSV 后缀改成.xlsx;响应头先设Content-Type和Content-Disposition,再写流,最后在finally里关闭。导入时把学号列按文本读,否则前导零会丢。

5.3 选课人数和明细对不上

现象:开课列表显示已选 60,点进去明细只有 58;退课后人数不减;候补转正后总数多了 1。

原因:只更新了选课明细没更新计数;扣减和插入不在同一个事务;有人手工改过数据库;Redis 余量和数据库不同步,更新后没删缓存。

解决:以明细COUNT(status=0)为准做对账,别信展示字段。扣减和插入放同一事务,退课用条件更新,缓存 key 带termId,更新后删除而不是覆盖。下面这条 SQL 能直接找出不一致的开课:

SELECT o.id, o.capacity, o.selected_count, COUNT(e.id) AS real_count FROM edu_course_offer o LEFT JOIN edu_enroll e ON e.offer_id = o.id AND e.status = 0 GROUP BY o.id, o.capacity, o.selected_count HAVING o.selected_count <> COUNT(e.id);

5.4 排课保存成功但课表查不到

现象:管理员保存排课成功,学生端课表空白;或者周一课显示在周日;换学期后旧课表还在。

原因:前端星期传 0-6,后端按 1-7 存;查询没带term_id;week_start和week_end按自然周算,和教学周错位;数据库时区与 JVM 时区不一致,日期跨天。

解决:全链路统一星期 1-7,DTO 上加@Min(1)、@Max(7);课表查询必须带学期;教学周用学期配置算,别用LocalDate.now()推;连接串固定serverTimezone,服务器时区也统一。保存后回查一次课表接口,比只看保存成功提示可靠。

5.5 权限菜单改了不生效

现象:给角色加了菜单,用户重新登录还是看不到;按钮权限时有时无;管理员能看到,教师看不到。

原因:权限缓存在 Redis 没清;前端路由是登录时一次性生成的,角色变更后没重新挂载;菜单表里的权限码和接口注解不一致;只做了前端隐藏,后端没校验。

解决:角色变更后删perm:role:{roleId}一类缓存;前端用router.addRoute动态挂载,刷新权限时先重置路由;权限码统一常量,菜单表和注解从同一份数据生成;后端接口必须加权限校验,别指望前端隐藏按钮。测试时用教师账号直接调管理员接口,能挡住才算过。

6. 上线前先跑三条对账 SQL:一份教务源码的验收习惯

改完页面、跑通登录不算验收。教务系统最怕“页面显示正常,数据对不上”,上线前我会用只读账号跑三条 SQL,先把数据层的问题揪出来:

-- 1. 选课人数对账:展示人数和真实明细不一致的开课 SELECT o.id, o.selected_count, COUNT(e.id) AS real_count FROM edu_course_offer o LEFT JOIN edu_enroll e ON e.offer_id = o.id AND e.status = 0 GROUP BY o.id, o.selected_count HAVING o.selected_count <> COUNT(e.id); -- 2. 排课冲突抽查:同一学期、同一天、节次重叠且教师相同 SELECT a.id, b.id FROM edu_course_schedule a JOIN edu_course_schedule b ON a.term_id = b.term_id AND a.week_day = b.week_day AND a.teacher_id = b.teacher_id AND a.id < b.id AND a.section_start <= b.section_end AND a.section_end >= b.section_start; -- 3. 成绩缺失:已选课但没有成绩记录,属于待录入清单 SELECT e.offer_id, e.student_id FROM edu_enroll e LEFT JOIN edu_score s ON s.offer_id = e.offer_id AND s.student_id = e.student_id WHERE e.status = 0 AND s.id IS NULL;

第一条和第二条通过标准是 0 行,出现任何一行都先别上线;第三条不是错误,是成绩录入的待办清单,但数量要和教务手里的人数对得上。Excel 导入模板也顺手验一遍:表头固定、学号列设文本、成绩限制 0-100、导入前先做一次 dry-run 只校验不落库。从那以后我每次拿到教务源码,都先跑这三条 SQL 再改页面,页面可以慢慢调,数据错了就是血泪经验。需要整包练手的,按项目简介里的入口自取,先跑通登录和对账,再动业务表。希望帮到你。

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

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

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

立即咨询