SpringBoot+MyBatis学生成绩信息管理系统设计与实现

发布时间:2026/10/27 13:07:28
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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 的尴尬。

批量导入的思路一样,只是把循环放在一个事务里,并且逐行记录失败原因,而不是遇到第一条错误就整体失败。常见做法是返回一个List<String>,把「第 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.0

Maven 建议在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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true 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 # 下划线转驼峰,少了这个查出来全是 null

serverTimezone=Asia/Shanghai不能省,MySQL 8 的驱动对时区很敏感,不写会报The server time zone value is unrecognized。allowPublicKeyRetrieval=true是 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 'root'@'localhost'或者The server time zone value ... is unrecognized。

原因:前者是application.yml里的密码不对,或者 MySQL 根本没启动;后者是连接串缺serverTimezone。还有一种隐蔽情况:本机装了 MySQL 5.7 和 8.0 两个实例,3306被占用的那个并不是你导数据的那个。

解决:先用命令行mysql -uroot -p登一次,确认账号密码和实例;再检查连接串里的端口。时区问题直接在 URL 后面补&serverTimezone=Asia/Shanghai,不要靠改 MySQL 全局变量,换台机器又得配一遍。

5.2 页面中文变问号或乱码

现象:数据库里存的中文正常,页面上显示成???;或者反过来,页面提交的中文进了库变成乱码。

原因:三处字符集只要有一处不是 utf8mb4 就会出问题——建库语句、连接串、页面编码。JDBC 老版本驱动里characterEncoding=utf8只支持三字节,遇到生僻字会截断。

解决:建库时显式写DEFAULT CHARACTER SET utf8mb4,连接串统一用useUnicode=true&characterEncoding=utf8,Thymeleaf 页面在<head>里保留<meta charset="UTF-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 → 改页面 → 重跑三条查询」的流程,一步不落。希望这些能帮到你,少走点弯路。

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