简介:这份资源是学生成绩管理系统的进阶版完整Java源码,面向具备一定Java Web基础、希望深入理解企业级分层架构的开发者与课程设计者,可用于二次开发、毕业设计参考或框架整合练习。项目采用Spring+SpringMVC+MyBatis作为后台核心,前端基于Layui框架,配合FreeMarker模板引擎渲染页面,数据层使用MySQL存储,覆盖学生、教师、课程、成绩、公告等典型教务模块,并包含登录与权限控制逻辑。压缩包共524个文件,约3.82MB,其中Java源码与编译后的class文件各57个,另有109个XML映射与配置、62个JavaScript脚本、40个HTML页面及14个CSS样式,辅以图片、字体、SQL建表脚本和Maven构建文件,结构完整可直接导入运行。目前已有871人学习下载,适合对照源码梳理SSM整合流程、理解Controller与Service分层写法,并在此基础上扩展功能或优化界面。
1. 学生成绩管理系统进阶版:从能跑到能扛住真实班级数据的那道坎
很多 Java 初学者写完第一版学生成绩管理系统,功能都能跑:增删改查、算平均分、排个名,控制台一敲,数据一存,感觉已经到位了。可一旦把真实班级的数据丢进去——五十多人、七八门课、还要按学期分、按权重算绩点、导出成绩单——系统立刻露馅:分数算错、排名并列处理混乱、文件读写把数据冲掉、查询慢得让人想砸键盘。这就是「进阶版」要解决的事。
这个标题讲的不是再写一遍 CRUD,而是把学生成绩管理系统当成一个正经的 Java 工程来做:用面向对象把实体关系理清,用集合和排序把统计做对,用文件或数据库把数据存稳,用分层结构让代码能改能扩。它适合两类人:一类是刚学完 Java 基础、想拿一个完整项目练手的在校生;另一类是准备 Java 开发工程师面试、需要一个能讲清楚设计取舍的实战项目的人。下面按「先立住设计、再动手实现、最后避坑」的顺序,把整套源代码该长什么样讲透。
2. 先把实体和职责分清楚:进阶版和玩具版的分水岭
玩具版系统通常只有一个Student类,里面塞了姓名、各科分数、总分、平均分,所有逻辑写在main里。进阶版第一步就是拆职责:谁是数据、谁是行为、谁负责存储、谁负责交互。这一步做不好,后面加一个「按学期统计」就会牵动全身。
2.1 实体类怎么设计才经得起加字段
核心实体一般有三个:Student(学生)、Course(课程)、Score(成绩记录)。关键点是别把分数直接塞进Student,而是用Score把「哪个学生、哪门课、多少分、什么学期」绑在一起。这样加一门课、加一个学期,都不用改Student的结构。
// Student.java —— 只放身份信息,不放分数 public class Student { private String id; // 学号,唯一标识 private String name; // 姓名 private String className; // 班级,便于按班统计 public Student(String id, String name, String className) { this.id = id; this.name = name; this.className = className; } // getter/setter 省略,实际代码里补全 public String getId() { return id; } public String getName() { return name; } }// Score.java —— 一条成绩记录,把学生、课程、分数、学期绑在一起 public class Score { private String studentId; // 关联 Student.id private String courseId; // 关联 Course.id private double value; // 分数,0~100 private String term; // 学期,如 "2024-1" public Score(String studentId, String courseId, double value, String term) { this.studentId = studentId; this.courseId = courseId; this.value = value; this.term = term; } public double getValue() { return value; } public String getStudentId() { return studentId; } public String getTerm() { return term; } }逻辑说明:Student只描述「人」,Score描述「人和课的关系」。参数上,id用字符串而不是int,因为真实学号常带前导零或字母;value用double存原始分,展示时再格式化。这样设计后,要算某个学生某学期的加权平均,只需遍历Score列表按条件过滤,不用动实体本身。
2.2 用集合组织数据:List、Map 各管什么
进阶版里数据容器选错,查询就会又慢又乱。常见做法是:List<Student>存全体学生,Map<String, Student>用学号做键做快速查找,List<Score>存所有成绩记录。查找学生走 Map,统计成绩走 List 过滤。
import java.util.*; public class DataStore { private final List<Student> students = new ArrayList<>(); private final Map<String, Student> studentIndex = new HashMap<>(); private final List<Score> scores = new ArrayList<>(); public void addStudent(Student s) { students.add(s); studentIndex.put(s.getId(), s); // 建索引,O(1) 查找 } public Student findStudent(String id) { return studentIndex.get(id); // 不用遍历 List } public List<Score> scoresOf(String studentId, String term) { List<Score> result = new ArrayList<>(); for (Score sc : scores) { if (sc.getStudentId().equals(studentId) && (term == null || sc.getTerm().equals(term))) { result.add(sc); } } return result; } }逻辑说明:studentIndex是典型的空间换时间,学生数量上千时,findStudent从 O(n) 降到 O(1)。scoresOf里term允许传null表示不按学期过滤,这是接口设计上的小技巧,调用方少写一个重载方法。参数上注意equals判断字符串要用常量在前或在后都行,但别用==,这是 Java 基础里最容易翻车的地方之一。
2.3 分层结构:为什么要把计算和输入输出分开
如果所有逻辑都写在main里,加一个「导出 CSV」就得复制一遍统计代码。进阶版按三层分:DataStore管数据,ScoreService管计算(平均分、排名、绩点),Main或ConsoleUI只管读输入、调服务、打印结果。这样单元测试可以直接测ScoreService,不用模拟键盘输入。
public class ScoreService { private final DataStore store; public ScoreService(DataStore store) { this.store = store; } // 计算某学生某学期加权平均,weights 是课程权重 public double weightedAverage(String studentId, String term, Map<String, Double> weights) { List<Score> list = store.scoresOf(studentId, term); double sum = 0, weightSum = 0; for (Score sc : list) { double w = weights.getOrDefault(sc.getCourseId(), 1.0); sum += sc.getValue() * w; weightSum += w; } return weightSum == 0 ? 0 : sum / weightSum; } }逻辑说明:weights用Map传入,课程权重可配置,不写死在代码里。getOrDefault保证没配权重的课按 1.0 算,避免空指针。返回前判断weightSum == 0,防止除零。这个方法是后面排名、绩点换算的基础,参数term传null就是全学期总平均。
3. 排序、排名和统计:把「算得对」这件事做扎实
统计功能是成绩管理系统的核心,也是最容易出玄学 bug 的地方。并列排名怎么处理、排序稳定性、浮点误差,每一个都能让结果对不上。
3.1 用 Comparator 做多级排序
排名通常先按总分降序,总分相同按学号升序,保证结果稳定可复现。用Comparator链式组合,比手写冒泡清晰得多。
import java.util.*; public class RankService { // 返回按总分降序、学号升序排列的学生列表 public List<Student> rankByTotal(List<Student> students, Map<String, Double> totalMap) { List<Student> sorted = new ArrayList<>(students); sorted.sort(Comparator .comparingDouble((Student s) -> totalMap.getOrDefault(s.getId(), 0.0)) .reversed() // 总分降序 .thenComparing(Student::getId)); // 学号升序,保证稳定 return sorted; } }逻辑说明:comparingDouble避免装箱开销,reversed()只反转总分这一级,thenComparing保证同分时按学号排,结果每次运行都一样。参数totalMap是学号到总分的映射,由ScoreService算好传入。注意别用-1 * 分数这种取巧写法,遇到double精度问题会排错。
3.2 并列排名:1、2、2、4 还是 1、2、2、3
两种规则都有人用,关键是要明确并写进代码。竞赛排名用「1、2、2、4」,班级排名常用「1、2、2、3」。下面实现竞赛式并列排名。
public Map<String, Integer> competitionRank(List<Student> sorted, Map<String, Double> totalMap) { Map<String, Integer> rank = new HashMap<>(); int pos = 0; double lastScore = Double.NaN; for (int i = 0; i < sorted.size(); i++) { double cur = totalMap.getOrDefault(sorted.get(i).getId(), 0.0); if (Double.compare(cur, lastScore) != 0) { pos = i + 1; // 分数变化,名次跳到当前位置 lastScore = cur; } rank.put(sorted.get(i).getId(), pos); } return rank; }逻辑说明:pos只在分数变化时更新,同分沿用上一个名次,得到 1、2、2、4。用Double.compare而不是!=,因为浮点直接比较不可靠。参数sorted必须是已排好序的列表,否则名次无意义。
3.3 浮点误差:为什么平均分算出来是 84.99999999
分数用double累加会出现精度问题,展示时四舍五入即可,但比较和排名前建议统一保留两位。常见做法是用BigDecimal做最终计算,或输出时格式化。
import java.math.BigDecimal; import java.math.RoundingMode; public double round2(double v) { return BigDecimal.valueOf(v) .setScale(2, RoundingMode.HALF_UP) .doubleValue(); }逻辑说明:BigDecimal.valueOf比new BigDecimal(double)更稳,后者会把二进制误差带进来。HALF_UP是常见的四舍五入规则。参数v是原始计算结果,round2只用于展示和比较,内部累加仍用double保证性能。
4. 数据持久化:文件、CSV 和数据库怎么选
数据存不住,系统就是玩具。进阶版至少要支持文件持久化,规模再大就上数据库。选型看数据量和并发:几十人用 CSV 足够,几百人以上、要多人查,用 SQLite 或 MySQL。
4.1 CSV 读写:最小可用的持久化方案
CSV 简单、可读、能用 Excel 打开,适合课程作业。写的时候注意转义逗号,读的时候处理空行。
import java.io.*; import java.nio.charset.StandardCharsets; import java.util.*; public class CsvStore { // 保存成绩:studentId,courseId,value,term public void saveScores(List<Score> scores, String path) throws IOException { try (BufferedWriter w = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(path), StandardCharsets.UTF_8))) { for (Score s : scores) { w.write(String.join(",", s.getStudentId(), s.getCourseId(), String.valueOf(s.getValue()), s.getTerm())); w.newLine(); } } } public List<Score> loadScores(String path) throws IOException { List<Score> list = new ArrayList<>(); try (BufferedReader r = new BufferedReader( new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) { String line; while ((line = r.readLine()) != null) { if (line.isBlank()) continue; // 跳过空行 String[] p = line.split(","); if (p.length < 4) continue; // 跳过脏数据 list.add(new Score(p[0], p[1], Double.parseDouble(p[2]), p[3])); } } return list; } }逻辑说明:显式指定UTF_8,否则中文姓名在 Windows 上会乱码,这是血泪经验。try-with-resources保证流关闭。读取时跳过空行和字段不足的行,避免一条脏数据让整个加载失败。参数path建议用绝对路径或配置项,别写死在代码里。
4.2 用 JDBC 接 SQLite:单文件数据库的落地步骤
数据量上来后,CSV 每次全量读写太慢。SQLite 单文件、免安装,适合本地进阶版。建表、插入、查询三步走。
import java.sql.*; public class DbStore { private static final String URL = "jdbc:sqlite:scores.db"; public void init() throws SQLException { try (Connection c = DriverManager.getConnection(URL); Statement st = c.createStatement()) { st.execute("CREATE TABLE IF NOT EXISTS score (" + "student_id TEXT, course_id TEXT, " + "value REAL, term TEXT)"); st.execute("CREATE INDEX IF NOT EXISTS idx_stu " + "ON score(student_id, term)"); // 按学生+学期查得快 } } public void insert(Score s) throws SQLException { String sql = "INSERT INTO score VALUES(?,?,?,?)"; try (Connection c = DriverManager.getConnection(URL); PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, s.getStudentId()); ps.setString(2, s.getCourseId()); ps.setDouble(3, s.getValue()); ps.setString(4, s.getTerm()); ps.executeUpdate(); } } }逻辑说明:CREATE INDEX是进阶版和玩具版的关键差别,按student_id, term建索引后,单学生查询从全表扫描变成索引查找。用PreparedStatement防注入,也避免拼接字符串出错。参数URL指向当前目录的scores.db,部署时改成配置项。
4.3 文件与数据库的取舍对照
| 维度 | CSV 文件 | SQLite | MySQL |
|---|---|---|---|
| 部署成本 | 零依赖 | 加一个 jar | 需装服务 |
| 适合数据量 | 百条级 | 万条级 | 十万条以上 |
| 并发查询 | 不支持 | 弱 | 强 |
| 事务支持 | 无 | 有 | 有 |
| 课程作业推荐 | 首选 | 进阶 | 偏重 |
选型建议:先做 CSV 版跑通全流程,再把存储层抽成接口,换 SQLite 实现,这样代码结构不用大改。
5. 避坑与排查:那些让成绩系统当场翻车的地方
这一章全是踩过的坑,每条按现象、原因、解决写,照着排查能省不少时间。
5.1 中文姓名存进文件变乱码
现象:CSV 里学生姓名显示成问号或方块。原因:FileWriter默认用平台编码,Windows 中文环境是 GBK,读取端按 UTF-8 解就乱。解决:读写都显式指定StandardCharsets.UTF_8,用InputStreamReader/OutputStreamWriter包一层,别直接用FileWriter。
5.2 排名结果每次运行都不一样
现象:同分学生顺序随机变。原因:List.sort对相等元素不保证顺序,HashMap遍历顺序也不固定。解决:排序时加thenComparing(Student::getId)做兜底,所有输出前先按学号排一遍,保证可复现。
5.3 删除学生后成绩记录成了孤儿
现象:学生删了,成绩还在,统计时出现查不到人的记录。原因:Student和Score分开存,删除只删了一边。解决:删除学生时级联删除其所有Score,或在统计时用studentIndex过滤掉无效记录。数据库方案里用外键加ON DELETE CASCADE。
5.4 平均分算出来比最高分还高
现象:加权平均超过 100。原因:权重没归一化,或把权重当分数加了进去。解决:加权平均公式是Σ(分×权)/Σ权,分母是权重和不是课程数。检查weightSum累加的是权重,别写成sum += w后又拿sum当分母。
5.5 读取大文件时内存溢出
现象:几万条成绩一次性loadScores后OutOfMemoryError。原因:全量读进List再处理。解决:改流式处理,边读边算,或分批查询数据库。CSV 场景用BufferedReader逐行读,别先readAllLines。
6. 进阶技巧:把成绩系统做成能写进简历的项目
到这一步,系统能跑、能存、算得对。但要在 Java 开发工程师面试里讲出彩,还得再加一层:可测试、可扩展、有边界处理。我一般会做三件事。
第一,给ScoreService写单元测试。用 JUnit 覆盖加权平均、并列排名、空数据三个场景,跑通后再改代码心里有底。测试用例本身就是最好的文档,面试时能直接讲「我怎么保证算分逻辑没错」。
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class ScoreServiceTest { @Test void weightedAverage_shouldIgnoreMissingWeight() { DataStore store = new DataStore(); store.addStudent(new Student("S1", "张三", "一班")); // 直接构造成绩,权重只配一门课 ScoreService svc = new ScoreService(store); // 断言省略,实际补全数据后验证 assertTrue(true); } }逻辑说明:测试重点不是覆盖率数字,而是把「同分怎么排」「缺权重怎么算」这些边界固定下来。参数上,测试数据要包含正常值、边界值(0 分、100 分)、异常值(空列表)。
第二,把存储层抽成接口ScoreRepository,CSV 和 SQLite 各写一个实现。这样面试时能讲清楚「面向接口编程」和「依赖倒置」在实际项目里怎么落地,而不是背八股。
第三,加一个简单的导出功能,把排名结果写成带表头的 CSV,方便直接交给老师。导出时注意表头用中文、数据用 UTF-8 BOM,否则 Excel 打开还是乱码——这个坑我踩过不止一次,加 BOM 的写法是在写文件前先写\uFEFF。
最后说个习惯:每次改完统计逻辑,先拿一个手算得出结果的小数据集跑一遍,对上了再上真实数据。成绩系统最怕的不是功能少,是算错了还没人发现。希望帮到你。
本文还有配套的精品资源,点击获取