SpringBoot+MyBatis学生成绩信息管理系统设计与实现
简介面向教育管理课程设计、毕业设计与ASP.NET自学场景的学生成绩信息管理系统完整资料包围绕学生、课程、教师、成绩与管理员等角色覆盖信息维护、成绩录入查询、统计分析和报表输出等典型业务适合需要从需求分析推进到系统实现的读者参考。压缩包共311个文件约3.48MB以86个C#源码、29个ASP.NET页面、27个DLL程序集为主并含CSS、JavaScript、配置文件、mdf/ldf数据库文件及doc论文文档可据此观察前后端组织、数据表设计与部署结构。已有60人学习。其价值在于将论文中的需求分析、数据模型和架构设计同可运行源码对应起来便于拆解模块、对照数据库字段理解成绩管理流程并借鉴页面交互与统计报表的实现思路用于课程作业、毕业设计选题或同类系统改造。1. 期末那张越改越乱的成绩表这套学生成绩信息管理系统到底替谁干活每到期末最让人头疼的不是排课而是一张反复修改、反复外发的成绩汇总表任课教师交一份 Excel班主任按班级筛一遍教务处再按课程统计一遍中间只要有一个人改了分数三个文件就全部对不上。这套「学生成绩信息管理系统论文源码」就是针对这个场景设计的它把学生、教师、课程、成绩四类数据收进同一个库用角色权限把录入、查询、统计拆开谁改了哪条记录都留痕。压缩包里除了可运行的工程还有一份完整的设计论文文档和代码同源适合拿来做毕业设计、课程设计也适合刚学完 SpringBoot、想找个带真实业务规则的 CRUD 项目练手的人。2. 技术栈与工程结构拆开压缩包先别双击先认清三层2.1 技术选型这套组合为什么几乎是同类项目的标准配置这类教务型系统有个共同特点表不多、字段不复杂但角色多、查询条件杂、统计口径经常变。所以选型上不会追求新而是追求「改起来不用问人」。学生成绩信息管理系统最常见的搭配是 SpringBoot 2.x MyBatis MySQL Thymeleaf/Layui前端页面直接由后端渲染省掉一套 Node 环境答辩现场插上 U 盘就能跑。层次常见技术在这个项目里承担什么启动与容器SpringBoot 2.x 内置 Tomcat一个 jar 包启动不依赖外部容器控制层SpringMVC 拦截器接收表单、做登录态和角色校验业务层Service Transactional成绩计算、重复校验、批量导入持久层MyBatis XML 映射手写 SQL统计报表改起来直观数据库MySQL 5.7 / 8.0五张核心表字符集 utf8mb4视图层Thymeleaf Layui一个角色一套菜单目录有一点要提前说清楚如果你手上那份是 JSP Servlet 的老版本分层思路完全一样只是把 Controller 换成 Servlet、把 Thymeleaf 换成 JSP后面讲的校验顺序和表结构照样能用。真正决定这套代码好不好改的不是框架版本而是业务规则有没有沉到 Service 层。我见过太多同类项目把算总评分的逻辑写在 Controller 里结果加一个「补考」场景就要改三处这种结构后面会讲到怎么收拾。2.2 目录结构逐层对照每个包只干一件事解压之后先别急着点启动类按下面这个结构对一遍心里就有地图了。这份目录是同类工程里最规矩的一种划分包名可能叫com.xxx.score但层级基本一致。score-system/ ├── doc/ # 论文与开题材料改代码前先读第 4、5 章 │ ├── 毕业论文.docx # 功能模块图、E-R 图、测试用例都在这 │ └── 数据库设计说明书.docx ├── sql/ │ └── score_db.sql # 建表 演示数据导入一次就能跑 ├── src/main/java/com/demo/score/ │ ├── ScoreApplication.java # 启动类MapperScan 挂在这里 │ ├── controller/ # 只收参数、跳视图不写业务 │ │ ├── LoginController.java │ │ ├── StudentController.java │ │ ├── CourseController.java │ │ └── ScoreController.java │ ├── service/ # 业务规则 事务边界 │ │ └── impl/ │ ├── mapper/ # 接口和 resources/mapper 下的 XML 一一对应 │ ├── entity/ # 与表字段一一对应的实体 │ ├── common/ # 统一返回体、业务异常、常量 │ └── interceptor/ # 登录拦截器 角色校验 ├── src/main/resources/ │ ├── application.yml # 数据源、端口、context-path │ ├── mapper/ # 手写 SQL 都在这儿 │ ├── static/ # layui、css、js │ └── templates/ # 页面按 admin/teacher/student 分目录 └── pom.xml看这个结构判断代码质量的第一个信号就出来了templates下面是不是按角色分了目录。分了说明菜单权限是从路由层控制的没分、所有页面堆在一个文件夹里那多半是靠前端th:if藏按钮改权限会非常痛苦。第二个信号是entity和mapper的数量是否对得上。学生、教师、课程、成绩、用户五张表就该有五个实体、五个 Mapper。如果发现ScoreMapper.xml里同时塞了学生查询和课程查询那是典型的图省事写法后面加字段容易漏。common包里通常会有一个BizException和一个全局异常处理器这是这套代码里最值得保留的部分。业务校验失败时抛BizException处理器统一转成 JSON 或错误页Controller 里就不需要满地try-catch。2.3 数据库表设计五张表撑起整条成绩链路表结构是这套系统真正的骨架论文里那张 E-R 图对应的就是下面这些表。设计上最关键的一点成绩表必须用「学号 课程号 学期」做联合唯一索引靠数据库物理约束堵住重复录入而不是指望前端按钮置灰。-- 学生表学号唯一班级名称冗余存储避免列表页频繁 join CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, stu_no VARCHAR(20) NOT NULL COMMENT 学号, name VARCHAR(50) NOT NULL COMMENT 姓名, gender TINYINT DEFAULT 0 COMMENT 0未知 1男 2女, class_name VARCHAR(50) COMMENT 班级名称, major VARCHAR(50) COMMENT 专业, phone VARCHAR(20) COMMENT 联系电话, UNIQUE KEY uk_stu_no (stu_no), KEY idx_class (class_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生信息表; -- 课程表平时分权重放这里不同课程可以有不同算法 CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL COMMENT 课程号, course_name VARCHAR(80) NOT NULL COMMENT 课程名, credit DECIMAL(3,1) DEFAULT 2.0 COMMENT 学分, usual_weight DECIMAL(4,2) DEFAULT 0.30 COMMENT 平时分权重, teacher_no VARCHAR(20) COMMENT 任课教师工号, UNIQUE KEY uk_course_no (course_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表; -- 成绩表联合唯一索引是最后一道防线 CREATE TABLE score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, stu_no VARCHAR(20) NOT NULL COMMENT 学号, course_no VARCHAR(20) NOT NULL COMMENT 课程号, term VARCHAR(20) NOT NULL COMMENT 学期如 2024-2025-1, usual_score DECIMAL(5,2) COMMENT 平时分, exam_score DECIMAL(5,2) COMMENT 考试分, total_score DECIMAL(5,2) COMMENT 总评, teacher_no VARCHAR(20) COMMENT 录入人工号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_course_term (stu_no, course_no, term), KEY idx_term (term) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT成绩表;几个字段的取值理由值得单独说。分数用DECIMAL(5,2)而不是FLOAT是因为浮点数算 0.3×82 0.7×91 会出现 90.09999999999999前端展示两位小数时四舍五入偶尔会跳变答辩时被问到很难解释。term单独成字段而不是拼在课程号里是为了让「本学期」「上学期」这类筛选能走索引。class_name冗余在学生表里是刻意的反范式。班级在多数高校系统里是独立表但在这个规模下多一次 join 换来的规范化收益不大反而让列表页 SQL 变长。如果后续要接教务的班级调整再补一张class表、把class_name换成class_id也不迟改动面只有学生模块。teacher_no在成绩表里记的是录入人不是任课教师。这两个概念在实际业务里经常不是同一个人比如助教代录。论文里如果写的是「任课教师」改代码时记得别把这两个字段混用。3. 核心模块拆解一次成绩录入从表单到落库的完整链路3.1 登录与角色鉴权三种角色看到三套菜单这套系统的角色通常分管理员、教师、学生。三者能看到的菜单完全不一样管理员管学生和课程教师只管自己课程的成績学生只能查自己的。实现上最省事的做法是 Session 拦截器而不是上 JWT因为页面是后端渲染的Session 天然合适。Controller public class LoginController { Resource private UserService userService; /** * 登录账号可以是工号、学号或 admin * param account 前端表单字段不做 trim 以外的加工 * param password 明文比对教学项目里够用上线要换 BCrypt */ PostMapping(/login) public String login(String account, String password, HttpSession session, Model model) { User user userService.login(account, password); if (user null) { model.addAttribute(msg, 账号或密码错误); return login; } // 只往 session 放必要信息别把整个 User 对象塞进去 session.setAttribute(loginNo, user.getAccount()); session.setAttribute(role, user.getRole()); // ADMIN / TEACHER / STUDENT session.setAttribute(realName, user.getRealName()); // 按角色跳不同首页避免登录后看到空白菜单 switch (user.getRole()) { case ADMIN: return redirect:/admin/index; case TEACHER: return redirect:/teacher/index; default: return redirect:/student/index; } } }这段代码的重点不在登录而在session.setAttribute(role, ...)。后面所有权限判断都以 Session 里的 role 为准绝不接受前端传来的 role 参数——这是同类项目最常见的安全漏洞改一行 URL 参数就能越权查别人成绩。拦截器里做两件事没登录跳回登录页登录了但访问了不属于自己角色的路径返回 403 页面而不是静默跳首页。后者很重要静默跳转会让调试时完全看不出问题出在权限上。3.2 成绩录入与批量导入校验顺序决定你会不会半夜改数据成绩录入是整个系统里唯一有真实业务规则的地方也是最容易出事的地方。校验的顺序不能乱先确认学生和课程存在再校验分数区间最后查重。顺序反了会出现「提示分数不合法但其实这个学生根本不存在」这种让人困惑的报错。Service public class ScoreServiceImpl implements ScoreService { Resource private ScoreMapper scoreMapper; Resource private StudentMapper studentMapper; Resource private CourseMapper courseMapper; /** * 录入单条成绩 * param score 前端提交对象totalScore 允许为空由权重算出来 * param operatorNo 当前登录人工号从 Session 取不信任前端传值 */ Override Transactional(rollbackFor Exception.class) public void addScore(Score score, String operatorNo) { // 1. 学生必须存在否则统计报表里会凭空多出孤儿数据 if (studentMapper.selectByStuNo(score.getStuNo()) null) { throw new BizException(学号不存在 score.getStuNo()); } // 2. 课程必须存在 Course course courseMapper.selectByCourseNo(score.getCourseNo()); if (course null) { throw new BizException(课程号不存在 score.getCourseNo()); } // 3. 区间校验前端校验只是体验后端才是底线 checkRange(score.getUsualScore(), 平时分); checkRange(score.getExamScore(), 考试分); // 4. 前置查重给友好提示而不是让数据库抛唯一键异常 if (scoreMapper.selectOne(score.getStuNo(), score.getCourseNo(), score.getTerm()) ! null) { throw new BizException(该生本学期该课程已有成绩请走修改); } // 5. 总评按课程配置的权重算默认平时 30% / 考试 70% BigDecimal usual score.getUsualScore() null ? BigDecimal.ZERO : score.getUsualScore(); BigDecimal exam score.getExamScore() null ? BigDecimal.ZERO : score.getExamScore(); BigDecimal w course.getUsualWeight() null ? new BigDecimal(0.30) : course.getUsualWeight(); score.setTotalScore(usual.multiply(w) .add(exam.multiply(BigDecimal.ONE.subtract(w))) .setScale(2, RoundingMode.HALF_UP)); score.setTeacherNo(operatorNo); scoreMapper.insert(score); } private void checkRange(BigDecimal v, String field) { if (v ! null (v.compareTo(BigDecimal.ZERO) 0 || v.compareTo(new BigDecimal(100)) 0)) { throw new BizException(field 必须在 0~100 之间); } } }几个参数的含义要盯住。Transactional(rollbackFor Exception.class)里的rollbackFor不能省Spring 默认只对RuntimeException回滚而BizException如果继承了受检异常抛出去事务不回滚批量导入时会留下一半数据。operatorNo从 Session 取而不从入参取是为了防止有人改前端把成绩记到别人名下。setScale(2, RoundingMode.HALF_UP)保证总评和页面上展示的完全一致不然会出现列表显示 90.10、导出 Excel 里是 90.09 的尴尬。批量导入的思路一样只是把循环放在一个事务里并且逐行记录失败原因而不是遇到第一条错误就整体失败。常见做法是返回一个ListString把「第 12 行学号不存在」这样的信息回显到页面让老师自己改 Excel。这个体验差别很大一次性告诉他所有错误比让他改一条提交一次强得多。3.3 成绩查询与统计分页、加权平均和班级排名查询模块的复杂度全在统计口径上。列表页需要分页统计页需要平均分、最高分、及格率学生端还需要算加权平均分和排名。这几个查询最好都写进 XML别用 MyBatis-Plus 的链式调用硬拼统计类 SQL 一旦复杂起来代码里的wrapper几乎没法维护。-- 班级某学期各课程的成绩概览平均分、最高分、及格率一次算完 SELECT c.course_name, COUNT(*) AS totalCnt, ROUND(AVG(s.total_score), 2) AS avgScore, MAX(s.total_score) AS maxScore, ROUND(SUM(IF(s.total_score 60, 1, 0)) / COUNT(*) * 100, 2) AS passRate FROM score s JOIN course c ON c.course_no s.course_no WHERE s.term #{term} AND s.stu_no IN (SELECT stu_no FROM student WHERE class_name #{className}) GROUP BY c.course_no, c.course_name ORDER BY avgScore DESC;IF(s.total_score 60, 1, 0)这种写法在 MySQL 里比CASE WHEN短但要注意total_score为 NULL 时会被算成不及格。如果业务允许缺考记为 NULL 而不是 0就得改成IFNULL(s.total_score, 0) 60否则及格率会偏低。学生端的加权平均分要在 Java 里算因为要用到学分// 加权平均分 Σ(总评 × 学分) / Σ学分缺考课程不计入分母 BigDecimal sumWeighted BigDecimal.ZERO; BigDecimal sumCredit BigDecimal.ZERO; for (ScoreVO vo : list) { if (vo.getTotalScore() null || vo.getCredit() null) continue; sumWeighted sumWeighted.add(vo.getTotalScore().multiply(vo.getCredit())); sumCredit sumCredit.add(vo.getCredit()); } BigDecimal gpa sumCredit.compareTo(BigDecimal.ZERO) 0 ? BigDecimal.ZERO : sumWeighted.divide(sumCredit, 2, RoundingMode.HALF_UP);排名如果数据库是 MySQL 8.0直接用窗口函数一条 SQL 解决SELECT stu_no, totalSum, RANK() OVER (ORDER BY totalSum DESC) AS rankNo FROM ( SELECT stu_no, SUM(total_score) AS totalSum FROM score WHERE term #{term} GROUP BY stu_no ) t;5.7 没有窗口函数就得用变量自增那种写法在数据量大时容易出问题而且换个 MySQL 版本又要改。我在同类项目里一般会先看SELECT VERSION()版本不够就把排名放到 Java 里做用List.sort加一次遍历赋名次稳当得多几十万条以内性能完全够。分页用 PageHelper 是最省事的注意PageHelper.startPage(pageNum, pageSize)必须紧跟在查询方法上一行中间插任何语句都会导致分页失效——这个坑后面还会提。3.4 论文与代码对照把文档里的图落到类名上压缩包里那份论文不是摆设改代码前对着看一遍能省很多时间。论文的章节结构和代码有稳定的对应关系通常是这样的论文位置对应代码改动时要注意第 4 章 功能模块图controller包下的类名新增模块时先补图再补类避免结构走形第 5 章 数据库设计sql/score_db.sql加字段同步改 entity 和 XML 的 resultMap第 6 章 关键代码service/impl论文里的代码片段往往是简化版别直接粘回去第 7 章 测试用例手工测试步骤补几条边界用例答辩时用得上最容易出问题的是第 6 章。论文里为了排版好看代码通常是删减过的方法签名和实际工程对不上。我一般会把论文里的代码当成「伪代码」看理解意图就行真要改还是以工程里的为准。反过来说如果论文里的模块图和代码里的 Controller 差了两个以上那这份源码大概率是拼凑的改之前先跑通再说。4. 跑起来从环境对齐到登录成功的一整套动作4.1 环境与版本对齐别用新 JDK 硬跑老工程同类项目十有八九是 SpringBoot 2.3 到 2.7 之间配套 JDK 8。用 JDK 17 直接跑十次有八次报反射相关的错尤其是用了 Lombok 老版本或者javax包的地方。# 先确认版本三行输出对不上就先装环境别急着改代码 java -version # 建议 1.8.0_xxx mvn -v # 3.6 及以上 mysql --version # 5.7 或 8.0Maven 建议在settings.xml里换成国内镜像源不然第一次拉依赖能等到怀疑人生。依赖下载失败是新手最容易卡住的地方报错信息里出现Could not resolve dependencies基本就是网络问题换源、清~/.m2/repository里对应的目录再试。如果坚持用高版本 JDK至少要把pom.xml里的 Lombok 升到 1.18.24 以上并且在 IDE 里开启注解处理否则Data生成的 getter 全部找不到满屏红线。4.2 建库、导数据与改配置这三步顺序不能反先建库再导数据最后改配置。反过来做配置对不上库启动时会报连接失败你还得回头确认库到底建没建。# 1. 建库字符集必须是 utf8mb4否则姓名里的生僻字会变成问号 mysql -uroot -p -e CREATE DATABASE score_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 导入结构和演示数据路径按实际解压目录改 mysql -uroot -p score_db sql/score_db.sql # 3. 确认表和数据都在 mysql -uroot -p score_db -e SHOW TABLES; SELECT COUNT(*) FROM student;然后改application.yml。这一段是整个配置里唯一必须动的地方重点看三个参数数据库账号密码、serverTimezone、context-path。server: port: 8080 servlet: context-path: /score # 有这个前缀访问地址就是 /score/login spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/score_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 改成你自己的密码 thymeleaf: cache: false # 开发期关缓存改页面不用重启 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.demo.score.entity configuration: map-underscore-to-camel-case: true # 下划线转驼峰少了这个查出来全是 nullserverTimezoneAsia/Shanghai不能省MySQL 8 的驱动对时区很敏感不写会报The server time zone value is unrecognized。allowPublicKeyRetrievaltrue是 MySQL 8 默认加密方式下的必备参数少了会报公钥检索失败。map-underscore-to-camel-case如果没有stu_no查出来映射不到stuNo页面上学号列全空——这个现象很迷惑看着像 SQL 没查到数据其实是映射没开。4.3 启动验证与默认账号mvn clean package -DskipTests java -jar target/score-0.0.1-SNAPSHOT.jar看到Started ScoreApplication in x.xxx seconds就算起来了。浏览器访问http://127.0.0.1:8080/score/login注意别漏掉context-path。默认账号一般在sql/score_db.sql的user表插入语句里常见是管理员admin / 123456教师用工号登录学生用学号登录初始密码统一是123456。第一次登进去建议按这个顺序点一遍管理员登录进学生管理看一眼列表有没有数据进成绩管理查一次然后退出换教师账号登录确认只能看到自己课程的按钮最后用学生账号查自己的成绩。这四步走完说明库、权限、页面渲染三条链路都通了后面改代码心里才有底。提示如果登录后页面样式全丢先看浏览器控制台静态资源的请求路径八成是context-path和页面里写死的/static/xxx对不上。5. 常见问题排查几个让我改到凌晨的坑5.1 启动报 Access denied 或时区错误现象启动日志里一大段红字核心信息是Access denied for user rootlocalhost或者The server time zone value ... is unrecognized。原因前者是application.yml里的密码不对或者 MySQL 根本没启动后者是连接串缺serverTimezone。还有一种隐蔽情况本机装了 MySQL 5.7 和 8.0 两个实例3306被占用的那个并不是你导数据的那个。解决先用命令行mysql -uroot -p登一次确认账号密码和实例再检查连接串里的端口。时区问题直接在 URL 后面补serverTimezoneAsia/Shanghai不要靠改 MySQL 全局变量换台机器又得配一遍。5.2 页面中文变问号或乱码现象数据库里存的中文正常页面上显示成???或者反过来页面提交的中文进了库变成乱码。原因三处字符集只要有一处不是 utf8mb4 就会出问题——建库语句、连接串、页面编码。JDBC 老版本驱动里characterEncodingutf8只支持三字节遇到生僻字会截断。解决建库时显式写DEFAULT CHARACTER SET utf8mb4连接串统一用useUnicodetruecharacterEncodingutf8Thymeleaf 页面在head里保留meta charsetUTF-8。已经存坏的字段只能重导改配置救不回来。5.3 分页查询第二页开始数据重复或总数不对现象第一页正常翻到第二页出现和第一页相同的记录或者总数显示 10 条但实际有 200 条。原因PageHelper.startPage()必须紧挨着查询语句中间插入任何一次其他查询分页参数就会被那条语句消耗掉。另一种情况是统计总数的 SQL 里带了ORDER BY某些版本下会被优化掉导致 count 不准。解决把startPage放在 Mapper 调用的上一行中间不留空。XML 里的count语句去掉ORDER BY。如果用的是手写 limit检查offset算的是(pageNum - 1) * pageSize这一步写错的人不在少数。5.4 成绩保存提示成功但列表查不到现象页面弹出「保存成功」刷新列表还是空的。原因三种可能。一是事务没提交Service 上漏了Transactional或者异常被 catch 后没重新抛出二是前端字段名和实体对不上比如页面传studentNo实体是stuNo插进去的是一条学号为空的数据三是查询条件默认带了当前学期而你录入的是另一个学期。解决先去数据库SELECT * FROM score ORDER BY id DESC LIMIT 5看一眼数据在不在。在就是查询条件问题不在就是字段映射或事务问题。这一步比看代码快得多。5.5 删除学生时报外键约束错误现象删除某个学生时页面报 500日志里是Cannot delete or update a parent row。原因成绩表里还有这个学生的记录外键约束挡住了删除。有的工程干脆没建外键那就更麻烦删完学生留下一堆孤儿成绩统计报表直接算错。解决优先做逻辑删除在student表加status字段删除只改状态列表查询过滤掉。如果一定要物理删除先在 Service 里判断有没有成绩记录有就提示「该生已有成绩不能删除」别把数据库的错误直接抛给用户看。6. 二次开发把单机成绩管理改成能进教务流程的模块这套代码跑通之后绝大多数人下一步都会遇到同一个问题它能演示但进不了真实流程。最典型的缺口是没有学期管理所有成绩混在一起其次是权限太粗教师能改所有课程的成绩再就是缺导出教务处最终还是要 Excel。改造我一般从「加学期维度」开始因为它牵动面最广做完这一项整个工程的结构就顺了。第一步在score表已经有term字段的基础上补一张term字典表把「2024-2025-1」这类字符串变成可选下拉避免手输出现2024-2025-01和2024-2025-1两种写法混存。第二步把所有统计 SQL 的WHERE里加上term条件这一步最容易漏漏了就会出现「上学期的不及格率算进本学期」的诡异数字。权限细化改起来更简单也更见效果。在ScoreServiceImpl里加一段归属校验即可// 教师只能操作自己任课课程的成绩管理员放行 if (TEACHER.equals(role)) { Course course courseMapper.selectByCourseNo(score.getCourseNo()); if (course null || !operatorNo.equals(course.getTeacherNo())) { throw new BizException(无权操作该课程成绩); } }这段放在所有写操作的最前面包括新增、修改、删除。参数role和operatorNo都从 Session 取不从请求参数取。校验位置也有讲究放 Controller 里只能挡住页面入口放 Service 里才挡得住所有调用方包括以后可能加的定时任务。导出功能建议用 EasyExcel比 POI 少写一大半样板代码几十行就能把班级成绩导成带表头的 xlsx。导出的列顺序别直接照搬数据库字段顺序按老师习惯排学号、姓名、班级、平时分、考试分、总评、备注。总评那一列记得用同一个setScale(2)的口径别在导出时重新算一遍两处算法一旦不一致对账能耗掉一整天。验证改造有没有做对我习惯用三个动作过一遍用教师 A 的账号尝试改教师 B 课程的成绩应该被拦切到上学期统计数字应该和这学期完全不同导出的 Excel 里挑三条记录回数据库对一遍总评。三个都过基本就没问题了。说个我自己的教训。早年改这类项目我最爱干的事是直接在原表上加字段改完能跑就不管了。后来有一次加「补考成绩」字段忘了同步改 XML 里的resultMap页面上一整列全是 null排查了两个小时才发现是映射漏了一行。从那以后我每次动表结构都强制走一遍「改 DDL → 改实体 → 改 resultMap → 改页面 → 重跑三条查询」的流程一步不落。希望这些能帮到你少走点弯路。本文还有配套的精品资源点击获取