ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JavaWeb成绩管理系统:Servlet+JSP+MySQL+ECharts

JavaWeb成绩管理系统:Servlet+JSP+MySQL+ECharts

简介:面向JavaWeb课程设计、毕业设计入门及二次开发学习者的一套学生成绩管理系统源码包,技术栈为Servlet+jsp+MySQL,在原版基础上加入ECharts统计图表,可用于成绩录入、查询、修改及可视化分析等典型教学场景。压缩包共344个文件,约3.59MB,含86个java源文件与86个class文件、37个jsp页面、62个js脚本、28个css样式,另有6个jar依赖、1个sql数据库脚本及图片字体等静态资源,覆盖后端分层、前端交互与建库部署所需。项目按Dao、Service、Servlet等层次组织,便于理解用户、学生、教师、课程与成绩模块的调用关系,也能参考文件上传下载等实现方式,对课程作业、功能演示和二次开发具有实际参考价值。目前已有6724人学习下载,适合需要完整案例对照练习或在此基础上扩展统计功能的读者。

1. 学生成绩管理系统:从一张 Excel 汇总表说起的 JavaWeb 实战项目

学期末最典型的场景是:教务老师手里有三四个版本的 Excel 成绩表,班主任手里有另一份,任课老师又交来一份格式不同的,最后谁也不敢说哪份是最新的。这个基于 Servlet + JSP + MySQL 的学生成绩管理系统,解决的就是这类手工汇总问题——把学生、课程、成绩、班级、用户五个维度落到数据库里,登录后按角色分配菜单,成绩录入、条件查询、分页展示、及格率统计一路走完,最后用 ECharts 把平均分、分数段分布、班级对比画成图。它不是什么企业级中台,而是一个结构完整、代码量可控的 JavaWeb 单体应用,适合正在做课程设计、毕业设计,或者想从零把 MVC 分层跑通的同学。技术栈很朴素:Servlet 做控制器,JSP 做视图,JDBC 直连 MySQL,前端 JQuery 发请求,ECharts 负责统计图渲染。下面按「项目长什么样 → 怎么跑起来 → 核心链路怎么写的 → 图表模块怎么接的 → 踩过哪些坑 → 怎么二次开发」的顺序拆开讲。

2. 技术选型与工程结构:一个能直接跑起来的 Maven Web 工程

2.1 Servlet 与 JSP 的分工边界:谁该干什么

这个项目用的是最经典的 Model2 模式,也就是 Servlet 只负责收参数、调业务、转发视图,JSP 只负责把数据渲染成 HTML,两者之间用 request 域或者 Session 传值。很多同学写 JavaWeb 容易写成「Servlet 里 out.print 拼 HTML」,那样改一次页面就要动 Java 代码,维护成本极高。这个项目的边界划得比较清楚:所有/score/*、/student/*的请求都走 Servlet,Servlet 处理完之后request.getRequestDispatcher("/WEB-INF/views/score/list.jsp").forward(request, response),页面放在 WEB-INF 下,浏览器直接访问不到,只能由 Servlet 转发,顺带就挡住了绕过登录直接敲 URL 的情况。

为什么不直接用 JSP 里写<jsp:useBean>或者脚本片段查数据库?因为那样 SQL 会散落在几十个 JSP 里,改一张表结构要全站搜索。分层之后,DAO 层只有一处写 SQL,Servlet 只有一处做参数校验,出问题的时候排查路径是明确的。这也是我一般建议新手先照着抄一遍的原因——先体会「改动一个字段要碰几个文件」,再谈框架。

2.2 目录结构:Maven 标准 Web 工程长什么样

项目按 Maven 的src/main/java、src/main/webapp、src/main/resources三件套组织,下面是实际落地的目录树,照着建就不会出现「类编译了但 Tomcat 找不到」的问题:

student-score/ ├── pom.xml └── src/main/ ├── java/com/example/score/ │ ├── controller/ # Servlet 控制器 │ │ ├── LoginServlet.java │ │ ├── StudentServlet.java │ │ ├── ScoreServlet.java │ │ └── ChartServlet.java │ ├── service/ # 业务逻辑 │ │ ├── ScoreService.java │ │ └── ChartService.java │ ├── dao/ # JDBC 数据访问 │ │ ├── BaseDao.java │ │ ├── StudentDao.java │ │ └── ScoreDao.java │ ├── entity/ # 实体类,字段与表一一对应 │ │ ├── Student.java │ │ ├── Course.java │ │ └── Score.java │ ├── filter/ # 登录拦截、编码过滤 │ │ ├── LoginFilter.java │ │ └── EncodingFilter.java │ └── util/ # 连接池、JSON 工具 │ ├── DBUtil.java │ └── JsonUtil.java ├── resources/ │ └── db.properties # 数据库连接参数 └── webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ # JSP 视图,外部不可直接访问 │ ├── login.jsp │ ├── score/list.jsp │ └── chart/index.jsp ├── static/ │ ├── js/echarts.min.js │ ├── js/jquery.min.js │ └── css/style.css └── index.jsp # 入口,重定向到登录

这里有个细节值得说:static目录放前端资源,WEB-INF/views放 JSP。前者可以被浏览器直连,后者不行。这样划分之后,静态资源交给 Tomcat 默认 Servlet 处理,JSP 只做内部转发,权限控制就集中在一个 Filter 里,不用在每个页面顶部写「判断 Session 是否存在」。

2.3 依赖与配置:pom.xml 与 db.properties

依赖控制在四个以内,避免新手被依赖冲突拖住:Servlet API(provided,因为 Tomcat 自带)、JSTL(页面循环和格式化)、MySQL 驱动、JSON 库(手写拼 JSON 也行,但用库更稳)。连接池用 Druid 或者 HikariCP,项目里常见做法是 Druid,配置简单,监控页面也方便排查连接泄漏。

<dependencies> <!-- Servlet API 由 Tomcat 提供,scope 必须是 provided,否则打包进 war 会和容器冲突 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSTL 用于 JSP 中的 c:forEach、fmt:formatNumber --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 8 驱动,注意驱动类名是 com.mysql.cj.jdbc.Driver --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- Druid 连接池,负责连接的复用与回收 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> </dependencies>

参数说明:scope=provided表示编译期可见、运行期由容器提供,这是 Servlet 项目最常见的翻车点之一;MySQL 8 的useSSL、serverTimezone两个参数不写会在启动时抛时区异常。数据库配置单独放db.properties,由DBUtil静态块加载,避免把账号密码写死在 Java 代码里:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/score_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=123456

useUnicode=true&characterEncoding=utf8这两个参数决定了写入的中文姓名会不会变成问号,serverTimezone=Asia/Shanghai解决 8 小时时差,useSSL=false是本地开发常用写法,生产环境应当开启。配置文件放在src/main/resources下,编译后自动进入WEB-INF/classes,DBUtil用getResourceAsStream("/db.properties")就能读到,不用关心部署路径。

3. 数据库与成绩业务链路:五张表撑起整个系统

3.1 表结构设计:用户、学生、课程、成绩、班级

成绩管理看起来简单,但表设计一旦做歪,后面统计图基本没法写。核心是五张表:sys_user存登录账号和角色,student存学号姓名班级,course存课程名和学分,score是中间表,存学生、课程、分数,clazz存班级名。关键是score表上要建(student_id, course_id)联合唯一索引,防止同一门课被录两次。

-- 用户表:role 区分管理员、教师、学生三种角色 CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, role TINYINT NOT NULL DEFAULT 3 COMMENT '1管理员 2教师 3学生', ref_id INT COMMENT '学生角色时关联 student.id' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 学生表:class_id 关联班级,后续按班级分组统计 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, gender TINYINT DEFAULT 1, class_id INT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 成绩表:联合唯一索引防止重复录入 CREATE TABLE score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2) NOT NULL DEFAULT 0, term VARCHAR(20) NOT NULL COMMENT '学期,如 2024-2025-1', UNIQUE KEY uk_stu_course_term (student_id, course_id, term), KEY idx_course (course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

参数说明:DECIMAL(5,2)比FLOAT更适合存分数,避免 59.999999 这种比较尴尬的误差;term字段冗余在成绩表里,而不是另建学期表,是为了让分页查询少一次 JOIN,代价是学期名称变更时要批量更新,属于这个体量项目可以接受的取舍。idx_course索引是给统计查询用的,按课程聚合平均分时会走这个索引。

3.2 连接池与基类:DBUtil 和 BaseDao 怎么写

JDBC 裸写最容易出两个问题:连接忘了关、SQL 拼接被注入。项目用 Druid 做连接池,DBUtil只负责返回DataSource,所有Connection的关闭交给BaseDao里的模板方法统一处理,子类只关心 SQL 和参数。

public class DBUtil { private static DataSource ds; static { Properties p = new Properties(); try (InputStream in = DBUtil.class.getResourceAsStream("/db.properties")) { p.load(in); DruidDataSource dataSource = new DruidDataSource(); dataSource.setDriverClassName(p.getProperty("jdbc.driver")); dataSource.setUrl(p.getProperty("jdbc.url")); dataSource.setUsername(p.getProperty("jdbc.username")); dataSource.setPassword(p.getProperty("jdbc.password")); dataSource.setInitialSize(5); // 初始化连接数 dataSource.setMaxActive(20); // 最大连接数,超出会等待 dataSource.setMaxWait(3000); // 等待超时 3 秒,避免线程长时间挂起 ds = dataSource; } catch (Exception e) { throw new ExceptionInInitializerError("数据库连接池初始化失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return ds.getConnection(); } }

BaseDao提供两个泛型方法:queryList和update。前者接收 SQL、参数数组和一个RowMapper回调,后者返回受影响行数。这样ScoreDao里只剩下 SQL 语句和字段映射,没有一行try-catch-finally。

public abstract class BaseDao { // 查询多行,mapper 负责把 ResultSet 当前行转成实体 protected <T> List<T> queryList(String sql, RowMapper<T> mapper, Object... params) { List<T> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); // 下标从 1 开始,别写错 } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapper.map(rs)); } } } catch (SQLException e) { throw new RuntimeException("查询失败: " + sql, e); } return list; } // 增删改统用,返回影响行数 protected int update(String sql, Object... params) { try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("更新失败: " + sql, e); } } public interface RowMapper<T> { T map(ResultSet rs) throws SQLException; } }

参数说明:setObject的下标从 1 开始,这是 JDBC 规范,写 0 会直接抛异常;try-with-resources保证Connection、PreparedStatement、ResultSet三层都能关闭,连接池里的连接会被归还而不是真正销毁;RowMapper用函数式接口,调用方可以直接传 Lambda,省掉一堆匿名内部类。这套模板一旦写好,后面加十张表都只写 SQL。

3.3 登录与权限过滤:Session + Filter 的组合

登录流程是:LoginServlet校验账号密码,成功后把userId、role、realName塞进 Session,然后重定向到首页。LoginFilter拦截所有/score/*、/student/*、/chart/*请求,检查 Session 里有没有用户,没有就跳回登录页。这里要注意 Filter 的放行名单,登录页、登录接口、静态资源必须排除,否则会陷入重定向死循环。

@WebFilter(urlPatterns = {"/score/*", "/student/*", "/chart/*"}) public class LoginFilter implements Filter { // 无需登录即可访问的路径前缀 private static final List<String> WHITE_LIST = Arrays.asList( "/login", "/static/", "/api/login" ); @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()); for (String prefix : WHITE_LIST) { if (uri.startsWith(prefix)) { chain.doFilter(req, resp); return; } } Object user = request.getSession().getAttribute("loginUser"); if (user == null) { // 区分 AJAX 请求和页面请求,AJAX 返回 401 让前端跳转 if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) { response.setStatus(401); } else { response.sendRedirect(request.getContextPath() + "/login"); } return; } chain.doFilter(req, resp); } }

参数说明:urlPatterns用通配符匹配一整组路径,比在 web.xml 里逐个配置清晰;X-Requested-With是 JQuery 的$.ajax默认带上的请求头,用它区分 AJAX 和整页跳转,避免 AJAX 请求收到一整段 HTML 登录页导致前端解析报错。编码过滤EncodingFilter要排在登录过滤之前,统一request.setCharacterEncoding("UTF-8"),否则 POST 表单里的中文姓名会在 Filter 阶段就已经乱码,后面再转都来不及。

3.4 条件分页查询:一条 SQL 同时解决筛选和翻页

成绩列表页支持按学号、姓名、课程、学期筛选,还要分页。新手容易写成「先查全量再在内存里 subList」,数据量一大就完蛋。正确做法是两次查询:一次COUNT(*)拿总数,一次带LIMIT拿当前页数据,两个查询共用同一段WHERE条件。

public PageResult<ScoreVO> pageQuery(ScoreQuery query, int pageNo, int pageSize) { StringBuilder where = new StringBuilder(" WHERE 1=1 "); List<Object> params = new ArrayList<>(); if (StringUtils.isNotBlank(query.getSno())) { where.append(" AND s.sno LIKE ? "); params.add("%" + query.getSno() + "%"); } if (StringUtils.isNotBlank(query.getCourseName())) { where.append(" AND c.name LIKE ? "); params.add("%" + query.getCourseName() + "%"); } if (StringUtils.isNotBlank(query.getTerm())) { where.append(" AND sc.term = ? "); params.add(query.getTerm()); } String from = " FROM score sc " + " JOIN student s ON sc.student_id = s.id " + " JOIN course c ON sc.course_id = c.id "; // 第一条:总数,用于算总页数 long total = queryCount("SELECT COUNT(*) " + from + where, params.toArray()); // 第二条:当前页数据,ORDER BY 保证分页稳定 String dataSql = "SELECT s.sno, s.name, c.name AS courseName, sc.score, sc.term " + from + where + " ORDER BY s.sno, c.id LIMIT ?, ?"; params.add((pageNo - 1) * pageSize); params.add(pageSize); List<ScoreVO> rows = queryList(dataSql, rs -> { ScoreVO vo = new ScoreVO(); vo.setSno(rs.getString("sno")); vo.setName(rs.getString("name")); vo.setCourseName(rs.getString("courseName")); vo.setScore(rs.getBigDecimal("score")); vo.setTerm(rs.getString("term")); return vo; }, params.toArray()); return new PageResult<>(total, pageNo, pageSize, rows); }

逻辑说明:WHERE 1=1是为了后面统一用AND拼接,不用判断「是不是第一个条件」;所有用户输入都走?占位符,彻底杜绝 SQL 注入,这个项目里没有任何一处字符串拼接 SQL 值;ORDER BY必须加,否则 MySQL 的LIMIT在无排序时返回顺序不稳定,会出现「第二页出现第一页的数据」这种玄学问题;PageResult里同时算好totalPages,JSP 只负责循环页码按钮。

4. ECharts 统计图模块:后端出 JSON,前端只管渲染

4.1 图表数据接口:把聚合 SQL 转成 ECharts 能吃的结构

新增的统计图模块是这个项目最有价值的部分,因为它把「数据库聚合」和「前端可视化」串起来了。核心思路是:ChartServlet接收type参数(courseAvg / classPass / scoreRange),调用ChartService执行对应的聚合 SQL,把结果拼成 JSON 返回,JSP 里用$.getJSON拿到数据后setOption。后端不生成任何 HTML,只输出数据,这样将来换成 Vue 或者小程序,接口可以原样复用。

@WebServlet("/chart/data") public class ChartServlet extends HttpServlet { private final ChartService chartService = new ChartService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("application/json;charset=UTF-8"); String type = req.getParameter("type"); String term = req.getParameter("term"); // 可选,按学期过滤 Map<String, Object> result = new HashMap<>(); try { switch (type == null ? "" : type) { case "courseAvg": result = chartService.courseAvg(term); break; case "classPass": result = chartService.classPassRate(term); break; case "scoreRange": result = chartService.scoreDistribution(term); break; default: result.put("msg", "未知的图表类型"); resp.setStatus(400); } result.put("code", 200); } catch (Exception e) { resp.setStatus(500); result.put("code", 500); result.put("msg", "统计查询失败"); } resp.getWriter().write(JsonUtil.toJson(result)); } }

参数说明:type决定走哪条聚合逻辑,term是可选过滤条件,为空则统计全部学期;resp.setContentType("application/json;charset=UTF-8")必须显式设置,否则浏览器会按 HTML 解析,前端$.getJSON直接进 error 回调;异常时返回 500 和code字段,前端可以统一提示,而不是把堆栈打到页面上。

4.2 聚合 SQL:三种图表背后的三条查询

课程平均分图需要「每门课的平均分」,班级及格率图需要「每个班的及格人数除以总人数」,分数段分布图需要把分数按区间分组。三条 SQL 都不复杂,但都有坑:平均分要ROUND保留两位,及格率要防止除零,分数段用CASE WHEN分组后要补全没有数据的区间。

public Map<String, Object> courseAvg(String term) { String sql = "SELECT c.name AS courseName, ROUND(AVG(sc.score), 2) AS avgScore " + "FROM score sc JOIN course c ON sc.course_id = c.id " + (StringUtils.isNotBlank(term) ? "WHERE sc.term = ? " : "") + "GROUP BY c.id, c.name ORDER BY avgScore DESC"; List<Map<String, Object>> rows = queryForMapList(sql, term); return buildBarData(rows, "courseName", "avgScore"); } public Map<String, Object> scoreDistribution(String term) { // 分五档统计,CASE WHEN 的顺序必须从高到低,否则 90 会先命中 60 那档 String 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' " + " ELSE '0-59' END AS rangeName, " + " COUNT(*) AS cnt " + "FROM score sc " + (StringUtils.isNotBlank(term) ? "WHERE sc.term = ? " : "") + "GROUP BY rangeName"; List<Map<String, Object>> rows = queryForMapList(sql, term); return buildBarData(rows, "rangeName", "cnt"); }

参数说明:GROUP BY c.id, c.name同时带上 id,是因为课程名可能重复,只按 name 分组会合并两门同名课;CASE WHEN从上到下匹配,>= 90必须写在最前面,写反了所有 90 分以上都会落进>= 60那一档;queryForMapList是BaseDao里另加的一个方法,把ResultSet转成List<Map>,专门给图表用,省去为每条统计 SQL 建实体类。

4.3 前端渲染:JSP 里挂载容器与异步加载

JSP 页面本身很薄,只放三个div容器,初始化交给 ECharts。关键是echarts.init必须在容器有宽高之后调用,并且窗口resize时要调chart.resize(),否则侧边栏收起或窗口缩放后图表会变形。

// chart/index.jsp 中的脚本片段 var courseChart = echarts.init(document.getElementById('courseAvg')); function loadBar(url, chart, title) { $.getJSON(ctx + url, function (res) { if (res.code !== 200) { alert(res.msg); return; } chart.setOption({ title: { text: title, left: 'center' }, tooltip: { trigger: 'axis' }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'category', data: res.xAxis, axisLabel: { interval: 0, rotate: 20 } // 课程名长时倾斜,避免重叠 }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: res.series, label: { show: true, position: 'top' } }] }); }); } // 页面加载完成后再初始化,保证容器已有尺寸 $(function () { loadBar('/chart/data?type=courseAvg', courseChart, '各课程平均分'); loadBar('/chart/data?type=scoreRange', scoreChart, '分数段分布'); window.onresize = function () { courseChart.resize(); scoreChart.resize(); }; });

逻辑说明:ctx是 JSP 里通过${pageContext.request.contextPath}注入的上下文路径,直接写/chart/data在非根路径部署时会 404;axisLabel.interval: 0强制显示所有类目名,课程数量少的时候必须加;rotate: 20是血泪经验,课程名一长,横轴标签就会叠成一团黑。这套「后端返回xAxis+series两个数组」的约定,让三种图表共用同一个loadBar函数,只有饼图需要单独写一个loadPie,因为它的series.data是{name, value}对象数组。

5. 踩坑排查:五类高频故障的现象、原因与处置

5.1 中文全部变成问号或者乱码

现象:录入的学生姓名在列表页显示正常,但写入数据库后用客户端查是???,或者反过来页面显示乱码。原因通常有三层:JSP 页面没写<%@ page contentType="text/html;charset=UTF-8" %>、POST 请求没设setCharacterEncoding("UTF-8")、数据库连接串缺characterEncoding=utf8。解决顺序是先查连接串,再查EncodingFilter是否在所有 Filter 之前执行,最后确认表字符集是utf8mb4而不是默认的latin1。三步都对了还乱码,基本就是 Tomcat 的server.xml里 Connector 没配URIEncoding,不过新版本 Tomcat 默认就是 UTF-8,一般不用动。

5.2 连接池报「wait millis 3000, active 20」然后整站卡死

现象:系统跑一段时间后所有查询都超时,日志里反复出现连接池等待超时。原因只有一个——某处Connection没有关闭。BaseDao用了 try-with-resources 不会漏,但手动写的方法、或者ChartService里临时加的查询很容易忘记。解决方式是打开 Druid 的监控页面/druid/index.html,看ActiveCount是不是只增不减;再在DBUtil里加removeAbandoned=true&removeAbandonedTimeout=60,让池子强制回收超过 60 秒未关闭的连接,先把线上稳住,再回去补close。

5.3 ECharts 图表区域一片空白,控制台无报错

现象:接口返回了正确的 JSON,但页面上什么都不显示。原因通常不是 ECharts 本身,而是容器没有高度。<div id="courseAvg"></div>这种写法,div 的高度是 0,echarts.init虽然不报错,但画布高度为 0,自然什么都看不到。解决方式是给容器写死高度style="width:100%;height:400px;",或者用 CSS 类统一设置。第二个常见原因是echarts.init在$(function(){})之外执行,DOM 还没加载完,拿到的是 null,这种会报错,反而好排查。

5.4 分页点第二页出现重复数据或数据缺失

现象:第一页 10 条,第二页还是那 10 条,或者中间少了几条。原因是没有ORDER BY的LIMIT在 MySQL 里顺序不稳定,尤其是带 JOIN 的查询。解决方式是给dataSql加一个唯一性足够的排序字段组合,比如ORDER BY s.sno, c.id;如果业务上要求按分数排序,就用ORDER BY sc.score DESC, sc.id ASC,把主键作为最后的兜底排序,保证任何时候顺序都是确定的。

5.5 本地跑得好好的,部署到服务器后静态资源 404

现象:页面样式全丢,ECharts 不加载,F12 看是/static/js/echarts.min.js返回 404。原因通常是 war 包部署在非根路径(比如/score),而页面里写的是绝对路径/static/...。解决方式是把所有静态资源引用改成${pageContext.request.contextPath}/static/...,或者在 JSP 顶部用<c:set var="ctx" value="${pageContext.request.contextPath}"/>统一变量。另外检查web.xml里 DispatcherServlet 的映射是不是url-pattern写成了/,把静态资源也拦截了,这个项目没用 SpringMVC,一般不会撞上,但二次开发引入框架后很容易踩。

6. 二次开发改造:从「能跑」到「能改」的几个切入点

拿到这个项目之后,改字段是最常见的需求,比如给student加一个「联系电话」。改动路径是:先ALTER TABLE加列,再在Student实体里加属性,然后改StudentDao的INSERT、UPDATE、SELECT三处 SQL 和RowMapper,最后在 JSP 表单里加一个input。五处漏一处就会出现「填了保存不上」或者「列表不显示」的问题,我一般的做法是先把 DAO 改完,用单元测试直连数据库验证一遍,再动 JSP,避免前后端同时改导致定位困难。

第二个改造点是角色权限。现在LoginFilter只判断「有没有登录」,如果要限制「只有教师能录成绩」,就在 Filter 里读 Session 中的role,对/score/save这类写操作路径做角色校验,或者在ScoreServlet的doPost里判断。更彻底的做法是加一张role_permission表,把路径和角色做配置化,但项目体量不大时没必要,硬编码一个if (role != 2) { resp.sendError(403); return; }就够了,代码可读性反而更好。

第三个改造点是导出 Excel。这是成绩管理系统绕不开的需求,思路是在ScoreServlet加一个action=export分支,用 POI 的XSSFWorkbook建表头、循环PageResult里的数据写行、response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")、Content-Disposition设文件名,最后workbook.write(response.getOutputStream())。注意文件名要做 URL 编码,中文名在部分浏览器会乱码;还要注意导出不能走分页,得单独写一个不分页的查询,否则只能导出当前页 10 条。

最后一个技巧是关于 ECharts 的:新增图表时,不要在后端为每种图写一个 Servlet 方法,而是把「数据组装」和「图表配置」分开。后端统一返回{xAxis: [], series: []}这种通用结构,前端用一个renderBar(domId, url, title)函数包住。这样加一个「教师工作量统计」只需要在ChartService里加一条 SQL,前端加一行调用,十分钟就能上线。

从那以后我每次接这类课程设计项目,都强制自己先跑通「登录 → 列表 → 新增 → 图表」这一条最小链路,再往上堆功能,因为这条链路一旦断了,后面加再多模块都是空中楼阁。希望这个拆解能帮你少走几段弯路,先把项目跑起来,再挑一处动手改,比通读十遍代码都管用,希望帮到你。

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

返回列表