ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

JavaWeb学生成绩管理系统:三层架构与ECharts统计实战

JavaWeb学生成绩管理系统:三层架构与ECharts统计实战

简介:这是一套面向 JavaWeb 入门与课程设计场景的学生成绩管理系统源码,基于 Servlet+JSP+MySQL 分层实现,适合计算机专业学生完成课设、毕设,也适合自学者练习 Servlet 与 JDBC 的完整业务链路。升级版在原有增删改查基础上补充了 ECharts 统计图表,可将成绩数据以可视化方式呈现。压缩包共 344 个文件、约 3.59MB,包含 86 个 java 源文件与对应的 86 个 class 编译文件、37 个 jsp 页面、62 个 js 脚本和 28 个 css 样式,另有 6 个 jar 依赖与数据库 sql 脚本;源文件、页面、静态资源与运行库分目录存放,便于按模块阅读。项目按 Dao、ServiceImpl、Servlet 分层组织,覆盖学生、教师、课程、成绩与用户管理等模块,并包含文件上传下载实现。目前已有 6724 人学习下载,可作为理解分层架构、复用统计图表与二次开发的参考。

1.

做 javaweb 学生成绩管理系统的人,十有八九是从「增删改查」四个字开始的:学生表、课程表、成绩表各来一套 JSP,能录能查就算完事。真到答辩现场,老师往往不看你的录入页,而是问一句「这个班这次考试的平均分多少、及格率多少、跟上学期比是升了还是降了」——答不上来,前面写的成绩管理功能就都只是基础框架。这套基于 Servlet+JSP+MySQL 的方案把重点放在两个环节:一是把成绩的录入、校验、权限和数据一致性处理扎实,二是用 ECharts 把分散在数据表里的分数变成能直接讲出来的图。它适合正在做毕设或课程设计、需要一套完整可运行结构的同学照着抄,也适合要交一份能当场演示的系统的从业者参考。下面按架构、数据、业务、图表、排错、验收的顺序展开,每一段都对应到具体文件和可执行的操作。

2. 总体架构与数据模型:三层链路、七个包与六张核心表的职责划分

2.1 从浏览器到 MySQL:一次请求经过的四道关

这套系统跑在 Tomcat 上,没有 Spring,没有 MyBatis,纯 Servlet + JSP + JDBC。很多人一听到「不用框架」就觉得简陋,其实恰恰相反:正因为没有框架自动完成映射和依赖注入,每一层的职责边界反而必须自己划定,否则代码很快就会变得混乱。我一般会把一次「教师查询某班某课成绩」的请求拆成下面这条链路:

  1. 浏览器发起GET /score/list?classId=3&courseId=7&pageNo=1;
  2. LoginFilter先拦下来,检查 session 里有没有登录用户、当前用户角色能不能进这个路径前缀;
  3. ScoreServlet只做三件事:取参数、做基础校验、调用 Service,然后把结果放进 request 域转发给 JSP;
  4. ScoreService组装查询条件、决定要不要做缓存或二次计算;
  5. ScoreDao拼 SQL、设参数、拿ResultSet,映射成ScoreVO列表;
  6. JSP 用 JSTL 遍历渲染,EL 表达式取值。

这条链路上每一层的「不该做的事」比「该做的事」更重要。

层主要职责常见越界写法
Filter编码、登录态、角色校验在里面查数据库做业务判断
Servlet收参、校验、转发、返回 JSON直接写 SQL、直接拼 HTML
Service事务边界、业务规则、多表组装返回ResultSet给上层
DAOSQL、参数绑定、结果映射抛业务异常、做权限判断
JSP只负责展示用<% %>写 JDBC 代码

提示:JSP 里写 JDBC 是课程设计里最常见的错误做法。页面一多,同样的连接代码要复制十几遍,改一个字段名就得全项目搜索。把这部分抽到 DAO 里,哪怕只抽一层,后面加统计功能时你会省下大量时间。

这套分层带来的直接好处是:新增 ECharts 统计模块时,只需要在 DAO 里加几个聚合查询,在 Servlet 里加一个返回 JSON 的接口,原有页面一行都不用动。

2.2 包结构:七个包各自的职责

目录结构定下来之后,后面所有代码都有明确的书写位置,不会出现「这个工具类到底放哪」的争论。我一般会按下面这个结构组织:

src/main/java/com/example/sms/ ├── filter/ # LoginFilter、EncodingFilter ├── servlet/ # LoginServlet、StudentServlet、ScoreServlet、StatsServlet ├── service/ # ScoreService、StatsService ├── dao/ # BaseDao、ScoreDao、StudentDao ├── entity/ # Student、Course、Score、User ├── vo/ # ScoreVO、PageResult、ChartData └── util/ # DBUtil、JsonUtil、DateUtil src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ # 所有 JSP 放这里,禁止浏览器直接访问 ├── static/ # echarts.min.js、css、图片 └── login.jsp

entity和vo分开是有讲究的:Score对应score表的字段,一个成绩记录就是一条;ScoreVO是给页面看的,里面多带了学生姓名、班级名、课程名这些联表字段。混在一起写,用不了多久,实体类里就会多出七八个数据库里根本不存在的属性,等到做 JSON 序列化时就会出问题。

views目录放在WEB-INF下面,是为了防止学生绕过 Servlet 直接访问/views/scoreList.jsp,那样 session 校验就形同虚设。这类越权访问正是答辩时最容易被要求现场演示的环节。

2.3 六张核心表:字段、约束与索引

数据模型围绕「谁能考、考什么、考了多少」三件事展开。用户表和角色合并成一张,用role字段区分;学生、教师单独建表,避免用户表里塞进大量只有学生才有的字段。

-- 1. 登录账号表,三种角色共用 CREATE TABLE sys_user ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT '学号或工号', password VARCHAR(64) NOT NULL COMMENT '加盐后的摘要,不存明文', role TINYINT NOT NULL COMMENT '1管理员 2教师 3学生', real_name VARCHAR(32) NOT NULL, status TINYINT DEFAULT 1 COMMENT '1启用 0停用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 2. 班级表 CREATE TABLE clazz ( class_id INT PRIMARY KEY AUTO_INCREMENT, class_name VARCHAR(32) NOT NULL, grade VARCHAR(8) NOT NULL COMMENT '如 2023', major VARCHAR(64) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 3. 学生表,user_id 关联登录账号 CREATE TABLE student ( student_id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(32) NOT NULL, gender TINYINT DEFAULT 1, class_id INT NOT NULL, user_id INT, KEY idx_class (class_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 4. 课程表,一门课由一个教师负责 CREATE TABLE course ( course_id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(64) NOT NULL, credit DECIMAL(3,1) DEFAULT 2.0, teacher_id INT, KEY idx_teacher (teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 5. 成绩表,全系统的核心 CREATE TABLE score ( score_id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, term VARCHAR(16) NOT NULL COMMENT '如 2024-2025-1', exam_type TINYINT NOT NULL DEFAULT 1 COMMENT '1期末 2期中 3补考', score DECIMAL(5,2) NOT NULL, input_by INT NOT NULL COMMENT '录入人 user_id', input_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_course (student_id, course_id, term, exam_type), KEY idx_course_term (course_id, term), CONSTRAINT fk_score_student FOREIGN KEY (student_id) REFERENCES student(student_id), CONSTRAINT fk_score_course FOREIGN KEY (course_id) REFERENCES course(course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个关键决策值得单独说清楚:

成绩字段用DECIMAL(5,2)而不是FLOAT或DOUBLE。浮点数在求和、求平均时会出现79.99999999这种结果,页面上四舍五入看着没事,但你要做「平均分 80 分以上班级」的筛选时,边界数据就会莫名其妙地掉出去。这类问题极难复现,排查起来很麻烦。DECIMAL(5,2)最多支持到999.99,对百分制成绩绰绰有余。

唯一索引uk_stu_course承担的是业务约束。同一个学生、同一门课、同一学期、同一考试类型,只能有一条成绩记录。这个约束写在数据库里,比在 Service 里写if (exists) update else insert靠谱得多——后者在两个人同时录入时会出现并发写入,两条记录都能插进去,事后清理数据非常麻烦。

idx_course_term是为统计查询准备的。ECharts 的每个图背后几乎都是「按课程 + 学期聚合」,没有这个联合索引,数据量一上千条,统计接口就开始出现明显卡顿。

3. 核心业务实现:登录拦截、成绩录入与分页查询的三段实现

3.1 一次登录管三个角色:会话与路径级拦截

系统的角色区分没有做得很复杂,靠 URL 前缀就够了:/admin/*管理员,/teacher/*教师,/student/*学生。拦截逻辑集中在一个 Filter 里,避免每个 Servlet 都写一遍if (session.getAttribute("user") == null)。

@WebFilter(urlPatterns = {"/admin/*", "/teacher/*", "/student/*"}) public class LoginFilter implements Filter { // 白名单:登录页、登录接口放行,否则会自己把自己拦死 private static final Set<String> WHITE = new HashSet<>(Arrays.asList( "/login.jsp", "/login", "/logout")); @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().substring(request.getContextPath().length()); if (WHITE.contains(uri) || uri.startsWith("/static/")) { chain.doFilter(req, resp); return; } HttpSession session = request.getSession(false); User user = (session == null) ? null : (User) session.getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 角色与路径前缀不匹配时直接 403,不给二次跳转的机会 boolean adminOnly = uri.startsWith("/admin/") && user.getRole() != 1; boolean teacherArea = uri.startsWith("/teacher/") && user.getRole() == 3; if (adminOnly || teacherArea) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "no permission"); return; } chain.doFilter(req, resp); } }

这段代码里有三处容易写错的地方。getSession(false)不能传true,否则每个未登录请求都会在服务端生成一个空 session,用户多了内存占用会升高。WHITE白名单必须包含登录页本身,否则重定向过去又被拦,浏览器会报「重定向次数过多」。角色判断要用sendError(403)而不是sendRedirect,因为 403 是明确的语义,前端看到就知道是权限问题,不用猜。

登录接口本身没什么花哨的,用户名查出来之后比对摘要,成功就把User对象塞进 session,然后按角色跳不同首页:

User user = userDao.findByUsername(username); if (user == null || !PasswordUtil.matches(password, user.getPassword())) { request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 登录成功后重置 sessionId,防止会话固定 request.changeSessionId(); request.getSession().setAttribute("loginUser", user); response.sendRedirect(request.getContextPath() + (user.getRole() == 3 ? "/student/index" : "/teacher/index"));

changeSessionId()这一步很多人会省略。它的作用是登录成功后换一个新的会话标识,防止攻击者事先拿到一个 sessionId 再诱导用户登录。答辩时如果被问到「你的系统怎么防会话固定」,这一行就是现成的答案。

3.2 成绩录入:先把校验写死,再谈批量提交

成绩录入有两个入口:教师单条录入,以及从 Excel 整理好之后批量提交。不管哪个入口,落到 DAO 上的都是同一个批量插入方法,因为逐条insert在几十条数据时就会明显变慢,事务边界也不好控制。

public int batchSave(List<Score> list) throws SQLException { // 唯一索引冲突时改为更新,避免重复录入报错 String sql = "INSERT INTO score(student_id, course_id, term, exam_type, score, input_by) " + "VALUES(?,?,?,?,?,?) " + "ON DUPLICATE KEY UPDATE score = VALUES(score), " + "input_by = VALUES(input_by), input_time = NOW()"; Connection conn = null; PreparedStatement ps = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 一批成绩要么全进,要么全退 ps = conn.prepareStatement(sql); for (Score s : list) { ps.setInt(1, s.getStudentId()); ps.setInt(2, s.getCourseId()); ps.setString(3, s.getTerm()); ps.setInt(4, s.getExamType()); ps.setBigDecimal(5, s.getScore()); ps.setInt(6, s.getInputBy()); ps.addBatch(); } int[] rows = ps.executeBatch(); conn.commit(); return rows.length; } catch (SQLException e) { if (conn != null) { conn.rollback(); // 任何一条失败整批回滚 } throw e; } finally { DBUtil.close(ps, conn); } }

ON DUPLICATE KEY UPDATE是这段代码的核心。它把「判断存不存在」和「插入或更新」合并成一条原子语句,交给数据库自己保证一致性,Service 层不需要先select再分支。MySQL 5.7 和 8.x 都支持这种写法,VALUES(score)在 8.0.20 之后会有废弃警告,但功能正常;如果用的是较新版本,可以换成AS new的行别名写法。

真正容易出问题的是校验环节。分数必须是 0 到 100 之间,不能是负数,不能是null,不能是「缺考」这种中文。我的做法是在 Service 入口一次性校验完,全部合法才进 DAO:

for (Score s : list) { BigDecimal v = s.getScore(); if (v == null || v.compareTo(BigDecimal.ZERO) < 0 || v.compareTo(new BigDecimal("100")) > 0) { throw new BizException("学号 " + s.getStudentNo() + " 的成绩不在 0~100 区间"); } }

注意这里的错误信息带上了学号。批量导入时如果只提示「成绩不合法」,教师根本不知道是哪一条出的问题,只能一行行翻,体验很差。带上定位信息,前端弹窗直接显示,改一行重传就行。

注意:不要在前端用 JS 做唯一校验。前端能改、能绕过,真正的守门人必须是数据库约束加后端校验,前端校验只是为了少一次请求。

3.3 成绩查询:动态条件拼接与物理分页

查询页的筛选条件有四个:班级、课程、学期、学号或姓名关键字。这四个都是可选的,用户可能只填其中一个,也可能一个都不填。用拼接 SQL 是必然的,关键是参数必须走PreparedStatement,不能拼进字符串。

public PageResult<ScoreVO> page(ScoreQuery q) throws SQLException { StringBuilder where = new StringBuilder(" WHERE 1=1 "); List<Object> params = new ArrayList<>(); if (q.getClassId() != null) { where.append(" AND st.class_id = ? "); params.add(q.getClassId()); } 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.getKeyword())) { where.append(" AND (st.name LIKE ? OR st.student_no LIKE ?) "); String like = "%" + q.getKeyword().trim() + "%"; params.add(like); params.add(like); } String baseFrom = " FROM score s " + " JOIN student st ON s.student_id = st.student_id " + " JOIN course c ON s.course_id = c.course_id " + " JOIN clazz cl ON st.class_id = cl.class_id "; // 先查总数,再查当页数据,分页组件需要 total long total = count(baseFrom + where, params); int offset = (q.getPageNo() - 1) * q.getPageSize(); String listSql = "SELECT s.score_id, st.student_no, st.name, cl.class_name, " + "c.course_name, s.term, s.exam_type, s.score " + baseFrom + where + " ORDER BY st.student_no LIMIT ?, ?"; params.add(offset); params.add(q.getPageSize()); // ... 执行查询并映射成 ScoreVO }

用WHERE 1=1开头是为了让后面每个条件都能以AND起手,不用判断「这是不是第一个条件」。这种写法看起来有点粗糙,但在动态查询里是最不容易出错的。ORDER BY必须显式指定,否则 MySQL 返回的顺序不确定,翻页时会出现同一条数据在两页都出现的情况。

分页用LIMIT offset, size做物理分页,而不是把所有数据查出来在 Java 里subList。课程设计的数据量小,两种写法看不出差别;但只要成绩表过了几万条,全量查询会把 Tomcat 的内存迅速消耗干净,页面直接卡死。这个习惯建议从第一天就养成。

4. ECharts 统计图模块:从聚合 SQL 到前端渲染的数据链路

4.1 统计接口:一个 Servlet 按 type 分发四类数据

统计模块是整个系统里唯一需要向前端返回 JSON 的地方。我没有为每种图表单独建一个 Servlet,而是用一个/api/stats/*加路径参数分发,减少重复代码:

@WebServlet("/api/stats/*") public class StatsServlet extends HttpServlet { private final StatsService statsService = new StatsService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("application/json;charset=UTF-8"); String type = req.getPathInfo(); // /courseAvg、/distribution 等 String term = req.getParameter("term"); Integer courseId = parse(req.getParameter("courseId")); Object data; switch (type == null ? "" : type) { case "/courseAvg": data = statsService.courseAvg(term); break; case "/classAvg": data = statsService.classAvg(term); break; case "/distribution": data = statsService.distribution(courseId, term); break; case "/trend": data = statsService.termTrend(courseId); break; default: resp.sendError(404); return; } resp.getWriter().write(JsonUtil.toJson(data)); } }

接口返回结构统一成两个字段:categories存横轴标签,series存数值数组。这样前端拿到就能直接用,不用为每个图写一套解析逻辑。

{ "categories": ["高等数学", "大学英语", "数据结构", "操作系统"], "series": [78.35, 81.20, 74.60, 79.05] }

提示:resp.setContentType("application/json;charset=UTF-8")这一行不能省。少了它,浏览器会按 HTML 解析,前端response.json()直接抛异常,控制台报的是「Unexpected token <」,看着像接口 404,实际是编码问题。

如果项目里没有引入 Gson 或 Jackson,用StringBuilder手拼也能用,但要注意数值不要加引号、字符串里的双引号要转义。数据里有中文课程名时,转义这一步漏掉,图表标签就会变成空白。

4.2 聚合 SQL 与分数段分桶

四张图背后是四段聚合查询。课程平均分这张用得最多,它同时给出平均分、最高分、最低分和及格率:

SELECT c.course_name, COUNT(*) AS cnt, ROUND(AVG(s.score), 2) AS avg_score, MAX(s.score) AS max_score, MIN(s.score) AS min_score, ROUND(SUM(CASE WHEN s.score >= 60 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS pass_rate FROM score s JOIN course c ON s.course_id = c.course_id WHERE s.term = ? AND s.exam_type = 1 GROUP BY c.course_id, c.course_name ORDER BY avg_score DESC;

GROUP BY里带上c.course_id是有原因的。如果只按course_name分组,两门同名但不同编号的课会被合并成一条,数据就对不上了。虽然实际中很少出现同名课程,但这种「看起来没错」的写法一旦出问题,排查成本极高。

分数段分布图用的是CASE WHEN分桶,这段 SQL 有个细节容易被忽略:

SELECT CASE WHEN score < 60 THEN '不及格' WHEN score < 70 THEN '60-69' WHEN score < 80 THEN '70-79' WHEN score < 90 THEN '80-89' ELSE '90-100' END AS bucket, COUNT(*) AS cnt FROM score WHERE course_id = ? AND term = ? AND exam_type = 1 GROUP BY bucket ORDER BY FIELD(bucket, '不及格', '60-69', '70-79', '80-89', '90-100');

ORDER BY FIELD(...)是必须的。MySQL 对GROUP BY别名的排序默认按字符串字典序,结果是「60-69、70-79、80-89、90-100、不及格」——「不及格」会排到最后,柱状图看起来完全不符合直觉。用FIELD显式指定顺序,图表的横轴才和人的认知一致。

学期趋势图则按term分组,用来回答「这个班成绩是在进步还是退步」。这四类查询都建在idx_course_term索引上,几百上千条数据基本是毫秒级返回。

4.3 前端 option:柱状图作为主图,平均线作参照

图表容器的高度必须在 CSS 里写死或给最小值,这是 ECharts 最常见的「不显示」原因。容器高度是 0,init能成功,但画布高度为零,页面上什么都看不到。

// 课程平均分柱状图 + 全局平均线 const dom = document.getElementById('courseAvgChart'); const chart = echarts.init(dom); fetch(ctx + '/api/stats/courseAvg?term=' + encodeURIComponent(term)) .then(resp => resp.json()) .then(data => { const all = data.series; const avg = all.reduce((a, b) => a + b, 0) / (all.length || 1); chart.setOption({ tooltip: { trigger: 'axis' }, grid: { left: 40, right: 20, bottom: 60, top: 40 }, xAxis: { type: 'category', data: data.categories, axisLabel: { interval: 0, rotate: 30 } // 课程名长,倾斜避免遮挡 }, yAxis: { type: 'value', min: 0, max: 100, name: '分数' }, series: [{ name: '平均分', type: 'bar', data: all, barMaxWidth: 40, itemStyle: { color: '#4a7fd4' }, markLine: { silent: true, symbol: 'none', data: [{ type: 'average', name: '总平均' }] }, label: { show: true, position: 'top', formatter: '{c}' } }] }); }); // 窗口尺寸变化时重绘,否则侧边栏收起后图表会错位 window.addEventListener('resize', () => chart.resize());

axisLabel.interval设为 0 表示强制显示所有标签,不设的话 ECharts 会自动抽稀,课程一多就只剩几个名字,答辩时被问到「这个柱子是哪门课」会很尴尬。markLine用type: 'average'让 ECharts 自己算平均线,省得在 Java 端再算一次。

饼图和折线图的配置结构基本相同,区别只在series.type和数据维度。四张图共用一套resize监听和一个统一的term下拉框,切换学期时把四个接口重新请求一遍即可。

5. 避坑与常见问题排查:五个最容易在答辩前夜翻车的点

5.1 中文乱码:三条链路要一起改

现象:表单里输入的中文姓名,存进数据库变成???,或者页面上显示成æŽå°。

原因:乱码可能发生在三个位置——请求体解码、数据库连接、表字符集。只改一处,另外两处照样出问题。Tomcat 对 POST 请求默认按 ISO-8859-1 解码,JDBC 连接串不写字符集就用服务端默认值,建表时没指定utf8mb4又会用库的默认字符集。

解决:三处一起改。第一,在 Filter 链最前面加一个EncodingFilter,执行request.setCharacterEncoding("UTF-8"),注意必须排在LoginFilter之前;第二,JDBC URL 加上?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai;第三,建表和建库统一DEFAULT CHARSET=utf8mb4。改完之后要清一次 Tomcat 的work目录,因为编译过的 JSP 会被缓存,旧编码的结果还会继续出现。

5.2 404 与 500:映射没生效还是依赖发生冲突

现象:浏览器访问/score/list报 404,或者进了页面直接 500,堆栈里出现ClassNotFoundException: javax.servlet.http.HttpServlet。

原因:404 多半是@WebServlet没生效——web.xml里写了metadata-complete="true"会关闭注解扫描,注解就变成摆设。500 里的ClassNotFoundException则是把servlet-api.jar一起打进了WEB-INF/lib,与 Tomcat 自带的版本冲突,容器加载时找不到正确的实现。

解决:先看web.xml的metadata-complete属性,用注解就设成false或不写;再检查WEB-INF/lib下有没有servlet-api.jar和jsp-api.jar,有就删掉,编译时用provided作用域引入。另外,JSP 放在WEB-INF/views下时浏览器访问/WEB-INF/views/xx.jsp一定是 404,这是规范限制,只能通过 Servlet 转发进去。

5.3 重复录入:唯一索引比 if 判断更可靠

现象:同一个学生的同一门课,成绩表里出现了两条记录,统计出来的平均分偏低。

原因:Service 层的写法是「先select判断有没有,没有就insert」。两个人同时提交、或者教师连点两次提交按钮,两个线程都查到「不存在」,然后都执行了插入。

解决:把约束下沉到数据库,建UNIQUE KEY uk_stu_course (student_id, course_id, term, exam_type),插入语句改用ON DUPLICATE KEY UPDATE。同时在前端提交按钮上做一次禁用,防止连击。这是唯一能真正解决问题的组合——前端防的是误操作,数据库防的是并发。已经存在重复数据的老库,要先按唯一键分组、保留score_id最大的一条,再补建索引。

5.4 图表一片空白:九成是接口没返回 JSON

现象:页面其他部分正常,只有 ECharts 那块是白的,控制台报Unexpected token '<'。

原因:最常见的是统计接口被LoginFilter拦了,返回的是一个 302 重定向到登录页,前端拿到的是 HTML,response.json()解析失败。其次是 Servlet 的urlPatterns写成/api/stats而不是/api/stats/*,getPathInfo()拿到null,直接走到default分支返回 404。第三是容器 CSS 没给高度。

解决:在浏览器开发者工具的 Network 面板里看那个请求,如果状态是 302 或者响应体是 HTML,就是拦截问题,把/api/*加进 Filter 的放行判断,或者在统计接口内部自行校验 session 并返回 401 JSON。如果getPathInfo()为null,检查urlPatterns末尾的*。最后在 CSS 里给容器写死height: 360px,别用height: 100%去撑一个没有高度的父元素。

5.5 连接泄漏:页面越点越慢的元凶

现象:刚启动时响应很快,用了十几分钟后页面开始转圈,最后整个应用无响应,日志里出现Timeout waiting for idle object。

原因:DAO 里拿了Connection和PreparedStatement,但只在正常分支里close(),catch块里忘了关,或者ResultSet压根没关。每次异常都漏掉一个连接,连接池很快就被占满。

解决:把所有资源的关闭统一放到finally里,或者干脆用 try-with-resources。如果没上连接池,直接用DriverManager每次新建连接,性能会很差,建议至少引入一个轻量连接池,把最大连接数控制在 20 到 50 之间。排查阶段可以临时在DBUtil.getConnection()里打印获取和归还的计数,两个数字差距持续扩大,就说明有地方在漏。

6. 验收与答辩:一份能照着念的演示脚本与一张验收清单

系统做完不等于能交。答辩现场只有十来分钟,顺序错了或者中间卡一次,后面全乱。我的习惯是提前写一份演示脚本,按「角色切换 + 功能递进」排好,每一屏都对应一个能说出口的设计点。

步操作路径演示要点预期结果
1管理员登录 → 用户管理密码不入库明文、角色字段区分列表分页正常,新增学生可登录
2教师登录 → 成绩录入输入 105 分时的校验提示前端拦截 + 后端报错,不入库
3同一学生同一课再录一次唯一索引与覆盖更新表里仍是一条记录,分数已更新
4成绩查询 → 组合三个条件动态 SQL 与物理分页结果正确,翻页不重复
5统计页 → 切换学期四张图联动刷新柱状图、饼图、折线图数据一致
6用学生账号访问/teacher/score/input权限拦截403,不跳登录页
7直接访问/WEB-INF/views/scoreList.jsp目录保护404

演完这套流程,基本把「权限、校验、并发、统计、安全」五个点都覆盖到了。剩下的时间留给提问,常见的问题无非这几类:为什么不用框架、Filter 和拦截器的区别、唯一索引为什么放在数据库、ECharts 的数据从哪来。前三个用上面的分层表和uk_stu_course就能答;最后一个直接打开StatsServlet,指出switch分发的四个分支,再切到对应的聚合 SQL,比嘴上解释清楚得多。

有个细节值得单独提醒:演示前一定要用另开一个浏览器的无痕窗口登录学生账号。同一个浏览器里 session 是共享的,用教师账号登录后再开标签页访问学生路径,看起来像越权成功了,实际上是 session 没切换,现场很容易被问住,解释起来也尴尬。

血泪经验是,我每次交东西前都会强制走一遍「换角色把所有菜单点一遍」的流程,特别是那些平时不用的入口。上一次翻车就出在这里:一个只在教师端出现的导出按钮,参数拼的是班级 id,而测试数据里恰好没有那个班级,点下去白屏。这种问题在开发阶段几乎不可能发现,只有按角色完整走一遍才会遇到。

希望这份结构能帮到你,把系统跑通只是起点,把每个设计决策讲清楚才是验收的关键。

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

RELATED READING

延伸阅读

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