简介:面向Java后端初学者与SSM整合开发者,这份教务系统项目以典型的学生、教师、课程管理为主线,覆盖Spring IoC、SpringMVC、MyBatis、Shiro安全框架等核心环节,适合用来练习分层架构与权限控制,也可作为课程设计或毕业设计的参考原型。资源包共271个文件,压缩后约35.32MB,主要包含Java源码、class编译文件、Spring与MyBatis的xml配置、JSP页面、SQL脚本、Bootstrap前端样式及少量文档,目录结构基本对应Controller、Service、Dao等分层,便于按模块查看。目前已有412人学习下载。通过阅读源码和配置,可以了解C3P0数据源、log4j日志、Shiro认证授权在实际项目中的组织方式,还可借助配套SQL脚本快速搭建运行环境,对理解SSM项目从配置到页面的完整调用链很有帮助。
1. 基于 java 开发教务系统,为什么先从“分学期”而不是“建表”开始
一个教务系统,网上搜“基于java开发的教务系统”,出来的 demo 多半是学生、教师、课程三张表,外加一个选课接口,跑起来像模像样。但真拿到学校里去用,最先翻车的往往不是并发,而是“这学期数据还没归档,下学期就开始了,两个学期的课表怎么共存”。教务系统的核心问题从来不是 CRUD,而是数据的时间维度和状态流转。这篇笔记我会从数据模型、Spring Boot 落地、选课并发控制到常见坑位,按一套能交付的方案讲清楚。适合刚接手教务系统开发、或准备拿它做毕业设计/面试项目的 Java 开发者,新手能照着搭,熟手可以跳着看边界和参数设置。
2. 先把实体关系理清:核心表设计与“三范式”的取舍
2.1 学生、班级、课程之外,教务系统真正的主干是“开课任务”
几乎所有教务系统 demo 都漏了一张关键表:开课任务(teaching_task)。学生、班级、课程只是基础档案,真正驱动业务的是“某学期、某专业、某老师、某门课、在哪个教室、每周哪几节”这一条任务记录。排课、选课、成绩录入全部挂在这条任务上,而不是挂在课程表上。
我一般这样设计:课程表(course)只存课程名称、学时、学分、考核方式;开课任务表(teaching_task)存学期、课程ID、教师ID、上课班级范围、容量上限、上课时间、教室ID。学生选课选的是 teaching_task,不是 course。好处是同一门课在不同学期可以由不同老师上、不同容量限制,互不干扰;坏处是多了一次关联查询,但这个代价完全值得。
建表时有一个判断标准:如果一张表的主键里塞进了课程代码,那这张表一定活不过第三个学期。课程代码本身就不是稳定的业务主键——培养方案调整后同一课程代码可能改课名,毕业生数据还要保留原课程名。所以我所有的业务表都用自增ID或者雪花ID做主键,课程代码只是普通索引。
CREATE TABLE teaching_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, semester VARCHAR(20) NOT NULL COMMENT '学期,如2024-2025-2', course_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, capacity INT NOT NULL DEFAULT 0, selected_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1发布 2选课中 3已结束', start_week INT NOT NULL, end_week INT NOT NULL, UNIQUE KEY uk_semester_course_teacher (semester, course_id, teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里唯一索引是 uk_semester_course_teacher,限制同一学期同一门课同一个老师不能重复创建任务。选课中字段 selected_count 是冗余计数,后面讲并发时会用到。status 字段是状态机,所有业务操作之前先判断状态,而不是靠某个时间字段去推理。
2.2 成绩表不设计成宽表:一行一门课,而不是一行一个学生一整行成绩
很多教务系统的成绩表设计成 student_id、course1_score、course2_score……这种宽表,属于典型的长尾灾难。下学期加一门课就要 ALTER TABLE 加列,毕业生补考还要再开一列。正确的做法是成绩表只存 task_id、student_id、score,一行就是一条选课成绩记录,成绩册视图再通过行转列聚合出来。
成绩行存两份:原始分(score)和成绩等级(grade_point),等级由规则引擎换算,不在录入时手工填。原因很实际——不同课程可能采用不同计分规则(五级制、百分制、两级制),规则会变,历史数据不能跟着变。所以成绩表里还要留一个 convert_rule_id,记录当年用的是哪条换算规则。
CREATE TABLE score_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, student_id BIGINT NOT NULL, score DECIMAL(5,2) NULL, grade_point DECIMAL(3,2) NULL, convert_rule_id BIGINT NULL, record_status TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1缓考 2缺考 3作弊', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_task_student (task_id, student_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;UNIQUE KEY uk_task_student 在这里是刚需:同一学生同一开课任务只能有一条成绩记录。后续做补考还是重修,是再开一条新开课任务,而不是覆盖旧记录。我见过用“成绩表加版本号”的方案,但学生端查成绩时逻辑会非常拧巴,而且“本学期成绩”和“历史最好成绩”混在一起,报表没法写。成绩表里不存课程的学期,学期通过 task 关联,否则一旦转专业或者重修,成绩归属会完全错乱。
2.3 三范式不能全守:必要的冗余字段该加就加
纯粹按第三范式设计教务系统,会得到一张巨大的“选课事实表”,所有信息靠 join 现算。这样做出来的系统查询接口慢得离谱,而且排课冲突检测根本没法写SQL。合理的方案是:事实表只存外键和少数状态位,但允许冗余“快照型”字段,比如选课记录里冗余一个 semester,这样查“某学期我选了哪些课”就不用 join 回 teaching_task。
快照字段和普通冗余字段的区别在于:快照字段一旦写入就不随源数据变化。比如成绩表里冗余 course_name,学生毕业后课程改名了,成绩单上仍然显示当年那个课程名。这一点在毕业审核、成绩单打印场景里非常重要,属于教务系统的硬需求。
我的取舍原则是:能通过唯一索引约束住的口径做范式化,比如task+student;需要跨学期稳定的描述做快照冗余,比如课程名、教师名。至于班级表、专业表、培养方案表,这些保持标准的主从结构即可,不再展开。设计阶段花半天把这张ER图扯清楚,后面写出干净SQL的时间能省出好几倍。
3. 用 Spring Boot 把服务搭起来:权限、分页与成绩录入
3.1 选型:Spring Boot + MyBatis-Plus,而不是 MyBatis 裸写
教务系统这种业务,SQL 复杂度高、字段变化频繁,用 MyBatis 裸写 mapper 的维护成本偏高。我常用 Spring Boot 2.7 + MyBatis-Plus 3.5 的组合,分页、逻辑删除、自动填充都开箱即用。Java 环境变量配置和 JDK 版本先对齐,Spring Boot 2.7 对应 JDK 8/11 都没问题,3.x 则要求 JDK 17。很多“java启动失败”的案例其实是 JDK 版本和 Spring Boot 大版本不匹配,而不是代码问题。
MyBatis-Plus 的分页插件需要手动配置 PaginationInnerInterceptor,不配的话 Page 对象不会生效,查出来还是全量数据。这个坑在教务系统的“选课名单分页”“成绩册分页”里都会踩到。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }PaginationInnerInterceptor 的 maxLimit 设成 500,防止有人在查询接口里传 pageSize=10000,把数据库拖垮。教务系统里“导出全部名单”和“分页查询”要分两个接口,导出走异步任务,不走 pageSize 拉满的方案,这一点后面会再提。
3.2 权限模型:用 RBAC,但菜单表要先想清楚
教务系统的角色至少有:教务管理员、院系教务员、教师、学生、教学督导。不同角色看同一份数据的范围还不同,比如院系教务员只能看本院系的数据。RBAC 五张表(用户、角色、菜单、用户角色、角色菜单)是标配,但菜单表要设计成两部类:目录菜单和按钮权限。按钮权限不是前端隐藏一个按钮那么简单,后端接口也要用注解做二次校验。
Spring Boot 里我习惯用 Sa-Token 而不是 Shiro 或 Spring Security。Sa-Token 对“登录+权限校验”这两个场景封装得很直接,注解式鉴权一行搞定,学习成本比 Security 低很多,适合教务系统这种后台管理系统。
@SaCheckRole("teacher") @PostMapping("/score") public Result<Void> submitScore(@RequestBody @Valid ScoreSubmitDTO dto) { TeachingTask task = teachingTaskService.getById(dto.getTaskId()); if (!Objects.equals(task.getTeacherId(), currentUserId())) { return Result.fail("只能录入自己所带课程的成绩"); } scoreRecordService.saveOrUpdate(dto); return Result.ok(); }这里除了角色校验,还做了数据范围校验:老师只能录自己课程的成绩。这是一个很典型的“行级权限”场景,Sa-Token 的注解管“能不能进这个接口”,代码里的判断管“进了之后能操作哪些数据”。两者缺一不可。权限配置层面,角色和菜单的关联最好用页面配置后台管理,不要写死在 SQL 里,否则开学季调一次权限就要发一次版。
3.3 成绩录入要做成“先暂存再提交”,别一个接口直接 UPDATE
一线开发容易把成绩录入做成一个 update 接口,老师在前端改一格保存一格。这种设计在真实教务场景里很难用:老师手里有一整张 Excel 成绩单,需要先批量粘贴、临时保存、检查一遍、最后统一提交。所以成绩录入要分两步:saveDraft 和 submit。draft 阶段只写缓存或临时表,submit 阶段才校验后落入 score_record。
批量导入是成绩录入的核心场景,Excel 用 EasyExcel 解析,每一行校验班级、学号、任务ID、分数范围。校验错误的行要返回给前端下载,而不是整体失败。我常用一个 BatchResult 对象,记录成功条数和每条失败原因,导出成错误清单。
public BatchImportResult importScores(MultipartFile file, Long taskId) { List<ScoreRow> rows = EasyExcel.read(file.getInputStream()) .head(ScoreRow.class) .sheet().doReadSync(); BatchImportResult result = new BatchImportResult(); for (int i = 0; i < rows.size(); i++) { ScoreRow row = rows.get(i); String error = validate(row, taskId); if (error != null) { result.addError(i + 2, error); } else { scoreRecordService.saveDraft(taskId, row); result.addSuccess(); } } return result; }注意 doReadSync 会一次把整个文件读进内存,文件控制在 2000 行以内没问题,超过 5000 行建议改成分批读取模式。还有一点:Excel 里的学号如果超过 15 位,会被 Excel 自动转成科学计数法,导入时要用字符串单元格读取。这个属于做教务系统必踩的坑之一,下文会集中再讲。
4. 选课并发:事务隔离级别、唯一索引与防超选
4.1 选课的本质是“短事务加唯一约束”,不是 Sync 关键字
选课高峰时系统要扛住几百上千人同时点选课。很多新手的第一反应是给选课方法加 synchronized,这在单机部署下勉强能用,一旦多实例部署就完全失效,而且 synchronized 锁的是 JVM 内同一个对象,两台机器之间根本不构成互斥。正确做法是靠数据库约束加事务隔离级别。
流程上,选课接口要做四件事:判断开课任务状态是否允许选课、判断所选课程是否冲突(同一时间已有其他课)、用 update 语句原子扣减容量余量、插入选课记录。这四步必须在同一个事务里,且事务隔离级别至少要到 READ_COMMITTED。
防超选的核心是那句带条件的 update,而不是先 select 再 update:
@Transactional(rollbackFor = Exception.class) public Result<Void> selectCourse(Long taskId) { TeachingTask task = teachingTaskService.getById(taskId); if (task.getStatus() != CourseStatus.SELECTING.getCode()) { return Result.fail("当前不在选课时间内"); } int updated = teachingTaskMapper.reduceCapacity(taskId); if (updated == 0) { return Result.fail("该课程已选满"); } try { studentCourseMapper.insert(new StudentCourse(taskId, currentUserId())); } catch (DuplicateKeyException e) { throw new BusinessException("请勿重复选同一门课"); } return Result.ok(); }4.2 reduceCapacity 的 SQL 为什么要带 capacity 条件
reduceCapacity 对应的 SQL 大概是:update teaching_task set selected_count = selected_count + 1, capacity = capacity - 1 where id = #{taskId} and capacity > 0。capacity 是剩余容量,每次选课先原子减一,减到负数就拒绝,这样就不会超选。这条 SQL 在高并发下依赖 InnoDB 的行锁串行化同一行的更新,天然保证了不会出现两个人同时选走最后一个名额。
那么插入选课记录为什么不先查重?原因很简单:查重和插入之间有时差,两个请求都查不到记录,然后都插入成功,唯一索引会拦住第二条。DuplicateKeyException 捕获后,事务会因为异常标记 rollback-only,此时不能再执行其他 SQL,只能抛出业务异常让事务整体回滚。注意这里不能用 try-catch 吞掉异常,否则事务提交时会报 UnexpectedRollbackException,更麻烦。
另一种方案是纯用唯一索引防重,不先做 capacity 扣减。但这样会出现“选课记录插入成功、容量已经超了”的状态,补数据和通知学生的成本非常高。先扣容量再插记录,即使插入失败回滚,容量也会一并还原,逻辑最干净。回滚后自增ID会跳号,这一点无伤大雅,不用追求ID连续。
4.3 选课批次与定时任务:用 Spring 的 @Scheduled 而不是手动线程池
一个学期通常有好几轮选课批次:首轮、跨专业补选、退改选。每一轮对应一批“开课任务状态切换”的动作,这个切换用定时任务扫表完成,比人工操作接口更可靠。Spring 自带的 @Scheduled 在这里足够用,不需要引入 xxl-job 之类的重框架,除非有多机部署定时任务互斥的需求。
@Component public class CourseRoundScheduler { @Scheduled(cron = "0 0 0 * * ?") public void autoCloseSelectingRounds() { List<TeachingTask> tasks = teachingTaskMapper.selectByStatus(CourseStatus.SELECTING.getCode()); for (TeachingTask task : tasks) { if (task.getEndTime().isBefore(LocalDateTime.now())) { teachingTaskMapper.updateStatus(task.getId(), CourseStatus.CLOSED.getCode()); } } } }cron 表达式按实际业务节奏设。注意定时任务里的 updateStatus 要加条件 status = 当前状态,防止任务重复执行时把已经关闭的任务再次关闭,或者把正在选课的新一轮任务关掉。幂等性是定时任务的第一要求。
4.4 事务隔离级别与锁的调优参数
选课相关的 Service 事务建议显式指定隔离级别为 READ_COMMITTED,不要依赖 MySQL 默认的 REPEATABLE_READ。读已提交在并发更新时锁等待时间更短,对选课这种“读多写少但写冲突集中”的场景更友好。InnoDB 的默认行锁用的是索引,如果 where 条件没走索引,会升级成锁全表,所以 teaching_task 的主键查询必须走主键索引,student_course 的查询必须有 task_id + student_id 的联合索引。
如果选课高峰时死锁报错频繁,先看两张表插入顺序是否一致。所有请求都先更新 task 再插入 course_selection,锁顺序一致才能避免死锁。如果先插选课记录再扣容量的代码混进了同一个事务,死锁概率会明显上升。
5. 教务系统避坑指南:五个高频翻车点与排查路径
5.1 班级人数统计翻倍:count(distinct) 救不回的笛卡尔积
现象:统计“某课程选课人数”时,SQL 写成了 JOIN 班级表后再 count,结果人数总是比实际多几倍。原因是选课记录和学生之间是一对多——一个学生选了多门课,join 到班级表后同一学生会出现多行。
原因:对一对多关系做 join 后直接 count,没有意识到中间结果集已经被放大。教务系统里这种统计很常见,比如按班级统计平均分、按专业统计挂科率。
解决:统计行数用 count(distinct student_course.student_id),或者把统计和明细拆成两个 SQL。聚合统计不要 join 明细表,尽量在子查询里先完成聚合。
5.2 逻辑删除和唯一索引打架:删过的选课记录还能再“选”吗
现象:用 MyBatis-Plus 的逻辑删除功能后,学生退课再选同一门课,提示“Duplicate entry”,但界面上看不到任何记录。
原因:逻辑删除只是 update deleted = 1,唯一索引仍然把这条记录算数。uk_task_student 里有两个 (task_id, student_id),物理上删除的旧记录依然占着索引位置。
解决:选课记录表不要用逻辑删除,退课就是物理 delete。教务系统的选课记录是“行为流水”,不像用户表那样需要恢复。真需要历史追溯,可以单独建一张 course_drop_log 记录退课操作,保留原始数据。
5.3 Excel 里的学号变成科学计数法,批量导入批量失败
现象:用 EasyExcel 导入学生名单,学号为 202300123456789 的单元格被读成了 2.02300123456789E14,数据库里存了浮点数。
原因:Excel 对超过 11 位的数字默认转科学计数法,POI 读出来就成了 double。学号本来应该按字符串处理。
解决:EasyExcel 里对学号字段用 @ExcelProperty 标注,并且在实体类里定义成 String。读取时如果还是读到 Double,写一个 Converter 强制把数值转成字符串再去除小数位。这段属于导入场景必修课。
5.4 时区问题:服务器时间晚 8 小时,导致选课批次提前开放
现象:定时任务在 0 点执行正常,但学生端显示选课通道比预定时间早 8 小时开放。查了代码没发现问题,最后发现是 MySQL 连接串里的 serverTimezone 没配。
原因:MySQL 驱动版本在 8.x 之后强制时区敏感,JDBC URL 里没有 serverTimezone=Asia/Shanghai 时,驱动默认取 JVM 时区或 UTC,导致 DateTime 字段读写偏移 8 小时。
解决:JDBC 连接串显式加 serverTimezone=Asia/Shanghai&useLegacyDatetimeCode=false。同时实体类里的 LocalDateTime 和数据库 datetime 字段对好类型,不要用 java.util.Date 混用。排课时间、选课时间这类业务时间全部以数据库时区为基准。
5.5 一个接口超时排查思路:先看 SQL 再看锁,不是先加缓存
现象:选课名单查询接口在选课结束后变慢,从 100ms 涨到 3 秒。
原因:通常不是查询本身慢,而是接口里有些字段需要循环查子表,比如查 100 个学生的成绩、班级、学籍信息,每个都用单条 SQL 查一次,产生 N+1 问题。MyBatis-Plus 的 listByIds 没传批量集合的时候也会触发这种问题。
解决:一次性查出所有学生ID集合,然后用 in 查询批量补全信息;再不行用冗余字段直接命中主表。排查时先看慢查询日志,如果单条 SQL 都是毫秒级,就是应用层循环查询问题,缓存救不了这种场景。这个方向要按“先定位再优化”的顺序走。
6. 用接口验证代替“肉眼测试”:一个全链路自检清单
选课、排课、成绩录入这个系统做完,不能只在前端点一遍就交付。我习惯写一套接口层自检脚本,专门覆盖“数据正确性”而不是“页面能打开”。这套脚本可以用 Spring Boot 的测试模块跑,也可以直接用 curl 打接口,关键是每个场景的预期结果要写得具体。
# 1. 学生选课:容量剩余为 1 时抢最后名额 curl -X POST /api/selection/2024001 -d 'taskId=1001' # 预期:返回成功,再执行一次,返回“已选满” # 2. 同一时间冲突:任务 1002 与 1001 同一时间段 curl -X POST /api/selection/2024002 -d 'taskId=1002' # 预期:返回“时间冲突” # 3. 成绩提交:教师非本课程授课人 curl -X POST /api/score -H 'Role: teacher' -d 'taskId=1003&studentId=2024003&score=88' # 预期:返回“只能录入自己所带课程的成绩”这三条验证完,核心业务的主干逻辑就没有大体量问题了。之后再加一条链路测试:从创建开课任务到发布、选课、录成绩、查成绩册,全流程用同一批数据跑一遍,看每个环节的状态流转是否符合预期。这条链路测通,不仅功能闭环了,也为后续接报表、毕业审核打好了基础。
教务系统这类项目,真正考验人的地方不是“会不会写 Spring Boot”,而是能不能把学期切换、容量控制、权限边界这些容易糊弄过去的细节钉死。我自己当年在第一版里就栽过唯一索引和逻辑删除的坑,后来把选课记录改成物理删除、成绩表改成快照冗余,系统才稳定下来。希望帮到你。
本文还有配套的精品资源,点击获取