简介:面向JavaWeb学习者与课程设计人群,这是一份学生成绩管理系统完整项目,基于Servlet、JSP、JSTL、EL等技术栈,配合Eclipse与SQL Server实现成绩录入、查询、统计分析和报表生成,适合毕业设计、课设或项目实践参考。压缩包共138个文件,涵盖27个Java源文件与对应class字节码、23个SQL脚本、20个JSP页面,以及Eclipse工程配置、PowerDesigner物理模型(pdm/cdm)和设计文档,覆盖从建库到部署的完整链路,整体大小仅3.87MB。已有12758人学习浏览。通过研读源码、SQL脚本与设计模型,可掌握JavaWeb分层开发思路、数据库表结构设计及权限控制等关键实践;配合文档还能快速理解系统架构,便于二次开发与论文撰写。
1. 期末成绩出来那周:这个 JavaWeb 系统怎么把老师从 Excel 里捞出来
某高校一位计算机老师,期中考完拿到三个班 180 份纸质成绩单,助教手动录 Excel,合分、算及格率、按班级排名折腾了两天,最后还是因为某列排序点错返工了一次。学生成绩管理系统这种 JavaWeb 项目,解决的就是这类场景:老师登录后录入成绩、按课程查询、按班级统计,学生登录后只能看自己的成绩,管理员维护学生和课程的基础数据。整个系统用 Servlet + JSP + JDBC + MySQL 实现,不依赖 Spring 全家桶,Tomcat 一跑就能用。对正在做 JavaWeb 课设、或者想找一个分层清晰还能二次开发的成绩管理系统源码的人来说,它是很好的起步项目:没有复杂框架包裹,三层结构一眼能看懂,改起来很快。
2. 整体架构与工程结构:Servlet + JSP + JDBC 的三层拆分
2.1 技术选型:为什么不用 Spring 全家桶也能跑通
这个系统没有引入 SSM 或者 Spring Boot,而是用传统的 JavaWeb 技术栈:Servlet 处理请求,JSP 负责页面展示,JDBC 直接操作数据库,Maven 管理依赖,Tomcat 作为运行时容器。放在现在的环境里看好像有点“原始”,但恰恰是这种原始让它特别适合学习:没有 AOP、IoC 那一层,请求从浏览器到数据库的路径是直线,断点打下去每一步都能跟到。
常见做法是每张表对应一个实体类,Servlet 调 Service,Service 调 DAO,DAO 里写 JDBC 代码。Service 层负责事务和业务校验,DAO 层只做增删改查,Servlet 里不出现 SQL,JSP 里不出现 Java 脚本片段,而是用 EL 和 JSTL 取值。这套规范和现在企业中 Spring MVC 的分层思路完全一致,只不过容器换成了 Tomcat、注入换成了手动 new——理解了这套,后面切到 Spring Boot 就是换壳而已。
2.2 工程目录结构与各个包的分工
先看目录结构,项目是按标准 Maven Web 工程组织的:
src/main/java ├── com.demo.sms │ ├── entity # Student、Course、Score、User 实体类 │ ├── dao # 数据库访问层,JDBC 操作 │ ├── service # 业务逻辑层,事务、校验 │ ├── servlet # 控制器层,处理请求跳转 │ ├── util # DBUtil 数据库连接工具 │ └── filter # 登录拦截、编码过滤器 src/main/webapp ├── login.jsp # 登录页 ├── admin # 管理员页面:学生管理、课程管理 ├── student # 学生页面:查看成绩 ├── teacher # 教师页面:录入成绩、统计报表 └── WEB-INF/web.xml # 项目配置entity 是四张表对应的 Java 对象,字段和表字段一一对应;dao 包里每个实体一个 DAO 类,比如 ScoreDao 里有 insert、findPage、count 等方法;service 包是核心,比如录入成绩时要先校验再插库,这段逻辑放在 Service 里而不是 Servlet 里,是为了复用——以后成绩批量导入、Excel 导入都要走同一个入口,就不用在每个 Servlet 里重写一遍。filter 包里通常有两个过滤器:一个处理全站编码,一个做登录拦截,静态资源放行,未登录用户一律重定向到 login.jsp。
这个分层有一点需要适应:Servlet 和 Service 之间没有接口,而是直接 new 实现类。小型系统没问题,但如果你打算以后扩展成接口加实现类,其实也很简单,抽个接口然后把 new 改成工厂即可。我一般会先把 Service 层的方法签名固定住,再决定要不要上接口。
3. 数据库设计与成绩统计:四张核心表和一段聚合 SQL
3.1 表结构设计:用户、学生、课程、成绩之间的关系
成绩管理系统的数据库不复杂,核心就四张表:用户表、学生表、课程表、成绩表。用户表管登录,学生表和课程表是基础数据,成绩表是中间表,承担学生和课程的多对多关系。
CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(32) UNIQUE NOT NULL COMMENT '登录名', `password` VARCHAR(64) NOT NULL COMMENT 'MD5密文', `role` VARCHAR(16) NOT NULL COMMENT 'admin/teacher/student', `ref_id` INT COMMENT '关联学生ID或教师ID' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `student` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `student_no` VARCHAR(20) UNIQUE NOT NULL COMMENT '学号', `student_name` VARCHAR(32) NOT NULL, `class_name` VARCHAR(64) NOT NULL COMMENT '班级,如计算机2301', `gender` VARCHAR(4) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `course` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `course_name` VARCHAR(64) NOT NULL, `credit` DECIMAL(3,1) DEFAULT 2.0 COMMENT '学分', `teacher` VARCHAR(32) COMMENT '任课教师' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `score` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `student_no` VARCHAR(20) NOT NULL, `course_id` INT NOT NULL, `score` DECIMAL(5,2) NOT NULL COMMENT '成绩0-100', `exam_type` VARCHAR(16) DEFAULT '期末' COMMENT '平时/期中/期末', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_stu_course` (`student_no`, `course_id`, `exam_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;设计上要特别注意两点:第一,user 表里存的是 MD5 密文而不是明文,登录时把用户输入的密码做同样加密再去比对,防止数据库泄露后明文密码直接暴露;第二,score 表加了唯一键 (student_no, course_id, exam_type),这个唯一键是防重复录成绩的最后一道防线——即使代码里忘了判断,数据库也会报错拦住你。这两个设计在课设项目里容易被忽略,但实际答辩时老师盯着的往往就是这两处。
成绩表通过 student_no 和 course_id 关联学生与课程,join 时不需要再加冗余字段。如果查询量大了想优化,可以给 student_no 建普通索引,但学生成绩系统的数据量一般几千条,非聚集索引足够用,不用做分区。
3.2 成绩统计的核心 SQL:平均分、及格率一次查出来
教师端最常用的功能是按课程统计成绩分布,对应 SQL 是这段聚合查询:
SELECT c.course_name AS 课程名, COUNT(s.id) AS 总人数, ROUND(AVG(sc.score), 2) AS 平均分, MAX(sc.score) AS 最高分, MIN(sc.score) AS 最低分, ROUND(SUM(CASE WHEN sc.score >= 60 THEN 1 ELSE 0 END) / COUNT(s.id), 4) AS 及格率 FROM course c LEFT JOIN score sc ON c.id = sc.course_id LEFT JOIN student s ON sc.student_no = s.student_no GROUP BY c.id, c.course_name ORDER BY c.id;这段 SQL 里有三个关键点。LEFT JOIN 而不是 INNER JOIN,选 LEFT 是为了让还没有任何人选课的课程也能出现在统计列表里,总人数为 0,及格率为 NULL,页面上显示时再做兜底;及格率用 CASE WHEN 把成绩转成 0 或 1 再求和,避免了先查总人数再查及格人数的两次查询;ROUND 到四位小数是及格率,页面上乘 100 显示成百分点,这些计算放 SQL 里比放 Java 代码里高效得多,数据库本身是更擅长做聚合的。
统计班级维度时,把 GROUP BY 的字段换成 s.class_name 即可,查询条件加一个 WHERE c.id = ? 就可以看单门课程的数据。这段 SQL 在 MySQL 5.7 和 8.0 下都能跑,但要注意 8.0 里如果开了 ONLY_FULL_GROUP_BY,SELECT 出来的字段必须都在 GROUP BY 中出现或者被聚合函数包裹,上面这段 SQL 已经规避了这个问题,直接复制可用。
4. 核心代码拆解:从登录到成绩发布的完整链路
4.1 登录鉴权:把 Session 用起来
登录是整个系统的入口。LoginServlet 接收用户名和密码,调 UserDao 查询数据库,比对成功把 User 对象放进 Session,失败则转发回登录页并携带错误提示。代码按照 Servlet 3.0 的标准写法,用 @WebServlet 注解代替 web.xml 配置:
@WebServlet(urlPatterns = "/login") public class LoginServlet extends HttpServlet { private UserDao userDao = new UserDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = DigestUtils.md5DigestAsHex(req.getParameter("password").getBytes()); User user = userDao.findByUsernameAndPassword(username, password); if (user == null) { req.setAttribute("errorMsg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } req.getSession().setAttribute("loginUser", user); if ("admin".equals(user.getRole())) { resp.sendRedirect(req.getContextPath() + "/admin/index.jsp"); } else if ("teacher".equals(user.getRole())) { resp.sendRedirect(req.getContextPath() + "/teacher/index.jsp"); } else { resp.sendRedirect(req.getContextPath() + "/student/index.jsp"); } } }这段代码有两个细节值得说明。密码在进入 DAO 之前先做了 MD5,这样即使 SQL 查询日志被拿到,里面也只有密文,不会暴露原始密码;比对失败时用的是 forward 转发而不是 redirect 重定向,因为要把 errorMsg 放进 request 域里让登录页显示,重定向会丢掉 request 属性。登录成功后走的是 redirect,这个跳转方式虽然多了一次请求,但能让浏览器地址栏变成真实的首页 URL,用户刷新页面不会重复提交表单。
至此 Session 中有了 loginUser,后面的所有请求都可以通过它判断身份。配合 Filter 做拦截,在 web.xml 或注解里配置过滤路径 /admin/、/teacher/、/student/*,未登录用户访问这些路径直接重定向到 login.jsp。注意过滤链中要放行 login、css、js、images 这类资源,否则用户连登录页样式都加载不出来。
4.2 成绩录入:Service 层的事务处理和校验
成绩录入是教师端最核心的操作。一个教师可能同时录入一个班多门课的成绩,所以录入接口设计成了批量模式。成绩录入不能只有“插入”这一个动作——还要判断学生是否存在、成绩是否合法、考试类型是否重复,这些逻辑全部收敛到 ScoreService 中:
public void addScores(List<ScoreParam> params) { if (params == null || params.isEmpty()) { throw new IllegalArgumentException("成绩数据不能为空"); } for (ScoreParam p : params) { if (p.getScore() < 0 || p.getScore() > 100) { throw new IllegalArgumentException("成绩必须在0~100之间: " + p.getStudentNo()); } if (!studentDao.existsByNo(p.getStudentNo())) { throw new IllegalArgumentException("学号不存在: " + p.getStudentNo()); } } Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); for (ScoreParam p : params) { scoreDao.insert(conn, p); } conn.commit(); } catch (SQLException e) { try { if (conn != null) { conn.rollback(); } } catch (SQLException ex) { log.error("回滚失败", ex); } throw new RuntimeException("成绩批量录入失败", e); } finally { DBUtil.close(conn); } }这段逻辑的重点在事务。默认情况下 JDBC 的 autocommit 是 true,每条 insert 都是独立的,如果第 10 条数据插入失败,前面 9 条已经生效,数据就变成半成品了——这就是典型的“录到一半,库里多了半个班”。所以我会先 conn.setAutoCommit(false),全部插入成功再 commit,任何一条异常都会触发 rollback,保证一个批次的成绩要么全部入库,要么全部不落。
事务之外,异常设计也值得说一下。Service 层抛 IllegalArgumentException 表示“用户的输入有问题”,信息直接展示给教师;抛 RuntimeException 表示“系统内部故障”,信息记录在服务端日志,页面只提示“录入失败,请稍后重试”。这种方式在分层架构里叫异常翻译,把底层异常统一包装成业务异常再向上抛,比直接把 SQLException 甩给 JSP 页面更可控。唯一键 uk_stu_course 是兜底策略,代码没拦住重复会由数据库再拦一次。
4.3 分页查询:PageBean + limit 的经典搭法
成绩列表不能一次性把所有数据查出来,常见做法是 PageBean 封装当前页、每页条数、总记录数,DAO 层用 LIMIT 做物理分页。先看一下 DAO 里的分页查询方法:
public List<ScoreVO> findPage(int offset, int pageSize, Integer courseId, String keyword) { StringBuilder sql = new StringBuilder( "SELECT s.student_no, s.student_name, s.class_name, c.course_name, sc.score " + "FROM score sc " + "JOIN student s ON sc.student_no = s.student_no " + "JOIN course c ON sc.course_id = c.id WHERE 1=1 "); List<Object> args = new ArrayList<>(); if (courseId != null) { sql.append("AND sc.course_id = ? "); args.add(courseId); } if (keyword != null && !keyword.trim().isEmpty()) { sql.append("AND (s.student_name LIKE ? OR s.student_no LIKE ?) "); args.add("%" + keyword.trim() + "%"); args.add("%" + keyword.trim() + "%"); } sql.append("ORDER BY sc.create_time DESC LIMIT ?, ?"); args.add(offset); args.add(pageSize); return query(sql.toString(), args); }参数 offset 是跳过的记录数,pageSize 是每页条数,两者都是从 PageBean 计算得到的。字符串拼接 SQL 时用 StringBuilder 和一个 list 累积参数,条件用 IF 判断动态追加,这样课程筛选和姓名模糊搜索可以自由组合,WHERE 1=1 是为了避免拼接多个 AND 时写一堆判断。这里不能用字符串直接拼接参数值,而应该用占位符 ? 再加参数,否则用户输入的关键词如果带引号,SQL 就会语法报错甚至被注入。
Service 层计算 PageBean 时,还要先查一次总记录数,页面上才能展示“共 180 条 / 第 3 页”。常见实现是两个 DAO 方法,一个是 count 查询,一个查当前页数据,Service 把两个结果组合:
public PageBean findPage(int pageNo, int pageSize, Integer courseId, String keyword) { int total = scoreDao.countByCondition(courseId, keyword); PageBean pb = new PageBean(pageNo, pageSize, total); List<ScoreVO> list = scoreDao.findPage(pb.getOffset(), pageSize, courseId, keyword); pb.setList(list); return pb; }JDBC 拼接 LIMIT ? 时需要注意参数类型,MySQL 的 LIMIT 参数要用 setObject 传整数,不要拼成字符串,否则部分驱动会因为类型转换报错。查询之后 JSP 端用 JSTL 遍历 PageBean 的 list:
<c:forEach items="${pageBean.list}" var="item"> <tr> <td>${item.studentNo}</td> <td>${item.studentName}</td> <td>${item.className}</td> <td>${item.courseName}</td> <td> <c:choose> <c:when test="${item.score >= 60}">${item.score}</c:when> <c:otherwise><span style="color:red">${item.score}</span></c:otherwise> </c:choose> </td> </tr> </c:forEach>这段 JSP 用了 EL 表达式取值,用 c:choose 做条件判断,低于 60 分的成绩标红。页面上方的分页条再放一个表单提交当前页码,配合翻页按钮就能完成完整的分页交互。注意 EL 表达式里访问的是 JavaBean 的属性名,也就是 getStudentNo() 对应的 studentNo,写错大小写拿到的就是空值。
5. 避坑记录:部署和调试中的五个真实问题
5.1 环境与部署类:连不上数据库、跑错 Tomcat 版本
第一条:Tomcat 启动后访问登录页,输入账号密码点击登录,页面报 500,后台堆栈显示 ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因是 mysql-connector-java 的 jar 包没有打进 WEB-INF/lib。用 Maven 时常见做法是在 pom.xml 中声明依赖并用 war 包方式打包,如果你直接把项目作为目录部署到 Tomcat,要检查 target 目录里有没有对应的 jar——没有就执行 mvn clean package 重新打包,或者把 jar 手动复制到 WEB-INF/lib 下。这条坑在第一次部署时几乎必踩,因为 Maven 编译成功并不代表依赖被复制到了运行目录。
第二条:Tomcat 版本选错。这个项目里的 Servlet 用的是 javax.servlet 包下的注解和 API,对应的是 Tomcat 9。如果你电脑上装的是 Tomcat 10 或 11,项目启动后所有页面能打开,但一旦访问到 Servlet,直接 404 或 500,日志里提示 jakarta.servlet 包找不到。因为 Tomcat 10 开始把 javax.servlet 改名成了 jakarta.servlet,包名变了,旧项目不能在 Tomcat 10 上直接跑。解决方法是换回 Tomcat 9,或者把所有 import 从 javax.servlet 全部替换为 jakarta.servlet,明显前者省事。
第三条:项目明明能编译,但部署后点任何按钮都跳到 404。原因通常是 Servlet 的 @WebServlet 注解路径和表单的 action 路径不一致,比如注解写的是 /scoreList,表单却提交到了 /score/list。这类问题排查时先看浏览器地址栏的实际 URL,再在 IDEA 里按住 Ctrl 点击跳转到对应的 Servlet 类,路径一对就知道问题出在哪。我一般会定一个约定:所有 Servlet 都用注解配置路径,后台页面统一通过相对路径跳转,避免出现多层目录下的路径混乱。
5.2 编码与数据类:中文乱码、重复提交
第四条:页面上中文都正常,但是录进去的成绩在学生端查出来变成问号。这是典型的字符集问题,最常见的原因是 JDBC 连接串里少了编码参数。很多新手写连接如下:jdbc:mysql://localhost:3306/sms,这串是没有指定 characterEncoding 的。完整写法是 jdbc:mysql://localhost:3306/sms?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai,加 serverTimezone 是因为 MySQL 8.0 默认时区与驱动不一致会报错。除了连接串,还要检查三处:数据库表默认字符集是否为 utf8mb4、JSP 页面是否设置了 pageEncoding 与 contentType、Servlet 里是否调用了 req.setCharacterEncoding("UTF-8")。三处全部统一,乱码问题才能根治。
第五条:教师点击“保存成绩”后,刷新页面发现同一批成绩录入了两遍。原因是保存成功用了 forward 转发到列表页,刷新时浏览器把上一个 POST 请求重新提交了一次。解决这个问题专门有一个模式,叫 Post-Redirect-Get:Servlet 处理完 POST 请求后不要 forward,而是 redirect 到查询页面,浏览器地址栏变成 GET 请求,刷新就只会重新拉列表而不会重放插入操作。这段逻辑在 4.1 的登录跳转那里已经体现,但很多人只在登录用了重定向,业务保存时又习惯性转发,这是血泪经验。再配合 score 表上的唯一键兜底,双保险才不会翻车。
6. 二次开发指南:给系统加一个成绩导出功能
6.1 改造点:导出 CSV 的 Service 与 Servlet 实现
这个系统学生和教师端都有成绩列表,但缺一个“导出 Excel”的能力。给它加一个导出功能是个很典型的二次开发场景,涉及 Service 层新方法、Servlet 新路由、前端新增按钮三个改动。如果不想一下子引入 Apache POI 那么重的依赖,可以先做 CSV 导出——Excel 能直接打开 CSV 文件,实现成本低,代码量只有二十行左右:
public void exportCsv(OutputStream out, Integer courseId) { List<ScoreVO> list = scoreDao.findPage(0, 10000, courseId, null); StringBuilder sb = new StringBuilder(); sb.append('\uFEFF').append("学号,姓名,班级,课程,成绩,是否及格\n"); for (ScoreVO vo : list) { sb.append(vo.getStudentNo()).append(',') .append(vo.getStudentName()).append(',') .append(vo.getClassName()).append(',') .append(vo.getCourseName()).append(',') .append(vo.getScore()).append(',') .append(vo.getScore() >= 60 ? "及格" : "挂科") .append('\n'); } out.write(sb.toString().getBytes(StandardCharsets.UTF_8)); }这段代码第一个字符 \uFEFF 是 UTF-8 的 BOM 头,没有它 Excel 打开 CSV 文件时中文列名会乱码。经验是写导出功能时一定要先处理 BOM,否则导出给老师看,第一行就全是问号,体验直接崩塌。Servlet 端把输出流指向 resp.getOutputStream(),并设置 Content-Type 为 text/csv,文件名用 URLEncoder 处理一下再拼到 Content-Disposition 里,浏览器就会自动下载而不是在页面里打开。
6.2 验证方法:前后端两处检查
改完代码后的验证顺序:先用前端的“导出”按钮触发请求,看浏览器是否弹出文件下载;下载后打开文件,第一行列名正常、中文不乱码、成绩数字为两位小数;再把 courseId 参数传一个不存在的值,确认后端返回空文件而不是 500。如果以后要导出真正的 .xlsx 格式,无非是把字符串拼接换成 new XSSFWorkbook() 然后逐行 setCellValue,导出逻辑留在 Service 层的好处在这里就体现了——Service 返回统一的 List ,换成任何导出方式都不用动 Servlet 和页面。
我曾经在这个系统上踩过最大的坑,就是导出 CSV 时没带 BOM,自己本地用记事本看一切正常,发给老师后全部乱码。从那以后我每写一个导出功能,都强制检查文件流的编码头设置,导出完先自己下下来双击打开确认无误再交付。希望这篇解析能让你少走这段弯路——把一个基础的学生成绩管理系统跑起来不难,把分层的思路看明白,后面任何 JavaWeb 项目都能很快上手。
本文还有配套的精品资源,点击获取