Spring Boot+Vue教务管理系统源码:选课并发与成绩录入实战 简介这份教务管理系统源码面向教育机构管理员、计算机专业学生及需要课程设计或二次开发参考的开发者围绕学生信息、课程安排、教师管理、考勤与成绩统计等核心教务流程提供一套可运行、可拆解的完整实现。压缩包共171个文件约4.18MB以C#源码.cs、项目工程文件.csproj/.sln、界面资源.resx、.resources、bmp及配置说明文本为主并包含dll、exe等编译产物便于对照运行效果与代码结构。资源已有1995人学习下载适合用来研究C#桌面类系统的分层组织、数据库实体关系、表单交互和业务逻辑实现。读者可从中梳理模块目录、理解增删改查与选课冲突等处理思路并在此基础上做界面优化、功能扩展或课程设计改造。1. 开学前一周教务系统最怕的三件事开学前一周教务处要在半天内把两百多个教学班的容量、时间、教师关系全部调平然后让几千名学生在同一分钟内点下「选课」——用 Excel 硬扛这套流程基本必挂真正翻车的地方只有三个并发超卖、重复选课、成绩录错。这份教务管理系统源码就是冲着这类流程做的学生端选课查成绩教师端登分录分教务端管课程、教学班、学期和权限。后端是 Spring Boot 加 MyBatis 的常规组合前端是 Vue 加 Element UI 的分离结构数据库关系拆得比较清楚适合两类人一类是课程设计要交活、想直接跑通再改的同学另一类是中小型机构想拿一套能落地的管理后台骨架去做二次开发。下面按「先跑起来、再看核心链路、最后动手改」的顺序拆一遍。2. 技术栈与目录结构一套教务管理后台的骨架长什么样拿到源码先别急着mvn spring-boot:run先花十分钟看目录和依赖比后面出问题再回头找要省事得多。教务系统这类项目有个特点业务规则不算复杂但角色多、关系表多、批量操作多所以架构上真正决定后面好不好改的是模块怎么切和表怎么设计而不是用了哪个框架。2.1 技术选型为什么是这套组合常见的落地做法是后端 Spring Boot 2.x MyBatis-Plus MySQL 8 Redis鉴权走 JWT前端 Vue 2 Element UI axios打包用 Maven 多模块前端单独一个工程用 npm 管理。这套组合在教务管理系统源码里出现频率极高原因很实际MyBatis-Plus 省掉大量单表 CRUD。教务系统里学生、教师、课程、班级、学期这些表的增删改查占了代码量的一大半用条件构造器能少写几百行 XML同时复杂查询还能退回手写 SQL不至于被 ORM 锁死。JWT 比 Session 更适合前后端分离。学生端和教师端往往部署在不同域名或不同端口Session 共享要额外配JWT 把角色信息塞进 token 里拦截器解析完就能判断权限对中小规模系统足够。Redis 主要用在两个地方登录态缓存和选课时的容量预扣。前者是可选项后者在选课高峰期几乎是刚需后面第 3 章会展开。Vue 2 Element UI是这类管理后台的稳定搭配表格、表单、分页组件开箱即用改起来门槛低。如果你的源码里是 Vue 3 Element Plus逻辑一致只是组件写法换成组合式 API。需要注意的一点教务系统的「班级」和「教学班」是两个概念。行政班学生所属的班级和教学班一门课在一个学期由一个老师带的一批学生在多数项目里是分开建模的看源码时先确认这一点不然后面选课逻辑会看糊涂。2.2 目录结构与模块边界后端一般是 Maven 多模块前端独立一个目录SQL 脚本单独放。典型结构如下edu-admin/ ├── edu-common/ 公共模块 │ └── src/main/java/.../common │ ├── result/R.java 统一返回体 │ ├── exception/ 业务异常 全局异常处理 │ └── utils/ 日期、Excel、加密工具 ├── edu-api/ 接口层 │ └── controller/ 按角色分包admin / teacher / student ├── edu-service/ 业务层 │ ├── service/ 接口 │ └── impl/ 实现事务边界在这一层 ├── edu-dao/ 持久层 │ ├── mapper/ Mapper 接口 │ └── resources/mapper/ 复杂 SQL 的 XML ├── edu-web/ 前端 Vue 工程 │ └── src/{api,views,router,store} └── sql/ ├── schema.sql 建表 └── init-data.sql 初始账号与字典数据这个分法的核心是把「事务」压到 service 层controller 只做参数接收和返回包装dao 只碰数据库。改业务的时候你基本只在 service 里动不会牵一发动全身。前端目录里有两处值得先看src/api/下面按模块拆的请求文件和src/router/里的路由守卫。后者往往内置了「按角色过滤菜单」的逻辑加新页面时如果忘了在这里注册会出现菜单能看到、点进去 404 的经典问题。2.3 数据库表设计关系表决定改造难度教务系统的表大致分三类用户权限、基础档案、业务流水。下面这张表是核心几张看源码时对照着看会快很多。表名作用关键字段注意点sys_user登录账号username、password、role_code学生/教师/管理员共用一张edu_student学生档案student_no、name、admin_class_id与 sys_user 一对一edu_teacher教师档案teacher_no、name、dept_id同上edu_course课程字典course_no、name、credit课程本身不含学期edu_class教学班term_id、course_id、teacher_id、capacity、selected_count选课的核心表edu_course_selection选课记录class_id、student_id必须有联合唯一索引edu_score成绩selection_id、score、grade_point与选课记录关联建表脚本里最容易被忽略、但最不能省的两个约束一个是教学班表上的容量字段一个是选课记录表上的联合唯一索引CREATE TABLE edu_class ( 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 授课教师ID, class_no VARCHAR(32) NOT NULL COMMENT 教学班号如 2024-1-CS101-01, capacity INT NOT NULL DEFAULT 60 COMMENT 容量上限, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1开放选课 0关闭, UNIQUE KEY uk_class_no (class_no), KEY idx_term (term_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT教学班; CREATE TABLE edu_course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, class_id BIGINT NOT NULL, student_id BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 这条唯一索引是防重复选课的最后一道防线 UNIQUE KEY uk_class_student (class_id, student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选课记录;selected_count用独立字段而不是每次count(*)现算是为了让选课接口能走一条update ... where selected_count capacity的原子更新第 3 章会讲具体写法。uk_class_student这条唯一索引千万别删很多源码版本里它被写成普通索引压测时就会冒出同一个人选了两遍的记录这种脏数据事后清理非常麻烦。字符集统一utf8mb4也是必须的教师和学生的姓名里出现生僻字的情况比想象中多utf8三字节存不下插入时会直接报错或截断。3. 核心链路拆解一次选课请求从进入到落库跑通之后再回来读代码重点看三条链路登录鉴权、选课、成绩录入。这三条覆盖了教务系统里 80% 的坑。下面按请求流向拆每段代码后面说清楚参数和为什么这么写。3.1 登录与角色拦截JWT 怎么带上角色登录接口验证完账号密码后签发 token把用户 ID 和角色编码塞进 claim。拦截器在每次请求进来时解析 token把用户信息放进 ThreadLocal业务层直接取当前登录人不用每层都传参。Component public class JwtUtil { // 密钥从配置读别硬编码进代码也不要用短字符串 Value(${edu.jwt.secret}) private String secret; // 过期时间单位毫秒教务系统一般设 8 小时覆盖一个工作时段 Value(${edu.jwt.expire:28800000}) private long expire; public String createToken(Long userId, String roleCode) { Date now new Date(); return Jwts.builder() .claim(uid, userId) // 用户主键 .claim(role, roleCode) // STUDENT / TEACHER / ADMIN .setIssuedAt(now) .setExpiration(new Date(now.getTime() expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parse(String token) { // 解析失败会抛 JwtException交给全局异常处理返回 401 return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }对应拦截器里做两件事校验 token 有效性以及判断当前请求路径是否允许该角色访问。角色判断我一般用「路径前缀 注解」双重方式比如/api/admin/**只允许 ADMIN具体接口上再用自定义注解RequireRole(TEACHER)兜底。Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { String token req.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BizException(401, 未登录); } Claims claims jwtUtil.parse(token.substring(7)); LoginUser user new LoginUser( claims.get(uid, Long.class), claims.get(role, String.class)); UserContext.set(user); // ThreadLocal请求结束记得在 afterCompletion 里 remove return true; }参数说明Authorization头格式固定为Bearer token前端 axios 拦截器统一注入expire建议配在application.yml里方便不同环境调整。UserContext用完必须清理线程池复用时 ThreadLocal 不 remove 会导致下个请求读到上个人的身份这类问题在压测时才会暴露排查起来很费劲。3.2 选课接口并发下的容量控制选课是整个系统里唯一真正有并发压力的接口。几千个学生同时抢如果先select查余量再update扣减中间那个窗口足够让容量被击穿超卖几十个很正常。合格的做法是让数据库自己做原子判断Service public class SelectionServiceImpl implements SelectionService { Resource private EduClassMapper classMapper; Resource private SelectionMapper selectionMapper; Override Transactional(rollbackFor Exception.class) public void select(Long classId, Long studentId) { // 1. 原子扣减只有还有余量时才会命中行返回值即影响行数 int affected classMapper.tryIncreaseSelected(classId); if (affected 0) { // 0 行有两种可能班级不存在 / 已满统一提示已满即可 throw new BizException(这门课已经选满了); } try { // 2. 写选课记录唯一索引挡住重复选课 selectionMapper.insert(classId, studentId); } catch (DuplicateKeyException e) { // 3. 重复选课时必须把刚才扣掉的余量还回去靠事务回滚 throw new BizException(你已经选过这门课了); } } }Mapper 里那条 SQL 是整段逻辑的核心update idtryIncreaseSelected UPDATE edu_class SET selected_count selected_count 1 WHERE id #{classId} AND status 1 AND selected_count lt; capacity /update逻辑说明selected_count capacity写在 WHERE 里InnoDB 会给这一行加行锁两个并发请求只有一个能更新成功另一个拿到 0 行直接返回。这比select ... for update好在锁的范围只在一个行上而且不用先查一次再更新少一次往返。参数说明status 1是选课开关教务端关闭选课后这个条件自然挡住请求capacity是教学班容量改容量只需要改这一列。遇到DuplicateKeyException时直接抛业务异常让事务回滚扣掉的计数会跟着退回去——这是把「扣减」和「写记录」放在同一个事务里的价值。反过来如果哪个版本把扣减写成set selected_count #{count}的覆盖式写法并发下就会丢更新计数永远对不上实际选课人数这类 bug 属于黑匣子不做对账根本看不出来。3.3 成绩录入与批量导入先校验再落库教师端录分有两个入口单条录入和 Excel 批量导入。批量导入最容易出问题因为文件是人工填的学号写错、成绩超范围、重复行都会出现。我的做法是先全量解析并校验全部通过才落库避免导一半失败留下半截数据。public ImportResult importScores(MultipartFile file, Long classId) { ListScoreRow rows ExcelUtil.read(file, ScoreRow.class); ListString errors new ArrayList(); for (int i 0; i rows.size(); i) { ScoreRow r rows.get(i); // 行号从 2 开始第 1 行是表头报错时直接告诉老师改哪一行 int lineNo i 2; if (r.getStudentNo() null || r.getStudentNo().isBlank()) { errors.add(第 lineNo 行学号为空); } if (r.getScore() null || r.getScore() 0 || r.getScore() 100) { errors.add(第 lineNo 行成绩必须在 0-100 之间); } } if (!errors.isEmpty()) { // 一次性把所有错误返回别让老师改一行提交一次 return ImportResult.fail(errors); } // 校验通过才批量写入batchSize 控制在 500 左右太大容易超过 max_allowed_packet scoreMapper.batchInsert(classId, rows); return ImportResult.ok(rows.size()); }参数说明classId由前端从当前教学班上下文带入不要相信 Excel 里的班级列batchSize建议 300 到 500具体看单行字段多少超过 1000 时 MySQL 的max_allowed_packet容易报错。成绩字段在数据库里用DECIMAL(5,2)而不是VARCHAR。用字符串存成绩的项目导出排序时会出现 100 排在 90 前面、9 排在 85 后面的情况这是血泪经验改表成本比一开始就选对类型高得多。4. 部署与启动从解压到登录后台的完整步骤源码跑不起来的原因九成集中在三处JDK 版本不对、数据库没初始化、前后端地址没对齐。这一章按顺序走一遍每一步都给出可验证的结果出问题时能定位到具体环节。4.1 环境准备与依赖安装先看根目录pom.xml里的java.version和spring-boot-starter-parent版本这两项决定了你要装什么。常见组合如下组件版本要求验证命令JDK与 pom 中 source 一致多为 8 或 11java -versionMaven3.6 以上mvn -vMySQL5.7 或 8.0mysql --versionRedis5.0 以上选课模块依赖redis-cli pingNode.js14 或 16Vue 2 工程别用 18node -v# 后端依赖预下载第一次会比较慢 cd edu-admin mvn clean install -DskipTests # 前端依赖建议用国内镜像源 cd edu-web npm install --registryhttps://registry.npmmirror.com-DskipTests不是偷懒很多源码包里的测试用例依赖本地特定的数据直接跑会失败但不影响启动。Node 版本这里要特别注意Vue 2 的node-sass在 Node 18 上编译会直接报错装 14 或 16 能省掉半小时折腾。4.2 数据库初始化与配置对齐建库、导表、导初始数据顺序不能反因为init-data.sql里往往有外键引用。mysql -uroot -p -e CREATE DATABASE edu_admin DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; mysql -uroot -p edu_admin sql/schema.sql mysql -uroot -p edu_admin sql/init-data.sql然后改edu-api/src/main/resources/application-dev.yml重点看这四项spring: datasource: url: jdbc:mysql://127.0.0.1:3306/edu_admin?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 你的密码 redis: host: 127.0.0.1 port: 6379 database: 0 edu: jwt: secret: 换成一串足够长的随机字符串 expire: 28800000serverTimezoneAsia/Shanghai必须带MySQL 8 不带这个参数启动时会直接抛时区无法识别的异常allowPublicKeyRetrievaltrue是 MySQL 8 默认加密方式下的必需项缺了会报公钥检索失败。JWT 的 secret 别用默认值默认值在源码里是公开的等于没有鉴权。4.3 启动与首次登录验证# 后端端口默认 8080 mvn -pl edu-api spring-boot:run # 前端另开一个终端 cd edu-web npm run dev前端vue.config.js里的proxy要指向后端实际地址常见配置是/api转发到http://localhost:8080。如果后端在application.yml里配了server.servlet.context-path: /edu代理的 rewrite 规则要跟着加否则会出现前端请求 404、后端日志一条没有的情况——说明请求根本没打到后端。启动成功的标志有三条控制台出现Started EduApplication访问http://localhost:8080/doc.html如果集成了接口文档能看到接口列表用初始数据里的管理员账号能登录进后台并看到菜单。这三条都过了再去看业务页面。初始账号一般在init-data.sql的注释里写着密码字段常见是 BCrypt 加密后的串登录时用明文。如果登录一直提示密码错误先确认导入的初始数据是不是被别的脚本覆盖过。5. 避坑与排查跑通过程中最容易翻车的五处这一节按「现象 → 原因 → 解决」写都是我在实际部署和压测中真实撞到过的问题按出现频率排序。5.1 环境与配置类问题现象一启动即报The server time zone value is unrecognized控制台一堆乱码。原因JDBC URL 没带serverTimezone参数MySQL 服务端时区名是本地化字符驱动解析不了。 解决在 URL 后面补serverTimezoneAsia/Shanghai同时顺手把characterEncodingutf8也加上。加完重启如果还乱码检查数据库和表的字符集是不是utf8mb4SHOW CREATE TABLE edu_student一眼就能看出来。现象二前端页面能打开但所有接口报 404后端日志里一条请求记录都没有。原因前端proxy的转发目标和后端实际的 context-path 不一致请求被转发到了一个不存在的路径。 解决先看后端application.yml里有没有配context-path再看vue.config.js的target和pathRewrite。判断方法很简单把前端请求的完整地址复制到浏览器地址栏直接访问能通说明是代理配置问题不通说明后端没起在预期端口。现象三登录成功但一刷新页面就退出登录。原因token 存在了内存或sessionStorage里但没同步或者路由守卫在页面初始化时读取时机早于 token 写入。 解决统一把 token 存localStorage在 axios 请求拦截器里读路由守卫里判断 token 时加一个「白名单」提前返回避免登录页也被拦。5.2 业务与数据类问题现象四选课压测时提示「已选满」但后台看到已选人数远小于容量。原因多实例部署时每个实例都缓存了一份班级余量或者用了synchronized锁但锁的是 JVM 内的对象跨实例失效。 解决余量判断必须落回数据库的那条update ... where selected_count capacity不要依赖内存缓存的值做最终判断Redis 预扣只能当第一层挡板最终一致性靠数据库。如果确实要上 Redis 计数器记得在选课成功后异步对账一次。现象五批量导入成绩时导入 200 条只成功了一半也没有报错提示。原因batchInsert过程中某一条触发唯一索引冲突整个批次抛异常但被上层吞掉了日志里只有一条DuplicateKeyException。 解决导入前先按学号去重再按学号查一次已有成绩把「已存在」的行单独挑出来返回给前端让老师确认是覆盖还是跳过。另外把batchInsert的 SQL 改成INSERT ... ON DUPLICATE KEY UPDATE语义更明确也不会因为一条脏数据废掉整批。6. 二次开发加一个「教学评价」模块的完整路径真正考验这套源码的地方不是跑起来而是加需求。教务系统里最典型的新增模块是教学评价——学生期末给课程打分。拿它举例走完一遍你会发现改造路径是固定的。第一步建表字段设计上要把评价和选课记录绑死只有选过这门课的学生才能评CREATE TABLE edu_evaluation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, selection_id BIGINT NOT NULL COMMENT 选课记录ID天然限制了评价人, score TINYINT NOT NULL COMMENT 1-5 星, content VARCHAR(500) DEFAULT NULL, anonymous TINYINT NOT NULL DEFAULT 1 COMMENT 是否匿名, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_selection (selection_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;第二步照着现有模块抄一套 Mapper、Service、Controller别自己另起风格。Controller 上加RequireRole(STUDENT)Service 里用UserContext.get()拿到当前学生反查他的选课记录再写评价这样不需要前端传学生 ID也堵住了越权评价。第三步改前端在src/router/里加路由在菜单配置里加一项权限过滤逻辑复用现有的角色判断。这一步最容易漏表现为菜单不显示或者点进去白屏。第四步验证我的顺序是先用接口文档单测评价接口的正常和重复提交两种场景再手动把edu_evaluation表清空跑一次完整流程最后用 JMeter 或ab压一轮读接口确认没有 N1 查询。改完任何模块我都会顺手把选课和成绩导入两条老链路再点一遍——新增模块把老功能带崩的情况比新模块自己有 bug 常见得多。顺带一个技巧改造时优先复用现有的统一返回体和全局异常处理别在新模块里自己return ResponseEntity。混用两种风格之后前端拦截器要写两套判断维护成本会翻倍。从那以后我每次加新模块都强制走一遍「建表 → 权限点 → 接口 → 前端菜单 → 老链路回归」这五步一步不跳。教务系统这类项目看着简单真正让人栽跟头的从来不是新功能写不出来而是新功能把选课和成绩悄悄弄坏了。希望帮到你。本文还有配套的精品资源点击获取