简介这是一套基于Servlet、JSP与MySQL开发的学生成绩管理系统JavaWeb源码包面向课程设计、毕业设计和项目实训人群用于解决成绩录入、查询、修改、统计及后台权限管理等常见业务需求。系统围绕管理员、教师、学生等角色组织功能涉及班级、课程、学生信息、成绩维护和统计报表等模块便于理解完整业务闭环。压缩包共344个文件约3.59MB含86个Java源文件、37个JSP页面、62个JavaScript脚本、28个CSS样式以及JAR依赖、SQL建表脚本和工程配置覆盖实体、DAO、Service、Servlet分层、文件上传下载与前端页面。相较原版新版加入ECharts统计图可直观展示成绩分布与趋势变化。已有6724人学习读者可据此梳理MVC请求流程、数据库表关系、前后端数据交互和图表接入方式也便于在此基础上二次开发、功能扩展与答辩演示准备。1. 从一份成绩单说起ServletJSPMySQL 这套组合还值不值得拆课程设计季一到后台就会冒出一类问题手里拿到一个基于 Servletjspmysql 开发的学生成绩管理系统压缩包里还带了一张 echarts 统计图双击能跑但打开代码就懵——DAO 里一个方法两百行JSP 里嵌着 Java 片段统计图的数据是写死在页面里的假数组。这类包的真实价值不在“能跑”而在于它是一个完整闭环的 CRUD 样本登录鉴权、分页查询、成绩录入、成绩修改、统计聚合、图表渲染每一环都对应一个以后一定会踩的坑。适合两类人一是要交课程设计、需要能讲清每一层为什么这么写的学生二是长期做业务系统、想找个轻量骨架对照自己代码的从业者。下面按“先建表、再分层、后接图”的顺序拆凡是能抄的地方都给到可直接运行的代码。2. 环境与库表先落地四张核心表和一段建表 SQL这套系统跑不起来八成不是代码问题是环境和表结构没对齐。我见过太多人在这一步翻车Tomcat 装了两个版本MySQL 驱动还是 5.x 的com.mysql.jdbc.Driver连 8.0 的库直接报Public Key Retrieval is not allowed。所以先把地基打平再谈业务。2.1 版本组合与依赖取舍原始项目没有锁死版本按这个场景下最省事的组合来JDK 8、Tomcat 8.5 或 9.0、MySQL 5.7 或 8.0、Servlet 3.1。选 Servlet 3.1 的好处是可以用WebServlet、WebFilter注解省掉一大坨web.xml配置但代价是 Tomcat 7 以下直接不认部署到老容器会 404。这个取舍要提前想清楚别写完才发现目标机器是 Tomcat 7。依赖层面这套东西刻意不引 Spring就是为了让人看清请求是怎么从浏览器走到 SQL 的。必要的 jar 只有几个依赖作用放置位置mysql-connector-java8.xJDBC 驱动WEB-INF/libdruid连接池WEB-INF/libjstlstandardJSP 标签库替代 Java 脚本片段WEB-INF/libservlet-api编译期用仅 scopeprovided不打进包gson或fastjson统计接口输出 JSONWEB-INF/lib最后一行是可选项。如果你只是做两三张图手拼 JSON 也行但字段一多就容易漏逗号、漏引号调试成本比引一个库高。我一般直接上 Gson两行代码解决序列化后面改字段不用动拼串逻辑。servlet-api千万别打进WEB-INF/libTomcat 自己有重复加载会报ClassCastException或者类加载冲突现象是过滤器突然不生效排查半天以为是注解没扫到。2.2 四张表的最小可用结构学生成绩管理的实体关系很固定学生、课程、成绩、用户。成绩表是核心它同时挂学生和课程所以必须有两个外键加一个联合唯一索引。没有这个唯一索引同一学期同一门课能被录两次统计出来的平均分直接失真。-- 学生表sno 是业务主键id 是物理主键 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL, gender TINYINT DEFAULT 1 COMMENT 1男 2女, class_id INT NOT NULL, status TINYINT DEFAULT 1 COMMENT 1在读 0离校 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 课程表 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, cname VARCHAR(80) NOT NULL, credit DECIMAL(3,1) DEFAULT 2.0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 成绩表联合唯一索引是防重复录入的最后一道闸 CREATE TABLE score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, term VARCHAR(20) NOT NULL COMMENT 如 2024-2025-1, score DECIMAL(5,2) NOT NULL, UNIQUE KEY uk_stu_course_term (student_id, course_id, term), KEY idx_course_term (course_id, term) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 用户表role 区分管理员和教师 CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT 存盐值哈希不存明文, role TINYINT DEFAULT 2 COMMENT 1管理员 2教师 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字符集统一utf8mb4不是utf8。utf8在 MySQL 里只有三字节存姓名里的生僻字、Emoji 会直接报错或截断等到页面上出现问号再回头改表数据已经脏了。idx_course_term这个索引是给统计查询用的按课程和学期聚合时能少扫很多行。score字段用DECIMAL(5,2)而不是FLOAT。浮点数在求和、求平均时会飘比如 89.1 89.2 可能得到 178.29999999999998页面上显示两位小数看不出来但做分数段统计时 90这种边界判断就会出错属于典型玄学问题。2.3 连接池与数据源初始化不用连接池也能跑但每次请求新建连接几十个人同时点查询就开始卡最后 Tomcat 线程池先撑不住。Druid 的配置文件放src/main/resources或src根目录看你用 Maven 还是传统 Web 项目。# druid.properties driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://127.0.0.1:3306/score_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue usernamescore_app password改成你自己的 initialSize5 minIdle5 maxActive20 maxWait3000 validationQuerySELECT 1 testWhileIdletrueURL 里四个参数一个都不能省。serverTimezone不写MySQL 8 驱动在存DATE类型时可能报时区异常useSSLfalse是本地开发关掉握手生产该开还得开allowPublicKeyRetrievaltrue是 MySQL 8 默认认证插件caching_sha2_password在非 SSL 连接下的必需项缺了就是那句经典的 Public Key Retrieval 报错。maxWait3000表示拿不到连接最多等 3 秒比默认无限等待好至少能在日志里看到超时而不是页面一直转圈。工具类用静态块初始化只加载一次public final class JdbcUtils { private static DataSource ds; static { try (InputStream in JdbcUtils.class.getClassLoader() .getResourceAsStream(druid.properties)) { Properties p new Properties(); p.load(in); ds DruidDataSourceFactory.createDataSource(p); } catch (Exception e) { // 初始化失败必须让启动阶段就炸不要吞掉 throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return ds.getConnection(); } }这里把异常包成ExceptionInInitializerError是有意为之。配置文件路径写错、密码不对这类问题如果只打印一行日志然后返回 null第一次请求就会抛空指针堆栈指向业务代码根本看不出是数据源没起来。让它在类加载阶段直接失败错误信息里带原始异常一眼定位。3. Servlet JSP 分层从 DAO 到分页查询的完整链路分层这件事课程设计里最容易写成“假分层”Servlet 里直接DriverManager.getConnectionJSP 里while(rs.next())输出表格。能跑但改一个字段要动三个文件。真正要拆的是三件事SQL 只出现在 DAO、参数校验只出现在 Servlet、页面只负责展示。3.1 分层边界怎么切一条请求的路径应该是JSP 表单 → Servlet 收参并校验 → DAO 执行 SQL → 返回List或分页对象 → Servlet 放进request→ 转发回 JSP。JSP 里不允许出现import java.sql.*也不允许出现Class.forName这是硬边界。分页对象单独封一个类别用Map传字段名打错编译器不会提醒public class PageResultT { private ListT rows; // 当前页数据 private int total; // 总记录数 private int pageNo; // 当前页码从 1 开始 private int pageSize; // 每页条数 public int getTotalPages() { return (total pageSize - 1) / pageSize; // 向上取整 } // 省略 getter/setter }getTotalPages用(total pageSize - 1) / pageSize而不是total / pageSize后者在 total21、pageSize10 时得 2实际需要 3 页最后一页的数据会永远显示不出来。这个 bug 很隐蔽因为只有总数不是整页倍数时才出现。3.2 DAO 与 try-with-resources成绩列表的查询要连三张表同时要算总数。常见的错误是把LIMIT也写进 count 语句里结果总数永远等于每页条数分页条只有一个「1」。public PageResultScoreVO queryPage(String term, Integer courseId, int pageNo, int pageSize) { StringBuilder where new StringBuilder( WHERE 11 ); ListObject args new ArrayList(); if (term ! null !term.isEmpty()) { where.append( AND sc.term ? ); args.add(term); } if (courseId ! null) { where.append( AND sc.course_id ? ); args.add(courseId); } String countSql SELECT COUNT(*) FROM score sc where; String dataSql SELECT s.sno, s.name, c.cname, sc.term, sc.score FROM score sc JOIN student s ON s.id sc.student_id JOIN course c ON c.id sc.course_id where ORDER BY sc.score DESC LIMIT ? OFFSET ?; PageResultScoreVO page new PageResult(); page.setPageNo(pageNo); page.setPageSize(pageSize); try (Connection conn JdbcUtils.getConnection()) { // 先查总数条件与数据查询完全一致 try (PreparedStatement ps conn.prepareStatement(countSql)) { for (int i 0; i args.size(); i) { ps.setObject(i 1, args.get(i)); } try (ResultSet rs ps.executeQuery()) { rs.next(); page.setTotal(rs.getInt(1)); } } // 再查当前页 try (PreparedStatement ps conn.prepareStatement(dataSql)) { int idx 1; for (Object a : args) { ps.setObject(idx, a); } ps.setInt(idx, pageSize); ps.setInt(idx, (pageNo - 1) * pageSize); try (ResultSet rs ps.executeQuery()) { ListScoreVO list new ArrayList(); while (rs.next()) { ScoreVO vo new ScoreVO(); vo.setSno(rs.getString(sno)); vo.setName(rs.getString(name)); vo.setCname(rs.getString(cname)); vo.setTerm(rs.getString(term)); vo.setScore(rs.getBigDecimal(score)); list.add(vo); } page.setRows(list); } } } catch (SQLException e) { throw new RuntimeException(分页查询失败, e); } return page; }几个关键点。where拼接的是列名和占位符参数全部走?绝不用字符串拼接用户输入这是 SQL 注入的唯一防线。args列表在两处按同样顺序回填保证了 count 和 data 的条件严格一致否则总数和列表对不上。OFFSET用(pageNo-1)*pageSize页码从 1 开始算如果前端传 0 要在这里兜一下否则负数 offset 直接报语法错误。try-with-resources三层嵌套看着丑但它是连接池不被耗尽的保证。ResultSet关掉才会释放游标Connection关掉其实是归还给池子少关一层压测几十轮就wait millis 3000, active 20。这个报错第一次见很懵其实就是没关连接。3.3 Servlet 的参数校验与转发Servlet 的职责只有三件拿参数、验参数、决定转发到哪个 JSP。业务计算不要写在这里否则这个方法会越滚越大。WebServlet(/score/list) public class ScoreListServlet extends HttpServlet { private final ScoreDao scoreDao new ScoreDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int pageNo parseInt(req.getParameter(pageNo), 1); int pageSize parseInt(req.getParameter(pageSize), 10); // 页码兜底负数、超大值都归一到合法区间 if (pageNo 1) pageNo 1; if (pageSize 1 || pageSize 100) pageSize 10; String term trimToNull(req.getParameter(term)); Integer courseId parseInteger(req.getParameter(courseId)); PageResultScoreVO page scoreDao.queryPage(term, courseId, pageNo, pageSize); req.setAttribute(page, page); req.setAttribute(term, term); req.setAttribute(courseId, courseId); req.getRequestDispatcher(/WEB-INF/views/score/list.jsp).forward(req, resp); } private int parseInt(String v, int def) { if (v null || v.trim().isEmpty()) return def; try { return Integer.parseInt(v.trim()); } catch (NumberFormatException e) { return def; } } }parseInt这个工具方法看着啰嗦但它是防 500 的关键。用户手动改 URL 把pageNoabc传进来直接Integer.parseInt就抛NumberFormatException整个页面白屏。给个默认值页面照常渲染体验完全不同。JSP 放在WEB-INF/views下面是为了禁止浏览器直接访问.jsp。放在外面的话用户不经过 Servlet 直接敲/score/list.jsp页面上所有${page.rows}都是空报一堆PropertyNotFoundException。放WEB-INF里只能通过forward进绕不过去了。3.4 JSP 用 JSTL 渲染列表并做分页条JSP 里只留标签不留% %。头部引 JSTL% page contentTypetext/html;charsetUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % table classtbl trth学号/thth姓名/thth课程/thth学期/thth成绩/th/tr c:forEach items${page.rows} varr tr td${r.sno}/td td${r.name}/td td${r.cname}/td td${r.term}/td tdfmt:formatNumber value${r.score} pattern0.00//td /tr /c:forEach c:if test${empty page.rows} trtd colspan5当前条件下没有成绩记录/td/tr /c:if /table %-- 分页条上一页 / 页码 / 下一页 --% div classpager c:if test${page.pageNo 1} a href?pageNo${page.pageNo - 1}term${term}上一页/a /c:if span第 ${page.pageNo} / ${page.totalPages} 页共 ${page.total} 条/span c:if test${page.pageNo page.totalPages} a href?pageNo${page.pageNo 1}term${term}下一页/a /c:if /divfmt:formatNumber处理BigDecimal的显示比${r.score}直接输出稳后者遇到89.50可能显示成89.5成绩单上看着别扭。分页链接里的term要带上否则翻页时筛选条件丢失用户会以为筛选失效了——这是最常被投诉的一类“功能问题”其实只是链接拼漏了参数。4. 把 SQL 聚合接上 ECharts三个统计接口的落地写法统计图是这个包相对普通 CRUD 的增量价值也是最容易做成摆设的部分。写死数组的图谁都会画难点在于数据得从库里真实聚合出来而且口径要能和教务的口径对上。4.1 统计口径先定死分段、平均分、及格率三个图对应三个口径先写清楚再写代码不然后面改 SQL 改到怀疑人生。分数段分布按 90-100、80-89、70-79、60-69、60 以下五档计数用柱状图。课程平均分按课程分组求AVG(score)用横向条形图课程多时比柱状图好读。及格率趋势按学期分组算及格人数/总人数用折线图。分数段统计的 SQL 有个坑CASE WHEN出来的别名不能直接拿来ORDER BY保证顺序MySQL 会按字符串排60以下可能排到60-69前面。用FIELD()显式指定顺序SELECT CASE WHEN sc.score 90 THEN 90-100 WHEN sc.score 80 THEN 80-89 WHEN sc.score 70 THEN 70-79 WHEN sc.score 60 THEN 60-69 ELSE 60以下 END AS bucket, COUNT(*) AS cnt FROM score sc WHERE sc.course_id ? AND sc.term ? GROUP BY bucket ORDER BY FIELD(bucket, 90-100,80-89,70-79,60-69,60以下);课程平均分这条要注意AVG会自动忽略NULL但成绩表里score是NOT NULL所以不用额外处理。如果以后允许缓考存NULL统计口径就要改成AVG(IFNULL(score,0))或者按有效条数算这个决定要提前和业务确认。-- 课程平均分同时带出参考人数方便前端在 tooltip 里展示样本量 SELECT c.cname, ROUND(AVG(sc.score), 2) AS avg_score, COUNT(*) AS stu_cnt FROM score sc JOIN course c ON c.id sc.course_id WHERE sc.term ? GROUP BY c.id, c.cname ORDER BY avg_score DESC;GROUP BY c.id, c.cname两个字段都写上只写cname在某些ONLY_FULL_GROUP_BY模式下会报错MySQL 5.7 之后默认开这个模式。4.2 Servlet 输出 JSON 的两种写法接口统一返回数组前端fetch拿到直接map。用 Gson 的写法WebServlet(/api/chart/scoreDist) public class ScoreDistApi extends HttpServlet { private final ScoreDao scoreDao new ScoreDao(); private final Gson gson new Gson(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType(application/json;charsetUTF-8); Integer courseId parseInt(req.getParameter(courseId)); String term req.getParameter(term); ListMapString, Object data; if (courseId null || term null || term.isEmpty()) { data Collections.emptyList(); // 参数不全返回空数组不返 500 } else { data scoreDao.distByCourse(courseId, term); } resp.getWriter().write(gson.toJson(data)); } }setContentType必须带charsetUTF-8。只写application/json时浏览器按 ISO-8859-1 解析中文课程名变乱码图表 x 轴全是问号这类问题查起来非常费劲因为 JSON 本身是合法的只是编码声明缺了。data的 key 用英文不要用分数段这种中文 key。中文 key 在前端d.分数段也能跑但一旦文件编码不一致就变成d.鍒嗘暟娈报undefined。DAO 里返回Map时 key 固定为bucket、cntpublic ListMapString, Object distByCourse(int courseId, String term) { String sql SELECT CASE ... END AS bucket, COUNT(*) AS cnt FROM score WHERE course_id ? AND term ? GROUP BY bucket ORDER BY FIELD(bucket,90-100,80-89,70-79,60-69,60以下); ListMapString, Object list new ArrayList(); try (Connection conn JdbcUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, courseId); ps.setString(2, term); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { MapString, Object m new LinkedHashMap(); m.put(bucket, rs.getString(bucket)); m.put(cnt, rs.getInt(cnt)); list.add(m); } } } catch (SQLException e) { throw new RuntimeException(分数段统计失败, e); } return list; }LinkedHashMap保证字段顺序稳定调试时看得舒服也避免前端某些序列化工具对顺序敏感。4.3 前端 ECharts 初始化与自适应ECharts 放本地webapp/lib/echarts.min.js别图省事引公共 CDN——课程设计演示环境经常断外网图出不来最尴尬。// 页面加载后初始化容器必须有显式高度 const chart echarts.init(document.getElementById(distChart), null, { renderer: canvas }); function loadDist(courseId, term) { const url ctx /api/chart/scoreDist?courseId encodeURIComponent(courseId) term encodeURIComponent(term); fetch(url) .then(r r.json()) .then(data { chart.setOption({ tooltip: { trigger: axis }, grid: { left: 40, right: 20, top: 30, bottom: 40 }, xAxis: { type: category, data: data.map(d d.bucket) }, yAxis: { type: value, minInterval: 1 }, series: [{ type: bar, data: data.map(d d.cnt), barWidth: 45%, itemStyle: { color: #4c7cf3 }, label: { show: true, position: top } }] }, true); // notMergetrue切课程时清掉旧数据 }) .catch(err console.error(图表数据加载失败, err)); } // 容器尺寸变化必须 resize否则侧边栏收起后图会糊 window.addEventListener(resize, () chart.resize());三个细节值得说。setOption(option, true)第二个参数不传或传false是合并模式从「课程 A」切到「课程 B」时如果 B 的分数段比 A 少旧的多余柱子会残留看着像数据错了。传true强制不合并。yAxis.minInterval: 1防止人数少的时候 y 轴出现 0.5 这种小数刻度。容器必须有高度#distChart如果只有width:100%没有heightcanvas 高度是 0图完全看不见也不报错这是最典型的“代码没毛病但图没了”。4.4 联动筛选班级→课程→学期真实用起来一定是三联筛选不是单一下拉。做法是三个select任意一个change都触发重新拉数据两个接口分工一个返回某班级可选课程列表一个返回图数据。document.getElementById(courseSel).addEventListener(change, renderAll); document.getElementById(termSel).addEventListener(change, renderAll); function renderAll() { const courseId document.getElementById(courseSel).value; const term document.getElementById(termSel).value; if (!courseId || !term) return; loadDist(courseId, term); loadPassRate(courseId, term); // 及格率趋势另一个接口 }课程下拉联动班级下拉时有个顺序问题班级变了要先清空课程下拉再重新加载否则用户会在旧课程列表上选中一个不属于新班级的课程 id接口查出来空数组页面显示“暂无数据”用户以为系统坏了。清空动作放在请求前请求回来再重新填充。5. 避坑与排查五类翻车现场的现象、原因和解法这套技术栈跑了这么多年出问题的永远是那几个位置。下面五条是我实际排查过、并且在别人的课程设计包里反复见到的按现象、原因、解决三段写遇到同类问题可以直接对号。5.1 表单提交中文变问号或乱码现象新增学生时输入中文姓名列表页显示????数据库里查出来也是问号。原因有三层缺一不可Tomcat 的server.xml里Connector没配URIEncodingUTF-8GET 参数请求体没设setCharacterEncodingPOST 参数数据库连接 URL 没带characterEncodingutf8。任何一层没配中文都会在那一层被按默认编码解掉。解决Tomcat 8 以后默认 URI 编码就是 UTF-8主要补后两层。加一个全局过滤器在所有 Servlet 之前执行WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); chain.doFilter(req, resp); } }注意resp.setCharacterEncoding要在getWriter()之前调用写在 Servlet 里输出之后就没用了。过滤器顺序按类名或web.xml里的声明顺序编码过滤器必须排第一。5.2 分页总数和列表条数对不上现象列表显示 8 条分页条写“共 10 条”翻到第二页又是 8 条。原因基本是两个count 语句里误带了LIMIT或者 count 和数据查询用的where条件不一致比如数据查询加了status1count 忘了加。极少数情况是total用了int但查询结果COUNT(*)被getInt读成 0那是字段索引写错了。解决把 count 和数据查询的条件拼装抽成同一个方法返回同一份where和args两边共用。这是唯一能长期保证一致的写法靠人工比对早晚出错。上线前用两条 SQL 手工跑一遍SELECT COUNT(*)和SELECT ... LIMIT 10数字对得上再交付。5.3 ECharts 图空白控制台无报错现象页面结构正常div也在就是不显示图F12 里canvas宽高是 0。原因容器在echarts.init时处于隐藏状态比如在未激活的 tab 页里或者父元素没有确定高度init拿到的是 0×0后续不会自动重算。解决两种方式。一是延迟初始化等 tab 切换后再init二是初始化后调chart.resize()。tab 场景我一般用后者在 tab 的shown事件里调 resize比重建实例省事。CSS 上给容器写死height: 360px别用height: 100%套在一个没高度的父级里。5.4 压测几十次后请求全部超时现象单点测试都正常连续点几十次查询后页面一直转圈日志里出现wait millis 3000。原因DAO 里Connection、PreparedStatement、ResultSet有一个没关。连接池 20 个连接被占满后新请求全部阻塞在maxWait。解决全量改try-with-resources三层嵌套写清楚。排查阶段可以临时把 Druid 的removeAbandonedtrue、removeAbandonedTimeout60打开让池子主动回收泄漏的连接并在日志里打出堆栈定位到具体是哪个方法没关改完再关掉这个开关——它本身有性能开销不能长期开。5.5 同一学生同一门课出现两条成绩现象成绩列表里一个学生一门课有两条记录平均分被拉低。原因score表少了(student_id, course_id, term)联合唯一索引前端提交按钮也没做防重复点击用户手快点了两次就插两条。解决先补索引再清理历史重复数据保留id最小的那条最后在前端提交时禁用按钮。索引是根本前端禁用只是体验优化绕过页面直接调接口照样能插重复所以数据库这层约束不能省。6. 进阶用法把统计图做成可复用的查询组件图写到第三张就会发现每个 Servlet 都在重复setContentType、解析参数、try-catch、写 JSON。这时候该抽一个通用的图表查询入口用chartType参数分发新增一种图只加一个分支不用再建 Servlet。6.1 通用 ChartQueryServlet 的分发设计WebServlet(/api/chart/*) public class ChartQueryServlet extends HttpServlet { private final ChartService service new ChartService(); private final Gson gson new Gson(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType(application/json;charsetUTF-8); // 用 pathInfo 取图表类型/api/chart/scoreDist - scoreDist String type req.getPathInfo(); type (type null || type.length() 2) ? : type.substring(1); MapString, String params new HashMap(); req.getParameterMap().forEach((k, v) - params.put(k, v[0])); Object result; switch (type) { case scoreDist: result service.scoreDist(params); break; case courseAvg: result service.courseAvg(params); break; case passTrend: result service.passTrend(params); break; default: resp.setStatus(404); resp.getWriter().write({\msg\:\unknown chart type\}); return; } resp.getWriter().write(gson.toJson(result)); } }req.getPathInfo()拿到的是/scoreDist这种形式去掉首字符就是类型名。用路径而不是 query 参数区分类型好处是 URL 语义清楚也方便以后加缓存规则。getParameterMap转成普通 Map 是为了让 service 层不依赖 Servlet API测试时可以直接new HashMap传进去不用起容器。6.2 用一个接口聚合多图首屏经常要同时出三张图三次请求会有明显的瀑布延迟。加一个dashboard类型一次返回三份数据case dashboard: MapString, Object all new LinkedHashMap(); all.put(dist, service.scoreDist(params)); all.put(avg, service.courseAvg(params)); all.put(trend, service.passTrend(params)); result all; break;前端一次fetch拿三个 key分别喂给三个setOption。代价是响应体变大单张图刷新时反而更重所以两种模式都保留首屏用dashboard后续单图筛选走单独类型。这个取舍做不做取决于你的首屏是不是真的一次要三张图别为了“看起来高级”把简单请求复杂化。6.3 手工对账验证法图表最容易出的不是渲染问题是数字和直觉对不上。我的习惯是每次改完统计 SQL先在数据库客户端里手工跑一遍明细再和图上的数字比随便挑一个课程一个学期SELECT COUNT(*)出总数手工按分数段数一遍和柱状图的五根柱子加总对上SELECT AVG(score)的结果和 tooltip 里的平均值比到小数点后两位。对不上时先怀疑WHERE条件漏了term再怀疑JOIN用了LEFT JOIN把没成绩的学生也算进去了。这两种错都很隐蔽图能出来数字也“像那么回事”只有对账才会露馅。从那以后我每次交付这类统计页面都会留一个隐藏的“对账模式”——URL 加个?debug1就把原始 SQL 和结果条数打到页面底部验收时当场比一遍比事后被问“这个平均分怎么算的”从容得多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?