news 2026/11/2 15:35:37

Servlet+JSP+MySQL成绩管理系统:CRUD与ECharts统计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Servlet+JSP+MySQL成绩管理系统:CRUD与ECharts统计

简介:面向JavaWeb初学者、课程设计开发者与学生成绩管理场景的一套完整可运行源码包,采用Servlet、JSP和MySQL实现,并在基础版本上加入ECharts统计图表。系统围绕学生、教师、课程、成绩与用户信息维护展开,提供成绩录入查询、列表管理以及文件上传下载等常见功能,适合用作课程设计、毕业设计原型或JavaWeb分层开发练习。压缩包共344个文件、约3.59MB,包含86个Java源文件与86个class文件、37个JSP页面、62个JavaScript脚本、28个CSS样式,另有MySQL建库脚本、JAR依赖、字体与图片资源,分层目录便于对照阅读。已有6724人学习下载。读者可重点参考DAO、Service、Servlet分层写法、数据库增删改查、JSP页面渲染和ECharts成绩分布与趋势图的接入方式,从而理解从后端接口到前端可视化的完整链路,也能在此基础上做功能扩展与界面调整。

1. 从一张期末成绩单说起:Servlet+JSP+MySQL成绩管理系统能解决什么

期末周前后,教务办公室里最常见的一幕是:任课老师把几个 Excel 表发来发去,班主任想知道班级均分,学生又追着问某门课成绩,最后汇总的人一边对 VLOOKUP 一边怀疑人生。这个基于 Servlet+JSP+MySQL 的学生成绩管理系统就是冲这类场景来的:学生信息、课程、成绩录入、分页查询、按课程和分数段出 ECharts 统计图,一套 JavaWeb 老技术栈全包。它不适合高并发大平台,但特别适合课程设计、毕业项目二次开发,或者小团队内部先跑起来。反直觉的是,这种体量硬上 Spring Boot 反而增加配置负担;原生 Servlet+JSP 把请求链路摊在明面上,登录、CRUD、统计接口各自一个 Servlet,改起来直接,调试时也少一层黑匣子。下面按工程骨架、核心链路、图表落地和改造顺序拆一遍。

2. 技术栈与工程骨架:Servlet、JSP、MySQL三层怎么落目录

这个项目的技术栈很“直给”:Servlet 接请求、JSP 做服务端渲染、MySQL 存学生成绩数据,ECharts 只在统计页做前端图表。没有前端框架的构建链,也没有复杂拦截器栈,部署时把编译后的 class 和 JSP 丢进 Tomcat 就能跑。对二次开发的人来说,这种结构的价值是链路短:一个成绩查询请求从score_list.jsp发起,进ScoreServlet,调ScoreDao拿List<ScoreVO>,再转发回 JSP 渲染,出问题就这几层,不用在框架配置里绕。

2.1 为什么这个场景适合原生Servlet+JSP

学生成绩管理系统的功能边界很清楚:用户登录、学生管理、课程管理、成绩录入与修改、成绩分页查询、统计图展示。页面数量不多,交互以表单提交和表格展示为主,JSP 的 JSTL 循环刚好能把成绩列表铺出来。常见做法是 Servlet 只做控制器和参数校验,JSP 只做视图,DAO 封装所有 SQL,这样即使后面要加导出 Excel 或者改统计维度,也不会把 SQL 散落到 JSP 脚本里。

原生 Servlet+JSP 的另一个好处是依赖少。项目里通常只需要mysql-connector-java、JSTL 相关 jar,以及一个echarts.min.js静态文件。Tomcat 配好数据源或者项目里直接写 JDBC 工具类都能跑。对比 Spring Boot 那一套,少掉自动配置、起步依赖和打包插件,课程设计场景下反而更容易讲清楚“一个请求到底经过谁”。当然,JSP 页面里如果到处写<% %>脚本,后期会很难受,我一般会强制自己只留 JSTL 和 EL 表达式,业务判断全部回 Servlet。

选型上还要注意 Servlet 包名差异。老项目常见javax.servlet.*,Tomcat 10 之后换成了jakarta.servlet.*,如果直接把老代码丢进新容器,编译能过、运行报ClassNotFoundException的情况并不少见。下面代码按javax.servlet写,迁移时把 import 和依赖一起换掉,不要只改一半。

2.2 目录结构与各层职责

传统 JavaWeb 工程一般按src放 Java 源码,WebContent或webapp放 JSP、静态资源和WEB-INF。下面这张表是我拆这类项目时先对照的清单,路径名可能因 IDE 不同略有差异,但职责分层基本一致。

路径职责
src/com/demo/score/servlet登录、成绩、统计等 Servlet 控制器
src/com/demo/score/dao用户、学生、课程、成绩的 SQL 封装
src/com/demo/score/entityUser、Student、Course、ScoreVO等实体
src/com/demo/score/utilDBUtil、分页对象、JSON 拼装工具
src/com/demo/score/filter登录鉴权、编码过滤
WebContent/jsplogin.jsp、score_list.jsp、statistics.jsp
WebContent/static/jsecharts.min.js、统计页脚本
WebContent/WEB-INF/libMySQL 驱动、JSTL 等 jar
WebContent/WEB-INF/web.xmlServlet、Filter 映射和欢迎页

请求流转可以按登录后的成绩查询走一遍:浏览器访问login.jsp,表单提交到LoginServlet,UserDao.findByUsername查库,成功后把用户对象放进session,重定向到ScoreServlet?action=list。LoginFilter拦截/score/*和/statistics/*,没有 session 就回登录页。ScoreServlet调ScoreDao.queryPage,把PageResult<ScoreVO>放进 request,forward 到score_list.jsp。这条链路跑通,后面加统计图只是多一个 Servlet 和一个 JSP。

可执行步骤不复杂:新建 Dynamic Web Project,勾选生成web.xml;把 MySQL 驱动和 JSTL 放进WEB-INF/lib;在web.xml里配好欢迎页和过滤器;把echarts.min.js放到static/js下。不要先把所有页面都写完再联调,先让登录和一条成绩查询跑通,再补课程管理,最后接统计图,返工少很多。

2.3 数据库表与JDBC连接配置

成绩管理系统的表不用设计得很花,核心就是用户、学生、课程、成绩四张表。成绩表里加term学期字段和(student_id, course_id, term)唯一索引,能挡住同一学期同一门课重复录入。下面 SQL 是常见建表方式,字段长度按学校实际编码规则调,别直接照抄到生产。

CREATE DATABASE IF NOT EXISTS score_db DEFAULT CHARSET utf8mb4; CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT 'TEACHER', real_name VARCHAR(50), status TINYINT NOT NULL DEFAULT 1 ); CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(30) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, class_name VARCHAR(50), gender VARCHAR(10), phone VARCHAR(20) ); CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(80) NOT NULL, credit DECIMAL(4,1) DEFAULT 0, teacher_name VARCHAR(50) ); CREATE TABLE score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, score DECIMAL(5,2) NOT NULL, term VARCHAR(20) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course_term (student_id, course_id, term), CONSTRAINT fk_score_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_score_course FOREIGN KEY (course_id) REFERENCES course(id) ); INSERT INTO sys_user(username, password, role, real_name) VALUES ('admin', '123456', 'ADMIN', '管理员');

这段 SQL 的逻辑是先建库再建四张表,score表用外键约束学生和课程,避免成绩挂到不存在的记录上;唯一索引负责防重复录入。参数上,DECIMAL(5,2)能存 0 到 999.99 的分数,成绩场景够用;term用字符串是为了兼容“2024-2025-1”这类学期写法。示例管理员密码是明文,只适合本地跑通,真正给学生用之前至少加盐哈希,别把明文密码直接留在库里。

连接配置常见做法是放一个db.properties,不要把用户名密码写死在 Java 里。下面DBUtil用静态块加载驱动,提供获取连接和统一关闭资源的方法。小项目用DriverManager能跑,但并发一上来会频繁建连,后面避坑章会讲连接池的替换点。

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/score_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC&useSSL=false jdbc.username=root jdbc.password=root
public class DBUtil { private static final Properties PROPS = new Properties(); static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { PROPS.load(in); Class.forName(PROPS.getProperty("jdbc.driver")); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection( PROPS.getProperty("jdbc.url"), PROPS.getProperty("jdbc.username"), PROPS.getProperty("jdbc.password")); } public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r != null) { try { r.close(); } catch (Exception ignored) { } } } } }

DBUtil的逻辑是类加载时读db.properties、注册驱动,之后每次调getConnection拿新连接;close用可变参数统一关Connection、PreparedStatement、ResultSet,调用方不用写三层 try-catch。参数上,jdbc.url里的characterEncoding=UTF-8和useUnicode=true是中文成绩数据不乱码的前提,serverTimezone=UTC避免日期字段读写时区偏移。MySQL 8 驱动类用com.mysql.cj.jdbc.Driver,老版本驱动类名不同,别混着用。

注意:db.properties不要提交到公开仓库,里面是真库账号密码。本地跑通后把账号换成权限受限的库用户,别用 root 一直跑。

3. 核心链路拆解:登录鉴权、成绩CRUD与分页查询

成绩管理系统的业务重点不在页面多,而在几条链路要稳:登录后 session 不能丢,成绩录入不能把非法分数写进库,分页查询不能因为页码越界报 500。下面按登录、成绩保存、分页查询三段拆,每段都落到具体 Servlet 和 DAO 的写法上。把这几个点跑通,再去看统计图,排查范围会小很多。

3.1 登录Session鉴权:从login.jsp到LoginFilter

登录页只放用户名、密码和提交按钮,表单 action 指向LoginServlet。Servlet 里不做花哨逻辑:取参数、判空、调UserDao.findByUsername、比对密码、成功写 session、失败带回错误信息。下面代码是常见写法,密码比对先用明文,后面改造章会讲怎么换哈希。

@WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserDao userDao = new UserDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); if (username == null || username.trim().isEmpty() || password == null || password.trim().isEmpty()) { req.setAttribute("error", "用户名和密码不能为空"); req.getRequestDispatcher("/jsp/login.jsp").forward(req, resp); return; } User user = userDao.findByUsername(username.trim()); if (user == null || !password.equals(user.getPassword())) { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("/jsp/login.jsp").forward(req, resp); return; } req.getSession().setAttribute("loginUser", user); resp.sendRedirect(req.getContextPath() + "/score?action=list"); } }

这段逻辑的关键是登录成功后只往 session 放用户对象,不放密码;重定向而不是 forward,避免刷新页面重复提交表单。参数上,username和password来自登录表单,userDao.findByUsername内部必须用PreparedStatement,不要拼 SQL 字符串。失败时把错误信息放 request 再 forward 回登录页,页面用 EL 取error展示。

只有登录校验还不够,直接访问/score?action=list仍然能绕过登录页。常见做法加一个LoginFilter,拦截成绩和统计路径,从 session 取loginUser,为空就重定向到登录页。web.xml里配置 filter 的url-pattern为/score/*和/statistics/*,或者用@WebFilter注解。用注解时注意web.xml的metadata-complete不要设成 true,否则注解不生效。

3.2 成绩录入与修改:ScoreServlet的参数校验

成绩录入最容易翻车的地方是参数校验。前端input type="number"只能挡普通用户,接口被直接调用时仍然能传-10或者101。ScoreServlet里要把studentId、courseId、score、term都转成数字并做范围判断,分数限定 0 到 100,学期不能为空。下面代码按action分流,保存和修改共用一套校验。

@WebServlet("/score") public class ScoreServlet extends HttpServlet { private final ScoreDao scoreDao = new ScoreDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String action = req.getParameter("action"); if ("save".equals(action) || "update".equals(action)) { long studentId = Long.parseLong(req.getParameter("studentId")); long courseId = Long.parseLong(req.getParameter("courseId")); String term = req.getParameter("term"); BigDecimal score = new BigDecimal(req.getParameter("score")); if (score.compareTo(BigDecimal.ZERO) < 0 || score.compareTo(new BigDecimal("100")) > 0) { req.setAttribute("error", "分数必须在0到100之间"); req.getRequestDispatcher("/jsp/score_form.jsp").forward(req, resp); return; } Score entity = new Score(); entity.setStudentId(studentId); entity.setCourseId(courseId); entity.setTerm(term); entity.setScore(score); if ("update".equals(action)) { entity.setId(Long.parseLong(req.getParameter("id"))); scoreDao.update(entity); } else { scoreDao.insert(entity); } resp.sendRedirect(req.getContextPath() + "/score?action=list"); return; } if ("delete".equals(action)) { scoreDao.deleteById(Long.parseLong(req.getParameter("id"))); resp.sendRedirect(req.getContextPath() + "/score?action=list"); } } }

这段逻辑是按action区分新增、修改、删除,新增和修改都先做分数范围校验,再交给 DAO。参数上,studentId和courseId必须是库里真实存在的 id,外键会兜底,但页面下拉框应该只展示有效学生和课程;term建议用固定选项而不是自由输入,减少“2024第一学期”和“2024-1”混用。BigDecimal比double更适合成绩,避免 89.999 这类小数误差。

成绩保存还要处理重复录入。数据库唯一索引是最后一道门,DAO 里捕获SQLIntegrityConstraintViolationException后给页面返回“该学期该课程已录入”,比直接抛 500 友好。修改时不要允许改student_id和course_id到冲突组合,否则同样会撞唯一索引。

3.3 分页查询:DAO的limit offset与JSP渲染

成绩列表不能一次select *全查出来,学生成绩数据一多,页面会卡,内存也吃不住。常见做法是count(*)查总数,再limit ?, ?查当前页。下面queryPage把关键字、页码、每页条数收进来,返回一个PageResult,Servlet 只负责把结果放进 request。

public PageResult<ScoreVO> queryPage(String keyword, int page, int pageSize) { if (page < 1) page = 1; if (pageSize < 1 || pageSize > 100) pageSize = 10; String where = " WHERE 1=1 "; List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { where += " AND (s.name LIKE ? OR s.student_no LIKE ? OR c.course_name LIKE ?) "; String like = "%" + keyword.trim() + "%"; params.add(like); params.add(like); params.add(like); } String countSql = "SELECT COUNT(*) FROM score sc " + "JOIN student s ON sc.student_id = s.id " + "JOIN course c ON sc.course_id = c.id " + where; String dataSql = "SELECT sc.id, s.student_no, s.name AS studentName, " + "c.course_name AS courseName, sc.score, sc.term " + "FROM score sc JOIN student s ON sc.student_id = s.id " + "JOIN course c ON sc.course_id = c.id " + where + " ORDER BY sc.id DESC LIMIT ?, ?"; int total = 0; List<ScoreVO> rows = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement countPs = conn.prepareStatement(countSql); PreparedStatement dataPs = conn.prepareStatement(dataSql)) { for (int i = 0; i < params.size(); i++) { countPs.setObject(i + 1, params.get(i)); dataPs.setObject(i + 1, params.get(i)); } try (ResultSet rs = countPs.executeQuery()) { if (rs.next()) total = rs.getInt(1); } int offset = (page - 1) * pageSize; dataPs.setInt(params.size() + 1, offset); dataPs.setInt(params.size() + 2, pageSize); try (ResultSet rs = dataPs.executeQuery()) { while (rs.next()) { ScoreVO vo = new ScoreVO(); vo.setId(rs.getLong("id")); vo.setStudentNo(rs.getString("student_no")); vo.setStudentName(rs.getString("studentName")); vo.setCourseName(rs.getString("courseName")); vo.setScore(rs.getBigDecimal("score")); vo.setTerm(rs.getString("term")); rows.add(vo); } } } catch (SQLException e) { throw new RuntimeException("分页查询成绩失败", e); } PageResult<ScoreVO> result = new PageResult<>(); result.setRows(rows); result.setTotal(total); result.setPage(page); result.setPageSize(pageSize); result.setTotalPages((total + pageSize - 1) / pageSize); return result; }

这段逻辑先拼where条件和参数列表,再分别执行 count 和 data 两条 SQL;offset按(page - 1) * pageSize算,page小于 1 时强行拉回第一页,pageSize超过 100 时压回 100,防止有人传pageSize=99999拖垮查询。参数上,keyword同时匹配学生姓名、学号和课程名;params.size() + 1和+2是给LIMIT占位符留位置,别把参数顺序搞反。

JSP 侧用 JSTL 循环渲染表格,分页链接只传page和keyword。页码越界时 Servlet 里判断page > totalPages就跳到最后一页,别让 DAO 查出一个空列表还显示“第 99 页”。表格列建议固定:学号、姓名、课程、学期、分数、操作。操作列只放修改和删除,删除加确认,避免误点。

提示:分页查询的ORDER BY要稳定,最好带主键排序。只按分数排序时同分记录顺序可能变,翻页会出现同一条记录重复出现。

4. ECharts统计图落地:从SQL聚合到前端图表配置

新增的 ECharts 统计图是这个项目里最容易出效果、也最容易空白的一块。后端不需要把明细全丢给前端,按课程算平均分、按分数段算人数,两个聚合结果足够撑起柱状图和饼图。统计接口单独放StatisticsServlet,JSP 只负责初始化图表和发请求,SQL 聚合和 JSON 输出都在后端收口。

4.1 统计接口设计:按课程平均分与分数段聚合

统计页一般看两个维度:每门课程的平均分,以及全部分数落在 0-59、60-69、70-79、80-89、90-100 的人数。平均分用GROUP BY一门课一行,分数段用CASE WHEN打标签再分组。下面 SQL 可以直接在数据库客户端跑一遍,确认有数据再写进 DAO。

-- 按课程统计平均分、最高分、最低分 SELECT c.course_name, ROUND(AVG(sc.score), 2) AS avg_score, MAX(sc.score) AS max_score, MIN(sc.score) AS min_score FROM score sc JOIN course c ON sc.course_id = c.id GROUP BY c.id, c.course_name ORDER BY c.id; -- 按分数段统计人数 SELECT CASE WHEN score < 60 THEN '0-59' WHEN score < 70 THEN '60-69' WHEN score < 80 THEN '70-79' WHEN score < 90 THEN '80-89' ELSE '90-100' END AS segment, COUNT(*) AS cnt FROM score GROUP BY segment ORDER BY FIELD(segment, '0-59', '60-69', '70-79', '80-89', '90-100');

这段 SQL 的逻辑是第一条按课程聚合平均分和最高最低分,第二条用CASE把连续分数映射成固定区间再计数。参数上,ROUND(AVG(sc.score), 2)保留两位小数,分数段顺序用FIELD固定,不然分组结果可能按字符串排序,前端饼图颜色顺序会乱。统计接口不要查明细再在 Java 里循环算平均分,数据量一大就慢,聚合交给 MySQL 更稳。

如果统计页要支持按学期筛选,给两条 SQL 都加WHERE sc.term = ?,参数从请求里取。学期为空时不要拼term = null,直接不加条件。统计结果为空时返回空数组,前端显示“暂无数据”,不要返回null让 ECharts 自己猜。

4.2 后端JSON输出:StatisticsServlet的拼装与异常兜底

统计接口返回 JSON,Content-Type设成application/json;charset=UTF-8。小项目可以不引 Jackson/Gson,用StringBuilder拼,但字段值必须转义,课程名里出现引号就会把 JSON 拼坏。下面代码用简单拼装演示结构,生产环境建议换成熟 JSON 库。

@WebServlet("/statistics/data") public class StatisticsServlet extends HttpServlet { private final StatisticsDao statisticsDao = new StatisticsDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("application/json;charset=UTF-8"); try { List<CourseStatVO> courseStats = statisticsDao.courseAvg(); Map<String, Integer> segments = statisticsDao.scoreSegments(); StringBuilder json = new StringBuilder(); json.append("{\"courseNames\":["); for (int i = 0; i < courseStats.size(); i++) { if (i > 0) json.append(","); json.append("\"").append(escape(courseStats.get(i).getCourseName())).append("\""); } json.append("],\"avgScores\":["); for (int i = 0; i < courseStats.size(); i++) { if (i > 0) json.append(","); json.append(courseStats.get(i).getAvgScore()); } json.append("],\"segmentLabels\":[\"0-59\",\"60-69\",\"70-79\",\"80-89\",\"90-100\"],"); json.append("\"segmentValues\":["); String[] labels = {"0-59", "60-69", "70-79", "80-89", "90-100"}; for (int i = 0; i < labels.length; i++) { if (i > 0) json.append(","); json.append(segments.getOrDefault(labels[i], 0)); } json.append("]}"); resp.getWriter().write(json.toString()); } catch (Exception e) { resp.setStatus(500); resp.getWriter().write("{\"error\":\"统计查询失败\"}"); } } private String escape(String value) { return value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\""); } }

这段逻辑是先查课程聚合和分数段两个结果,再按前端约定的字段拼 JSON;异常时返回 500 和错误 JSON,不让 Tomcat 默认错误页把 HTML 塞给前端。参数上,courseNames和avgScores两个数组长度必须一致,ECharts 柱状图按索引对齐;segmentValues固定按 0-59 到 90-100 的顺序输出,缺的区间补 0。escape只处理了反斜杠和双引号,真实项目用 JSON 库更省心。

统计 DAO 里注意AVG返回BigDecimal,COUNT返回long,别用getInt接平均分。分数段结果用Map<String, Integer>接,SQL 里没出现的区间在前端补 0,不然饼图会少一块。

4.3 前端ECharts配置:柱状图与饼图联动

统计页放两个容器,一个柱状图一个饼图,引入echarts.min.js后用fetch拉/statistics/data。下面配置里柱状图展示课程平均分,饼图展示分数段人数,两个图共用一次请求结果。

// statistics.jsp 中引入 static/js/echarts.min.js 后执行 const barChart = echarts.init(document.getElementById('avgChart')); const pieChart = echarts.init(document.getElementById('segmentChart')); fetch(contextPath + '/statistics/data') .then(response => response.json()) .then(data => { if (!data.courseNames || data.courseNames.length === 0) { barChart.setOption({ title: { text: '暂无成绩数据', left: 'center', top: 'middle' } }); return; } barChart.setOption({ title: { text: '各课程平均分' }, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: data.courseNames }, yAxis: { type: 'value', min: 0, max: 100 }, series: [{ name: '平均分', type: 'bar', data: data.avgScores, barMaxWidth: 42, label: { show: true, position: 'top' } }] }); pieChart.setOption({ title: { text: '分数段人数分布' }, tooltip: { trigger: 'item' }, legend: { bottom: 0 }, series: [{ name: '人数', type: 'pie', radius: ['40%', '68%'], data: data.segmentLabels.map((label, index) => ({ name: label, value: data.segmentValues[index] })) }] }); }) .catch(() => { barChart.setOption({ title: { text: '统计加载失败', left: 'center', top: 'middle' } }); }); window.addEventListener('resize', () => { barChart.resize(); pieChart.resize(); });

这段逻辑是一次请求拿两个图的数据,柱状图xAxis.data放课程名,series.data放平均分;饼图用map把标签和人数拼成 ECharts 需要的{name, value}结构。参数上,contextPath是 JSP 里通过<%= request.getContextPath() %>拿到的项目根路径,不要写死/statistics/data;yAxis.max固定 100,避免平均分都在 80 多时柱子顶满看起来没差别。空数据时不要硬渲染 0 数组,直接给标题提示,图表区域不会一片空白。

两个图放在同一行时,容器要给固定高度,比如height: 360px,宽度用百分比。ECharts 容器没有高度是最常见的空白原因之一。窗口缩放时调resize(),不然侧边栏收起后图表还按旧尺寸显示。

5. 避坑与排查:部署、中文乱码、连接池和图表空白

这类原生 JavaWeb 项目跑不起来,问题往往不在业务代码,而在环境、编码和请求路径。下面几条是拆成绩管理系统时反复遇到的翻车记录,每条按现象、原因、解决写,后面再给一个从日志到 SQL 再到前端的排查顺序。

5.1 部署与编码类问题:三条高频翻车记录

现象:登录页能打开,提交后成绩列表中文全是问号,学生姓名显示成???。原因通常是三层编码没统一:JSP 页面pageEncoding和contentType不是 UTF-8,Servlet 里没设request.setCharacterEncoding("UTF-8"),MySQL 连接串缺characterEncoding=UTF-8,或者数据库表本身不是utf8mb4。解决时从后往前查:先确认库和表字符集,再看连接串,再看EncodingFilter,最后看 JSP 头部。EncodingFilter里在doFilter开头设请求和响应编码,web.xml里放在最前面。

现象:项目在 IDE 里点运行正常,单独部署到 Tomcat 后 404。原因多半是上下文路径和web.xml欢迎页不一致,或者@WebServlet注解没生效。web.xml如果metadata-complete="true",注解会被忽略,Servlet 映射只能靠 XML;另外访问地址要带项目名,比如/score/login,不是/login。解决时先看 Tomcatlogs下启动日志有没有 Servlet 映射异常,再确认web.xml的url-pattern和表单 action 是否一致。路径问题没有玄学,逐层打印req.getContextPath()和req.getServletPath()最快。

现象:本地跑得好好的,换一台机器启动报No suitable driver found或Access denied。原因通常是驱动 jar 没进WEB-INF/lib,或者db.properties里账号密码、端口、库名还是原来那台机器的。解决时先看异常堆栈第一行是驱动类加载失败还是连接认证失败;驱动问题检查 jar 是否在运行时 classpath,认证问题用数据库客户端拿同一套账号密码连一次。DriverManager方式下,驱动类名写错也会报这个,MySQL 8 和老版本驱动类名不同,别凭记忆抄。

注意:部署到 Tomcat 后改db.properties,要重新编译或确认文件被复制到WEB-INF/classes,只改源码目录里的文件不一定生效。

5.2 数据与图表类问题:两条定位路径

现象:统计页两个图都是空白,浏览器控制台没有明显报错。原因常见有三种:fetch请求 404,接口返回的不是 JSON,或者 ECharts 容器高度为 0。解决时先打开浏览器 Network 看/statistics/data的状态码和响应体,404 就查 Servlet 映射和contextPath;状态 200 但响应是 HTML,说明请求被登录过滤器重定向到了登录页,检查 session;响应是 JSON 但图不显示,检查容器height和echarts.init是否在 DOM 渲染后执行。把fetch的catch里加一行console.error,比盯着空白页猜快。

现象:统计图有数据,但平均分和手工算的对不上。原因多半是 SQL 聚合范围不对,比如AVG时 join 了学生表导致重复行,或者分数段CASE的边界写成了<= 60,把 60 分算进了不及格。解决时把统计 SQL 复制到数据库客户端,先select *看明细,再跑聚合,逐步缩小范围;分数段边界统一用< 60、< 70这种左闭右开写法,和页面标签对应。补考、重修数据如果也在score表里,要想清楚统计的是原始成绩还是最终成绩,别把两条记录一起平均。

提示:统计接口返回前把课程名和平均分打印到日志,前端拿到的数据和服务端算的对不上时,先确认是哪一段偏了,不要直接改 ECharts 配置。

5.3 排查顺序:先日志后SQL再前端

遇到成绩管理系统跑不通,我一般按固定顺序走,避免东改一下西改一下。第一步看 Tomcat 日志和浏览器 Network,确认请求有没有到 Servlet、状态码是多少、响应体是什么。第二步看参数,把action、page、keyword、studentId这些关键参数在 Servlet 入口打印出来,很多问题是参数名和表单name对不上。第三步把 DAO 里的 SQL 和参数复制到数据库客户端执行,确认数据层没问题。第四步才看 JSP 和 ECharts,检查 EL 表达式取不到值、容器高度、JS 报错。

连接池这块也提前留个替换点。DBUtil用DriverManager跑课程设计没问题,但成绩录入并发一高,频繁建连会拖慢响应。常见做法是换成连接池,把getConnection内部改成从池里取,外部 DAO 不用动。换的时候注意池的关闭方法不是close连接,而是归还连接,别把连接池关掉。这个改造放在二次开发章更合适,先保证当前链路跑通。

6. 二次开发:把成绩管理改成可扩展的教学数据看板

这份源码包跑通之后,最值得动的不是页面样式,而是把 DAO 和权限收一收。成绩管理系统很容易被改成“教学数据看板”,但前提是 SQL 不要散在 Servlet 里,角色不要只靠一个 session 判断。下面三个改造点按优先级排,先做接口化,再做权限,最后用一张检查表验证。

6.1 DAO接口化:把硬编码SQL收进实现类

现在ScoreDao里直接写 SQL,改统计维度时容易复制出第二套查询。更稳的做法是抽ScoreDao接口,把分页、插入、更新、删除、统计聚合定义成方法,JdbcScoreDao做实现。这样后面换连接池、加缓存或者写测试,都不用改 Servlet。

public interface ScoreDao { PageResult<ScoreVO> queryPage(String keyword, int page, int pageSize); int insert(Score score); int update(Score score); int deleteById(long id); List<CourseStatVO> courseAvg(String term); Map<String, Integer> scoreSegments(String term); }

接口里的term参数是为按学期筛选留的口子,传null表示全部学期。实现类里继续用PreparedStatement,不要因为接口化就把 SQL 拼成字符串。改造完先跑一遍原有成绩列表和统计页,确认返回结构没变,再动页面。

6.2 角色与权限:从单一登录到教师/学生分权

登录只判断用户名密码,所有登录用户都能改成绩,这在真实场景里不够。可以在sys_user.role上做文章:ADMIN管用户和课程,TEACHER只能录自己课程的成绩,STUDENT只能看自己的成绩。Servlet 里取session中的角色做分支,别把角色判断写进 JSP 的隐藏字段,前端参数不可信。

改造点现状改法验证
成绩修改权限登录即可改教师只能改自己课程用两个教师账号交叉改,应被拒绝
学生查询范围可看全部学生只查本人学号改 URL 里的学生 id,应查

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 15:34:09

从零实现SVM:纯NumPy手写支持向量机与决策边界可视化

简介&#xff1a;本资源是一份面向机器学习初学者与Python实践者的支持向量机&#xff08;SVM&#xff09;原理与实现教学包&#xff0c;聚焦算法核心逻辑的代码级解析与可运行验证。资源包含6个文件&#xff0c;涵盖3个关键Python源码&#xff08;含主算法svm.py、测试脚本tes…

作者头像 李华
网站建设 2026/11/2 15:29:49

规模化指纹浏览器怎么选?环境隔离与 API 并发的量化评估指南

规模化指纹浏览器指可同时承载数十至数千个独立浏览器环境、且能用程序批量驱动的防关联浏览器产品。本文面向企业客户与规模化运营团队给出选型结论&#xff1a;多开环境规模上到三位数后&#xff0c;应当用「环境隔离深度、订阅成本边际、API 并发能力」三个可量化维度做技术…

作者头像 李华
网站建设 2026/11/2 15:26:39

手搓 LVS-DR 负载均衡

手搓 LVS-DR 负载均衡&#xff1a;从原理到 6 节点双机轮询&#xff08;附 ARP 抑制详解&#xff09;前段时间从零搭了一套 6 节点高可用集群&#xff1a;2 台 LVS 调度器 2 台 Nginx 2 台 BIND DNS&#xff0c;用 Keepalived 做双活互备。 本篇是这个系列的第一篇&#xff1…

作者头像 李华
网站建设 2026/11/2 15:25:16

小Why的循环置换【牛客tracker 每日一题】

小Why的循环置换 时间限制&#xff1a;1 秒 空间限制&#xff1a;256M 网页链接 牛客tracker 牛客tracker & 每日一题&#xff0c;完成每日打卡&#xff0c;即可获得牛币。获得相应数量的牛币&#xff0c;能在【牛币兑换中心】&#xff0c;换取相应奖品&#xff01;助力每…

作者头像 李华