简介:这是一套基于Java技术栈的学生成绩管理系统完整项目,采用Servlet+JSP+Bootstrap+MySQL四层协作,面向Java Web初学者、课程设计及毕业设计参考者,可用来掌握动态网页开发、MVC分层与数据库交互。压缩包共575个文件,约9.17MB,内含85个Java源码、74个JSP页面、122个JS脚本、56个CSS样式及12个JAR依赖库,并附带SQL数据库脚本,覆盖从后端逻辑到前端展示的完整链路。已有3117人学习使用。项目中包含学生管理、成绩录入、查询与统计等核心模块,实体类、DAO与Servlet分层清晰,便于对照理解请求处理、页面渲染和CRUD操作;Bootstrap组件与自定义CSS的结合也展示了如何在传统JSP项目中构建响应式界面。对想快速上手传统Web开发或准备相关实验报告的人,这份资源兼具参考性和可运行价值。
1. 学生成绩管理系统的Java老四样技术栈:现在还能打吗
用 Java+Servlet+JSP+Bootstrap+Mysql 这套组合去做学生成绩管理系统,放在今天看像考古,但课程设计和毕业设计里这种 Java Web 老四样项目一直是重灾区:老师要一个能跑、能讲、能改的完整案例,学生要一份自己能说清原理的源码。这个系统解决的就是典型增删改查场景——老师录入成绩、学生按学号查分、按班级统计平均分和不及格人数,技术栈恰好覆盖从数据库到浏览器的一条完整链路。适合 Java 基础刚入门、需要完整案例源码做课设或基于 JSP 的毕设选题的人,也适合想补 Java Web 基本功的初级开发。这套组合还有个附加价值:它涉及的问题基本也是 java 面试八股文里的高频考点。
2. 从数据库到页面的请求链路:Servlet+JSP 的核心分工
2.1 三层架构与包结构:DAO、Service、Servlet、JSP 各管什么
学生成绩管理系统规模不大,但我不建议把所有代码堆在一个 Servlet 里。见过太多课设源码是ScoreServlet里既拼 SQL 又输出 HTML,改一个字段要在三个页面来回找。常见做法是分 Controller(Servlet)、Service、DAO、View(JSP)四层,包结构固定下来,后面加功能只用对号入座。
com.sms ├── controller // Servlet,接收请求、调度视图 │ ├── LoginServlet.java │ ├── StudentServlet.java │ └── ScoreServlet.java ├── service // 业务逻辑,成绩校验、统计规则 │ ├── ScoreService.java │ └── impl │ └── ScoreServiceImpl.java ├── dao // 数据访问,只负责 JDBC 和 SQL │ ├── ScoreDao.java │ └── impl │ └── ScoreDaoImpl.java ├── entity // 实体类,对应数据库表 │ ├── Student.java │ ├── Course.java │ └── Score.java ├── util │ ├── DBUtil.java // 连接池初始化 │ └── ResultUtil.java // 统一返回结构,处理 json 或消息提示 └── filter └── EncodingFilter.java分层原则很简单:Servlet 里不出现 SQL,DAO 里不出现业务判断,JSP 里不写 Java 脚本片段。Service 这一层在成绩系统里主要管两件事,一是校验成绩数值在 0 到 100 之间,二是判断同学生同课程是否已经录过成绩。把这两段逻辑放在 DAO 里也能跑,但后面加业务规则时,Service 层是唯一的改动点,不会牵动页面。
2.2 一个成绩查询请求的完整流转
用户在前端输入关键字点查询,请求在系统里走这样一条链:
- 浏览器 GET 请求
/score/list?keyword=张三,带上下文路径,比如http://localhost:8080/sms/score/list。 - Tomcat 根据
@WebServlet("/score/*")的映射,把请求交给ScoreServlet。 ScoreServlet.doGet解析keyword参数,调用ScoreService.findByKeyword(keyword)。ScoreService判断 keyword 是否为空、是否要拼模糊查询,再调用ScoreDao.findByKeyword(keyword)。ScoreDaoImpl用 PreparedStatement 执行带 LIKE 的 SQL,从 Mysql 查出结果,封装成List<Score>。- Servlet 把列表放到 request 作用域,
request.getRequestDispatcher("/score_list.jsp").forward(req, resp)转发到 JSP。 - JSP 用 JSTL 遍历列表,渲染成 Bootstrap 表格,响应回浏览器。
这里有两个关键点。转发和重定向的区别后面单独说,先记住查询结果用转发,因为 JSP 需要拿到 request 里的scoreList属性。另外 Servlet 在整个过程里只做参数接收和视图调度,它不该知道 SQL 长什么样。这个链路在面试里也很常被追问,能画清楚这条线,Servlet 生命周期和 MVC 两题基本就稳了。
2.3 核心 Servlet 代码:doGet/doPost 与路由设计
成绩模块的 Servlet 我习惯用一个类处理列表、新增、修改、删除四个操作,用 URL 路径区分动作,而不是用?action=xxx的查询参数。REST 风格在传统 JSP 项目里用@WebServlet("/score/*")就能实现,路径可读性更好。
@WebServlet("/score/*") public class ScoreServlet extends HttpServlet { private ScoreService scoreService = new ScoreServiceImpl(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String path = req.getPathInfo(); // 返回 /list /delete 这类子路径 if ("/delete".equals(path)) { delete(req, resp); } else { // /list 和未知路径都走列表,避免空指针 list(req, resp); } } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String path = req.getPathInfo(); if ("/add".equals(path)) { add(req, resp); } else if ("/update".equals(path)) { update(req, resp); } else { resp.sendError(HttpServletResponse.SC_NOT_FOUND); } } private void list(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String keyword = req.getParameter("keyword"); List<Score> list = scoreService.findByKeyword(keyword); req.setAttribute("scoreList", list); req.setAttribute("keyword", keyword == null ? "" : keyword); req.getRequestDispatcher("/score_list.jsp").forward(req, resp); } private void delete(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String id = req.getParameter("id"); scoreService.deleteById(Integer.parseInt(id)); // 删除完重定向,防止刷新页面重复提交 resp.sendRedirect(req.getContextPath() + "/score/list"); } }说明几个参数和设计选择。@WebServlet("/score/*")里的*匹配/score后面的所有子路径,getPathInfo()拿到的就是*匹配到的部分,比如请求/score/list,path 就是/list。doGet 管查询和删除,doPost 管新增和修改,符合 HTTP 方法语义,也方便前端表单区分 method。req.setCharacterEncoding("UTF-8")放在每个方法开头是双保险,虽然后面还会加 Filter,但 Servlet 里再设一次能防止某些容器环境下 Filter 顺序异常导致乱码。删除操作用 GET 是课设里常见写法,链接点击即删除,生产系统必须改成 POST 加确认,这个第 5 章还会提。
2.4 编码过滤器、重定向与转发:两个必设参数
整个项目里最容易翻车的不是业务代码,是中文乱码和提交后重复刷新。乱码的根子有三个地方:请求参数解码、响应编码、数据库连接串字符集。请求和响应层面的问题用一个 Filter 一次解决。
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); } }Filter 把编码统一在入口处处理,Servlet 里的setCharacterEncoding就变成了兜底。注意response.setContentType如果每次请求都设,会把响应头刷成 text/html,如果某个 Servlet 要输出 JSON,Filter 里就别写这一行,只保留两个 setCharacterEncoding 更稳妥。
新增成绩成功后的处理,我坚持用重定向而不是转发。看下面这段:
private void add(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String studentNo = req.getParameter("studentNo"); String courseId = req.getParameter("courseId"); String score = req.getParameter("score"); int rows = scoreService.addScore(studentNo, Integer.parseInt(courseId), Double.parseDouble(score)); // 保存成功重定向到列表,刷新页面不会重复提交 resp.sendRedirect(req.getContextPath() + "/score/list"); }重定向是浏览器重新发起一次 GET 请求,地址栏变成/score/list,此时再按 F5 刷新,重新执行的是查询而不是新增。转发是服务器内部跳转,浏览器地址栏不变,刷新会再次提交表单,成绩就会插两条。所以规则是:写操作(增删改)成功后一律重定向,读操作(查询)用转发。req.getContextPath()返回部署上下文名,比如/sms,拼 URL 时带上它,部署时改 war 名不会导致链接全部失效。
3. Mysql 表设计与 JDBC 连接池:成绩数据怎么存、怎么取
3.1 学生成绩系统的四张核心表结构
成绩管理系统最少需要四张表:学生表、课程表、成绩表、用户表。这里踩过的坑是直接用score和course做表名,它们在 MySql 里不是保留字,但在某些工具和动态 SQL 拼接时容易和字段名混淆。我习惯给业务表加前缀,比如t_score,或者统一用反引号包裹。下面这套建表 SQL 是可直接执行的。
CREATE DATABASE IF NOT EXISTS sms_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE sms_db; CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', name VARCHAR(50) NOT NULL COMMENT '姓名', class_name VARCHAR(50) DEFAULT NULL COMMENT '班级', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(50) NOT NULL COMMENT '课程名', credit DECIMAL(3, 1) DEFAULT 2.0 COMMENT '学分' ); CREATE TABLE t_score ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL COMMENT '学号', course_id INT NOT NULL COMMENT '课程id', score DECIMAL(5, 1) COMMENT '成绩,保留一位小数', exam_date DATE DEFAULT NULL COMMENT '考试日期', UNIQUE KEY uk_student_course (student_no, course_id), CONSTRAINT fk_score_student FOREIGN KEY (student_no) REFERENCES t_student (student_no), CONSTRAINT fk_score_course FOREIGN KEY (course_id) REFERENCES t_course (id) ); CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, role TINYINT DEFAULT 1 COMMENT '1管理员 2学生' );几个设计决定背后的原因。成绩字段用DECIMAL(5,1)而不是 DOUBLE,浮点数算平均分会有误差,DECIMAL 是定点数,存 99.5 就是 99.5。uk_student_course唯一键保证同一学生同一课程只能有一条成绩,这是业务上最硬的需求,就算漏写在 Service 里的判断,数据库也能兜住。外键在这个系统里保留没问题,数据量小,不用考虑删除性能。t_user表单独加t_前缀是因为user在某些 Mysql 版本里是信息模式相关词,裸用容易在 Navicat 里报语法高亮问题。
3.2 Druid 连接池配置与参数
连接池用 Druid 还是 HikariCP 在这个项目里差别不大,课设里 Druid 更常见,监控页面还能当答辩亮点。配置集中在src/main/resources/druid.properties,一行一参数,参数含义要能讲清楚。
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/sms_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username=root password=你的密码 initialSize=5 maxActive=20 maxWait=30000 minIdle=5 validationQuery=SELECT 1 testWhileIdle=true timeBetweenEvictionRunsMillis=60000连接串里这四个参数缺一不可。characterEncoding=utf8保证从数据库读出来的中文不乱码,它和 Filter 的 setCharacterEncoding 是两条线,缺哪个都白搭。serverTimezone=Asia/Shanghai是给 Mysql 8.0 用的,不设的话 JDBC 驱动会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,本质是驱动拿不到 JVM 默认时区对应的数据库时区,直接显式指定最省事。useSSL=false关闭 SSL 握手,本地开发没必要加密连接。allowPublicKeyRetrieval=true是 Mysql 8.0 的 caching_sha2_password 插件要求的,不设会报Public Key Retrieval is not allowed。
连接池初始化代码固定成工具类,整个项目只有这一个地方接触 DataSource。
public class DBUtil { private static DruidDataSource dataSource; static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("druid.properties")) { Properties props = new Properties(); props.load(in); dataSource = (DruidDataSource) DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new RuntimeException("初始化连接池失败", e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }maxWait=30000这个参数值得单独说。它是获取连接的最大等待毫秒数,默认 -1 表示无限等,数据库挂了时线程会全部卡死在getConnection(),Tomcat 直接假死。设成 30 秒,连不上会快速抛异常,错误日志里能看到具体 SQL。testWhileIdle=true配合validationQuery=SELECT 1,让空闲连接定期探活,Mysql 的 wait_timeout 默认 8 小时,超过这个时间空闲连接被服务端断开,连接池里的连接变成死连接,下次取出来用直接报 Communications link failure,探活能把这个坑提前排掉。
3.3 动态 SQL 拼接:按学号/姓名/课程查成绩
成绩列表的搜索框要支持按学号、姓名、课程三个字段模糊查询。DAO 层最常见的写法是动态拼接 SQL,这里最容易出安全问题,必须在 PreparedStatement 里用占位符。
public List<Score> findByKeyword(String keyword) { StringBuilder sql = new StringBuilder( "SELECT s.student_no, s.name AS studentName, s.class_name, " + "c.course_name, sc.score, sc.exam_date " + "FROM t_score sc " + "JOIN t_student s ON sc.student_no = s.student_no " + "JOIN t_course c ON sc.course_id = c.id " + "WHERE 1=1"); List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { sql.append(" 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); } sql.append(" ORDER BY sc.exam_date DESC, s.student_no ASC"); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); } try (ResultSet rs = ps.executeQuery()) { // 封装成 List<Score>,返回给 Service } } catch (SQLException e) { throw new RuntimeException("查询成绩失败", e); } return Collections.emptyList(); }这里WHERE 1=1常被说成性能问题,其实 Mysql 的优化器会把它直接忽略,它的价值是让后面拼AND时不需要判断前面有没有条件。另一种写法是先判断 keyword 非空再拼WHERE,代码里会出现两个分支,维护起来反而容易漏。AS studentName这个别名是给实体映射用的,JDBC 的getObject("studentName")按列标签取值,如果查询返回的是s.name,列标签就是 name,和实体属性对得上;但多表查询里name和course_name混在一起,起别名能规避掉后续封装时字段错位的麻烦。
3.4 连接 Mysql 的典型翻车点:报错 2002、SSL、时区
开发环境连接 Mysql 的报错,一半以上集中在三个地方,都属于看日志能定位、不看日志全靠猜的问题。
第一个是ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock'。现象是程序连不上,命令行 mysql 也连不上。原因分两种,Mysql 服务没启动,或者客户端找的 socket 路径不对。解决方法是先确认服务:Linux 上执行systemctl status mysqld或service mysql status,Windows 上在服务管理器里看 MySql 服务是否运行。确认服务在跑还报这个错,就在连接串里把 localhost 换成 127.0.0.1 强制走 TCP,绕开 socket 文件路径问题。
第二个是 SSL 连接错误。Mysql 8.0 默认开启 SSL 加密连接,客户端驱动版本和服务器协议不匹配时,会报 SSL handshake 失败。解决就是在 url 里加useSSL=false,本地开发或内网环境这么做没有安全隐患。
第三个是时区报错,信息里带一串乱码时区名,前面已经说过用serverTimezone=Asia/Shanghai解决。还有个小众问题,密码里有@或#特殊字符时,url 里的密码部分要 URL 编码,否则参数解析直接把密码截断了,这种问题看一眼异常信息里的 url 就能发现。
4. JSP+Bootstrap 做成绩管理界面:表格、模态框与分页
4.1 用 JSTL+EL 在 JSP 里渲染成绩列表
JSP 页面最忌讳写<% for (int i = 0; ...) %>这种脚本片段,既难看又难维护。常见做法是用 JSTL 核心标签库加 EL 表达式。页面顶部引入标签库,列表部分用c:forEach循环。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>成绩管理</title> <link href="${pageContext.request.contextPath}/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div class="container mt-3"> <form class="row g-2 mb-3" method="get" action="${pageContext.request.contextPath}/score/list"> <div class="col-auto"> <input type="text" class="form-control" name="keyword" value="${keyword}" placeholder="学号/姓名/课程"> </div> <div class="col-auto"> <button type="submit" class="btn btn-primary">查询</button> </div> </form> <table class="table table-striped table-hover align-middle"> <thead> <tr> <th>学号</th><th>姓名</th><th>班级</th> <th>课程</th><th>成绩</th><th>考试日期</th><th>操作</th> </tr> </thead> <tbody> <c:forEach items="${scoreList}" var="s"> <tr> <td><c:out value="${s.studentNo}"/></td> <td><c:out value="${s.studentName}"/></td> <td><c:out value="${s.className}"/></td> <td><c:out value="${s.courseName}"/></td> <td>${s.score}</td> <td>${s.examDate}</td> <td> <a href="${pageContext.request.contextPath}/score/delete?id=${s.id}" class="btn btn-sm btn-danger" onclick="return confirm('确定删除这条成绩?');">删除</a> </td> </tr> </c:forEach> </tbody> </table> <c:if test="${empty scoreList}"> <p class="text-muted">没有符合条件的成绩记录</p> </c:if> </div> <script src="${pageContext.request.contextPath}/js/bootstrap.bundle.min.js"></script> </body> </html>几个细节值得注意。${pageContext.request.contextPath}这个表达式会输出部署上下文名,所有 CSS、JS、表单 action 都拼上它,war 包改名为/sms或/sms_war_exploded,页面里的路径不用跟着改。c:out转义输出,学生姓名里如果含<script>标签,显示出来是纯文本,XSS 攻击被挡在渲染层。${keyword}直接放在 value 里回显搜索词,用户按 F5 后关键字还在,这个体验细节比什么都实用。
4.2 Bootstrap 表格与模态框选型:手写还是插件
成绩管理页面要不要用 Bootstrap Table 插件,我的结论是数据量没过万就别用。这个系统的数据量级决定了手写原生<table class="table">加少量 JS 就够,插件的搜索、排序、分页全是给后端大数据准备的,引入后还要学它的 API、列配置写法,答辩时被问到底层实现往往答不上来。Bootstrap 方法里对这个项目最有用的几样,一是表格样式table-striped和table-hover,二是栅格系统row和col-auto做查询表单布局,三是模态框组件做新增编辑弹窗,四是form-control和form-select统一表单控件外观。就这四个,覆盖 90% 的界面需求。
4.3 新增/编辑成绩的表单与数据回显
新增和编辑共用一个模态框,是减少重复代码最直接的办法。新增时表单里是空值,编辑时用 JS 把对应行的数据塞进表单。注意这里有个选择:编辑数据回显走不走第二次请求。不走,因为列表页已经有关键数据,直接传给 JS 函数即可。
<button type="button" class="btn btn-success" onclick="openAddModal()">新增成绩</button> <div class="modal fade" id="scoreModal" tabindex="-1"> <div class="modal-dialog"> <form id="scoreForm" class="modal-content" method="post" action="${pageContext.request.contextPath}/score/add"> <div class="modal-header"> <h5 class="modal-title" id="modalTitle">新增成绩</h5> <button type="button" class="btn-close" ><servlet> <servlet-name>InitServlet</servlet-name> <servlet-class>com.sms.controller.InitServlet</servlet-class> <load-on-startup>1</load-on-startup> </servlet>这个配置的数值越小,优先级越高。用注解开发时对应@WebServlet(value = "/init", loadOnStartup = 1)。还有一个并发层面的坑必须知道:Servlet 是单实例多线程,所有请求共享同一个实例,所以在 Servlet 里写实例字段存请求数据,会出现 A 请求的数据被 B 请求覆盖的灵异问题。成绩模块里不要在 ScoreServlet 里写private String keyword;这种字段,局部变量才是安全的。
5.2 传统 JSP 项目打 war 包部署到 Tomcat
传统 JSP 项目打包 war,常见做法有两条线。项目没有用 Maven 时,在 IDEA 里 File → Project Structure → Artifacts → 点加号选 Web Application Archive,然后 Build → Build Artifacts 生成 war。项目用 Maven 时直接mvn clean package,产物在 target 目录。我一般推荐第二条,因为 Maven 还能统一管理 servlet-api、jstl 这些依赖的版本。
war 的部署结构是固定的,解压后必须是WEB-INF/classes、WEB-INF/lib、页面文件在根目录。把 war 放到tomcat/webapps目录,重启 Tomcat 自动解压,访问路径就是 war 文件名去掉.war后缀。这里有三个容易踩的地方:
- 上下文路径不对导致 404。IDEA 里运行时的路径是
项目名_war_exploded,部署到 Tomcat 后变成 war 文件名,页面里的路径必须全部用${pageContext.request.contextPath}拼接才不会断。 - 数据库配置在
druid.properties里,打包后文件进了classes/druid.properties,上线要改密码时直接改解压目录下的这个文件再重启即可,不用重新打包。 - servlet-api.jar 不能打进 war 的
WEB-INF/lib,否则和 Tomcat 自带版本冲突,启动报java.lang.LinkageError或NoClassDefFoundError。Maven 里 servlet-api 和 jsp-api 的 scope 必须设置为 provided。
5.3 开发环境配置:vscode 写 servlet 与 idea 新建 jsp 项目
被问得最多的是 vscode 编写 servlet 代码需要怎么配置环境。说实话,vscode 写 Java Web 是能跑但不好调试的路线,适合临时改代码,不适合做完整项目。配置最少需要四步:装 Red Hat Java 扩展、装 Tomcat for Java 插件、把 Tomcat 目录下的lib/servlet-api.jar和lib/jsp-api.jar加入项目的 classpath、用插件启动 Tomcat 部署当前目录。它能编译能启动,但断点调试不如 IDEA 顺手,遇到复杂变量排查会很痛苦。
IDEA 新建 jsp 项目我更推荐,路径是 New Project → Java Enterprise → 勾选 Web Application,IDEA 自动生成web/WEB-INF/web.xml和 index.jsp。如果是 Maven 项目,用maven-archetype-webapp骨架创建,IDEA 会补全目录结构。开发时注意 Tomcat 的版本要和本地 JDK 匹配,Tomcat 9 配 Java 8/11 没问题,配 Java 17 要小心类模块限制,遇到java.lang.reflect.InaccessibleObjectException时在启动 VM 参数加--add-opens java.base/java.lang=ALL-UNNAMED。
5.4 避坑清单:开发到部署的五条血泪经验
以下是这个项目里最常复现的五类问题,每一条都是现象先行,原因跟上,解决收尾。
1. 现象:JSP 页面中文全部乱码。
原因:JSP 文件本身是 UTF-8 编码,但容器用默认的 ISO-8859-1 解码,页面声明和实际编码不一致。
解决:JSP 第一行page指令写全contentType="text/html;charset=UTF-8" pageEncoding="UTF-8",项目里所有 JSP 统一这两个属性,加上第 2 章的 Filter 双重保障,乱码问题基本绝迹。
2. 现象:部署后请求数据库报Communications link failure。
原因:Mysql 服务没启动,或者连接池里的连接因为 wait_timeout 变成死连接。
解决:先命令行mysql -uroot -p验证服务,再检查 Druid 的testWhileIdle是否开启。注意 Mysql 8.0 的默认 wait_timeout 和连接池探活周期要匹配,探活间隔小于数据库断开时间就不会踩。
3. 现象:删除成绩记录后,再新增同学生同课程报Duplicate entry。
原因:表上的唯一键在拦截,但没有被友好处理,页面显示 500。
解决:在 add 的 Service 里捕获SQLIntegrityConstraintViolationException,把它转换成业务提示,返回"该学生此课程成绩已存在",这是数据库约束对业务最好的保护。
4. 现象:war 包部署到 Tomcat 后访问全部 404,但 IDEA 里运行正常。
原因:上下文路径变了。IDEA 运行时的路径包含_war_exploded,Tomcat 部署后是 war 文件名。
解决:所有跳转路径用req.getContextPath()或${pageContext.request.contextPath}拼接,不要在代码里写死/sms/score/list。
5. 现象:Control 台报ClassNotFoundException: javax.servlet.http.HttpServlet。
原因:编译期依赖了 servlet-api,但运行时没有提供,或者打进了 war 和 Tomcat 自带的冲突。
解决:确认 Tomcat 的 lib 目录存在 servlet-api.jar,Maven 依赖里 scope 写 provided,IDEA Artifacts 打包时不把该 jar 放进 WEB-INF/lib。
6. 把成绩管理系统往前推一步:统计报表、导出 Excel 与手工验证
成绩系统做到能增删改查只是及格,想让它有说服力,加两个小而实的功能:班级成绩统计和 Excel 导出。统计接口的 SQL 是加分项里的核心,按班级分组算平均分和不及格人数,一次查询出结果。
SELECT s.class_name, ROUND(AVG(sc.score), 1) AS avg_score, COUNT(*) AS total_cnt, SUM(CASE WHEN sc.score < 60 THEN 1 ELSE 0 END) AS fail_cnt FROM t_score sc JOIN t_student s ON sc.student_no = s.student_no GROUP BY s.class_name ORDER BY avg_score DESC;然后在这个 Servlet 里,把查询结果渲染成一个小页面,用 Bootstrap 的卡片和表格展示,不用引图表库。如果答辩想炫一点,可以花半小时接一个 ECharts 柱状图,数据源就是上面这个 JSON 接口。更实用的是 Excel 导出:用 Apache POI 的 XSSFWorkbook,三四十行代码把成绩列表生成 xlsx,设置response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")和Content-Disposition头,浏览器直接下载。这个小功能每次答辩都会被老师当亮点追问,成本却很低。
做完功能一定要有一份手工测试清单,别只验证正常路径。我每次验收这个系统会固定跑一遍:新增成绩后列表总数加一,总分正确;学号或成绩留空时,浏览器校验拦截,绕过浏览器直接 POST 时服务端也拒绝;删除同一条记录两次,第二次列表没变化且不报错;搜索"张"能返回所有姓张学生的全部课程成绩;停止 Mysql 服务后访问列表,页面显示友好错误而不是 Tomcat 白页;成绩边界值 0 和 100 都能保存,99.5 这种 0.5 步进值正常显示。这套清单跑完,系统才算真正能交付。
这个技术栈我做得越多越有一个体会:老四样项目真正的价值不在技术新,而在链路短、可变因素少,一套代码能从建库一路讲到浏览器渲染,中间每一步都是能亲眼看到结果的。我最早做课程设计时把所有 SQL 都堆在 Servlet 里,改一个查询条件要翻三個方法,后来规规矩矩分了层,才发现 90% 的问题都出在参数没对上和编码没统一这两件事上。希望这篇笔记能帮你少走这段弯路,把精力留在真正能学到东西的地方。
本文还有配套的精品资源,点击获取