ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Servlet+JSP+MySQL的学生成绩管理系统与ECharts统计

基于Servlet+JSP+MySQL的学生成绩管理系统与ECharts统计

简介:这是一套基于Servlet+JSP+MySQL开发的学生成绩管理系统完整源码,面向JavaWeb初学者、课程设计及毕业设计人群,帮助读者在真实项目中理解MVC分层、数据库访问与前后端交互;系统在原有版本上新增ECharts统计图表,可对学生成绩分布、课程数据等进行可视化展示,适合作为功能扩展与图表集成的参考案例。压缩包共344个文件,约3.59MB,以86个Java源文件及对应的class文件、37个JSP页面、62个JavaScript脚本和28个CSS样式为主,另含数据库SQL脚本、jar依赖以及字体、图片等静态资源,覆盖实体、Dao、Service、Servlet与前端页面等层次,结构完整,导入后便于按模块阅读和调试。目前已有6724人学习下载,读者可获得可运行的工程源码、建库脚本以及成绩管理、文件上传下载等典型功能实现,既能用于课程作业快速搭建,也适合在此基础上改造界面、补充统计维度,作为JavaWeb综合实践的参考。

1. 从一张汇总不完的成绩表说起:这套系统到底要替谁干活

每到期末,任课老师手里都会多出一张自己维护的成绩 Excel,教务那边再收上来手工合并,班里几十门课、几百个学生,版本一多就没人说得清哪张才是最终的。这个场景下需要的不是什么高深架构,而是一套能把「录入—查询—统计」三件事收拢到同一个入口的 javaweb 学生成绩 成绩管理系统。Servlet 负责请求分发和业务编排,JSP 承担页面渲染,MySQL 存下学生、课程、成绩这些结构化数据,最后用 ECharts 把分数段分布、课程平均分、及格率画出来,替掉手工统计这一环。这套组合适合两类人:正在做课程设计、需要一套完整可运行工程的在校学生,以及需要快速交付一个内部成绩管理原型的小团队。它不追求技术栈新颖,追求的是每一层都看得见、改得动、答辩时讲得清。

2. 技术选型与总体架构:Servlet+JSP+MySQL 的三层结构与包约定

动手写第一行代码之前,先把「谁负责什么」定下来,比后面写多少代码都重要。学生成绩管理这类系统的业务复杂度不高,真正的麻烦在于角色多、页面多、数据关系多,如果没有清晰的分层,写到第三个模块就会开始互相调用、SQL 散落各处,最后连自己都不敢改。

这一章把选型理由、包结构和表结构一次性定死,后面三章都是在这套骨架里填肉。

2.1 为什么这套作业型项目仍然值得用 Servlet+JSP 打底

很多人第一反应是「都什么年代了还用 JSP」。如果目标是做一个高并发的互联网产品,这个质疑成立;但如果是课程设计、毕设或者内部小系统,Servlet+JSP 反而有几个别的东西替代不了的好处。

第一是链路上没有任何黑匣子。一个请求从浏览器发出,经过 Filter、Servlet、Service、DAO,最后落到 JDBC 和 MySQL,每一环你都能打断点、打日志、看清参数是怎么变的。用惯了高度封装框架的人,一旦遇到「明明传了值却查不出来」,往往要在框架源码里泡半天;而在这套结构里,多半就是request.getParameter拼错了名字,五分钟能定位。

第二是部署成本低。一个 war 包扔进 Tomcat 的webapps目录就能跑,数据库建好表、改一下jdbc.properties里的四行配置就完事。答辩现场不用装一堆运行时,评委机器上有个 Tomcat 和 MySQL 就能演示。

第三是它逼着你想清楚 MVC 的边界。JSP 只做展示,Servlet 只做接收参数和跳转,Service 管事务和业务规则,DAO 管 SQL。这条线一旦守住,后面加统计图、加导出、加批量导入,都是往固定位置塞代码,不会把已有逻辑搅乱。

技术版本上,通常的做法是 Tomcat 8.5 或 9、JDK 8、MySQL 5.7 或 8.0、Servlet 3.1+。选 Servlet 3.0 以上是因为可以用@WebServlet、@WebFilter注解替代 web.xml 里那一大段配置,少写几十行 XML,改起来也直观。

2.2 三层结构怎么切:entity / dao / service / servlet / jsp 的职责边界

包结构建议按下面这样切,不要按「所有 Servlet 一个包、所有 JSP 一个文件夹」这种图省事的方式堆:

com.sms ├── entity // 实体类,与表字段一一对应 │ ├── Student.java │ ├── Course.java │ ├── Score.java │ └── User.java ├── dao // 数据访问,只写 SQL 和结果集映射 │ ├── BaseDao.java │ ├── StudentDao.java │ ├── CourseDao.java │ └── ScoreDao.java ├── service // 业务规则与事务边界 │ ├── ScoreService.java │ └── StatService.java ├── servlet // 接收请求、校验参数、调用 service、转发视图 │ ├── LoginServlet.java │ ├── ScoreServlet.java │ └── StatServlet.java ├── filter // 登录拦截、编码统一 │ └── LoginFilter.java └── util // 连接池、JSON 输出、分页封装 ├── DBUtil.java └── Page.java

职责边界用一句话概括:Servlet 不写 SQL,DAO 不做业务判断,JSP 不写 Java 逻辑。

具体到一次成绩查询请求:ScoreServlet从request里取课程号、学号、页码,做非空和类型校验,然后调ScoreService.queryByPage(...);Service 里判断「如果是学生角色,强制把学号替换成当前登录人」,再调ScoreDao;DAO 拼WHERE条件、执行LIMIT分页、把ResultSet映射成List<Score>;Servlet 拿到结果后request.setAttribute("page", page),forward到score_list.jsp;JSP 里只用 JSTL 的<c:forEach>遍历。

这条线守住了,好处在后期特别明显。比如要加一个「按班级筛选」,只需要在 DAO 的条件拼装里加一个分支、在 JSP 里加一个下拉框,其他层一行不动。反过来,如果把 SQL 写在 JSP 的<% %>里,加这个功能就要翻遍所有页面。

提示:entity类不要图省事用Map<String,Object>代替。字段名写错在编译期抓不到,运行期才报空指针,这是最常见的返工来源。

2.3 数据库表设计与约束:六张表撑起成绩管理

成绩管理的核心关系其实就一句话:一个学生选多门课,一门课被多个学生选,成绩是学生和课程之间的多对多关联。落到表上,通常需要这六张:

表名作用关键字段约束
t_user登录账号id, username, password, roleusername 唯一
t_class班级id, class_name, gradeclass_name 唯一
t_student学生id, sno, name, class_idsno 唯一,class_id 外键
t_course课程id, course_name, credit, teacher_idcourse_name 唯一
t_score成绩id, student_id, course_id, score, term(student_id, course_id, term) 联合唯一
t_teacher教师id, tno, nametno 唯一

成绩表的建表语句是这个系统的地基,值得单独写清楚:

CREATE TABLE t_score ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', student_id BIGINT NOT NULL COMMENT '学生ID', course_id BIGINT NOT NULL COMMENT '课程ID', score DECIMAL(5,1) DEFAULT NULL COMMENT '成绩,保留一位小数', term VARCHAR(16) NOT NULL COMMENT '学期,如 2024-2025-1', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_stu_course_term (student_id, course_id, term), KEY idx_course_term (course_id, term) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成绩表';

这里有三个设计点需要说明。

score用DECIMAL(5,1)而不是FLOAT或DOUBLE。成绩是精确值,FLOAT存 89.5 可能变成 89.499999,后面统计平均分时误差会累积,导出到 Excel 还会出现一串小数位。DECIMAL(5,1)表示总共 5 位、小数 1 位,够存 100.0 以内的分数。

联合唯一索引uk_stu_course_term是防重复录入的最后一道闸。业务代码里当然也会先查一遍,但并发提交、页面重复点击这类情况防不胜防,数据库约束是唯一可靠的兜底。注意要把学期带上,否则同一个学生重修同一门课就没法录第二次。

idx_course_term这个普通索引是给统计查询准备的。ECharts 那几张图基本都是「按课程 + 学期聚合」,没有这个索引,几万条成绩做GROUP BY会明显卡顿。索引不是越多越好,成绩表写入频繁,每多一个索引写入就多一份开销,所以只给真正高频的查询条件建。

注意:外键约束在课程设计里建议加上,答辩时能体现完整性;但如果你打算做批量导入几万条数据,导入前要先关掉外键检查,导完再打开,否则逐行校验会慢到怀疑人生。

3. 核心业务实现:登录鉴权、成绩录入与分页查询的三段落地

骨架搭好之后,真正决定这套系统「能不能用」的是三个动作:谁能进来、成绩怎么安全地写进去、几百条记录怎么让人看得下去。这一章按这三个动作各给一段可复现的实现。

3.1 登录与会话:Filter + Session 把住入口

登录本身很简单,查一次t_user比对密码就完了。真正容易出问题的是「登录之后」。如果每个 Servlet 开头都写一遍if (session.getAttribute("user") == null) { 跳登录页 },代码会重复到吐,而且只要漏写一个,那个接口就是裸奔的。

统一的做法是用 Filter 拦截:

// LoginFilter.java —— 统一登录拦截,白名单直接放行 @WebFilter(urlPatterns = "/*") public class LoginFilter implements Filter { // 白名单:登录相关页面与静态资源,不需要登录态 private static final String[] WHITE_LIST = { "/login.jsp", "/login", "/css/", "/js/", "/img/", "/fonts/" }; @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 去掉上下文路径,得到 /score/list 这样的相对路径 String uri = request.getRequestURI() .substring(request.getContextPath().length()); for (String white : WHITE_LIST) { if (uri.startsWith(white)) { chain.doFilter(req, resp); // 白名单命中,直接放行 return; } } Object loginUser = request.getSession().getAttribute("loginUser"); if (loginUser == null) { // 未登录,重定向到登录页;用 sendRedirect 让地址栏同步变化 response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }

几个参数和行为需要说清楚。urlPatterns = "/*"表示拦截所有请求,包括 JSP 和 Servlet,所以白名单必须显式列出登录页和静态资源,否则登录页自己也被拦,会陷入无限重定向。uri.substring(contextPath.length())是为了去掉项目名,让白名单写成/css/这种与部署路径无关的形式,换一个 context path 也不用改代码。

用sendRedirect而不是forward,是因为跳转后地址栏要变成登录页的真实地址,用户刷新才不会又提交一次原请求。

登录成功时往 session 里放的不只是用户名,通常是一个精简的User对象,含 id、username、role。role 这个字段是后面所有权限判断的源头:学生进来只能看自己的成绩,教师能录自己教的课,管理员什么都能干。这个判断不要写在 Filter 里,Filter 只管「有没有登录」,具体到「这个学生能不能看这条成绩」应该放在 Service 层,因为那属于业务规则。

session 超时时间在web.xml里配:

<session-config> <session-timeout>30</session-timeout> <!-- 单位:分钟 --> </session-config>

30 分钟是个折中值。太短,演示到一半要重新登录,很尴尬;太长,机房公用电脑上别人接着用你的账号。答辩场景建议调到 60,正式部署再改回去。

3.2 成绩录入的事务控制与批量导入

单个成绩录入是一条INSERT,没什么好说的。麻烦的是两种情况:教师一次性给整个班录一门课,几十条记录要么全成功要么全失败;以及修改成绩时「先删后插」这种容易把自己坑进去的写法。

先看批量录入。JDBC 的addBatch()比循环执行executeUpdate()快一个数量级,因为减少了网络往返。但批量操作必须包在事务里:

// ScoreService.java —— 批量录入成绩,全部成功才提交 public int batchSave(Long courseId, String term, Map<Long, BigDecimal> stuScoreMap) throws SQLException { Connection conn = null; PreparedStatement ps = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 String sql = "INSERT INTO t_score(student_id, course_id, score, term) " + "VALUES(?, ?, ?, ?) " + "ON DUPLICATE KEY UPDATE score = VALUES(score)"; ps = conn.prepareStatement(sql); for (Map.Entry<Long, BigDecimal> e : stuScoreMap.entrySet()) { ps.setLong(1, e.getKey()); // 学生ID ps.setLong(2, courseId); // 课程ID ps.setBigDecimal(3, e.getValue()); // 成绩,可能为 null 表示缺考 ps.setString(4, term); // 学期 ps.addBatch(); // 攒着,不立即执行 } int[] rows = ps.executeBatch(); // 一次性发给数据库 conn.commit(); // 全部成功,提交 return rows.length; } catch (SQLException ex) { if (conn != null) { conn.rollback(); // 任何一条失败,整体回滚 } throw ex; } finally { DBUtil.close(ps, conn); // 关闭顺序:先语句后连接 } }

这里有几个细节值得展开。

ON DUPLICATE KEY UPDATE配合前面的联合唯一索引,把「新增」和「修改」合并成一条语句。教师重复提交同一门课的成绩时,不会报主键冲突,而是直接覆盖。这比「先DELETE再INSERT」安全得多——后者如果在两步之间出错,成绩就凭空消失了,而且删除操作在 binlog 里很难追。

setAutoCommit(false)之后,一定要在finally里保证连接被关闭,否则这个连接会带着未提交的事务回到连接池,下一个借到它的人会看到脏数据或者莫名锁等待。这是 JDBC 事务里最容易翻车的点之一。

成绩值允许为null,表示缺考。setBigDecimal(3, null)是合法的,插入后数据库里就是NULL,统计平均分时AVG()会自动忽略NULL,正好符合「缺考不计入平均分」的口径。如果业务上要求缺考记 0 分,那就在 Service 里把null换成BigDecimal.ZERO,这个策略要提前定,不要中途改。

提示:批量录入前一定要在后端再校验一次分数范围(0 到 100)。前端input type="number"的min/max谁都能改掉,后端不校验等于没校验。

3.3 多条件分页查询的 SQL 拼装与参数绑定

成绩列表页是最考验体验的地方。一个学期下来几千条记录,不分页浏览器直接卡死;条件筛选做不好,教务想查「某班某门课不及格的」要翻半天。

分页的核心是两条 SQL:一条查总数,一条查当前页数据。参数用LIMIT ?, ?绑定,不要字符串拼接:

// ScoreDao.java —— 多条件分页查询,条件用占位符绑定 public Page<Score> queryByPage(ScoreQuery q, int pageNo, int pageSize) throws SQLException { StringBuilder where = new StringBuilder(" WHERE 1=1 "); List<Object> params = new ArrayList<>(); if (q.getCourseId() != null) { where.append(" AND s.course_id = ? "); params.add(q.getCourseId()); } if (StringUtils.isNotBlank(q.getTerm())) { where.append(" AND s.term = ? "); params.add(q.getTerm()); } if (StringUtils.isNotBlank(q.getClassName())) { where.append(" AND c.class_name = ? "); params.add(q.getClassName()); } if (q.getMinScore() != null) { where.append(" AND s.score >= ? "); params.add(q.getMinScore()); } // 第一条:统计总数,供前端算总页数 String countSql = "SELECT COUNT(*) FROM t_score s " + "JOIN t_student st ON s.student_id = st.id " + "JOIN t_class c ON st.class_id = c.id " + where; int total = count(countSql, params.toArray()); // 第二条:取当前页,按成绩倒序,同分按学号升序保证稳定 String dataSql = "SELECT s.id, st.sno, st.name, co.course_name, " + "s.score, s.term FROM t_score s " + "JOIN t_student st ON s.student_id = st.id " + "JOIN t_class c ON st.class_id = c.id " + "JOIN t_course co ON s.course_id = co.id " + where + " ORDER BY s.score DESC, st.sno ASC LIMIT ?, ? "; params.add((pageNo - 1) * pageSize); params.add(pageSize); List<Score> list = query(dataSql, params.toArray()); return new Page<>(list, total, pageNo, pageSize); }

WHERE 1=1这种写法很多人嫌丑,但它在动态拼条件时确实省事——每个条件都能以AND开头,不用判断是不是第一个条件。参数全部走?占位符,是防 SQL 注入的底线,别图省事用+ q.getTerm() +拼进去。

ORDER BY里加了st.sno ASC这个次级排序,是为了让结果稳定。只按score DESC排,同分记录的先后顺序由数据库决定,翻页时可能出现某条记录在第 1 页和第 2 页都出现、另一条凭空消失的「玄学」现象。这不是 bug,是不稳定排序的正常表现,但用户会当成 bug 报给你。

LIMIT的偏移量(pageNo - 1) * pageSize要在这里算好。页码从 1 开始,偏移量从 0 开始,这个差一是最常见的翻页错误来源。

前端 JSP 侧用 JSTL 渲染,不要在页面里写<% for (...) %>:

<c:forEach items="${page.list}" var="s" varStatus="st"> <tr> <td>${st.index + 1 + (page.pageNo - 1) * page.pageSize}</td> <td>${s.sno}</td> <td>${s.name}</td> <td>${s.courseName}</td> <td><fmt:formatNumber value="${s.score}" pattern="0.0"/></td> <td>${s.term}</td> </tr> </c:forEach>

序号列要算上前面已翻过的页,否则每页都从 1 开始,看着很怪。成绩用fmt:formatNumber统一格式化成一位小数,避免89.5和89.50混着显示。

4. ECharts 统计模块:从聚合 SQL 到前端图表的完整链路

统计图是这套系统里最容易被低估的部分。很多人以为「查出来数据丢给图表库就完了」,实际做下来会发现:统计口径没定清楚,图画出来是对的但业务上没用;JSON 格式差一个引号,图表就是一片空白;数据量一大,页面加载慢到超时。

这一章按「定口径 → 出数据 → 画图」的顺序走一遍完整链路。

4.1 统计口径先定死:分数段、平均分、及格率三个指标

写 SQL 之前,先把要回答的问题写清楚。成绩管理场景下,真正有人看的图其实就三张:

分数段分布,回答「这门课考得怎么样」。分段通常是 90-100、80-89、70-79、60-69、60 以下五档。分段边界要写进需求,否则不同人算出来的数对不上。注意score >= 90和score > 90差一个人,教务对账时能跟你较半天。

课程平均分对比,回答「哪个班/哪门课整体偏低」。这个是横向比较,横轴是课程或班级,纵轴是平均分。AVG()会自动跳过NULL,也就是缺考不计入,这一点要跟使用者说清楚。

及格率,回答「达标情况」。及格线定 60,COUNT(score >= 60) / COUNT(*),注意分母要不要排除缺考,两种口径结果不一样,得选一种。

对应的聚合 SQL:

-- 某课程某学期的分数段分布 SELECT CASE WHEN score >= 90 THEN '90-100' WHEN score >= 80 THEN '80-89' WHEN score >= 70 THEN '70-79' WHEN score >= 60 THEN '60-69' WHEN score IS NOT NULL THEN '60以下' END AS segment, COUNT(*) AS cnt FROM t_score WHERE course_id = ? AND term = ? GROUP BY segment ORDER BY FIELD(segment, '90-100','80-89','70-79','60-69','60以下');

ORDER BY FIELD(...)这一步很关键。GROUP BY出来的顺序是不保证的,如果不显式排序,柱子可能按字母序排成「60以下、60-69、70-79…」,看着别扭还可能被误读。FIELD按给定顺序返回位置序号,是 MySQL 里做自定义排序的常用手段。

WHEN score IS NOT NULL THEN '60以下'这个写法把缺考(NULL)排除在分段之外。如果把NULL也归进「60以下」,及格率会被拉低,但那不是真实的学业表现。

4.2 后端 JSON 输出:一个 Servlet 对应一组图表数据

Servlet 这边的工作是把ResultSet变成前端能直接用的 JSON。手写字符串拼接容易漏逗号、漏引号,建议用 Gson 或者 Jackson,这里以 Gson 为例:

// StatServlet.java —— 输出分数段分布的 JSON @WebServlet("/stat/segment") public class StatServlet extends HttpServlet { private final StatService statService = new StatService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("application/json;charset=UTF-8"); Long courseId = Long.valueOf(req.getParameter("courseId")); String term = req.getParameter("term"); // 返回 LinkedHashMap,保证 key 顺序与 SQL 的排序一致 Map<String, Object> result = new LinkedHashMap<>(); try { List<Map<String, Object>> rows = statService.segmentStat(courseId, term); List<String> segments = new ArrayList<>(); List<Integer> counts = new ArrayList<>(); for (Map<String, Object> row : rows) { segments.add(String.valueOf(row.get("segment"))); counts.add(((Number) row.get("cnt")).intValue()); } result.put("code", 0); result.put("segments", segments); result.put("counts", counts); } catch (Exception e) { result.put("code", 500); result.put("msg", "统计查询失败"); getServletContext().log("segment stat error", e); } resp.getWriter().write(new Gson().toJson(result)); } }

几个要点。resp.setContentType("application/json;charset=UTF-8")必须在getWriter()之前调用,顺序反了设置不生效,中文课程名会变成问号。把数据拆成segments和counts两个平行数组,是因为 ECharts 的xAxis.data和series.data就是这个结构,前端拿到直接赋值,中间少一层转换。

异常也要返回 JSON,不能让它抛成 HTML 错误页。前端$.getJSON拿到一坨 HTML 会直接进error回调,控制台只报「Unexpected token <」,排查起来很费劲。用code字段区分成功失败,前端按code判断,比看 HTTP 状态码更可控。

4.3 前端渲染与联动:ECharts option 的关键配置

页面里引入 ECharts 的脚本后,初始化一个容器,把后端数据喂进 option:

// stat_segment.jsp 中的图表初始化 var chart = echarts.init(document.getElementById('segmentChart')); function loadSegment(courseId, term) { $.getJSON(ctx + '/stat/segment', { courseId: courseId, term: term }, function (res) { if (res.code !== 0) { alert(res.msg || '数据加载失败'); return; } chart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'category', data: res.segments, // 分数段,顺序由后端 SQL 决定 axisLabel: { interval: 0 } // 强制显示所有标签,避免被自动隐藏 }, yAxis: { type: 'value', name: '人数', minInterval: 1 // 人数是整数,不要出现 0.5 这种刻度 }, series: [{ type: 'bar', data: res.counts, barWidth: '45%', itemStyle: { // 60 以下标红,其余标蓝,一眼看出不及格人数 color: function (p) { return p.name === '60以下' ? '#e74c3c' : '#4a90d9'; } }, label: { show: true, position: 'top' } }] }, true); // 第二个参数 true 表示不合并,每次全量替换 }); }

axisLabel: { interval: 0 }是必须加的。不加的话,分数段标签在窄屏上会被自动隐藏,只剩两根柱子,用户根本不知道代表什么。minInterval: 1保证纵轴刻度是整数,人数不会出现 2.5 这种值。

setOption(option, true)的第二个参数控制是否合并。切换课程时如果传false,旧的系列数据可能残留,出现两根重叠的柱子。传true表示全量替换,每次都是干净的状态。

如果要做「点击柱子下钻到学生名单」,在 option 里加chart.on('click', function(p){ ... }),拿到p.name(分数段)再发一次请求查明细。这个联动是答辩时比较出彩的点,实现成本却很低。

5. 避坑与常见问题排查:这套组合最容易翻车的五个地方

这一章是血泪经验汇总。下面五个问题,几乎每一个做过这套技术栈的人都至少踩过一次,按「现象 → 原因 → 解决」写清楚,遇到时可以直接对着查。

5.1 中文乱码:请求、响应、数据库三个不同的源头

现象:表单提交的中文姓名入库变成????,或者查询出来的课程名在页面上显示成乱码,或者 JSON 接口返回的中文变成问号。

原因:乱码有三个完全独立的源头,很多人只堵了一个就以为解决了。第一是请求体编码,POST 表单默认按 ISO-8859-1 解,需要在读参数之前request.setCharacterEncoding("UTF-8");第二是响应编码,response.setContentType("text/html;charset=UTF-8")要写在getWriter()之前;第三是数据库连接,JDBC URL 里要带useUnicode=true&characterEncoding=utf8,同时表本身得是utf8mb4。

解决:最省事的做法是加一个编码 Filter,放在所有 Filter 最前面,统一处理前两项:

@WebFilter(urlPatterns = "/*") 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); } }

数据库那项在jdbc.properties的 URL 里配好:jdbc:mysql://localhost:3306/sms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai。serverTimezone不加的话,MySQL 8 驱动会直接抛时区异常,这是另一个高频报错。

5.2 数据库连接未关闭:连接池耗尽的前兆

现象:系统跑一段时间后,所有页面都转圈,日志里出现Timeout waiting for idle object或者Too many connections,重启 Tomcat 又能撑一会儿。

原因:某个 DAO 方法在异常路径上没有关闭Connection。正常流程走finally关掉了,但一旦抛出SQLException跳到 catch 块,如果 catch 里没有关闭逻辑,连接就泄漏了。每泄漏一个,连接池就少一个,池子大小通常只有 10 到 20,泄漏够数就彻底卡死。

解决:Connection、PreparedStatement、ResultSet的关闭一律放finally,并且按「后开的先关」顺序。更好的办法是写个工具方法统一收口:

public static void close(ResultSet rs, Statement st, Connection conn) { try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (st != null) st.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} }

每个catch里单独 try-catch,是因为关ResultSet失败不应该影响关Connection。这个细节看起来啰嗦,但正是它保证了「一定关得掉」。

5.3 JSP 里堆 Java 代码:改一个字段要动五个文件

现象:页面里<% %>越写越多,一个列表页三百行,一半是 Java 逻辑。后来要给成绩加个「等级」字段,得在五个 JSP 里各改一遍,改漏一个就显示不一致。

原因:图省事,把本该在 Service 里算的东西写到了页面里。JSP 编译成 Servlet 之后再排查问题,堆栈指向_jspService这种自动生成的方法,根本看不出是哪一行。

解决:JSP 里只允许出现 JSTL 标签和 EL 表达式,禁止<% %>(声明引入除外)。所有计算逻辑上移到 Service 或 entity 的 getter 里。比如「根据分数算等级」,在Score实体里加一个getLevel()方法,页面直接${s.level},改规则只改一处。

5.4 成绩的浮点数:0.1+0.2 的经典陷阱

现象:平均分算出来是 78.69999999999999,导出 Excel 后一列全是长小数;或者两门课平均分明明一样,比较却不相等。

原因:FLOAT/DOUBLE是二进制浮点,无法精确表示 0.1 这类十进制小数,累加和除法会引入误差。用float存成绩、在 Java 里用double算平均分,误差就出来了。

解决:存储用DECIMAL,Java 侧用BigDecimal,除法必须指定精度和舍入模式:

BigDecimal total = new BigDecimal("0"); for (BigDecimal s : scoreList) { total = total.add(s); } // 保留两位小数,四舍五入;除数不能为 0,提前判断 BigDecimal avg = total.divide(new BigDecimal(scoreList.size()), 2, RoundingMode.HALF_UP);

divide不指定精度时,遇到除不尽的数(比如 10 除以 3)会直接抛ArithmeticException,这个报错很突然,一定要带精度参数。

5.5 统计图空白:数据为空、类型不匹配与浏览器缓存

现象:页面打开了,图表容器是空的,控制台没有明显报错,或者偶尔刷新一下又出来了。

原因:常见的有三种。一是查询结果为空,segments和counts都是空数组,ECharts 画了个空坐标系;二是后端返回的counts是字符串数组而不是数字数组,ECharts 无法定位柱子高度;三是 GET 请求被浏览器缓存,切换课程后拿到的还是上一次的 JSON。

解决:空数据要在前端显式提示,不要留白;数字类型在后端intValue()转换时就要保证;缓存问题给请求加个时间戳参数_t: new Date().getTime(),或者后端响应头加Cache-Control: no-store。

排查时最快的办法是直接浏览器打开接口地址/stat/segment?courseId=1&term=2024-2025-1,看返回的 JSON 结构对不对。JSON 对了就是前端问题,JSON 不对就是 SQL 或 Service 问题,一步就把范围切一半。

6. 验收与答辩:一份能自证的测试清单和演示动线

代码写完不等于项目做完。答辩现场最怕的不是被问到不会的,而是系统在你手上当场翻车——点了没反应、数据不对、图表空白。这一章给一份可以自己先跑一遍的验收清单,以及一条八分钟讲完的演示动线。

6.1 功能验收清单

按角色把关键路径走一遍,每条都要有明确的预期结果,不能靠「大概能用」:

验收项操作路径预期结果判定
未登录拦截直接访问 /score/list跳转登录页通过/不通过
登录失败输入错误密码停留登录页并提示通过/不通过
角色隔离学生账号访问成绩录入页拒绝并提示无权限通过/不通过
重复录入同一学生同一课程同一学期提交两次覆盖而非报错通过/不通过
分数校验录入 101 分后端拒绝并提示通过/不通过
分页边界翻到最后一页再点下一页按钮置灰或停在末页通过/不通过
空数据图表查询无成绩的课程显示「暂无数据」而非空白通过/不通过
中文完整性录入含中文的姓名后查询前后端显示一致通过/不通过

清单里最容易被跳过的是「重复录入」和「分页边界」。前者涉及唯一索引和ON DUPLICATE KEY,后者涉及偏移量计算,都是出问题概率最高的地方。

6.2 答辩演示动线:八分钟讲完一套系统

演示最忌讳从登录页开始一步步点,讲到第五分钟还在录数据。按这条线走,八分钟能覆盖所有亮点:

第 1 分钟,讲架构图。三层结构、六个模块、技术栈一句话带过。重点说清楚「为什么这么分层」,而不是「用了什么技术」。

第 2 到 3 分钟,用管理员登录,展示成绩列表的多条件筛选和分页。故意选一个组合条件,让评委看到 SQL 拼接的效果。

第 4 到 5 分钟,切到教师角色,批量录入一门课的成绩,当场提交,然后回到列表验证数据已更新。这一步要提前准备好数据,别现场手打。

第 6 到 7 分钟,打开统计页,切换课程看图表联动,点柱子看下钻明细。这是整个演示的高潮,务必放在数据已经准备好的状态下。

第 8 分钟,说一条自己踩过的坑和怎么解决的。选真实发生过的,比如「分页翻页时记录重复」或者「图表切换后数据叠加」,讲清楚现象、定位过程、最终改了什么。这一段比任何功能演示都有说服力,因为它证明这套系统是你自己写出来的。

6.3 现场被追问的三个问题怎么答

「为什么不用 Spring?」——答清楚适用场景:这个项目的规模不需要 IoC 容器和 AOP,Servlet 直接 new Service 已经足够清晰,少一层抽象反而好调试。如果被追问「那你会不会」,就说说如果换成 Spring 之后,DAO 层会怎么改。

「数据量大了怎么办?」——答索引和分页。前面建的idx_course_term就是为这个准备的,统计查询走覆盖索引,列表查询强制分页,不一次性捞全表。

「统计口径怎么定的?」——这是最容易出彩的一题。把缺考不计入平均分、及格率分母包含缺考等几个决策说清楚,说明你是想过业务而不是照着模板抄的。

从那以后我每次交课程设计前,都会把这份清单从头到尾自己走一遍,包括故意输入错误数据、故意点重复提交、故意断网看错误提示——翻车的成本在演示前是零,在答辩现场是全部。这套工程的完整代码、建表脚本和 ECharts 配置我都整理好了,需要的可以拿去对照着改。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进