简介:面向Java Web入门学习者和课程设计需求者的学生信息管理实战项目,采用JSP+Servlet配合Layui、jQuery完成界面与交互,后端基于MySQL存储数据,支持学生自主注册登记、教师集中管理学生信息等基础流程,适合用来理解Servlet请求处理、JSP页面渲染和分层协作。压缩包共2个文件,以zip工程包和htm说明页为主,工程包用于导入IDEA后配置Tomcat运行,说明页便于快速了解项目结构与部署要点,整体约2.13MB,体量轻便。已有6437人学习下载,说明其在同类课程设计资料中具有一定参考热度。读者可据此获得一套可直接运行的基础CRUD范例,并借助数据库脚本、lib依赖和页面模块梳理登录注册、信息维护、列表查询等实现思路,适合作为期末课程设计、Java Web入门练习或二次扩展的起点。
1. 需求先立住:为什么学生信息管理系统用 JSP + Servlet 而不是直接上框架
选题会上常有人问,都什么年代了还写 JSP + Servlet?换 Spring Boot 不是更体面?我的看法相反:对一份 Java Web 课程设计来说,JSP + Servlet 恰好是最能讲清原理的选择——请求怎么进来、会话怎么保持、页面怎么拿到数据,全在自己写的几行代码里,没有框架替你兜底。这套简单学生信息管理系统要解决的问题很具体:用最少的依赖,把登录、学生信息增删改查、分页列表和关键字搜索跑通,代码分层清楚到能一页页讲给答辩老师听。它适合三类人:交 Java Web 课程设计的同学、需要一份能跑的基础版毕设的人、刚学完 Servlet 想找完整项目练手的人。规模不大,但每一层都能落地。
2. 总体架构设计:三层结构与五类源文件的职责划分
2.1 工程目录与分层边界
JSP + Servlet 项目最容易出的问题不是功能写不出来,而是写着写着全塞进一个 Servlet 里,三百行 doPost,改个字段要翻半天。所以开工第一件事是把目录定死,后面所有代码只往对应格子里放。我一般按「页面 → 控制 → 数据访问 → 实体」四段切,Web 层只做参数接收和转发,业务判断放 Service,SQL 全部收进 DAO,实体类只当数据搬运工。标准 Maven Web 工程或普通 Dynamic Web Project 都适用,目录大致如下:
sms/ ├── src/main/java/com/demo/ │ ├── pojo/ # 实体类:Student、SysUser、Clazz │ ├── dao/ # 数据访问:StudentDao、UserDao │ ├── service/ # 业务组装:StudentService │ ├── servlet/ # 控制器:LoginServlet、StudentServlet │ ├── filter/ # 登录校验:LoginFilter、EncodingFilter │ └── util/ # 工具:JdbcUtils、PageBean ├── src/main/webapp/ │ ├── WEB-INF/ │ │ ├── web.xml # 部署描述符 │ │ └── lib/ # 第三方 jar │ ├── jsp/ # 页面:login.jsp、student_list.jsp、student_edit.jsp │ ├── css/ js/ │ └── index.jsp └── sql/sms.sql # 建表与初始数据脚本dao只认 SQL 和 ResultSet,不认 HttpServletRequest;servlet只认参数和转发,不写一句 SQL。这条边界守住,任何一个功能出问题都能快速定位到是「页面没取到值」还是「SQL 条件写错」。很多同学的代码之所以改一处崩三处,就是因为页面里直接new StudentDao()查库,跳过了控制层,参数校验和异常处理全丢了。
WEB-INF/lib下需要放的 jar 不多:MySQL 驱动、JSTL 实现包(jstl-1.2.jar 与 standard.jar)。这两个包忘记放,页面上的<c:forEach>会原样输出到浏览器,看起来像标签没生效,实际是 jar 没加载。
2.2 一次请求的完整链路
以「打开学生列表」为例,链路是这样的:浏览器请求/student?action=list&pageNum=1,先被 Filter 拦下判断 Session 里有没有登录用户,没有就重定向到login.jsp;通过后进入StudentServlet,doGet里解析 action,调StudentService.findByPage(),Service 调 DAO 执行两条 SQL——一条count求总数,一条带limit取当前页;DAO 把 ResultSet 映射成List<Student>,装进PageBean返回;Servlet 把 PageBean 放进request作用域,forward到student_list.jsp;JSP 用 EL 和 JSTL 把数据铺开成表格。
这条链路有两个关键约定。第一,查询类请求用forward,数据靠request.setAttribute传递,URL 不变,刷新只是重新查一次,没有副作用。第二,增删改这类写操作完成后必须sendRedirect回列表页,也就是常说的 PRG 模式,否则用户按 F5 会把新增再提交一遍,数据库里凭空多出一条重名记录。这一点后面避坑章节还会展开。
会话方面,登录成功后把SysUser对象塞进session,整个应用只认这一个 key。别在 Filter 里既查 Session 又查 Cookie,也别把用户 id 拼在 URL 上传着走——一旦链接被复制,权限就跟着链接走了。
2.3 技术版本组合与 web.xml 配置
版本组合建议整套对齐,别东拼西凑:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 8 | Tomcat 8.5/9 与 JDK 8 兼容性最好 |
| Tomcat | 8.5 或 9 | 9 的 Servlet 版本为 4.0,注解支持完整 |
| MySQL | 5.7 或 8.0 | 8.0 必须用com.mysql.cj.jdbc.Driver |
| Servlet API | 4.0 | 由 Tomcat 提供,不要打进 war 包 |
| JSTL | 1.2 | 配合 EL 表达式使用 |
Servlet 3.0 之后可以用@WebServlet和@WebFilter注解,省掉一堆 XML。但答辩时老师常问「映射关系在哪配的」,所以我一般保留web.xml的骨架,把欢迎页、错误页、Session 超时写进去,Servlet 映射用注解,两边都讲得通:
<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <display-name>student-info-system</display-name> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> <!-- 会话 30 分钟过期,避免演示时登录态一直保留 --> <session-config> <session-timeout>30</session-timeout> </session-config> <!-- 统一错误页,避免把 SQL 异常堆栈直接甩给用户 --> <error-page> <error-code>500</error-code> <location>/jsp/error.jsp</location> </error-page> </web-app>session-timeout的单位是分钟,写 30 就是半小时无操作自动失效。error-page这一条在演示时特别有用:万一现场数据库没起来,页面跳的是友好提示,而不是一片红字堆栈,观感差别很大。至于编码问题,Tomcat 8 之后 GET 默认按 UTF-8 解析,POST 仍需在读取参数前设置编码,这部分放到 Filter 里统一处理最省心。
3. 数据库与实体设计:三张核心表与 DAO 层的边界
3.1 建表 SQL 与字段取舍
学生信息管理系统的表不用多,三张足够撑起一个完整课程设计:学生表、用户表、班级表。学生表是主表,班级表提供下拉选项,用户表管登录。字段设计上有个小取舍值得讲:学号用varchar而不是自增 int,因为真实场景里学号常常带年份前缀,而且学号本身就该是业务主键,用自增 id 当主键、学号加唯一索引,是更稳妥的做法。
CREATE DATABASE IF NOT EXISTS sms DEFAULT CHARSET utf8mb4; USE sms; -- 班级表:给下拉框用,避免学生表里存班级名字符串 CREATE TABLE clazz ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, major VARCHAR(50) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 学生表:student_no 唯一,防止重复录入 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(30) NOT NULL, gender CHAR(1) NOT NULL DEFAULT '男', birthday DATE, clazz_id INT, phone VARCHAR(20), enroll_date DATE, remark VARCHAR(200), CONSTRAINT fk_student_clazz FOREIGN KEY (clazz_id) REFERENCES clazz(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 用户表:密码存 MD5 摘要,不存明文 CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(30) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO sys_user(username, password, real_name) VALUES ('admin', '21232f297a57a5a743894a0e4a801fc3', '管理员');utf8mb4而不是utf8,是因为后者在 MySQL 里其实只有三字节,存不了 emoji 和部分生僻字,备注字段一旦出现特殊字符就会插入失败。唯一索引加在student_no上,重复录入学号时数据库直接报错,比在 Java 里先查一遍再插入更可靠——并发场景下「先查后插」本身就有空窗。
初始化账号的密码是admin的 MD5 值。课程设计阶段用 MD5 摘要够用,至少数据库里不是明文,答辩被问「密码怎么存的」有话可说;真要上生产得换加盐哈希,这点在报告里写一句反而显得考虑过。
3.2 实体类与 DAO 封装
实体类就是一个字段对应一列、提供无参构造和 getter/setter 的 POJO,不加任何逻辑。DAO 层则要解决两个重复劳动:拿连接、关资源。写一个JdbcUtils把这两件事收掉,所有 DAO 方法只专注 SQL 和结果映射。
package com.demo.util; import java.sql.*; public class JdbcUtils { // MySQL 8 驱动类名带 cj,5.x 是 com.mysql.jdbc.Driver private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/sms?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PWD = "123456"; static { try { Class.forName(DRIVER); // 静态块只加载一次驱动 } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("数据库驱动未找到,检查 WEB-INF/lib 下是否有 mysql-connector"); } } public static Connection getConn() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } // try-with-resources 保证连接、语句、结果集一定关闭 public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r != null) { try { r.close(); } catch (Exception ignored) { } } } }URL 里三个参数都是血的教训:useUnicode和characterEncoding决定中文能不能正确写进库,serverTimezone不写会直接抛时区异常,useSSL=false免掉本地环境的一堆握手警告。Class.forName放在静态块里,工程启动后只执行一次,比每个方法里都写一行强。
DAO 方法统一长这样,以按条件分页查询为例:
public List<Student> findByPage(String keyword, int offset, int pageSize) throws SQLException { StringBuilder sql = new StringBuilder( "SELECT s.*, c.name AS clazz_name FROM student s " + "LEFT JOIN clazz c ON s.clazz_id = c.id WHERE 1=1 "); List<Object> params = new ArrayList<>(); // 关键字同时匹配学号和姓名,用 ? 占位防注入 if (keyword != null && !keyword.trim().isEmpty()) { sql.append("AND (s.student_no LIKE ? OR s.name LIKE ?) "); params.add("%" + keyword.trim() + "%"); params.add("%" + keyword.trim() + "%"); } sql.append("ORDER BY s.id DESC LIMIT ?, ?"); params.add(offset); params.add(pageSize); List<Student> list = new ArrayList<>(); try (Connection conn = JdbcUtils.getConn(); 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()) { while (rs.next()) { Student s = new Student(); s.setId(rs.getInt("id")); s.setStudentNo(rs.getString("student_no")); s.setName(rs.getString("name")); s.setGender(rs.getString("gender")); s.setPhone(rs.getString("phone")); s.setClazzName(rs.getString("clazz_name")); // 联表带出的班级名 list.add(s); } } } return list; }WHERE 1=1是个老写法,作用是在后面无脑拼AND而不必判断第一个条件。LEFT JOIN是为了列表页直接显示班级名称,否则页面上只有clazz_id,还得再查一次。用PreparedStatement占位符而不是字符串拼接,不只是防注入,还能让 MySQL 复用执行计划,这个点在答辩里是加分项。
3.3 分页查询与总数统计
分页要两个数:总记录数和当前页数据。总记录数决定上一页下一页的边界,当前页数据用LIMIT offset, size取。我习惯把这两个数连同页码一起封装成PageBean,Servlet 只需往 request 里塞一个对象,JSP 直接遍历。
package com.demo.util; import java.util.List; public class PageBean<T> { private int pageNum; // 当前页,从 1 开始 private int pageSize; // 每页条数 private int totalCount; // 总记录数 private int totalPage; // 总页数,由 totalCount 与 pageSize 算出 private List<T> list; // 当前页数据 public PageBean(int pageNum, int pageSize, int totalCount, List<T> list) { this.pageSize = pageSize; this.totalCount = totalCount; this.list = list; // 总页数向上取整,0 条数据时也保留 1 页,避免页码显示为 0 this.totalPage = Math.max(1, (totalCount + pageSize - 1) / pageSize); // 页码越界钳制,防止用户手改 URL 参数导致 offset 为负 this.pageNum = Math.min(Math.max(1, pageNum), this.totalPage); } public int getOffset() { return (pageNum - 1) * pageSize; } // 省略其余 getter }页码钳制这一步很多人省掉,结果用户把 URL 里的pageNum改成 -1,LIMIT -10, 10直接抛 SQL 语法错误。totalCount为 0 时把总页数兜底成 1,页面上就不会出现「第 1 页 / 共 0 页」这种自相矛盾的显示。Service 层的组装逻辑是:先算 offset,查总数,再查列表,最后 new 一个 PageBean 返回,三层职责一目了然。
4. 核心功能实现:登录拦截、学生 CRUD 与 JSP 渲染
4.1 登录态与 Filter 鉴权
登录本身很简单:取用户名密码,MD5 后比库,成功就session.setAttribute("loginUser", user)并重定向到列表页,失败回登录页带一句提示。真正需要设计的是「怎么保证没登录的人进不来」。答案是在每个受保护请求前加一道 Filter,而不是在每个 JSP 顶部写一段if (session == null) response.sendRedirect(...)——后者漏一个页面就是一个洞。
@WebFilter("/*") public class LoginFilter implements Filter { // 白名单:登录页、登录接口、静态资源不拦截 private static final List<String> WHITE = Arrays.asList( "/login.jsp", "/login", "/css/", "/js/", "/error.jsp"); @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 多个 Filter 时统一在这里设编码,必须早于任何 getParameter request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); String uri = request.getRequestURI().substring(request.getContextPath().length()); boolean isWhite = WHITE.stream().anyMatch(uri::startsWith); if (isWhite || request.getSession().getAttribute("loginUser") != null) { chain.doFilter(req, resp); // 放行 } else { // 未登录统一回登录页,而不是弹一个空白页 response.sendRedirect(request.getContextPath() + "/login.jsp"); } } }白名单用startsWith匹配,能覆盖/css/下所有样式文件;.do之类的后缀映射不在白名单里,一律要求登录。把编码设置也放进这个 Filter,是为了避免在每个 Servlet 的doPost第一行重复写setCharacterEncoding,少写一处就是一次乱码事故。注意setContentType在 Filter 里设了之后,JSP 页面头部的pageEncoding仍要写,两者管的不是一回事。
4.2 学生模块的 Servlet 分发
一个模块一个 Servlet,用action参数区分动作,是 JSP + Servlet 时代最常用的写法。比起给增删改查各建一个 Servlet,它少了四个类,路由逻辑集中在一处,答辩时也好讲。
@WebServlet("/student") public class StudentServlet extends HttpServlet { private final StudentService service = new StudentService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null) action = "list"; switch (action) { case "list": // 列表 + 分页 + 关键字 list(req, resp); break; case "toEdit": // 进入编辑页,回显单条数据 req.setAttribute("student", service.findById(parseInt(req.getParameter("id")))); req.getRequestDispatcher("/jsp/student_edit.jsp").forward(req, resp); break; case "delete": // 删除后重定向,防止刷新重复删除 service.delete(parseInt(req.getParameter("id"))); resp.sendRedirect(req.getContextPath() + "/student?action=list"); break; default: resp.sendError(404); } } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("save".equals(action)) { Student s = new Student(); s.setId(parseInt(req.getParameter("id"))); // 隐藏域回传,0 表示新增 s.setStudentNo(req.getParameter("studentNo")); s.setName(req.getParameter("name")); s.setGender(req.getParameter("gender")); s.setPhone(req.getParameter("phone")); s.setClazzId(parseInt(req.getParameter("clazzId"))); service.saveOrUpdate(s); resp.sendRedirect(req.getContextPath() + "/student?action=list"); } } private int parseInt(String v) { try { return Integer.parseInt(v); } catch (Exception e) { return 0; } } private void list(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int pageNum = Math.max(1, parseInt(req.getParameter("pageNum"))); String keyword = req.getParameter("keyword"); PageBean<Student> page = service.findByPage(keyword, pageNum, 8); req.setAttribute("page", page); req.setAttribute("keyword", keyword); // 回填搜索框,翻页时不丢条件 req.getRequestDispatcher("/jsp/student_list.jsp").forward(req, resp); } }几个容易忽略的点:parseInt包一层 try-catch,参数缺失时返回 0 而不是抛 500;删除和保存之后一律sendRedirect,走 PRG 模式;列表页把keyword也塞回 request,否则用户翻到第二页搜索词就丢了。saveOrUpdate内部按 id 是否为 0 决定 insert 还是 update,新增和修改共用一套表单,页面只做一次。
4.3 JSP 用 JSTL 与 EL 渲染列表
JSP 页面里不要出现<% %>脚本片段,这是最容易让代码变脏的地方。取值用 EL,循环和判断用 JSTL,页面就只剩标签结构:
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table class="table"> <tr><th>学号</th><th>姓名</th><th>性别</th><th>班级</th><th>电话</th><th>操作</th></tr> <c:forEach items="${page.list}" var="s"> <tr> <td>${s.studentNo}</td> <td>${s.name}</td> <td>${s.gender}</td> <td>${s.clazzName}</td> <td>${s.phone}</td> <td> <a href="${pageContext.request.contextPath}/student?action=toEdit&id=${s.id}">编辑</a> <a href="${pageContext.request.contextPath}/student?action=delete&id=${s.id}" onclick="return confirm('确认删除该学生?')">删除</a> </td> </tr> </c:forEach> </table> <!-- 分页条:翻页时带上 keyword,搜索条件不丢 --> <div class="pager"> 第 ${page.pageNum} / ${page.totalPage} 页,共 ${page.totalCount} 条 <c:if test="${page.pageNum > 1}"> <a href="${pageContext.request.contextPath}/student?action=list&pageNum=${page.pageNum - 1}&keyword=${keyword}">上一页</a> </c:if> <c:if test="${page.pageNum < page.totalPage}"> <a href="${pageContext.request.contextPath}/student?action=list&pageNum=${page.pageNum + 1}&keyword=${keyword}">下一页</a> </c:if> </div>所有链接都拼pageContext.request.contextPath,是因为部署到 Tomcat 后项目名会成为路径前缀,写死/student在本地能用,换台机器就 404,这个坑几乎每届都有人踩。c:forEach的var名和 EL 表达式里的名字必须一致,写错不会报错,只是页面空白。删除加一句confirm,演示时手滑点错也不至于把数据删光。
5. 避坑与排查:五个最容易翻车的点
5.1 中文乱码:POST 和 GET 是两套机制
现象:表单提交的中文姓名存进数据库变成???,或者页面上显示成问号方块。原因分两种:POST 请求的体编码由request.setCharacterEncoding("UTF-8")决定,且必须在第一次getParameter之前调用,写在之后完全无效;GET 请求的参数在 URL 里,由 Tomcat 的Connector决定,Tomcat 8 以后默认 UTF-8,但老项目或改过server.xml的环境仍可能是 ISO-8859-1。解决办法:把setCharacterEncoding提到 Filter 最前面,跳转页面用response.setContentType("text/html;charset=UTF-8"),GET 有问题就去server.xml的 Connector 上加URIEncoding="UTF-8"。另外数据库和表的字符集必须是utf8mb4,连接串带characterEncoding=utf8,三处对齐才不会漏。
5.2 连接泄漏:跑几分钟就卡死
现象:一开始功能全正常,反复刷新列表几十次后页面转圈,日志里出现Too many connections。原因基本是某个 DAO 方法里Connection开了没关,或者ResultSet关了但PreparedStatement漏了。解决办法:全部改用 try-with-resources,把Connection、PreparedStatement、ResultSet写在 try 的括号里,异常分支也会自动关闭。排查时在JdbcUtils.getConn()里打一行日志打印当前连接数,或者直接show processlist看哪个连接一直 Sleep 不释放,顺藤摸瓜就能找到漏关的那一处。
5.3 登录绕过:页面能直接输地址进去
现象:不登录,直接在地址栏敲/jsp/student_list.jsp,页面居然打开了,只是数据是空的。原因是 Filter 只拦了/student,没拦 JSP 页面,而 JSP 里又直接调了 DAO 或者显示空表。解决办法有两条路,一是 Filter 映射写/*并把白名单做全,二是把 JSP 页面全部挪进WEB-INF/jsp/下,这个目录浏览器无法直接访问,只能由 Servletforward进去。我一般两个都做:Filter 兜底,WEB-INF再上一道锁。注意挪进WEB-INF后,页面里的相对路径引用 CSS 会失效,得改用${pageContext.request.contextPath}/css/...的绝对路径。
5.4 刷新重复提交:一次新增变三条
现象:点「保存」后按 F5 刷新,数据库里多出一条一模一样的学生记录,学号唯一索引还会抛异常。原因是保存完用forward转到列表页,浏览器地址栏还停在POST /student上,刷新就是重发一次 POST。解决办法是严格 PRG:写操作完成后一律response.sendRedirect()到列表查询地址,浏览器收到 302 会自动发 GET,刷新只是重新查询。另外可以在保存表单里放一个隐藏的 token,提交时校验一次,不过对课程设计来说 PRG 已经够了。
5.5 修改变全表更新:主键丢了
现象:编辑某条学生信息保存后,发现所有学生的姓名都变成了同一个。原因是更新语句写成了UPDATE student SET name=? WHERE name=?,或者编辑页的隐藏域<input type="hidden" name="id" value="${student.id}">里${student.id}是空,parseInt返回 0,SQL 条件退化成匹配全部。解决办法:更新一律用主键WHERE id=?,隐藏域的值在页面上用开发者工具确认非空,DAO 层在id <= 0时直接抛异常拒绝执行。这类问题一旦发生就是事故级,改数据都改不回来,所以我的习惯是 update 方法第一行先断言 id 有效性。
6. 验收与答辩:一张检查表把功能跑通并讲清设计取舍
功能写完不等于能交。答辩现场最尴尬的不是被问难题,而是老师随手点了一个按钮就报错。所以我一般按下面这张表逐项过一遍,每项都要亲手点、亲手填,不能靠「应该没问题」:
| 检查项 | 操作路径 | 预期结果 | 常见失败 |
|---|---|---|---|
| 未登录拦截 | 直接访问/student?action=list | 跳回登录页 | Filter 白名单写错,误放行 |
| 登录失败提示 | 输入错误密码 | 停留登录页并提示 | 提示信息没回填,页面空白 |
| 新增学生 | 填全字段点保存 | 列表首行出现新记录 | 中文乱码、学号重复报 500 |
| 修改学生 | 点编辑改电话保存 | 对应行数据变化 | id 丢失导致全表更新 |
| 删除学生 | 点删除并确认 | 该行消失,总数减一 | 用了 GET 删除被浏览器预加载 |
| 关键字搜索 | 输入姓氏搜索 | 只显示匹配记录 | 翻页后搜索词丢失 |
| 分页边界 | 首页点上一页、末页点下一页 | 按钮不显示或不可点 | offset 为负抛 SQL 异常 |
| 空数据 | 清空表后打开列表 | 显示「暂无数据」 | 页面报空指针 |
过完表还有一件事:把项目从 IDE 里删掉,重新部署一次 war 包到干净的 Tomcat,跑同一套流程。这一步能暴露八成「本地能跑、拷过去就崩」的问题,通常是WEB-INF/lib少 jar、数据库连接串还是本机密码、或者 JSP 路径写成了绝对磁盘路径。演示前把数据库脚本sms.sql也准备一份,现场换机器时导入就能用。
答辩讲什么,其实就三个问题。第一,为什么这么分层——页面只管展示、Servlet 只管调度、DAO 只管 SQL,任何一层改动不牵连其他层,这是可维护性的最小代价。第二,登录鉴权为什么放 Filter 而不是每个页面判断——集中一处,漏不掉。第三,分页为什么用两条 SQL——count求总数、limit取当页,比一次性查全量再在内存里截断省内存,数据量大了差别明显。这三个回答对应的是设计意识,比背 API 有用得多。
我第一次带这类课设时,学生演示到一半数据库连不上,现场重装 MySQL 装了二十分钟。从那以后我每次交付前都强制走一遍「换机器部署 + 空库导入 + 全表点击」的流程,宁可多花一小时,也不在台上赌运气。这份 JSP + Servlet 学生信息管理系统的完整工程、建表脚本和部署说明已经整理成一份可直接导入的资料,照着目录结构替换包名就能改成自己的题目。希望帮到你。
本文还有配套的精品资源,点击获取