简介:基于Spring Boot框架的学生成绩管理系统源码包,面向高校计算机相关专业的毕业设计、课程设计及Spring Boot初学者。项目覆盖用户登录与角色权限、成绩录入与批量Excel导入、成绩多条件查询、统计报表,以及班级信息管理,功能完整,可直接运行或二次开发。源码包内共225个文件,压缩包约2.39MB,以88个Java源文件为核心,包含Controller、Service、实体及配置类;另有23个JS、13个HTML、6个CSS及Layui相关静态资源,用于前端页面和交互;附75个GIF演示图,便于快速了解界面操作流程;同时提供Gradle构建配置、application.yml和数据库脚本,环境搭建相对省心。目前已有136人浏览学习,适合用作论文配套源码或项目练手。整体目录结构清晰,能直观看到业务分层与前端资源组织,对理解Spring Boot整合前端模板有一定帮助。
1. 学生成绩管理系统的业务痛点与Spring Boot选型理由
学校的成绩管理从来不只是“存分数”这么简单。班主任要录平时分,任课老师要录期末分,教务处要按班级和课程汇总排名,学生和家长还要能查到自己孩子的历次成绩。几套不同角色,三种数据口径,落到系统里就是权限隔离和统计口径问题。我经手过的几套成绩管理系统,最费时间的往往不是写SQL,而是理清“谁在什么时间能改什么分数”这条规则。
Spring Boot在这个场景下的价值是开箱即用的工程化能力:内嵌Tomcat让部署包就是一个JAR,Spring Data JPA或MyBatis把数据访问层收敛起来,Spring Security负责角色控制,Actuator提供运行时监控。相比SSH那种需要手工组装的环境,Spring Boot让你把主要精力放在成绩业务本身,而不是框架配置。下面这套方案从架构、编码、查询优化到上线部署完整铺开,适合用Spring Boot做课设的开发者,也适合要接手类似教务系统的工程师。
2. 基于Spring Boot四层架构的成绩管理系统目录规范
2.1 四层架构的职责边界:Controller、Service、Repository的划分
很多Spring Boot项目把业务代码堆在Controller里,Controller直接调Repository,看起来代码量少,但成绩管理这类系统有明确的角色和状态转换,比如补考成绩不能覆盖原成绩、成绩发布后修改需要审批。如果缺了Service这一层,这些规则会散落在各个接口里,改一处漏三处。这里说的四层架构,是Controller、Service、Repository之外再加一层DTO/VO。DTO用来做接口入参校验和出参裁剪,避免把Entity直接暴露给前端,Entity和数据库表一一对应,VO按页面需求组装。
以成绩领域为例,前端提交的是一个ScoreCreateDTO,包括studentId、courseId、score、term、examType;而ScoreEntity里还包含creatorId、createTime、updateTime这类审计字段。两者分离后,接口文档清楚,数据库表结构也更容易调整。Spring Boot 3.x下,分层依然是最稳妥的工程约定,到了Spring Boot 4.x,自动配置类的位置有调整,但Controller/Service/Repository的分层边界不会变,反而因为模块化更强调接口隔离。
2.2 成绩管理系统的标准目录结构与包名规范
我一般会按下面这种方式组织包结构,这是一个可以直接套用的Spring Boot目录规范:
com.example.score ├── controller # Web层,接收请求和参数校验 │ ├── AuthController.java │ ├── ScoreController.java │ └── StudentController.java ├── service # 业务逻辑层,事务边界在这里 │ ├── ScoreService.java │ └── impl │ └── ScoreServiceImpl.java ├── repository # 数据访问层,Spring Data JPA或MyBatis Mapper │ ├── ScoreRepository.java │ └── StudentRepository.java ├── entity # 与数据库表对应的实体 │ ├── Score.java │ └── Student.java ├── dto # 入参校验对象和返回视图对象 │ ├── ScoreCreateDTO.java │ └── ScoreStatsVO.java ├── config # Security、数据源等自动配置类 │ └── SecurityConfig.java ├── common # 统一返回体、异常处理、工具类 │ ├── Result.java │ └── GlobalExceptionHandler.java └── ScoreApplication.java这个结构的关键点是:controller里不出现SQL,service里不出现HttpServletRequest,repository里不出现业务判断。分层之后,单测可以只针对service层mock repository,接口压测也能定位到具体瓶颈。常见做法是团队里新人一上来就往controller里写业务,review时我会要求他们把超过十行的业务逻辑下沉到service。还有一个约定:跨模块的调用只允许上层依赖下层,不允许repository直接调controller,否则循环依赖会随着版本迭代越来越难解。
2.3 成绩表设计、唯一索引与JPA实体映射
成绩管理涉及的核心表一般是四张:用户表user、学生表student、课程表course、成绩表score。学生和用户可以是同一张表,也可以分开,取决于学校是否给家长单独开放账号。下面是一份按常见需求设计的score表:
CREATE TABLE score ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, term VARCHAR(20) NOT NULL COMMENT '学期,如2024-2025-1', exam_type TINYINT NOT NULL COMMENT '1平时 2期中 3期末 4补考', score DECIMAL(5,2) NOT NULL, creator_id BIGINT NOT NULL, audit_status TINYINT DEFAULT 0 COMMENT '0未审核 1已发布', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course_term (student_id, course_id, term, exam_type) );表里唯一索引保证了同一学生在同一学期同一门课的同一考试类型只能有一条成绩,这是防止重复录入的第一道防线。audit_status字段用于控制成绩是否对学生的查询端可见,成绩录入后默认为0,教务处审核后置为1。DECIMAL(5,2)可以存放0到999.99,覆盖百分制及格率等常规场景。
对应的JPA实体里用@Table和@Column注解标明映射关系,其中score字段在Java侧用BigDecimal类型接收,避免浮点数精度问题。在Repository中声明一个根据学生和课程查成绩的方法:
public interface ScoreRepository extends JpaRepository<Score, Long> { Optional<Score> findByStudentIdAndCourseIdAndTermAndExamType( Long studentId, Long courseId, String term, Integer examType); }方法名的语义由Spring Data JPA自动解析,你在service里直接调用即可。如果不想使用JPA这种基于方法命名约定的方式,也可以换成MyBatis在XML里写SQL,效果等价,后面的聚合查询部分会给出MyBatis的写法。关于两者的选择,我的建议是:涉及大量动态条件组合查询就用MyBatis,CRUD为主且字段固定用JPA,成绩系统两种特性都有,所以混用也是常见的。
3. 学生成绩管理系统中的认证授权与成绩CRUD实现
3.1 基于Spring Security的角色访问控制
成绩管理系统里,不同角色能操作的接口完全不同。老师可以录成绩但不能改发布后的成绩,教务处可以审核和驳回,学生只能查自己的成绩。用Spring Security做这件事,核心是定义角色和对应的URL规则。Spring Security 6.x之后,原来的WebSecurityConfigurerAdapter被移除,现在推荐用SecurityFilterChain的Bean定义:
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/student/**").hasRole("STUDENT") .requestMatchers("/api/teacher/**").hasRole("TEACHER") .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }这里用requestMatchers按路径前缀做粗粒度控制,controller内部再做细粒度数据权限校验,比如学生只能查自己的成绩,不能传一个别人的studentId就绕过权限。JWT方案适合前后端分离的教务系统,token里携带userId、role和过期时间,过滤器解析后放入SecurityContext。三个角色的访问范围可以用一张表说清楚:
| 角色 | 可访问路径 | 操作范围 |
|---|---|---|
| STUDENT | /api/student/** | 查询本人成绩和排名 |
| TEACHER | /api/teacher/** | 录入、导入、导出所教课程成绩 |
| ADMIN | /api/admin/** | 审核发布、修改成绩、管理用户 |
需要注意的是,JWT的密钥要放在配置文件的加密占位里,不要硬编码在代码中。发布后修改成绩这类高风险操作,我一般会在service方法上再叠加@PreAuthorize("hasRole('ADMIN') && #dto.auditStatus == 0"),函数级的权限校验配合URL级校验,避免接口路径调整后漏掉规则。
3.2 成绩录入接口实现与参数校验
成绩录入是系统里调用最频繁的接口,需要考虑批量录入和单条更新两种场景。下面这段代码演示单条录入,核心逻辑放在ScoreService中:
@Service @Transactional public class ScoreServiceImpl implements ScoreService { @Autowired private ScoreRepository scoreRepository; @Override public Long createScore(ScoreCreateDTO dto) { // 同一学生同一考试类型重复录入时直接报错,由唯一索引兜底 Optional<Score> existed = scoreRepository .findByStudentIdAndCourseIdAndTermAndExamType( dto.getStudentId(), dto.getCourseId(), dto.getTerm(), dto.getExamType()); if (existed.isPresent()) { throw new BusinessException("该学生此考试类型成绩已存在"); } Score score = new Score(); score.setStudentId(dto.getStudentId()); score.setCourseId(dto.getCourseId()); score.setTerm(dto.getTerm()); score.setExamType(dto.getExamType()); score.setScore(dto.getScore()); score.setAuditStatus(0); return scoreRepository.save(score).getId(); } }@Transactional保证了保存操作和唯一索引检查在同一个事务里,并发下即使两个请求同时进入createScore,数据库的唯一索引也会兜底阻止重复插入。dto在这里已经通过了JSR 303参数校验,@NotBlank校验term,@NotNull校验studentId和courseId,@DecimalMax("100")限制成绩上限,避免异常数据进入业务层。进入接口层的参数校验由@Validated注解触发:
@PostMapping("/api/teacher/score") public Result<Long> create(@RequestBody @Validated ScoreCreateDTO dto) { return Result.success(scoreService.createScore(dto)); }传入非法的studentId时,GlobalExceptionHandler里的MethodArgumentNotValidException处理逻辑会返回统一的错误JSON,前端拿到message直接提示。这样service里不需要再写一堆if判断参数是否为空,代码可读性高出不少。如果你用的Spring Boot 3.x,注意javax.validation要换成jakarta.validation,@Validated和@Valid的使用方式不变。
3.3 成绩修改的乐观锁与审计日志
成绩发布之后被修改,必须留下记录,这是教务系统合规的硬要求。我一般会在score表之外再建一张score_audit_log表,记录修改前后的分数、操作人、操作时间。修改成绩时,用乐观锁防止两个管理员同时更新同一条记录:
@Modifying @Query("UPDATE Score s SET s.score = :newScore, s.version = s.version + 1 " + "WHERE s.id = :id AND s.version = :version") int updateScoreWithVersion(@Param("id") Long id, @Param("newScore") BigDecimal newScore, @Param("version") Integer version);score实体中加一个@Version字段后,Spring Data JPA执行save时也会自动带上版本判断,更新行数为0时说明版本冲突,service抛异常让用户重试。这种做法比select for update更轻,不需要持有数据库行锁,适合并发量不高的成绩录入场景。审计日志的写入放在同一个事务里,成绩更新成功则日志一并落库,失败则一起回滚。
到这里,成绩模块的增删改查主链路已经完整。接下来是查询端的问题:成绩列表和统计报表才是学生和老师每天打开系统看到的页面,查询性能比写入更影响体验。
4. 成绩查询与统计分析的SQL和代码落地
4.1 多条件组合查询的MyBatis动态SQL
成绩列表页最常见的过滤条件是:学期、班级、课程、考试类型、关键字。用MyBatis动态SQL写这个接口非常自然,因为每个过滤条件都可选:
<select id="pageScores" resultType="ScoreVO"> SELECT s.id, st.name AS studentName, c.name AS courseName, s.score, s.term, s.exam_type, s.audit_status FROM score s JOIN student st ON s.student_id = st.id JOIN course c ON s.course_id = c.id <where> <if test="term != null and term != ''"> AND s.term = #{term} </if> <if test="clazzId != null"> AND st.clazz_id = #{clazzId} </if> <if test="courseId != null"> AND s.course_id = #{courseId} </if> <if test="examType != null"> AND s.exam_type = #{examType} </if> <if test="keyword != null and keyword != ''"> AND (st.name LIKE CONCAT('%', #{keyword}, '%') OR st.student_no LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY s.create_time DESC </select>标签会自动去掉第一个多余AND,未传的字段直接跳过。分页用PageHelper插件,一行PageHelper.startPage(pageNum, pageSize)就能在查询前自动拼接limit。需要注意的是,LIKE查询在数据量上来之后会走全表扫描,student表超过十万行时,建议对student_no建普通索引,name字段做前缀索引。课设和中小型学校场景下MySQL加索引就够了。
4.2 班级课程均分、及格率的聚合查询
统计报表比列表查询更常见。教务处要一份每个班级每门课的平均分、最高分、最低分、及格率,这用MyBatis或JPA都能做,SQL写起来几乎一样:
<select id="summarizeByClazzAndCourse" resultType="ScoreStatsVO"> SELECT st.clazz_id AS clazzId, s.course_id AS courseId, COUNT(*) AS totalCount, ROUND(AVG(s.score), 2) AS avgScore, MAX(s.score) AS maxScore, MIN(s.score) AS minScore, ROUND(SUM(CASE WHEN s.score >= 60 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS passRate FROM score s JOIN student st ON s.student_id = st.id WHERE s.term = #{term} AND s.exam_type = 3 GROUP BY st.clazz_id, s.course_id ORDER BY st.clazz_id, s.course_id </select>GROUP BY后面只放clazz_id和course_id两个维度,选择列也必须是这两个维度或其聚合结果,否则MySQL开了only_full_group_by之后会直接报错。ROUND保留两位小数,CASE WHEN里判断及格线。这里将考试类型限定为期末,加在WHERE里能在分组前过滤掉平时分对统计的干扰。
4.3 排名的窗口函数写法与VO裁剪
除了均分和及格率,班级排名也是家长高频查询的功能。MySQL 8.0+支持窗口函数,用RANK()做排名比在Java里排序再分页更优雅:
SELECT student_id, score, RANK() OVER (PARTITION BY course_id ORDER BY score DESC) AS rank_no FROM score WHERE term = #{term} AND exam_type = 3PARTITION BY course_id表示按课程分组排名,ORDER BY score DESC决定排名依据。RANK()遇到并列分数会跳号,比如两个并列第一,下一个是第三名,需要连续排名就换成DENSE_RANK()。成绩排名接口返回的ScoreStatsVO不需要字段全量暴露,把studentId、score、rankNo、totalCount四个字段组装成VO返回,前端拿这个结构画榜单就够。
聚合查询返回的是VO,不要直接复用Score实体,因为Score里没有clazzId和passRate这些字段。MyBatis的resultType映射依赖列别名或开启驼峰转换,如果没有开启,需要在application.yml里加一行:
mybatis: configuration: map-underscore-to-camel-case: true这个配置开启后,数据库的clazz_id会自动映射到VO里的clazzId,省去每个查询都写resultMap的麻烦。开启前确保表字段命名统一为下划线风格,Java字段用驼峰风格,团队规范里明确这两条,能省掉后面大量隐患。
5. 学生成绩管理系统的Excel导入导出与文件处理
5.1 批量导入的MultipartFile上传接口
学校期末时,老师手里仍然是一份Excel表,系统里批量导入是不可缺的功能。批量导入的口径按课程和考试类型展开,Excel里一行是一个学生的成绩。接口接收MultipartFile,先校验文件扩展名和大小,再交给EasyExcel逐行解析:
@PostMapping("/api/teacher/score/import") public Result<String> importScores(@RequestParam("file") MultipartFile file, @RequestParam Long courseId, @RequestParam String term, @RequestParam Integer examType) { if (file.isEmpty()) { throw new BusinessException("上传文件为空"); } String filename = file.getOriginalFilename(); if (!filename.endsWith(".xlsx") && !filename.endsWith(".xls")) { throw new BusinessException("仅支持Excel文件"); } if (file.getSize() > 10 * 1024 * 1024) { throw new BusinessException("文件大小不能超过10MB"); } // 解析逻辑由EasyExcel完成,doReadSync同步读取小文件 List<ScoreImportRow> rows = EasyExcel.read(file.getInputStream()) .head(ScoreImportRow.class) .sheet() .doReadSync(); // 逐行校验和落库省略 return Result.success("导入成功"); }文件大小限制在Web层做一次,Nginx或网关层也要配client_max_body_size保持一致,否则请求会在网关层被直接拒绝,应用层根本收不到文件。EasyExcel用doReadSync同步读取适合小文件,批量导入超过一千行时建议改成监听器异步读取,配合线程池把解析和落库解耦。服务端要有临时目录存储上传文件,Spring Boot默认的spring.servlet.multipart.max-file-size是1MB,最大请求大小为10MB,需要按业务调整。
5.2 导出全校成绩时的流式Excel写入
导出场景比导入更需要注意内存。很多人直接用EasyExcel的write方法一条条往Workbook里塞,几千行没问题,但一旦导出全校成绩汇总,行数可能到几万,堆内存直接被撑爆。常用做法是流式写:
String fileName = "成绩汇总.xlsx"; response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); response.setHeader("Content-disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8")); ExcelWriter writer = EasyExcel.write(response.getOutputStream(), ScoreExportVO.class).build(); WriteSheet sheet = EasyExcel.writerSheet("成绩").build(); // 分批从数据库查询,避免一次性加载全部数据 for (int page = 1; page <= totalPage; page++) { List<ScoreExportVO> pageData = scoreService.pageForExport(page, 500); writer.write(pageData, sheet); } writer.finish();每次只从数据库查出500行写入Excel,内存占用固定在一个很小范围。response header里的Content-disposition用于告诉浏览器这是附件下载而不是HTML页面,文件名做了URL编码防止中文乱码。导出大数据量时数据库查询也要带上索引,不要对score表做全表扫描,否则慢SQL比内存问题先到。
5.3 导入失败的错误行回显设计
导入Excel最麻烦的是坏数据,比如某一行的学号在student表里不存在,或者分数超过100。逐行校验时把错误行号、学号、错误原因收集起来,导入结束后以错误报告的形式返回给前端:
{ "successCount": 198, "failedCount": 2, "errors": [ { "row": 15, "studentNo": "20230102", "reason": "学号不存在" }, { "row": 32, "studentNo": "20230217", "reason": "分数不能大于100" } ] }前端收到这个结构后,可以把错误行在表格里标红,老师按行修改后重新上传。这个信息差处理方式比导入时直接中断整个文件要好用得多,用户能直观看到哪行需要修。批量校验的逻辑写在service里,和数据库交互统一走Repository,不要在校验循环里多次查询数据库,先把studentId集合一次性查出来放Map,再逐行匹配,能省下大量IO。
6. Spring Boot Actuator监控与成绩系统的部署避坑
6.1 Actuator最小化暴露与防未授权访问
Actuator是Spring Boot自带的监控组件,但默认暴露端点时非常危险,历史上出现过未授权访问/env或/heapdump泄露配置和内存数据的问题。成绩管理系统里只保留健康检查和基础指标两个端点:
management: endpoints: web: exposure: include: health,metrics,info endpoint: health: show-details: always这段配置把暴露范围限制在health、metrics、info三个端点。health的show-details设为always后,可以查看数据库连接、磁盘空间、Redis等组件状态,方便排障。注意生产环境必须在Spring Security里对这些端点加IP白名单或认证,否则外网直接访问/actuator/health虽然只是状态信息,但/actuator/metrics会泄露接口调用次数等运行细节。
6.2 健康检查接入告警的验证方法
把health端点接到告警平台前,先手动验证一遍返回体是否满足预期:
curl http://localhost:8080/actuator/health | jq返回的status为UP,且各个组件的details里没有DOWN项,再接入定时探测。如果数据库连接池被耗尽,health的db组件会切到DOWN,此时需要回查慢SQL和连接池配置。spring.datasource.hikari.maximum-pool-size默认是10,并发大的成绩查询场景可以适当调到20,但同步上调连接池意味着数据库端max_connections也要留出余量,不然应用层不报错,数据库先拒绝连接。
6.3 启动参数、时区与日志保留策略
生产部署时的JVM参数、日志保留策略、时区设置直接影响系统稳定性。我通常这样启动jar:
java -Xms512m -Xmx1024m -Duser.timezone=Asia/Shanghai -jar score-system.jar-Xms和-Xmx直接决定堆内存大小,成绩系统如果包含大量导出功能,建议堆空间至少预留1G。Duser.timezone不设置的话,MySQL的连接时区和JVM时区不一致,DATETIME字段查询结果会差8小时,这是最容易踩的坑。日志方面,在logback配置里对score.log保留30天,避免导出功能打出的日志占满磁盘,频繁触发的GC反而拖垮接口响应。
本文还有配套的精品资源,点击获取