☰
JSP/Servlet学生信息管理系统开发实战:数据库设计、登录拦截与中文乱码排查
2026/10/17 7:04:28 网站建设 项目流程

简介:一套基于JSP与Servlet构建的学生信息管理系统项目源码,面向Java Web初学者、课程设计或毕业设计学生,覆盖动态页面生成、服务器端业务处理、数据库交互等完整开发链路。项目采用MVC分层设计,JSP负责视图展示,Servlet控制器接收请求并调用业务逻辑,DAO层通过JDBC完成数据持久化,可清晰理解HTTP请求响应、会话管理、增删改查及异常处理等核心知识点。压缩包共576个文件,以156个HTML页面、132个CSS样式、79个JavaScript脚本、20个Java源文件及20个class编译文件为主,另含4个JSP页面与1个SQL数据库初始化脚本,整体大小仅2.75MB,结构紧凑、便于快速导入部署。代码中包含完整的DAO与Servlet控制模块,配合JSP、静态资源和db_studentinfo.sql脚本,可快速搭建运行环境,并对照学习MVC模式在真实项目中的落地方式。目前已有997人学习/下载,适合作为Java Web开发实践的参考项目。

1. 课设项目跑不起来的症结:JSP/Servlet 学生信息管理系统到底卡在哪

如果你是第一次接触 JSP/Servlet 学生信息管理系统,大概已经经历过这样的场景:代码包解压、导入 IDE、启动 Tomcat,浏览器打开登录页,然后卡在用户名密码那一关过不去。或者好不容易登录进去,点“查询”直接 404,中文全变问号。这套经典的 Java Web 课设项目,表面上是“学生信息的增删改查”,实际考察的是三层架构、Session 控制、Filter 拦截和 JDBC 拼接这一整套链路。拿到代码后先不要急着点运行,花十分钟把数据库脚本、jar 包版本和项目路径理清楚,后面会少踩一大半坑。适合正在做课设的在校学生,也适合想快速复原一个 Java Web 工程、复习 Servlet 生命周期的开发人员。

2. 数据库与项目骨架:先立表,再分层,最后连 JDBC

2.1 表结构设计:账号表与学生表分开,避免后续改主表

学生信息管理系统的数据量不大,但表结构不要全部塞进一张表。登录用的账号信息和业务数据(学生信息)分开放,后续加角色、加权限、改密码策略时不用动 student 表,这是最基本的表设计原则。

我的习惯是先建库,再建两张表:user 表存登录账号,student 表存学生资料。字段尽量给默认值,create_time 这类字段用数据库的 CURRENT_TIMESTAMP 自动生成,JSP 页面不必关心。

CREATE DATABASE IF NOT EXISTS student_db DEFAULT CHARACTER SET utf8mb4; USE student_db; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号', password VARCHAR(64) NOT NULL COMMENT '密码', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, stu_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', name VARCHAR(50) NOT NULL COMMENT '姓名', gender VARCHAR(10) DEFAULT '男', age INT DEFAULT 0, class_name VARCHAR(50) COMMENT '班级', phone VARCHAR(20), email VARCHAR(100), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); INSERT INTO user (username, password) VALUES ('admin', '123456'); INSERT INTO student (stu_no, name, gender, age, class_name, phone, email) VALUES ('2021001', '张同学', '男', 20, '软件工程1班', '13800000000', 'zhangsan@example.com');

password 字段长度写成 64,是为后面改成 MD5 或 SHA 预留的。如果你只按课设的原始需求做明文对比,varchar(50) 也够,但一旦想加固,还得改表结构,不如一开始留宽一点。stu_no 加上 UNIQUE 约束,避免学号重复录入。

表建完后,后续所有 SQL 都围绕这两个表展开。登录走 user,列表走 student,二者不要混着查。

2.2 三层架构的目录划分:让每个类都有清晰归属

JSP/Servlet 课设最容易写成“Servlet 里直接写 JDBC,JSP 里直接写 Java 片段”,这样代码确实能跑,但排查问题的时候非常痛苦。实际开发中我一般会严格拆分三层:JSP 只负责展示,Servlet 只做参数接收和页面跳转,DAO 只访问数据库。

src/ com.example.entity Student.java User.java com.example.dao StudentDAO.java UserDAO.java com.example.service StudentService.java com.example.controller LoginServlet.java StudentListServlet.java StudentSaveServlet.java StudentDeleteServlet.java com.example.filter LoginFilter.java CharacterEncodingFilter.java com.example.util DBUtil.java web/ login.jsp student/ list.jsp form.jsp WEB-INF/ web.xml

entity 包里放实体类,字段和表结构一一对应。dao 包只写 JDBC 代码。controller 包是 Servlet,你可以在类上用 @WebServlet 注解声明访问路径,不需要在 web.xml 里一个 Servlet 一个 Servlet 地配置。

service 层在这个课设规模下看起来有点空,但建议保留。比如“保存学生之前先判断学号是否存在”这类逻辑,放在 service 里比放在 Servlet 里更好测试。如果项目原始代码里没有 service 层,也不用强行拆,核心是不要让 Servlet 直接持有 Connection。

2.3 JDBC 连接类:URL 参数决定了后续所有中文问题

DBUtil 是整个项目的数据出入口,封装方式每家课设略有不同,但核心都是 Class.forName 加载驱动、DriverManager 获取连接。写这个类时最容易翻车的是驱动类名和 URL 参数,特别是 MySQL 8 与 MySQL 5 之间的差异。

package com.example.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/student_db" + "?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }

URL 里有几个参数值得说明。useUnicode=true 和 characterEncoding=utf8 决定写入数据库的中文会不会乱码;useSSL=false 是为了关闭 MySQL 8 默认的 SSL 警告;serverTimezone=Asia/Shanghai 解决 MySQL 8 的时区报错。如果服务器在海外,这个时区需要按实际区域改,不能盲目照抄。

Class.forName 里写 com.mysql.cj.jdbc.Driver 是针对 MySQL 8 的写法。如果 pom.xml 或 lib 目录里放的是 MySQL 5.x 的驱动,类名必须改回 com.mysql.jdbc.Driver。后面避坑章节会专门说这个。

3. 登录和访问权限:Session、Filter 和编码问题一起处理

3.1 登录 Servlet:成功用重定向,失败用转发

登录逻辑是这套系统的第一道入口。login.jsp 里的表单提交到 LoginServlet,Servlet 取出用户名密码,调用 UserDAO 查询数据库。我这里把登录成功后跳转方式和失败后返回方式分开处理,这是很多初学版本忽略的细节。

package com.example.controller; import com.example.dao.UserDAO; import com.example.entity.User; 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 javax.servlet.http.HttpSession; import java.io.IOException; @WebServlet("/login") public class LoginServlet extends HttpServlet { private UserDAO userDAO = new UserDAO(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); if (username == null || username.isEmpty() || password == null || password.isEmpty()) { request.setAttribute("errorMsg", "用户名和密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } User user = userDAO.findByUsernameAndPassword(username, password); if (user != null) { HttpSession session = request.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(1800); response.sendRedirect(request.getContextPath() + "/student/list"); } else { request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } } }

登录成功用 sendRedirect,而不是 forward。原因很简单:如果使用转发,刷新页面时浏览器会再次提交上一次的 POST 请求,导致重复登录或重复写入数据。重定向会让浏览器重新请求 GET 地址,刷新时只是重新加载列表,不会重新执行 doPost。

登录失败用 forward 而不是重定向,是为了把 errorMsg 这个参数带回 login.jsp,在表单上方显示错误提示。若使用重定向,request 域里的 errorMsg 会丢失,还得改用 session 或 URL 参数传递,绕远了。

session.setMaxInactiveInterval(1800) 表示 30 分钟内无操作自动失效,这个值可以根据系统要求调整。如果你希望关闭浏览器就失效,保持默认即可。

3.2 登录拦截 Filter:getSession(false) 的使用误区

登录页能进,但学生列表、新增、删除这些页面不能裸访问。最简单的控制方式是用 Filter 拦截所有请求,检查 Session 中是否存在 loginUser。这个 Filter 的逻辑不难,但 getSession 的参数很关键。

package com.example.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @WebFilter("/*") public class LoginFilter implements Filter { @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(); if (uri.endsWith("/login.jsp") || uri.endsWith("/login") || uri.contains("/static/")) { chain.doFilter(req, resp); return; } HttpSession session = request.getSession(false); if (session != null && session.getAttribute("loginUser") != null) { chain.doFilter(req, resp); } else { response.sendRedirect(request.getContextPath() + "/login.jsp"); } } }

这里故意写了 request.getSession(false),而不是无参的 getSession()。无参 getSession() 在 Session 不存在时会主动创建一个新会话,这样未登录用户每访问一次页面就会生成一个无用的 Session 对象,浪费服务端内存。getSession(false) 只在已有会话时返回当前会话,否则返回 null,配合后面的 null 判断正好。

判断顺序也不能颠倒。先判断 session != null,再判断 session.getAttribute,否则当 session 为 null 时直接调用 getAttribute 会抛空指针异常。另外,uri 匹配用了 endsWith,如果项目里存在 /student/list.jsp 这种请求也能覆盖。生产环境建议精确到路径前缀,而不是后缀。

3.3 全站 UTF-8:不要在每个 Servlet 里重复设置编码

登录时表单提交中文用户名,新增学生时表单提交中文姓名,这些地方都会涉及编码问题。每个 Servlet 都写 setCharacterEncoding 也能工作,但容易漏。用 CharacterEncodingFilter 统一处理是更省心的做法。

package com.example.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; @WebFilter("/*") public class CharacterEncodingFilter 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 解决的是 request 参数乱码和 response 输出乱码两个问题。request.setCharacterEncoding 只对 POST 请求体中的参数有效,而 response 的编码控制影响的是 JSP 输出到浏览器的内容。

要注意 Filter 的执行顺序。通过注解方式注册 Filter 时,顺序无法像 web.xml 中那样精确控制,所以我在 LoginServlet 的 doPost 第一行仍然保留了一次 request.setCharacterEncoding("UTF-8")。这样即使 CharacterEncodingFilter 在 LoginFilter 之后执行,登录那一次 POST 的参数也不会乱码。这套组合是课设项目里最不容易出问题的小技巧。

4. 学生模块 CRUD 与分页搜索:把 Servlet、DAO 和 JSP 串成一个闭环

4.1 分页模型 PageBean:把页码、总数和当前页数据放在一起

学生列表如果直接查出所有记录,数据量稍微大一点,页面就会很长,数据库压力也大。标准做法是做分页,而分页查询需要返回的不只是当前页数据,还包括总记录数、总页数、当前页码。这些散着传会很乱,封装一个 PageBean 是常见的做法。

package com.example.entity; import java.util.List; public class PageBean<T> { private int currentPage; private int pageSize; private int totalCount; private int totalPage; private List<T> list; public PageBean(int currentPage, int pageSize, int totalCount) { this.currentPage = currentPage; this.pageSize = pageSize; this.totalCount = totalCount; this.totalPage = (int) Math.ceil(totalCount * 1.0 / pageSize); } // getter 和 setter 省略 }

PageBean 用了泛型,这样它不止能给 Student 用,以后加一个教师管理、课程管理模块,哪个列表需要分页,直接复用同一个类。totalPage 在构造方法里通过 totalCount 和 pageSize 算出,避免在 JSP 里写复杂公式。

currentPage 和 pageSize 这两个词的命名我建议固定下来,因为 JSP 页面、Servlet、DAO 都要用同一套参数名。改成 page 或 rows 也能运行,但对接时很容易对不上。

4.2 StudentDAO 查询:页码转换为 LIMIT 参数

分页的核心 SQL 是 LIMIT offset, pageSize,其中 offset 需要根据当前页码和每页条数计算出来。这里最容易出错的是把页码直接当成 offset,比如第二页写成 LIMIT 2, 5,这样会跳过第一页的两条数据。

package com.example.dao; import com.example.entity.PageBean; import com.example.entity.Student; import com.example.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class StudentDAO { public PageBean<Student> findByPage(String keyword, int currentPage, int pageSize) { int offset = (currentPage - 1) * pageSize; // 先查符合条件的总记录数 String countSql = "SELECT COUNT(1) FROM student WHERE name LIKE ?"; int totalCount = 0; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(countSql)) { ps.setString(1, "%" + keyword + "%"); ResultSet rs = ps.executeQuery(); if (rs.next()) { totalCount = rs.getInt(1); } } catch (SQLException e) { e.printStackTrace(); } // 再查当前页数据 String sql = "SELECT id, stu_no, name, gender, age, class_name, phone, email " + "FROM student WHERE name LIKE ? LIMIT ?, ?"; List<Student> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); ps.setInt(2, offset); ps.setInt(3, pageSize); ResultSet rs = ps.executeQuery(); while (rs.next()) { 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.setClassName(rs.getString("class_name")); s.setPhone(rs.getString("phone")); s.setEmail(rs.getString("email")); list.add(s); } } catch (SQLException e) { e.printStackTrace(); } PageBean<Student> pageBean = new PageBean<>(currentPage, pageSize, totalCount); pageBean.setList(list); return pageBean; } }

LIMIT 的第一个参数是 offset,含义是“跳过多少条”,不是“第几页”。第二页每页 5 条时 offset 是 5,第三页是 10。这个换算关系我在初学阶段经常记反,现在写分页 SQL 时会在注释里标一遍。

我这里只演示了按姓名模糊搜。实际项目里加条件很简单,把 SQL 改成 WHERE name LIKE ? OR stu_no LIKE ?,再往后加一个对应参数的 setString 即可。注意 setString 的顺序要和 SQL 里问号出现的顺序完全一致,否则查出来的结果会很奇怪。

4.3 JSP 列表页:数据显示和分页导航相互配合

列表页的核心工作有两块:用循环把当前页数据渲染成表格行,把 PageBean 中的页码信息变成上一页、下一页和页码链接。JSP 里兼顾这两种逻辑时,我建议用 JSTL 标签,页面更干净。

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <body> <table border="1"> <tr> <th>学号</th><th>姓名</th><th>性别</th><th>年龄</th><th>班级</th><th>操作</th> </tr> <c:forEach items="${pageBean.list}" var="stu"> <tr> <td>${stu.stuNo}</td> <td>${stu.name}</td> <td>${stu.gender}</td> <td>${stu.age}</td> <td>${stu.className}</td> <td> <a href="${pageContext.request.contextPath}/student/form?id=${stu.id}">编辑</a> <a href="${pageContext.request.contextPath}/student/delete?id=${stu.id}" onclick="return confirm('确定删除该学生吗?')">删除</a> </td> </tr> </c:forEach> </table> <a href="${pageContext.request.contextPath}/student/list?page=${pageBean.currentPage - 1}" <c:if test="${pageBean.currentPage <= 1}">disabled</c:if>>上一页</a> <span>第 ${pageBean.currentPage} / ${pageBean.totalPage} 页</span> <a href="${pageContext.request.contextPath}/student/list?page=${pageBean.currentPage + 1}" <c:if test="${pageBean.currentPage >= pageBean.totalPage}">disabled</c:if>>下一页</a> </body> </html>

${pageContext.request.contextPath} 是 JSP 内置的上下文路径,所有超链接都加上它,项目部署在任意 webapps 子目录下都不会 404。这里容易踩坑的是直接写 /student/list,后面避坑章节会详细说。

如果是没有引入 JSTL 的项目,也可以用 Java 片段循环。两种写法我都能接受,但 JSP 页面里大量出现 <% for %> 时,HTML 结构会被切开,调试时比较费眼睛。冷启动阶段使用 JSTL,熟悉之后再看 Scriptlet 版本的代码也不会太吃力。

4.4 新增、编辑、删除:一个表单页面两种用途

新增和编辑其实是同一个表单页面的两种状态:有 id 参数是编辑,无 id 参数是新增。写两个页面属于冗余,一个 form.jsp 就够了。保存时通过请求中是否有 id 判断执行 insert 还是 update。

package com.example.controller; import com.example.dao.StudentDAO; import com.example.entity.Student; 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("/student/save") public class StudentSaveServlet extends HttpServlet { private StudentDAO studentDAO = new StudentDAO(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String id = request.getParameter("id"); String stuNo = request.getParameter("stuNo"); String name = request.getParameter("name"); int age = 0; if (request.getParameter("age") != null && !request.getParameter("age").isEmpty()) { age = Integer.parseInt(request.getParameter("age")); } Student s = new Student(); s.setStuNo(stuNo); s.setName(name); s.setAge(age); s.setGender(request.getParameter("gender")); s.setClassName(request.getParameter("className")); s.setPhone(request.getParameter("phone")); s.setEmail(request.getParameter("email")); if (id == null || id.isEmpty()) { studentDAO.insert(s); } else { s.setId(Integer.parseInt(id)); studentDAO.update(s); } response.sendRedirect(request.getContextPath() + "/student/list"); } }

判断 id == null || id.isEmpty() 是必要的。表单里没有 id 字段时 getParameter 返回 null,但很多页面为了统一处理会保留一个空 id 字段,这时返回的是空字符串。只判断 null 会误走入 update 分支,然后因为 id 解析失败直接 500。

form.jsp 里的隐藏 id 字段是编辑回显的关键。列表页点“编辑”时把 id 放在链接里,这个 Servlet 在处理 GET 请求时需要根据 id 查出学生数据并 setAttribute 到 request,form.jsp 再用 ${student.name} 等表达式回显到 input 的 value 属性。

删除学生时,StudentDeleteServlet 的 doGet 中接收 id,调用 DAO 的 deleteById 方法,然后重定向回列表。注意 deleteById 使用 PreparedStatement 的 setInt,不要直接把 id 拼进 SQL 字符串。这个系统规模不大,但 SQL 注入的写法规避越早越好。

5. 避坑排查:五个让课设项目直接卡死的典型问题

5.1 驱动问题:ClassNotFound、SSL 警告和 MySQL 版本对应关系

现象:Tomcat 可以启动,登录页也正常显示,但输入账号密码点击登录后,页面直接报 500,控制台第一行出现 ClassNotFoundException: com.mysql.jdbc.Driver。

原因:MySQL 8 的官方驱动从 8.x 开始把主类改成了 com.mysql.cj.jdbc.Driver,旧的 com.mysql.jdbc.Driver 在驱动 jar 包中不再提供。项目 lib 目录里放的是 mysql-connector-java-8.x.jar,而代码里还是网上复制来的老写法。

解决:先去 WEB-INF/lib 确认 jar 包版本。如果是 8.x,把 Class.forName 改成 com.mysql.cj.jdbc.Driver。如果是 5.x,保持原来类名即可。同时把 URL 中的 serverTimezone=Asia/Shanghai 和 useSSL=false 补上,否则 MySQL 8 连接时会提示时区错误或 SSL 警告。记住一句话:驱动 jar 版本和 Class.forName 字符串必须一一对应。

5.2 中文乱码:请求、响应、数据库三处不一致

现象:登录页面输入中文正常,登录后新增一个学生,列表页显示的名字直接变成“??”或乱码。

原因:这是三个环节的编码不一致。第一,login.jsp 或 list.jsp 页面头部没有声明 charset;第二,Servlet 没有执行 request.setCharacterEncoding;第三,JDBC 连接 URL 没有带 characterEncoding=utf8。

解决:三处顺手一起改。JSP 页面顶部写 <%@ page contentType="text/html;charset=UTF-8" %>;Filter 或 Servlet 里调 request.setCharacterEncoding("UTF-8");JDBC URL 里带上 useUnicode=true&characterEncoding=utf8。另外数据库表字段的排序规则建议用 utf8mb4_general_ci,在 MySQL 8 下对中文支持最省心。改了数据库排序规则后,已存在的乱码数据需要重新插入才会恢复正常。

5.3 跳转 404:context-path 忘掉,项目永远进不去

现象:登录成功后地址栏显示 /student_db/student/list,但页面提示 404。直接在浏览器地址栏输入 http://localhost:8080/student/list 却能正常访问。

原因:Tomcat 部署项目后,访问路径必须带上下文路径。项目名是 student_db,那么所有请求前缀都应该是 /student_db/...。代码里写成了 response.sendRedirect("/student/list"),少了 contextPath,导致重定向去了一个不存在的路径。

解决:写作时使用 response.sendRedirect(request.getContextPath() + "/student/list")。JSP 里所有超链接用 ${pageContext.request.contextPath} 拼接。以后部署时如果改了项目名,contextPath 会自动跟随,不用手动改任何一处硬编码路径。

5.4 空指针和日志查看:不只看第一行,要看 Caused by

现象:登录后访问列表页,页面显示 500,控制台堆栈信息一大片,翻看前几行全是 javax.servlet.ServletException 的嵌套结构,根本看不出是谁抛出的异常。

原因:Tomcat 打印的异常是层层包装的,最外层通常只是框架的处理逻辑。真正的错误往往埋在堆栈中间的“Caused by”那一行,比如 java.lang.NullPointerException 出现在 StudentDAO 的某个 resultSet 操作上,或者 HttpSession 为 null 时被直接调用。

解决:看到 500 先不要急着猜,优先定位控制台或 catalina.out 日志里的 Caused by 行。它指向哪个类,就去哪个类的对应行附近检查。常见的空指针来源有三个:session 为 null 直接调属性、getParameter 返回 null 直接调用字符串方法、ResultSet 没有数据就直接 getString。对应修法是 getSession(false) 后做空判断、参数判空后再执行解析、rs.next() 确认后再取值。

5.5 数据库密码和端口问题:本地能跑,换台机器就认证失败

现象:项目在自己电脑上运行正常,拷贝给同学后报 Access denied for user 'root'@'localhost',或者连接超时。

原因:DBUtil 里的 USER 和 PASSWORD 是硬编码的,同学的 MySQL 密码可能是其他值,或者 MySQL 端口不是默认的 3306。还有可能是同学的 MySQL 服务没启动。

解决:先检查数据库连接串里的端口是否和服务端一致,再用命令行工具测试:

mysql -u root -p -h localhost -P 3306

如果命令行能登录,说明 DBUtil 里的账号密码写错了;如果命令行也进不去,先重置 MySQL 的 root 密码。把 DBUtil 里的连接参数改成可配置项,比如读取 properties 文件,这样换环境时只改配置文件,不用动 Java 代码。

6. 部署与复核:一晚上跑不起来的项目,用三个验证节点按顺序收尾

6.1 三个快速验证节点

项目写完不急着摆弄界面,先按顺序验证三个节点,任何一步不过关都先补齐再继续。

第一个节点是启动验证。Tomcat 启动后访问项目根路径,能打开 login.jsp,并且没有任何 ClassNotFoundException,说明依赖 jar 包和部署结构基本正确。第二个节点是登录验证。用 admin 账号登录,成功后地址栏能否跳转到 /student_db/student/list,未登录状态直接访问列表是否会被 Filter 弹回 login.jsp。第三个节点是 CRUD 闭环。新增一条学生记录,列表页能显示;编辑后刷新页面能看到修改结果;删除后记录消失。三个节点全过,这套系统就能用于演示了。

在本地验证时我习惯用 curl 辅助,浏览器缓存经常把旧页面展示出来,让人误以为代码没生效:

# 携带 cookie 模拟登录,-i 能看到响应头中的 302 跳转 curl -i -c cookies.txt -X POST -d "username=admin&password=123456" http://localhost:8080/student_db/login # 带着 cookie 访问学生列表,正常应返回 200 curl -b cookies.txt http://localhost:8080/student_db/student/list

第一次执行 curl 时,如果响应头里出现 302 且 Location 指向 /student_db/student/list,说明登录链路是通的。

6.2 改动最小的一次加固:密码不存明文

这个课设项目的登录逻辑通常直接对比明文密码,demo 无所谓,但如果要交作业或给别人演示,最好改成摘要存储。Java 原生 MessageDigest 就能实现 MD5,不需要引入额外依赖。

import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; public static String md5(String input) throws NoSuchAlgorithmException { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] bytes = md.digest(input.getBytes("UTF-8")); StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); }

改造时把 UserDAO 里查询密码的地方改成先对用户输入做 md5,再和数据库中的值比对。初始化数据的 SQL 也应该预先存 md5 后的值,比如 admin 的密码 123456 对应的摘要改成存储后的 32 位字符串。需要说明的是,MD5 在正式系统里不够安全,至少要用 BCrypt 加盐,但课设阶段做这一步已经能体现出对密码存储的基本意识。

从那以后我每次拿到一套 JSP/Servlet 课设代码,都会强制走一遍“DBUtil 连接、登录跳转、CRUD 闭环”这个顺序,确认没问题再碰页面样式。宁可先多打印几行日志,也不让数据库环节变成黑匣子。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询