每年都会有一批人学完Java基础,然后卡在同一个地方:语法看得懂,练习题也能做,但真让你独立写一个完整的Java程序,立马不知道从哪下笔。我一般会直接甩一个题目过去——用Java做个学生成绩管理系统。这个题目不是随便选的,它刚好卡在Java入门到进阶的中间位置:语法难度不高,却能把面向对象、集合框架、JDBC、SQL、异常处理这些实际开发里天天要用的东西串起来。做完一遍,你对"用Java写系统"这件事基本就有感觉了。这篇文章就把完整的实现思路、数据库设计、分层架构、核心代码、运行排错一条线拆开,零基础也可以照着跟下来。
1. 为什么我建议新手把学生成绩管理系统当第一个完整项目
1.1 学完语法不等于会写项目,中间缺的是"业务串联"
很多人学Java是这么学下来的:看视频,学变量、if、for循环,然后是类和对象、集合、IO流,每一块都懂了,合在一起就懵了。原因很简单,这些知识点在教材里是孤立的,而一个真正的项目是它们协作起来的结果。学生成绩管理系统恰好是一个足够小、但五脏俱全的业务场景。
学生要录入、课程要维护、成绩要关联学生和课程,还要有查询、统计、权限控制。这些需求拆开看,每一个都是基础操作,但组合起来,你会被迫去考虑类怎么设计、表怎么建、方法怎么拆、数据怎么流转。这个过程才是写项目真正要练的东西。如果一上来就做电商系统、社交软件那种大项目,新手会直接被复杂度淹没,连Bug出在哪都不好定位。
1.2 这个项目到底覆盖了Java的哪些核心知识点
我整理过这个项目对应的知识点清单,几乎每个都是从零基础到精通必需的内容:
| 知识点 | 在项目里的对应位置 |
|---|---|
| 类和对象 | Student、Course、Score实体类,以及属性封装 |
| 集合框架 | List保存查询结果,Map保存统计数据和登录用户信息 |
| 继承与多态 | 可选地把BaseDao作为父类,子DAO复用连接封装 |
| 异常处理 | SQLException、ClassNotFoundException的捕获与处理 |
| JDBC | 连接MySQL、执行SQL、ResultSet转对象 |
| SQL与事务 | 增删改查、聚合统计、成绩录入时的事务控制 |
| 文件与IO | 可扩展的按学号导出成绩功能 |
| 分层思想 | entity / dao / service / ui 各司其职 |
从面试的角度看,这些恰好是Java岗笔试和面试里出镜率最高的内容。很多同学背八股文背得很熟,但一问到"你在项目里怎么用JDBC的""事务是干什么的",就答不上来。做完这个项目,这些问题就变成你的真实经历了。
1.3 控制台版还是GUI版:先想清楚目标再动手
我见过不少人一上来就做Swing图形界面版,最后卡在窗口布局和事件监听上,业务逻辑反而没怎么写。第一个版本强烈建议做控制台版,就是黑窗口里用菜单交互的那种。
原因有三个。第一,控制台版能把全部精力放在Java核心语法和JDBC上,这是零基础阶段最该补的部分。第二,控制台版的交互流程简单,不需要处理鼠标事件、表格刷新这些边缘问题。第三,后面想做图形界面时,控制台版的service层和dao层可以直接复用,只需要换掉最外层的ui层。如果你时间充裕,可以按"控制台版→GUI版→Web版"的顺序一步步升级,每一步都有产出,不会推倒重来。
1.4 工具清单
- JDK 8或11都可以,如果电脑装的是17、21也能正常跑,只是要注意IDEA和MySQL驱动兼容性
- IntelliJ IDEA社区版,免费且够用
- MySQL 8.0,以及Navicat或MySQL Workbench任选一个作为数据库管理工具
- JDBC驱动:mysql-connector-java-8.0.x.jar,可以去Maven中央仓库直接下载
工具不在多,够用就行。我见过有人为了一个管理系统去折腾Maven、Spring Boot,结果光是环境就装了一周,本末倒置了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据底座先搭稳:数据库设计与JDBC连接细节
2.1 三张表,把实体和关联关系都讲清楚
很多新手容易犯一个错误,把学生姓名和课程名直接塞进成绩表,一张表走天下。这样做看起来简单,但一旦学生改了名字或者一门课程调整了学分,就要逐条修改成绩数据,而且查询统计时SQL会越写越绕。正确的做法是一张学生表、一张课程表、一张成绩表,用外键关联起来。
学生表student:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键,自增 |
| student_no | VARCHAR(20) | 学号,唯一 |
| name | VARCHAR(50) | 姓名 |
| gender | VARCHAR(10) | 性别 |
| class_name | VARCHAR(50) | 班级 |
| phone | VARCHAR(20) | 联系电话,可空 |
| create_time | DATETIME | 创建时间 |
课程表course:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键,自增 |
| course_no | VARCHAR(20) | 课程编号,唯一 |
| course_name | VARCHAR(100) | 课程名称 |
| credit | DECIMAL(3,1) | 学分 |
成绩表score:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键,自增 |
| student_id | BIGINT | 外键,关联student表 |
| course_id | BIGINT | 外键,关联course表 |
| score | DECIMAL(5,2) | 成绩 |
| exam_time | DATETIME | 考试时间 |
成绩表里唯一需要注意的就是联合唯一约束。如果不加约束,同一个学生同一门课程录了两条成绩,查出来的数据就会重复。加了UNIQUE KEY uk_stu_course(student_id, course_id),数据库在物理层面就阻止了重复录入。
2.2 建库建表SQL脚本
sql复制CREATE DATABASE IF NOT EXISTS score_manager DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE score_manager;
CREATE TABLE student (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
student_no VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
gender VARCHAR(10),
class_name VARCHAR(50),
phone VARCHAR(20),
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE course (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
course_no VARCHAR(20) NOT NULL UNIQUE,
course_name VARCHAR(100) NOT NULL,
credit DECIMAL(3,1)
);
CREATE TABLE score (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
student_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
score DECIMAL(5,2),
exam_time DATETIME,
UNIQUE KEY uk_stu_course (student_id, course_id),
CONSTRAINT fk_student FOREIGN KEY (student_id) REFERENCES student (id),
CONSTRAINT fk_course FOREIGN KEY (course_id) REFERENCES course (id)
);
这里有个自己踩过的坑:数据库字符集一定要设成utf8mb4。如果用了默认的latin1,Java程序里插入中文姓名时要么报错要么乱码,排查起来很头疼。utf8mb4是utf8的超集,能存emoji,也兼容所有中文字符,直接用它就对了。
2.3 JDBC连接参数逐个解释,下次乱码不再慌
JDBC连接这一块,新手最容易卡住的不是代码本身,而是那一长串URL参数不知道是什么意思。我给DBUtil里的连接配置拆开讲一下。
java复制public class DBUtil {
private static final String URL = "jdbc:mysql://localhost:3306/score_manager?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true";
private static final String USER = "root";
private static final String PASSWORD = "你自己的密码";
static {
try {
Class.forName("com.mysql.cj.jdbc.Driver");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USER, PASSWORD);
}
}
逐个说:
- jdbc:mysql:// 是固定的协议头,localhost:3306是MySQL服务器地址和默认端口
- score_manager 是数据库名,要和2.2里CREATE DATABASE的名字完全一致
- useSSL=false 表示不启用SSL加密,本地练习环境不需要,不加的话MySQL 8.0会打警告日志
- serverTimezone=Asia/Shanghai 指定时区,不加的话MySQL 8.0会直接抛"The server time zone value"异常
- characterEncoding=utf8 解决中文乱码问题,JVM和MySQL之间传输中文时按UTF-8编码
- allowPublicKeyRetrieval=true 是MySQL 8.0的密码认证机制需要的,不加会报Public Key Retrieval is not allowed
Class.forName这一行是在动态加载驱动类。用mysql-connector-java 8.x的话驱动类名是com.mysql.cj.jdbc.Driver,和老版本的com.mysql.jdbc.Driver不一样,别抄错。
2.4 封装DBUtil,别在每处重复连接代码
DBUtil的意义不只是复用。连接资源的释放和处理是一个高频易错点,新手写DAO方法时最容易漏掉finally块里的close()。资源不关闭,连接池里的连接很快会耗尽,最直观的报错就是后面跑几次程序发现数据库连不上了。
用try-with-resources可以简化,比如这样:
java复制try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
// 处理结果集
} catch (SQLException e) {
e.printStackTrace();
}
这种写法的好处是,try块结束后Connection、PreparedStatement、ResultSet会自动关闭,不需要在finally里手动判断null。零基础阶段养成这个习惯,后面用连接池、用Spring的JdbcTemplate时都能平滑过渡。
3. 分层架构:代码不是"能跑就行"
3.1 四层结构各管一件事
能跑和能维护是两码事。刚写完的时候功能都能用,但过两周你回头想加一个"按班级统计平均分"的功能,会发现不知道该改哪里,因为所有代码都堆在main方法里,这就是没有分层的后果。
我的建议是把项目拆成四个包:
- entity包:放Student、Course、Score三个实体类,字段和数据库表一一对应
- dao包:负责数据库交互,每个表写一个DAO类,方法名和SQL对应
- service包:负责业务逻辑,比如成绩录入前先判断学生和课程是否存在、统计数据的计算
- ui包:控制台入口,循环显示菜单,接收用户输入,调用service层
分层不是Java的特殊要求,而是一种管理复杂度的手段。你只需要记住一条规则:上层依赖下层,下层不依赖上层。ui调用service,service调用dao,dao操作数据库。这样即使哪天把控制台改成Swing图形界面,service和dao基本不动。
3.2 一次完整调用链:按课程查询成绩
以"按课程查询成绩"这个功能为例,把分层调用走一遍,你就会明白每个类该写什么。
用户在控制台输入课程ID后,ui层代码会调用:
java复制List<ScoreVO> list = scoreService.findScoresByCourseId(courseId);
ScoreVO是一个简单视图对象,包含了学生姓名、学号、课程名、成绩等展示字段。之所以不直接返回Score实体,是因为成绩表里只有student_id和course_id的数值,用户想看的是姓名和课程名,这个组装工作放在service层完成。
service层内部先检查courseId是不是合法数字,再调用dao层:
java复制public List<ScoreVO> findScoresByCourseId(int courseId) {
return scoreDao.findScoresByCourseId(courseId);
}
dao层拼接SQL,用JOIN把三张表关联起来:
sql复制SELECT s.student_no, s.name, c.course_name, sc.score
FROM score sc
JOIN student s ON sc.student_id = s.id
JOIN course c ON sc.course_id = c.id
WHERE sc.course_id = ?;
ResultSet转对象的代码放在dao层,封装成List后再往上传。整个调用链是单向的,每一层职责单一,出了问题很容易定位。
3.3 新手最常犯的三个结构问题
第一个问题,在main方法里直接写SQL。这是我改作业时见得最多的。一旦SQL语句和界面交互耦合在一起,后面想复用某个查询条件,只能复制粘贴一大段代码。正确做法是任何SQL都收进dao层方法,哪怕只有一个调用方。
第二个问题,实体类字段和数据库列名对不上,还硬用getString(1)、getInt(2)这种按下标取值的方式。这样做在表结构调整的瞬间就会崩,而且代码可读性极差。我建议ResultSet转对象时用列名取值,比如rs.getLong("student_id"),表加字段也不会影响既有代码。
第三个问题,DAO方法里做了太多业务处理。例如"删除学生时同时删除他的成绩记录",这个逻辑放在ScoreDAO里还是StudentService里?答案是在StudentService里串联两个DAO方法,先删除成绩,再删除学生。这样每个DAO的职责保持纯粹,以后如果需求变成"删除学生时保留成绩记录",只需要改service层的顺序。
3.4 从写SQL到PreparedStatement:不光是防注入
有些初学者会直接用Statement拼接SQL,比如:
java复制Statement st = conn.createStatement();
st.executeUpdate("DELETE FROM student WHERE id = " + id);
这样写确实省事,但有两个问题。第一,存在SQL注入风险。如果id来自用户输入,用户输入"1 OR 1=1",这条SQL就变成了删除全表数据。第二,每次执行都要重新解析SQL,性能差。
用PreparedStatement是更稳妥的方案:
java复制String sql = "DELETE FROM student WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
ps.executeUpdate();
}
PreparedStatement的预编译机制会把SQL结构和参数分离,参数值永远不会被当成SQL指令执行。零基础阶段就把这个习惯刻在脑子里,后面用MyBatis时你会发现,它底层的#{}语法本质上也是在帮你做同样的参数化处理。
4. 核心功能逐个击破:登录、增删改查与统计
4.1 登录模块:权限区分与密码安全
学生成绩管理系统通常有两类使用者:管理员和教师。管理员维护学生课程信息,教师录入和查询成绩。最简单的控制台实现是在user表里加一个role字段,值为admin或teacher。
登录逻辑用一句话就能说清楚:根据用户名查出用户记录,比对密码,再比对角色,全部通过就进入对应菜单。这里有两个细节需要注意。
一是密码绝不能明文存储。零基础项目可以不引入复杂加密算法,但至少用MD5处理一下。注册时把密码的MD5值存进数据库,登录时把输入密码也转成MD5再比对。虽然MD5有被碰撞的风险,但比明文强几个量级。如果想要更好的方案,可以用加盐哈希,比如引入jBCrypt库。
二是登录失败的提示一定要模糊。无论用户名不存在、密码错误还是角色不对,统一提示"用户名或密码错误"。别看这是个管理系统,不区分具体原因能有效防止别人逐个试探用户名。
4.2 成绩录入与修改:事务保证数据一致
成绩录入是整个项目里最值得讲事务的地方。如果业务只是insert一条记录,确实用不上事务。但更真实的场景通常是:录入成绩的同时,需要更新学生的总成绩、平均分统计表,或者维护一个"最近录入"的日志表。
假设录入一条成绩需要同时往score表插入记录、往statistics表更新统计数据,这两步必须全部成功或者全部失败。比如成绩插入成功了,统计数据更新却因为某个字段超长报错,如果不做事务控制,数据库里就会出现一条没有统计数据的成绩记录。
事务的标准写法如下:
java复制Connection conn = null;
try {
conn = DBUtil.getConnection();
conn.setAutoCommit(false); // 手动控制事务
// 插入成绩
String sql1 = "INSERT INTO score (student_id, course_id, score, exam_time) VALUES (?, ?, ?, NOW())";
try (PreparedStatement ps = conn.prepareStatement(sql1)) {
ps.setLong(1, studentId);
ps.setLong(2, courseId);
ps.setBigDecimal(3, scoreValue);
ps.executeUpdate();
}
// 更新统计表
String sql2 = "UPDATE statistics SET total_count = total_count + 1 WHERE course_id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql2)) {
ps.setLong(1, courseId);
ps.executeUpdate();
}
conn.commit(); // 全部成功才提交
} catch (SQLException e) {
if (conn != null) {
try {
conn.rollback(); // 发生异常就把前面所有操作回滚
} catch (SQLException ex) {
ex.printStackTrace();
}
}
e.printStackTrace();
} finally {
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
这里有个容易忽略的细节:Connection对象必须在try块外面声明,因为finally块里还要用它。如果只在try块内部声明,finally里就访问不到了。
4.3 统计功能:平均分、及格率与排名
成绩管理系统最出效果的部分是统计报表。Java代码做好基本增删改查只是及格水平,能把统计逻辑用SQL做出来,才是面试时能拿出来聊的亮点。
平均分和及格率可以一条SQL搞定:
sql复制SELECT c.course_name,
COUNT(*) AS total_count,
ROUND(AVG(sc.score), 2) AS avg_score,
ROUND(SUM(CASE WHEN sc.score >= 60 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS pass_rate
FROM score sc
JOIN course c ON sc.course_id = c.id
GROUP BY sc.course_id, c.course_name;
COUNT(*)统计人数,AVG算平均分,SUM配合CASE WHEN把及格人数求出来再除总人数,就是及格率。这套写法在面试里很常见,它考查的是对GROUP BY分组聚合的理解。
如果要做学生成绩排名,MySQL 8.0里可以直接用窗口函数:
sql复制SELECT s.student_no, s.name, sc.score,
RANK() OVER (ORDER BY sc.score DESC) AS rank_no
FROM score sc
JOIN student s ON sc.student_id = s.id
WHERE sc.course_id = ?;
如果你的MySQL是5.7版本,不支持窗口函数,就需要查出结果后在Java里用Comparator排序,或者用用户变量模拟,但可读性差很多。所以做这个项目最好直接用MySQL 8.0。
4.4 分页与模糊搜索:List的宿命,SQL的出路
我见过很多新手写查询功能时,直接把全表数据查到List里,然后在内存里过滤。数据量小看不出来问题,一旦表里几千条记录,界面会明显卡顿,甚至触发开头提到的OutOfMemoryError。正确思路是尽量用SQL完成过滤和分页,Java只负责接收结果。
分页查询用LIMIT:
sql复制SELECT * FROM student ORDER BY id LIMIT ?, ?;
第一个问号是偏移量,计算公式是(pageNum - 1) * pageSize,第二个问号是每页条数。PreparedStatement里:
java复制ps.setInt(1, (pageNum - 1) * pageSize);
ps.setInt(2, pageSize);
模糊搜索用LIKE,注意写法:
java复制String sql = "SELECT * FROM student WHERE name LIKE ?";
ps.setString(1, "%" + keyword + "%");
这里有一个小小的坑:很多新手喜欢写成"%" + name + "%"拼接进SQL,但PreparedStatement占位符的方式才是正确姿势。另外LIKE '%xxx%'这种写法因为前导百分号,索引会失效,数据量大了查询会慢。数据量小的时候不用太纠结,但要意识到这个问题。
5. 运行部署与高频报错自救手册
5.1 从建库到启动的完整流程
很多同学代码写完了,但不知道从哪一步开始让它跑起来。我给一个最小化流程:
- 安装JDK并配置JAVA_HOME环境变量,命令行执行java -version验证
- 安装MySQL并启动服务,用命令行或Navicat执行第2章里的SQL脚本
- 在IDEA里新建项目,把DBUtil里的USER和PASSWORD改成你自己的数据库账号密码
- 下载mysql-connector-java-8.0.x.jar,放到项目根目录的lib文件夹,在IDEA里右键jar包选择Add as Library
- 运行入口Main类
- 控制台会出现功能菜单,比如"1.学生管理 2.课程管理 3.成绩管理 4.统计报表 5.退出"
第一次跑通这个流程,整个过程大概半小时。如果卡在第4步,多半是jar包没有正确引入,代码能编译但运行时报ClassNotFoundException。
5.2 高频异常对照表
把这些异常抄下来贴在屏幕上,遇到直接查表:
| 报错信息 | 原因 | 解决方式 |
|---|---|---|
| ClassNotFoundException: com.mysql.cj.jdbc.Driver | 没引入MySQL驱动包 | 把jar包放到lib目录并Add as Library |
| Access denied for user 'root'@'localhost' | 数据库用户名或密码错误 | 检查DBUtil里的USER和PASSWORD |
| Unknown database 'score_manager' | 数据库名写错或没建库 | 执行CREATE DATABASE脚本,确认库名一致 |
| Public Key Retrieval is not allowed | MySQL 8.0密码认证问题 | URL加上allowPublicKeyRetrieval=true |
| The server time zone value ... unrecognized | 时区问题 | URL加上serverTimezone=Asia/Shanghai |
| java.sql.SQLException: Incorrect string value | 中文乱码或字符集不对 | 表字符集改成utf8mb4,URL加characterEncoding=utf8 |
| OutOfMemoryError: insufficient memory | 一次性查询数据量过大或JVM内存不足 | 加分页查询,或运行时加-Xmx512m参数 |
最后一个OutOfMemoryError,做这个项目时最容易出现在"查询全部成绩并转成Java对象"这种操作上。解决办法不是盲目调大JVM内存,而是用分页限制一次加载的数据量。这也是为什么我一直强调列表查询必须写分页的原因。
5.3 单独说一个隐蔽的坑:控制台输出中文乱码
源码和数据库都是UTF-8,但控制台输出乱码的情况也很常见,这是IDEA的默认编码问题。解决办法是在IDEA的Help菜单里找到Edit Custom VM Options,加一行-Dfile.encoding=UTF-8,重启IDEA。另外检查一下Run Configuration里的VM options,如果有-Dfile.encoding=GBK,把它删掉。这个坑不致命,但会拖慢你调试的节奏。如果你是Windows系统,控制台窗口有时还需要在运行配置里加-Dconsole.encoding=UTF-8。
6. 项目往哪延伸:GUI、Web与面试实战
6.1 从控制台改到GUI,其实只换一层
如果你想让项目看起来更像一个"产品",可以升级成Swing图形界面。很多新手以为这是个大工程,但只要你前面老老实实做了分层,改起来非常快。
Swing版通常需要一个主窗体类,里面放一个JTable和几个按钮。JTable的数据源用TableModel接口管理,你可以建立一个StudentTableModel,继承AbstractTableModel,把dao层查出来的List传入getValueAt方法里做展示。按钮的点击事件调用service层的同一个方法。
比如click查询按钮的逻辑:
java复制private void onSearchBtnClick() {
String keyword = searchField.getText();
List<Student> list = studentService.searchStudents(keyword);
tableModel.setData(list);
tableModel.fireTableDataChanged();
}
这里有个非常重要的体会:核心业务逻辑,也就是service层和dao层,一行都不用改,只改ui层。这就是分层带来的红利。你会第一次直观感受到"面向接口编程"和"高内聚低耦合"的含义。
6.2 Web化路线:什么时候该上框架
做完GUI版之后,如果想继续往Java Web方向走,可以进入Web化阶段。零基础不建议直接上Spring Boot,最好先做一遍Servlet + JSP + JDBC的版本,亲手感受一次请求怎么来、响应怎么回、参数怎么取。
Web版的本质是:页面是ui层,Servlet是控制器,service层和dao层继续复用。你只需要把控制台里的输入输出替换成HttpServletRequest和HttpServletResponse。
等Servlet版本跑通,再上Spring Boot就会很轻松。Spring Boot的Controller本质上是替代Servlet的位置,Service和DAO可以照搬思路,用MyBatis替换掉手写的JDBC代码。到了这个阶段,你可以顺理成章地学习IOC、AOP、MyBatis映射,看官方文档也更容易看懂了。
6.3 面试中怎么讲这个项目
面试官不太在乎你做的项目有多大,更在乎你是不是真的做过。讲学生成绩管理系统时,有几个点值得主动提:
- 成绩录入用了事务,保证多表操作的原子性
- SQL全部用PreparedStatement参数化,防止SQL注入
- 列表查询做了分页,避免内存溢出和大数据量卡顿
- 系统按dao、service、ui分层,控制台、GUI、Web可以平滑迁移
这些点都不是背来的,是你一行行写出来后自然知道的东西。当面试官追问"你刚才说用了事务,那什么情况下会发生死锁"时,你至少能联想到成绩录入场景里并发修改统计表的情况,哪怕答得不够完美,也比背书强得多。
说实话,每年我都能见到很多人收藏了一堆教程,却始终没动手写过一个完整项目。学生成绩管理系统最好的打开方式不是"等我准备好再开始",而是边看这篇边敲。先照着跑通,然后自己加一个功能,比如导出成绩单、统计每个班级的排名。踩坑踩够了,这个项目才算真正属于你。
