
简介面向Java Web课程期末作业的实战项目基于MVC架构完整实现学生信息的增删改查、用户登录及权限控制内容覆盖数据库设计到前端页面展示适合初学Java Web的学生作为课程设计参考或期末作业模板。资源共209个文件压缩包仅4.57MB包含11个Java源码、37个编译后的class文件、4个SQL数据库脚本、2个jar依赖包以及150张运行截图另有工程配置文件与说明文档可帮助理解项目目录结构与部署方式。已有414人学习浏览。借助这套资料可快速在Eclipse/IDEA中导入工程并执行SQL脚本还原数据库结合源码掌握JDBC或ORM框架的数据库操作、JSP/Servlet的请求响应流程及MVC分层思想运行截图和界面素材也能为实验报告和答辩PPT提供直观支撑。1. Java Web期末作业学生信息管理系统别急着写代码先想清楚交作业要什么Java Web期末作业做学生信息管理系统是计算机相关专业学生大概率绕不开的一道题。我见过太多人一拿到题目就打开 IDEA 新建项目闷头写了两周增删改查结果课堂演示三分钟翻车浏览器白屏、中文乱码、数据库连不上。这个作业真正的难点从来不是 CRUD 怎么写而是你能不能在一台相对干净的电脑上用最短时间把“登录 → 查询 → 增删改 → 分页 → 退出”这条闭环跑通并且把每个环节的技术点讲明白。下面按我实际交付课程设计时最省时间的路径来走从选型、建库、写代码到现场演示让新手拿到一条能复现的完整链路熟手也能直接抄到关键配置和踩坑边界。2. 技术选型与项目骨架Servlet JSP 还是 Spring Boot期末作业的边界在哪2.1 三种选型的对比从“能跑”到“能讲”期末作业的评分逻辑通常是三件事代码量够、功能能跑、老师追问答得上。其中第三点最容易被忽视也最决定你是 80 分还是 60 分。我见过用 Spring Boot 写完很流畅的学生被老师一句“你的自动配置是怎么起来的”问住也见过用 JSP Servlet 的因为每个环节都说得清原理最后拿了不错的分数。所以选型第一原则不是“哪个时髦”而是“哪个你能讲透”。常见做法有三种。第一种是 JSP Servlet JDBC直接对应课程里的 Java Web 章节Servlet、Filter、Session、JSP 标签库全部能用上。第二种是 Spring Boot Thymeleaf开发效率高配置文件少但自动配置像个黑匣子老师往深了问容易接不住。第三种是 Spring Boot Vue 前后端分离工程上很正规但对期末作业来说方案偏重要处理跨域、两套工程、打包部署演示现场一旦端口冲突或没网很难收场。我的建议很直接如果你是第一次做 Java Web 项目就选 JSP Servlet如果你已经工作、想用期末作业练手 Spring Boot那就提前准备“为什么用 Spring Boot、它和 Servlet 的关系”这类问题。别选前后端分离除非你已经有一个能独立演示的前端环境。下面所有代码示例都基于 JSP Servlet JDBC Druid 连接池这是课程设计中最稳的组合。技术方案学习成本演示稳定性老师追问难度代码量JSP Servlet JDBC低高低原理都在课程范围内中等Spring Boot Thymeleaf中中高容易追问自动配置和原理低Spring Boot Vue高低部署复杂高涉及前后端联调高2.2 用 IDEA 2024 创建 Maven Web 项目的最小步骤先把环境准备说清楚。JDK 用 8 或 11 都能跑通本文示例IDEA 用 2024 版本。手边没有 JDK 的话先去把 Java 环境变量配置好一个常见坑是JAVA_HOME指向了 JRE 而不是 JDK导致 Maven 编译时报javac找不到。环境变量这事属于基本功卡住了就先用java -version和javac -version两个命令确认。IDEA 2024 新建项目时选择 Maven Archetype 的maven-archetype-webapp骨架会自动生成一个最简 Web 工程。如果你的 IDEA 骨架列表里找不到它可以先创建一个普通 Maven 项目然后手动补出src/main/webapp目录并在pom.xml里把打包方式改成war。骨架生成后的目录结构是这样的src/main/java src/main/resources src/main/webapp src/main/webapp/WEB-INF/web.xml src/main/webapp/WEB-INF/jsp src/main/webapp/css src/main/webapp/jspom.xml里需要引入四类东西Servlet API、JSTL、MySQL 驱动、Druid 连接池。下面这份依赖可以当作最小集合project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdstudent-manager/artifactId version1.0-SNAPSHOT/version packagingwar/packaging dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency /dependencies /project这里的servlet-api依赖必须写成provided意思是编译时可用、运行时由 Tomcat 提供。如果不写provided打出来的 WAR 会把 Tomcat 自带的 Servlet 类重复打包启动轻则告警、重则冲突。JSTL 是用来在 JSP 页面里写循环和判断的后面列表页会用到。MySQL 驱动的版本要跟本机数据库版本匹配示例用的是 MySQL 8 对应版本。2.3 数据库三张表与连接池配置学生信息管理系统最少要有用户表和学生表为了让项目看着饱满一点我一般会再加一张班级表学生表通过class_id关联班级表。这样既方便做下拉筛选也天然多出一个“班级管理”的小功能点。建库脚本可以直接贴进 Navicat 或命令行执行CREATE DATABASE student_db DEFAULT CHARACTER SET utf8mb4; USE student_db; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_class ( id INT PRIMARY KEY AUTO_INCREMENT, class_name VARCHAR(50) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT, stu_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, gender VARCHAR(4), age INT, class_id INT, phone VARCHAR(20), email VARCHAR(50), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_student_class FOREIGN KEY (class_id) REFERENCES t_class(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO t_user(username, password) VALUES (admin, 123456); INSERT INTO t_class(class_name) VALUES (软件工程2101), (计算机科学2102);字符集一定要用utf8mb4而不是utf8。utf8mb4是 MySQL 里真正完整的 UTF-8 实现存表情符号和特殊字符不会乱码。t_student里的create_time字段是给列表排序用的默认值设为当前时间后面分页时按它倒序体验会自然很多。数据库连接这里不要用DriverManager每访问一次就开一个连接量小没事老师问“连接池是什么”的时候你容易答空。更稳的做法是引入 Druid用配置文件加一个工具类。src/main/resources下新建db.propertiesdriverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/student_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue usernameroot password你自己的密码 initialSize5 maxActive10 maxWait3000serverTimezoneAsia/Shanghai这个参数在 MySQL 8 下必须加否则 JDBC 会报时区错误useSSLfalse是跳过本地开发时的 SSL 握手allowPublicKeyRetrievaltrue是为了解决 MySQL 8 下用密码登录时偶发的公钥检索报错。连接池的工具类用一个静态代码块把数据源初始化好import com.alibaba.druid.pool.DruidDataSourceFactory; import javax.sql.DataSource; import java.io.InputStream; import java.sql.Connection; import java.sql.SQLException; import java.util.Properties; public class DbUtils { private static DataSource ds; static { try { InputStream in DbUtils.class.getClassLoader() .getResourceAsStream(db.properties); Properties props new Properties(); props.load(in); ds DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return ds.getConnection(); } }这段工具类里最关键的是getConnection()不再每次 new 连接而是从连接池里借。参数initialSize5是启动时预建 5 个空闲连接maxActive10是池子最多同时给出 10 个连接。期末演示场景下这两个值已经足够调大反而浪费本机资源。老师问为什么用连接池你就答“避免频繁创建销毁连接拿连接的时间能省几十毫秒”这已经是一个合格的加分点。3. 核心功能实现用 Filter Servlet 把登录、CRUD、分页一次做通3.1 登录会话与权限拦截Filter 只写一次所有页面都生效登录校验不能在每个 Servlet 里都写一遍那样代码重复度太高还容易漏。正确做法是用 Filter 做统一拦截请求先过 Filter检查 Session 里有没有登录用户没有就重定向到登录页有才放行到目标 Servlet。这样所有业务页面一次拦截老师问“怎么控制未登录访问”的时候你可以直接指这一处代码。import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String uri req.getRequestURI(); // 登录页、登录接口、静态资源不需要登录就能访问 if (uri.contains(/login) || uri.endsWith(.css) || uri.endsWith(.js) || uri.endsWith(.png) || uri.endsWith(.jpg)) { chain.doFilter(request, response); return; } // 没有登录用户就踢回登录页 if (req.getSession().getAttribute(user) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }注意uri.contains(/login)这个判断比较粗但足够覆盖login.jsp和LoginServlet两个路径。如果你把登录接口命名为doLogin之类的就要把判断改成对应字符串。.css、.js这些静态资源必须放行否则浏览器加载样式时会被拦到登录页页面打开后没有排版。登录成功后的会话控制我用一个LoginServlet来收口import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; WebServlet(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); UserDao userDao new UserDao(); User user userDao.findByUsernameAndPassword(username, password); if (user null) { req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); } else { req.getSession().setAttribute(user, user); resp.sendRedirect(req.getContextPath() /student?actionlist); } } }这里的两个细节值得背下来。登录失败用forward转发因为要带着error提示回到登录页并且保留用户输入登录成功用sendRedirect重定向因为如果不重定向用户按 F5 刷新会把登录请求重复提交。会话默认超时时间是 30 分钟Tomcat 的web.xml里可以调但期末项目用默认值就行。3.2 学生信息的增删改查DAO 层与 PreparedStatement 防注入DAO 层就是把所有 SQL 收在同一个类里Servlet 只负责拿参数和跳转页面。这样页面改了不碰 SQLSQL 改了不碰页面。下面这段是一个精简但完整的StudentDao核心方法覆盖了列表、新增、修改、删除、按 ID 查询五件事import java.sql.*; import java.util.ArrayList; import java.util.List; public class StudentDao { public ListStudent findAll() { String sql SELECT * FROM t_student ORDER BY create_time DESC; ListStudent students new ArrayList(); try (Connection conn DbUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { students.add(mapRow(rs)); } } catch (SQLException e) { throw new RuntimeException(e); } return students; } public Student findById(int id) { String sql SELECT * FROM t_student WHERE id ?; try (Connection conn DbUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { return mapRow(rs); } } } catch (SQLException e) { throw new RuntimeException(e); } return null; } public int insert(Student s) { String sql INSERT INTO t_student(stu_no, name, gender, age, class_id, phone, email) VALUES(?, ?, ?, ?, ?, ?, ?); try (Connection conn DbUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, s.getStuNo()); ps.setString(2, s.getName()); ps.setString(3, s.getGender()); ps.setInt(4, s.getAge()); ps.setInt(5, s.getClassId()); ps.setString(6, s.getPhone()); ps.setString(7, s.getEmail()); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(e); } } public int update(Student s) { String sql UPDATE t_student SET stu_no?, name?, gender?, age?, class_id?, phone?, email? WHERE id?; try (Connection conn DbUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, s.getStuNo()); ps.setString(2, s.getName()); ps.setString(3, s.getGender()); ps.setInt(4, s.getAge()); ps.setInt(5, s.getClassId()); ps.setString(6, s.getPhone()); ps.setString(7, s.getEmail()); ps.setInt(8, s.getId()); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(e); } } public int delete(int id) { String sql DELETE FROM t_student WHERE id?; try (Connection conn DbUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(e); } } private Student mapRow(ResultSet rs) throws SQLException { Student s new Student(); s.setId(rs.getInt(id)); s.setStuNo(rs.getString(stu_no)); s.setName(rs.getString(name)); s.setGender(rs.getString(gender)); s.setAge(rs.getInt(age)); s.setClassId(rs.getInt(class_id)); s.setPhone(rs.getString(phone)); s.setEmail(rs.getString(email)); return s; } }所有 SQL 都用?占位符和PreparedStatement这是期末作业里最容易拿分也最容易翻车的点。如果你图省事用字符串拼接写 SQL搜索框里输入1 OR 11就能把整个表拉出来甚至删掉这在答辩现场被演示出来基本等于交白卷。PreparedStatement的setXxx方法第一个参数从 1 开始第二个参数是实际值它会在数据库端做参数转义输入的内容永远只是数据不参与 SQL 语法解析。findAll在正式分页场景里其实用不上真正工作的是后面queryPage方法。但保留它是为了方便临时调试数据量小的时候直接findAll看一眼对不对再去谈分页。3.3 分页与条件搜索一个 PageBean 解决 80% 的列表需求期末作业里“分页”是老师最常问的功能。很多同学直接用 SQL 里LIMIT写死偏移量然后页面上一页下一页地裸调这也能跑但代码很散。我习惯的做法是封装一个极简的PageBeanT把所有和分页有关的字段都包进去import java.util.List; public class PageBeanT { private int currentPage 1; private int pageSize 10; private long total; private ListT list; public long getTotalPage() { if (total 0) { return 0; } return total % pageSize 0 ? total / pageSize : total / pageSize 1; } public int getCurrentPage() { return currentPage; } public void setCurrentPage(int currentPage) { this.currentPage currentPage; } public int getPageSize() { return pageSize; } public void setPageSize(int pageSize) { this.pageSize pageSize; } public long getTotal() { return total; } public void setTotal(long total) { this.total total; } public ListT getList() { return list; } public void setList(ListT list) { this.list list; } }getTotalPage()的写法是一个细节total % pageSize 0时正好整除页数是商否则商加 1。比如 25 条数据、每页 10 条总页数是 3。这个计算如果写成(int) Math.ceil(total / pageSize)反而容易因为整数除法丢精度不如直接用取余判断。分页查询带着关键词搜索是另一个容易写错的地方。很多人只给数据列表 SQL 加了LIMIT忘记COUNT查询也要带同样的搜索条件结果总条数是全表的页面跳着跳着页数就不对了。下面是我常用的queryPage写法public PageBeanStudent queryPage(int currentPage, int pageSize, String keyword) { PageBeanStudent pb new PageBean(); pb.setCurrentPage(currentPage); pb.setPageSize(pageSize); StringBuilder where new StringBuilder( WHERE 11 ); ListObject condParams new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { where.append( AND (name LIKE ? OR stu_no LIKE ?) ); String like % keyword.trim() %; condParams.add(like); condParams.add(like); } // 先查总条数条件必须和列表查询一致 String countSql SELECT COUNT(*) FROM t_student where; try (Connection conn DbUtils.getConnection(); PreparedStatement ps conn.prepareStatement(countSql)) { for (int i 0; i condParams.size(); i) { ps.setObject(i 1, condParams.get(i)); } try (ResultSet rs ps.executeQuery()) { if (rs.next()) { pb.setTotal(rs.getLong(1)); } } } catch (SQLException e) { throw new RuntimeException(e); } // 再查当前页数据排序交给 SQL不要在 Java 里做 String listSql SELECT * FROM t_student where ORDER BY create_time DESC LIMIT ?, ?; int offset (currentPage - 1) * pageSize; try (Connection conn DbUtils.getConnection(); PreparedStatement ps conn.prepareStatement(listSql)) { int idx 1; for (Object param : condParams) { ps.setObject(idx, param); } ps.setInt(idx, offset); ps.setInt(idx, pageSize); try (ResultSet rs ps.executeQuery()) { ListStudent students new ArrayList(); while (rs.next()) { students.add(mapRow(rs)); } pb.setList(students); } } catch (SQLException e) { throw new RuntimeException(e); } return pb; }注意两个参数列表必须分开处理countSql只需要搜索条件列表 SQL 才需要追加offset和pageSize。如果复用同一个参数列表把offset也传进count总条数就会算错。排序我用ORDER BY create_time DESC在数据库里做尽量不要把全部数据查出来在 Java 内存里排序那是冒泡排序该干的事不是 Web 项目该干的事。Servlet 里拿到请求参数后还要做一层数字容错。用户可能在地址栏手动输入?pageabc这时Integer.parseInt会抛异常页面直接 500。常见的兜底写法是String pageStr req.getParameter(page); int page 1; if (pageStr ! null) { try { page Integer.parseInt(pageStr); } catch (NumberFormatException e) { page 1; } } studentDao.queryPage(page, 10, keyword);页码越界也要处理小于 1 就设为 1大于总页数就设为总页数。否则用户点下一页点到没数据时页面空荡荡的现场体验很扣分。4. 前端页面与交互原生 HTML CSS 让作业看着像个成品4.1 布局一页式管理台与状态提示的写法期末评审里页面不用惊艳但必须整洁统一。最省力的做法是抽一个公共头部 JSP每个页面都 include 它顶部导航和侧边菜单只写一次。后续要加菜单改一个文件就够。这里的关键是路径必须用${pageContext.request.contextPath}拼不能写死/student或相对路径否则在不同层级的页面里会找错资源。% page pageEncodingUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % c:set varctx value${pageContext.request.contextPath} / div classtopbar span classbrand学生信息管理系统/span span classuser-info 欢迎你${sessionScope.user.username} a href${ctx}/logout退出/a /span /div div classsidebar a href${ctx}/student?actionlist学生列表/a a href${ctx}/student?actionaddPage新增学生/a a href${ctx}/class?actionlist班级管理/a /divsessionScope.user.username是 EL 表达式直接从 Session 里取当前登录用户名不需要在 Servlet 里再往 request 放一遍。logout是一个小 Servlet做的事情就是session.invalidate()然后重定向回登录页三行代码但能让你的系统有始有终。样式方面我一般不会引入整套 Bootstrap。第一演示现场可能没外网CDN 加载不出来页面就裸奔了第二老师知道你手写 CSS 反而会觉得你基础扎实。下面这份极简样式足够撑起演示场面* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Microsoft YaHei, sans-serif; background: #f5f7fa; } .topbar { height: 56px; background: #2c3e50; color: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; } .topbar a { color: #fff; text-decoration: none; } .sidebar { width: 200px; position: fixed; top: 56px; bottom: 0; background: #34495e; padding-top: 16px; } .sidebar a { display: block; color: #ddd; padding: 12px 20px; text-decoration: none; } .sidebar a:hover { background: #2c3e50; color: #fff; } .content { margin-left: 200px; padding: 24px; }布局上最需要注意的细节是侧边栏用了position: fixed内容区就要加margin-left抵消侧边栏宽度。如果你漏了margin-left内容会从侧边栏下面穿过去现场展示时所有页面都错位这种低级错误一旦出现比功能缺失更影响观感。表格页面再加一个常规的table样式边框、行间距、操作按钮的颜色统一一下整体就够看了。4.2 表单校验与二次确认把低级错误挡在提交前新增和编辑共用一个表单页面这是很常见的做法。表单里的校验我推荐纯 JavaScript不引 jQuery、不引 Vue。期末项目场景下任何外部依赖都是潜在风险点现场断网时CDN 加载失败你的校验就失效了。而且原生 JavaScript 几十行完全能覆盖“必填、格式、范围”这类需求。function checkStudentForm() { var name document.getElementById(name).value.trim(); if (!name) { alert(姓名不能为空); return false; } var stuNo document.getElementById(stuNo).value.trim(); if (!stuNo) { alert(学号不能为空); return false; } var age parseInt(document.getElementById(age).value, 10); if (isNaN(age) || age 10 || age 60) { alert(年龄必须在10到60之间); return false; } var phone document.getElementById(phone).value.trim(); if (phone !/^1[3-9]\d{9}$/.test(phone)) { alert(手机号格式不正确); return false; } return true; }这段代码有几个设计取舍要说清楚。手机号校验先判空再正则因为手机号可以不是必填正则^1[3-9]\d{9}$是大陆手机号最宽松的校验只判断首位和位数不引入复杂的号段规则演示场景够用。年龄用parseInt转换后判断范围能挡住“abc”这类输入。后面的表单提交这样绑定form action${ctx}/student methodpost onsubmitreturn checkStudentForm() input typehidden nameaction valueinsert / input typehidden nameid value${student.id} / !-- 其他输入框 -- button typesubmit保存/button /formonsubmit返回false时表单不会提交这是浏览器原生行为。同样的表单既用于新增也用于编辑靠一个隐藏的action字段区分新增时提交insert编辑时提交update。id字段在新增时为空编辑时回填Servlet 里判断这个字段即可。删除操作的二次确认也别省a hrefjavascript:void(0) onclickif (confirm(确定删除该学生吗)) { location.href${ctx}/student?actiondeleteid${s.id} } classbtn btn-danger删除/a确认框弹出来后点“确定”才发请求点“取消”什么都不发生。这行代码能挡住大部分手滑操作。需要强调一点前端校验只是体验不是安全边界。攻击者可以绕过页面直接往 Servlet 发请求所以 Servlet 里必须再校验一次必填字段和数字范围。前端校验帮你挡住正常用户的误操作后端校验帮你挡住异常请求两者不能互相替代。5. Java Web期末作业避坑指南5 个让项目当场翻车的问题5.1 中文乱码三个位置各管各的坑现象页面本身中文正常但往数据库里插入学生姓名后变成???或者 JSP 转发之后的列表页显示一串乱码又或者 URL 里带中文搜索关键字Servlet 取出来是乱码。原因Java Web 的中文乱码从来不是一个原因而是三个位置都要编码一致。第一请求参数解析时如果request.setCharacterEncoding(UTF-8)没在读取参数前调用POST 参数按ISO-8859-1解析中文必乱第二响应输出时response.setContentType(text/html;charsetUTF-8)没设置浏览器会乱猜第三JDBC 连接串里没带characterEncodingutf8MySQL 连接层的字符集和库表不一致数据入库时变成问号。解决最省心的做法是写一个EncodingFilter在所有业务代码之前统一处理请求和响应编码。import javax.servlet.*; import java.io.IOException; public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); } }在web.xml里注册时url-pattern必须写/*并且放在过滤器列表的第一个位置。如果还有别的 Filter编码 Filter 必须最先执行因为后续 Filter 和 Servlet 都要基于它设置好的编码读写数据。数据库那边确认db.properties里的 URL 带了characterEncodingutf8建表语句用utf8mb4这条链就闭合了。检查乱码时按“页面 → 请求 → 数据库”一层层排查不要一上来就改代码。5.2 页面能打开但样式全丢静态资源和路径双重坑现象登录页能打开但没有任何样式整个页面是纯 HTML登录成功后跳到学生列表图片和 CSS 全部加载失败控制台一堆 404。原因两个。第一LoginFilter拦截了/*后没有放行静态资源浏览器请求/css/style.css时被重定向到登录页重定向返回的是一段 HTML样式自然加载不了。第二JSP 里用了相对路径比如hrefcss/style.css在login.jsp里能加载到了student/list.jsp这种深层路径时浏览器会去找/student/css/style.css结果还是 404。解决Filter 放行静态资源这在 3.1 的代码里已经做了JSP 里统一用绝对路径。我习惯在每个 JSP 顶部先声明ctxc:set varctx value${pageContext.request.contextPath} / link relstylesheet href${ctx}/css/style.css这样无论页面在哪个目录层级浏览器都会从项目根路径开始找资源。检查的时候看一眼浏览器地址栏如果 CSS 地址没有contextPath前缀基本就是路径问题。5.3 数据库驱动加载失败和时区报错现象Tomcat 启动后第一次访问页面报ClassNotFoundException: com.mysql.cj.jdbc.Driver或者页面能打开但执行查询时报The server time zone value GMT is unrecognized。原因驱动包没进运行环境。如果你用 IDEA 的 Artifacts 方式部署Maven 依赖默认会进 lib但如果依赖的 scope 写错成provided运行时就拿不到。第二类是 MySQL 8 和 JDBC 驱动之间的时区协商失败必须显式指定时区。解决先检查pom.xml里 MySQL 驱动依赖的scope确认是compile默认值不是provided。再看db.properties的 URL 后面是否带了serverTimezoneAsia/Shanghai驱动类名也要对得上MySQL 8 用com.mysql.cj.jdbc.DriverMySQL 5 用com.mysql.jdbc.Driver。这两个驱动类名看起来只差一个cj但用错了会直接ClassNotFoundException。如果你的项目里两个版本驱动都试过记得清理一下本地 Maven 仓库的旧 jar避免 IDEA 拿了缓存里的老版本。5.4 Tomcat 启动失败端口被占用现象IDEA 点启动 Tomcat控制台几秒后报Address already in use: JVM_Bind null:8080或者弹窗提示端口冲突。原因上一次运行的 Java 进程没有退出8080 端口还被占用也可能是本地其它开发服务占了 8080。这个坑看起来低级但在答辩前的紧张时刻最容易出现因为好几个人共用一个演示机器前面的人项目没停你后启动就冲突了。解决Windows 下打开命令行netstat -ano | findstr :8080 taskkill /PID 1234 /F1234换成netstat结果显示的占用进程 PID。macOS 或 Linux 用lsof -i:8080和kill -9 PID。如果你不想杀进程直接改 Tomcat 端口也行IDEA 的 Run Configuration 里把 HTTP port 改成 8081但注意改完后所有访问地址都要带新端口。这里还有个血泪经验改端口前先确认老师演示用的就是新端口不然现场说“我项目在 8081”等于自己给自己加难度。5.5 SQL 注入搜索框里一句密码直接把表删了现象搜索框输入1 OR 11列表返回了全部学生甚至有人在登录页密码框输入 OR 11 --直接绕过登录成功。这是 Web 项目安全里最典型的应用层注入比防火墙和服务器安全配置更贴近你的代码。原因SQL 语句是用字符串拼接出来的用户输入的内容被当成了 SQL 语法的一部分。比如下面这段反面写法// 反面教材不要这样写 String sql SELECT * FROM t_user WHERE username username AND password password ;当password是 OR 11 --时拼出来的 SQL 变成SELECT * FROM t_user WHERE usernameadmin AND password OR 11 --OR 11恒为真后面的--把真实密码条件注释掉于是这条查询直接通过。如果业务代码里再顺手执行了DELETE结果就是整表数据被清空。解决所有 SQL 一律用PreparedStatement占位符就像本文所有 DAO 代码那样。占位符把输入当字符串参数传给数据库而不是拼进 SQL 语法树注入语句自然失效。老师问 SQL 注入时你把上面这个反面例子和PreparedStatement的正确写法对比讲出来这比背概念得分高得多。还有一个小细节登录接口的密码不要用 SQL 条件去匹配先按用户名查出用户再在 Java 里比对密码哈希这样一个用户存在与否就不会通过 SQL 暴露出来。6. 让作业多拿 10 分的验证清单与演示技巧6.1 验收自测清单答辩前最怕的是“自己觉得没问题现场一演示就崩”。我每次交付前都会走一遍下面的清单照着操作预期结果全部符合才算过关验证项操作预期结果冷启动关闭 Tomcat重新启动后访问首页正常进入登录页无报错登录输入错误密码页面提示用户名或密码错误登录成功输入 admin / 123456跳转到学生列表显示当前用户名未登录拦截不登录直接访问/student?actionlist被重定向回登录页中文新增新增一名“张三”并保存列表出现记录无乱码分页造 25 条数据后翻页共 3 页页码正确上一页/下一页可用搜索搜索框输入“张”只显示姓名带“张”的记录删除点删除并确认记录消失刷新后不再出现这个清单本身也可以写进项目 README老师打开你的项目时看到一份自测报告印象分会明显不一样。6.2 演示时的讲解顺序真正演示时不要从头念代码更不要在 IDE 里找文件找半天。我的习惯是先讲业务闭环再讲一个技术亮点。业务闭环就是“登录进入系统 → 看到分页列表 → 搜索 → 新增一条 → 修改它 → 删除它 → 退出登录”全程两三分钟功能全覆盖。技术亮点选一个你最熟的讲深一点。比如登录拦截的 Filter 是怎么放行静态资源的、PreparedStatement 为什么能防 SQL 注入、分页的COUNT和LIMIT为什么条件一致。一个点讲透胜于十个点泛泛而谈。有一个我踩过的教训演示前一定要做一次“冷启动测试”就是把浏览器缓存、Cookie 和本地历史记录全部清掉再打开项目。我有一次没清浏览器状态现场登录时直接带着上一次的旧 Session 跳到列表页老师没看到登录流程追问了一句“你是不是没做登录校验”解释了半天才说清。后来我每次答辩前都强制自己按上面的验证清单过一遍宁可多花十分钟也不在现场解释“刚才其实可以”。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取