JavaWeb学生成绩管理系统实战:Servlet+JSP+MySQL完整开发指南
简介这是一套面向JavaWeb初学者与课程设计实践者的学生成绩管理系统完整源码基于Java语言与MySQL数据库开发适用于高校《Java Web程序设计》《数据库应用》等课程实训或毕业设计参考。系统采用MVC架构涵盖用户权限管理、学生/课程/成绩全生命周期操作及多维度统计分析功能可直接部署于Tomcat运行。压缩包共244个文件包含21个JSP页面实现动态交互视图、36个JAR依赖库含Servlet、JDBC等核心组件、15个Java源文件如AdminController、StudentController等控制器类以及1个SQL建表脚本辅以XML配置、CSS样式与JS交互逻辑结构清晰、模块职责分明。资源大小为17.15MB已有1440人学习下载。读者可获得可运行的完整工程、典型三层架构代码范例、常见Web安全防护实践如防SQL注入及真实教学场景下的业务逻辑实现细节是理解JavaWeb开发全流程的优质实操素材。1. 为什么一个“JavaWeb学生成绩管理系统”至今仍是校内实训、毕设和中小教务模块的硬通货不是因为它多酷炫而是它精准卡在「教学闭环验证」的黄金切口上学生能提交作业、教师能录成绩、管理员能导报表——三类角色、四种核心操作增删改查、五种典型数据关系学生↔班级↔课程↔教师↔成绩全在一张可触摸的网页里跑通。它不追求高并发或微服务架构但必须暴露真实业务里的脏数据、权限越界、事务断裂和前端校验失灵。某高校连续三年把该项目作为Java Web课程设计唯一指定题型原因很实在80%的学生第一次写出带事务回滚的批量导入功能时才真正理解Transactional不是装饰品73%的开发者在调试“同一门课多个教师录入成绩冲突”时才吃透数据库乐观锁的实际写法。这不是玩具项目而是一套用HTTP请求SQL语句表单交互构成的“教学黑匣子”里面装着Servlet生命周期、JDBC连接池泄漏、JSP模板注入风险、以及最常被忽略的——浏览器缓存导致的成绩修改不刷新。适合刚学完Servlet/JSP/MySQL基础、正卡在“代码能跑但不敢上线”阶段的开发者也适合需要快速交付轻量教务模块的校内信息化小组。2. 从零搭起最小可运行骨架Servlet JSP MySQL 三件套落地实录2.1 环境与依赖拒绝IDE自动配置手动理清每一层职责我们不使用Spring Boot脚手架而是回归原生JavaWeb三层结构——这能让你看清每个HTTP请求到底穿过了几道门。开发环境锁定为JDK 8u291避免高版本Servlet API兼容问题、Tomcat 9.0.83稳定且对JSP支持最完整、MySQL 5.7.42InnoDB引擎严格模式开启。项目结构采用经典WAR布局student-score-system/ ├── src/ │ ├── main/ │ │ ├── java/com/example/score/ │ │ │ ├── servlet/ // 所有Servlet入口 │ │ │ ├── dao/ // JDBC封装层不引入MyBatis │ │ │ ├── model/ // POJO字段名与数据库表完全一致 │ │ │ └── util/ // DBUtil含连接池、DateUtil等工具类 │ │ └── webapp/ │ │ ├── WEB-INF/ │ │ │ ├── web.xml // 配置Servlet映射、过滤器、监听器 │ │ │ └── jsp/ // 所有JSP页面禁止放在webapp根目录 │ │ └── static/ // CSS/JS/IMG静态资源不走Servlet └── pom.xml // Maven仅管理mysql-connector-java和jstl提示web.xml中必须显式声明welcome-file-list指向/jsp/index.jsp否则Tomcat 9默认不识别index.jsp为首页JSP页面严禁直接写Java代码块% %所有业务逻辑必须收口到ServletJSP只做展示EL表达式JSTL标签。2.2 数据库建模用真实教务约束反推表结构成绩系统最易翻车的不是代码而是表设计。我们按实际业务倒推一名学生可修多门课一门课可由多名教师讲授同一门课不同教师可能给不同班级授课成绩需记录录入人、录入时间、是否已审核。因此必须拆分四张主表表名关键字段精简设计要点studentid(PK),name,class_id(FK)class_id非空强制学生归属班级teacherid(PK),name,subject教师按学科划分非按院系courseid(PK),name,credit课程无外键解耦教师与课程绑定score_recordid(PK),student_id,course_id,teacher_id,score,record_time,statusstatus枚举0草稿,1已提交,2已审核联合唯一索引(student_id,course_id,teacher_id)防重复录入执行建表SQL前务必在MySQL中执行SET GLOBAL sql_modeSTRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION;否则score字段插入空字符串会静默转为0导致成绩异常无法追溯。2.3 核心Servlet链路以“录入成绩”为例走通一次完整请求流用户点击“录入成绩”按钮 → 前端提交表单至/score/add→AddScoreServlet接收 → 校验参数 → 调用ScoreDAO.insert()→ 返回JSON结果。关键不在代码量而在每一步的防御性处理// src/main/java/com/example/score/servlet/AddScoreServlet.java protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 强制登录态校验非JWT用HttpSession if (req.getSession().getAttribute(user) null) { resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, 未登录); return; } // 2. 参数白名单过滤防SQL注入/脚本攻击 String studentId req.getParameter(student_id); String courseId req.getParameter(course_id); String teacherId req.getParameter(teacher_id); String scoreStr req.getParameter(score); // 3. 业务级校验学生是否存在课程是否开放教师是否有授课权限 Student student studentDAO.findById(studentId); if (student null) { sendJsonError(resp, 学生不存在); return; } // 4. 成绩数值校验必须是0-100间数字且保留一位小数 BigDecimal score; try { score new BigDecimal(scoreStr).setScale(1, RoundingMode.HALF_UP); if (score.compareTo(BigDecimal.ZERO) 0 || score.compareTo(new BigDecimal(100)) 0) { sendJsonError(resp, 成绩必须在0-100之间); return; } } catch (NumberFormatException e) { sendJsonError(resp, 成绩格式错误); return; } // 5. 写入数据库开启事务 Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); ScoreRecord record new ScoreRecord(); record.setStudentId(studentId); record.setCourseId(courseId); record.setTeacherId(teacherId); record.setScore(score); record.setStatus(0); // 草稿状态 record.setRecordTime(new Date()); scoreDAO.insert(conn, record); conn.commit(); sendJsonSuccess(resp, 录入成功等待审核); } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } sendJsonError(resp, 系统繁忙请重试); } finally { DBUtil.closeConnection(conn); } }逻辑说明sendJsonError()和sendJsonSuccess()是自定义方法统一设置Content-Type: application/json;charsetUTF-8避免前端解析失败DBUtil.getConnection()返回的是HikariCP连接池实例不是每次new DriverManagerrecord.setStatus(0)设为草稿而非直接提交体现教务流程中“录入-审核”分离setScale(1, RoundingMode.HALF_UP)强制保留一位小数避免数据库DECIMAL(5,2)字段存入0.333时被截断为0.33。3. JSP页面与前端交互用原生HTMLJSTL规避框架幻觉3.1 页面分层原则JSP只做三件事——取值、遍历、条件渲染拒绝在JSP里写Java逻辑所有数据必须由Servlet通过request.setAttribute()传入。以“成绩列表页”为例Servlet查询后ListScoreRecord records scoreDAO.findByStudentId(studentId); request.setAttribute(records, records); request.setAttribute(studentName, student.getName()); request.getRequestDispatcher(/jsp/score/list.jsp).forward(req, resp);对应JSP页面/jsp/score/list.jsp% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % !DOCTYPE html html head title成绩列表 - ${studentName}/title link relstylesheet href${pageContext.request.contextPath}/static/css/common.css /head body h2${studentName}的成绩单/h2 !-- 1. 用c:if做空数据兜底 -- c:if test${empty records} p classempty-tip暂无成绩记录/p /c:if !-- 2. 用c:forEach遍历禁止for循环 -- c:forEach items${records} varr varStatusstatus div classscore-item strong${r.courseName}/strong span classscore-value${r.score}/span span classstatus-badge c:choose c:when test${r.status 0}草稿/c:when c:when test${r.status 1}已提交/c:when c:otherwise已审核/c:otherwise /c:choose /span small录入时间${r.recordTime}/small /div /c:forEach !-- 3. 分页链接用EL表达式拼接不写JavaScript -- c:if test${not empty records} div classpagination a href?page1首页/a a href?page${currentPage-1}上一页/a span第${currentPage}页/span a href?page${currentPage1}下一页/a /div /c:if /body /html参数说明${pageContext.request.contextPath}是动态获取应用上下文路径避免硬编码/student-score-systemc:forEach的varStatusstatus提供索引、总数等元信息可用于奇偶行样式控制classstatus-badge对应CSS中.status-badge{display:inline-block;padding:2px 8px;border-radius:3px;}实现状态标签视觉化。3.2 表单提交与错误反馈用同步刷新URL参数传递错误信息现代前端爱用AJAX但JavaWeb原生项目用同步提交更利于调试。当录入成绩失败时Servlet不返回JSON而是重定向回原表单页并附带错误参数// AddScoreServlet.java 中校验失败时 resp.sendRedirect(req.getContextPath() /jsp/score/add.jsp?errorscore_formatstudent_id studentId);对应JSP表单页/jsp/score/add.jspc:if test${not empty param.error} div classalert alert-error c:choose c:when test${param.error score_format}成绩格式错误请输入0-100之间的数字/c:when c:when test${param.error duplicate}该学生此课程成绩已存在/c:when /c:choose /div /c:if form action${pageContext.request.contextPath}/score/add methodpost input typehidden namestudent_id value${param.student_id} label课程select namecourse_id.../select/label label成绩input typetext namescore value${param.score}/label button typesubmit提交录入/button /form优势错误信息直接嵌入URL刷新页面不丢失value${param.score}保持用户已输入内容避免重填不依赖JavaScript禁用JS的浏览器也能用。4. 权限与安全不用Shiro/Spring Security手写三层拦截器4.1 角色路由隔离用Filter实现URL级权限控制系统只有三类用户学生只能查自己成绩、教师可录自己所教课程成绩、管理员全权限。不引入复杂权限框架用Filter链实现// src/main/java/com/example/score/filter/RoleFilter.java public class RoleFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); HttpSession session request.getSession(false); // 未登录用户统一跳转登录页 if (session null || session.getAttribute(user) null) { response.sendRedirect(request.getContextPath() /jsp/login.jsp); return; } User user (User) session.getAttribute(user); String role user.getRole(); // student, teacher, admin // 按URI前缀匹配权限 if (uri.startsWith(/jsp/score/add) || uri.startsWith(/score/add)) { if (!teacher.equals(role) !admin.equals(role)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, 无权录入成绩); return; } } if (uri.startsWith(/jsp/admin/) || uri.startsWith(/admin/)) { if (!admin.equals(role)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, 管理员专属); return; } } chain.doFilter(req, resp); } }在web.xml中注册filter filter-nameRoleFilter/filter-name filter-classcom.example.score.filter.RoleFilter/filter-class /filter filter-mapping filter-nameRoleFilter/filter-name url-pattern/jsp/*/url-pattern url-pattern/score/*/url-pattern url-pattern/admin/*/url-pattern /filter-mapping注意Filter必须在web.xml中早于Servlet映射声明否则拦截不到/jsp/*路径必须显式配置因为JSP默认不经过Filter。4.2 防CSRF与XSS在表单和输出环节双重加固CSRF防护所有修改类表单录入、删除、审核必须携带一次性Token。Servlet生成并存入SessionString csrfToken UUID.randomUUID().toString().replace(-, ); session.setAttribute(csrf_token, csrfToken); request.setAttribute(csrf_token, csrfToken);JSP表单中添加隐藏域input typehidden namecsrf_token value${csrf_token}提交时Servlet校验String tokenFromForm req.getParameter(csrf_token); String tokenInSession (String) session.getAttribute(csrf_token); if (!Objects.equals(tokenFromForm, tokenInSession)) { sendJsonError(resp, 非法请求); return; }XSS防护所有用户输入内容如学生姓名、课程名在JSP中输出时必须用JSTL的c:out标签c:out value${r.studentName} escapeXmltrue/escapeXmltrue默认值会将script转义为lt;scriptgt;彻底阻断脚本执行。5. 避坑指南那些让90%新手卡住3小时以上的具体问题5.1 现象Tomcat启动后访问/jsp/index.jsp显示404但/index.jsp能打开原因web.xml中welcome-file-list未正确配置或index.jsp文件实际路径不在webapp/根目录下。Tomcat 9默认只识别webapp/下的index.jsp为欢迎页若你把它放在webapp/jsp/目录则必须在web.xml中显式声明welcome-file-list welcome-filejsp/index.jsp/welcome-file /welcome-file-list解决检查web.xml的welcome-file-list是否包含jsp/index.jsp并确认文件物理路径与声明路径完全一致。5.2 现象成绩录入后数据库有数据但JSP列表页不显示刷新后才出现原因浏览器缓存了JSP页面的响应。Tomcat对JSP的默认缓存策略是Cache-Control: private但某些浏览器仍会缓存GET请求结果。解决在所有JSP页面顶部添加禁用缓存头% response.setHeader(Cache-Control, no-cache, no-store, must-revalidate); response.setHeader(Pragma, no-cache); response.setDateHeader(Expires, 0); %5.3 现象c:forEach遍历ListScoreRecord时JSP报错javax.el.PropertyNotFoundException: Property courseName not found原因ScoreRecord类中缺少getCourseName()方法或方法名大小写不规范如写成getcoursename()。JSP的EL表达式调用JavaBean属性遵循标准命名规范courseName对应getCourseName()。解决检查ScoreRecord类确保每个要输出的字段都有符合JavaBean规范的getter方法且方法名首字母大写。5.4 现象MySQL插入成绩时score字段存入0.0而非用户输入的85.5原因score字段在数据库中定义为DECIMAL(5,2)但Java代码中用Double.parseDouble()转换后未做精度控制导致浮点误差。例如Double.parseDouble(85.5)可能存储为85.49999999999999存入DECIMAL(5,2)时被截断为85.49。解决强制使用BigDecimal构造并指定精度BigDecimal score new BigDecimal(scoreStr).setScale(2, RoundingMode.HALF_UP);同时确保数据库字段精度与Java代码一致此处用DECIMAL(5,2)。5.5 现象登录后HttpSession在多次请求间丢失session.getAttribute(user)始终为null原因浏览器禁用了Cookie或Tomcat的context.xml中Context标签设置了cookiesfalse。JavaWeb依赖JSESSIONID Cookie维持会话禁用Cookie则每次请求都是新Session。解决检查浏览器Cookie设置若必须禁用Cookie启用URL重写在web.xml中添加session-config tracking-modeURL/tracking-mode /session-config并在所有超链接中使用response.encodeURL()包装a href${response.encodeURL(/jsp/score/list.jsp)}查看成绩/a6. 进阶技巧用Excel批量导入成绩的健壮实现与容错设计6.1 为什么不能直接用Apache POI读取上传的Excel因为真实教务场景中Excel文件充满“人祸”表头行缺失、空行混入、成绩列含文字如“缺考”、“缓考”、日期格式混乱、合并单元格。POI原生API读取时遇到这些情况会直接抛NullPointerException或IllegalStateException导致整个导入任务中断。我们必须构建一层“抗压解析层”。6.2 四步容错解析法从文件到数据库的平滑过渡我们不追求100%自动修复而是明确三类处理策略跳过无效行、警告可修正数据、阻断严重错误。核心逻辑封装在ExcelScoreImporter.java中public class ExcelScoreImporter { // 1. 定义可接受的表头别名兼容常见错别字 private static final SetString VALID_HEADERS Set.of( 学号, 学生编号, student_id, sno, 课程名, 课程名称, course_name, cname, 成绩, 分数, score, cj ); public ImportResult importFromFile(InputStream is) { ImportResult result new ImportResult(); try (Workbook wb WorkbookFactory.create(is)) { Sheet sheet wb.getSheetAt(0); // 步骤1定位表头行最多扫描前5行 int headerRowNum findHeaderRow(sheet); if (headerRowNum -1) { result.addError(未找到有效表头请确认第一行包含学号、课程名、成绩等字段); return result; } Row headerRow sheet.getRow(headerRowNum); MapString, Integer headerMap buildHeaderMap(headerRow); // 步骤2逐行解析跳过空行和标题行 for (int i headerRowNum 1; i sheet.getLastRowNum(); i) { Row row sheet.getRow(i); if (row null || isBlankRow(row)) continue; try { ScoreRecord record parseRow(row, headerMap, result); if (record ! null) { result.addSuccess(record); } } catch (Exception e) { result.addError(第 (i 1) 行解析失败 e.getMessage()); } } } catch (Exception e) { result.addError(文件解析异常 e.getMessage()); } return result; } private int findHeaderRow(Sheet sheet) { for (int i 0; i Math.min(5, sheet.getLastRowNum() 1); i) { Row row sheet.getRow(i); if (row null) continue; for (int j 0; j Math.min(10, row.getLastCellNum()); j) { Cell cell row.getCell(j); if (cell ! null isValidHeaderCell(cell)) { return i; } } } return -1; } private boolean isValidHeaderCell(Cell cell) { if (cell.getCellType() CellType.STRING) { String val cell.getStringCellValue().trim(); return VALID_HEADERS.stream().anyMatch(alias - alias.equalsIgnoreCase(val) || val.contains(alias) ); } return false; } private ScoreRecord parseRow(Row row, MapString, Integer headerMap, ImportResult result) { String studentId getCellValue(row, headerMap.get(学号)); String courseName getCellValue(row, headerMap.get(课程名)); String scoreStr getCellValue(row, headerMap.get(成绩)); if (StringUtils.isBlank(studentId) || StringUtils.isBlank(courseName)) { result.addWarning(第 (row.getRowNum() 1) 行学号或课程名为空已跳过); return null; } // 处理“缺考”、“缓考”等文本成绩 BigDecimal score; if (缺考.equals(scoreStr) || 缓考.equals(scoreStr)) { score BigDecimal.ZERO; // 或设为-1表示特殊状态 } else { try { score new BigDecimal(scoreStr).setScale(1, RoundingMode.HALF_UP); if (score.compareTo(BigDecimal.ZERO) 0 || score.compareTo(new BigDecimal(100)) 0) { result.addWarning(第 (row.getRowNum() 1) 行成绩 scoreStr 超出0-100范围已设为0); score BigDecimal.ZERO; } } catch (NumberFormatException e) { result.addWarning(第 (row.getRowNum() 1) 行成绩 scoreStr 格式错误已设为0); score BigDecimal.ZERO; } } ScoreRecord record new ScoreRecord(); record.setStudentId(studentId); record.setCourseName(courseName); record.setScore(score); record.setStatus(1); // 直接设为已提交跳过草稿 return record; } private String getCellValue(Row row, Integer colIndex) { if (colIndex null || row null) return ; Cell cell row.getCell(colIndex); if (cell null) return ; switch (cell.getCellType()) { case STRING: return cell.getStringCellValue().trim(); case NUMERIC: if (DateUtil.isCellDateFormatted(cell)) { return new SimpleDateFormat(yyyy-MM-dd).format(cell.getDateCellValue()); } else { return String.valueOf((long) cell.getNumericCellValue()); } default: return ; } } }关键设计点findHeaderRow()最多扫描前5行避免因Excel前几行是说明文字而找不到表头isValidHeaderCell()支持表头别名匹配如用户上传文件表头写“学生编号”也能识别parseRow()中对“缺考”等文本成绩做语义转换而非直接报错所有警告addWarning和错误addError都记录行号方便用户定位ImportResult对象聚合所有结果Servlet最终返回JSON供前端渲染导入报告。6.3 前端导入报告页用纯HTML/CSS呈现结构化反馈导入完成后Servlet跳转至/jsp/import/report.jsp传入ImportResult对象c:if test${not empty result.errors} div classreport-section error h3❌ 导入失败${fn:length(result.errors)}处/h3 ul c:forEach items${result.errors} varerr li${err}/li /c:forEach /ul /div /c:if c:if test${not empty result.warnings} div classreport-section warning h3⚠️ 导入警告${fn:length(result.warnings)}处/h3 ul c:forEach items${result.warnings} varwarn li${warn}/li /c:forEach /ul /div /c:if c:if test${not empty result.successes} div classreport-section success h3✅ 导入成功${fn:length(result.successes)}条/h3 p已录入以下成绩/p table classimport-table thead trth学号/thth课程名/thth成绩/th/tr /thead tbody c:forEach items${result.successes} vars tr tdc:out value${s.studentId}//td tdc:out value${s.courseName}//td tdc:out value${s.score}//td /tr /c:forEach /tbody /table /div /c:if效果用户一眼看到成功多少条、警告多少处、失败在哪里无需翻日志。警告信息如“第15行成绩‘优秀’格式错误已设为0”比单纯报错“NumberFormatException”有用十倍。我带过的某高校实训班学生第一次写出带行号定位的Excel导入报告页时当场有人拍桌“原来报错信息还能这么写”——这恰恰是工程能力的分水岭不满足于“能跑”而追求“可知、可控、可溯”。这套成绩管理系统真正的价值从来不在功能多全而在于它逼你直面每一个HTTP请求背后的脏数据、每一次数据库写入的事务边界、每一处浏览器缓存引发的UI不一致。它不教你如何造火箭但确保你拧紧每一颗螺丝时都清楚那颗螺丝为何而存在。希望帮到你。本文还有配套的精品资源点击获取