简介:这是一份面向Java Web初学者的课程设计资源,基于JSP+Servlet+Mysql实现一套简单的学生信息管理系统,学生可自行注册登记、维护个人信息,教师登录后可对学生信息进行增删改查与统一管理,功能覆盖常见课设要求,适用于高校Java Web课程设计、毕业设计前的入门练手。项目集成Layui与jQuery搭建交互界面,代码分层清晰,运行环境为JDK7+Tomcat8+MySQL,并支持IntelliJ IDEA直接导入,数据库脚本及jar包配套齐全。资源包共2个文件,包含1个ZIP源码压缩包和1个HTM说明文档,总大小仅2.13MB,其中ZIP内附完整项目代码、lib目录下的所需jar包,数据库文件则通过文内百度网盘链接另行提供,整体结构紧凑、无冗余内容。目前已有6435人学习下载,对于需要快速搭建小型Web管理系统的同学而言,这份可直接运行、便于二次开发的源码值得参考。
1. 能在答辩现场讲明白的JSP+Servlet学生管理系统,到底该怎么搭
期末周,在某个高校实验室,A同学攥着一份课程设计任务书发呆:要求做一个基于Java Web的学生信息管理系统,技术栈限定JSP+Servlet+MySQL。他没有急着写代码,而是先问了自己一个问题——这东西就算做出来了,答辩的时候老师问“你用了什么设计思想”“为什么用连接池”“分页是怎么做的”,我能接得住吗?带着这个疑问,他把这个“简单学生信息管理系统”拆成了需求、设计、实现、验收四段来推进,最终交出了一份既能跑通、又能讲清楚的设计方案。这篇博文,就是把这套完整方案整理出来,给正在做JSP+Servlet课程设计、毕业设计的同学做参考。无论你是零基础起步,还是已经能跑通增删改查但总感觉哪里“不成体系”,这篇都能帮你把项目从“能跑”提升到“能答辩”。方案默认读者会一点Java语法、会装IDEA和MySQL,不会的地方我也会把操作细节写到命令和配置级别。
2. 技术选型与总体架构设计:为什么2023年还要用JSP+Servlet,以及三层结构怎么落地
2.1 技术栈选型:这个“简单”系统该用什么框架
技术上很容易头脑发热:明明学过Spring Boot,为什么作业非要限定JSP+Servlet?先别急着抱怨,这门课的教学目标其实是让你理解Web最底层的请求-响应模型。JSP负责视图渲染,Servlet负责控制逻辑,JDBC负责数据库交互——这正是Java Web最原始的MVC形态。你把它做扎实了,后面学Spring MVC时会有一种“原来框架帮我干的就是这些事”的顿悟感。
因此这套方案的技术栈定为:
- JDK版本:JDK 8,最稳妥,也能兼容后续的JSP语法
- Web容器:Tomcat 9,Java EE规范里的Servlet 4.0,JSP 2.3版本
- 数据库:MySQL 5.7,InnoDB引擎,字符集utf8mb4
- 前端:JSP + JSTL + EL表达式,少量CSS,不引入前端框架
- 连接管理:C3P0连接池,避免每次请求都用DriverManager创建新连接
实际动手前先做一件事:在IDEA里建一个Maven项目。别用Dynamic Web Project,因为手动拷jar包、手动配web.xml的体验堪称“锻炼耐心”,Maven能自动拉取依赖,以后你们会遇到的前人踩坑帖,基本也都基于Maven结构。
2.2 三层架构划分:Servlet不在这个层面里是谁的责任
很多同学做的管理系统会把JDBC代码直接写在Servlet里,页面一多就变成一片混乱。这个项目里我采用了经典的三层架构加控制层,总共四个包:
com.example.entity // 实体类:Student, Course, Score, User com.example.dao // 数据访问层:StudentDao, UserDao, ScoreDao com.example.service // 业务层:StudentService, UserService com.example.servlet // 控制层:各个Servlet + BaseServlet复用 com.example.util // 工具类:DBUtil, PageBean三层架构的意义是分离职责,但题目要求了“JSP+Servlet”,所以控制层的Servlet单独一个包,JSP放在webapp目录下。请求流程是:浏览器发起HTTP请求,Servlet接收并解析,调用Service层处理业务逻辑,Service层调用DAO层做数据库操作,最后Servlet把结果setAttribute后转发或重定向到JSP,JSP再用EL表达式和JSTL把数据渲染出来。
我把这张图直接画给过A同学看:
JSP页面 → Servlet控制层 → Service业务层 → DAO数据访问层 → MySQL数据库 ↑ | | | └──────────┴──────────────┴───────────────┘ 最终结果返回到JSP渲染这是一种单方向的依赖关系,每一层的修改原则上不影响上层。Service层存在的意义在这次的课程设计中可能体现得不够充分,因为逻辑简单,但它为后续扩展保留了位置,这也是答辩时可以主动讲的“设计冗余”。
2.3 数据库设计与建表SQL:四张表和一个外键约束
数据库设计是答辩时最容易考察“你有没有真正理解”的一环。这次系统的业务场景是:一个学生信息管理者要维护学生、课程和成绩,同时必须有一个用户表支撑登录功能。所以设计四张表:
- student(学生表):学号是自然主键,自带业务含义,无需自增ID
- course(课程表):课程编号为主键,来自教学计划的唯一编码
- score(成绩表):用自增ID作为逻辑主键,(student_id, course_id)做唯一索引,防止同一学生同一课程重复录入
- user(用户表):存储登录账号密码,密码用MD5加固定盐存储,杜绝明文
建表SQL如下,直接复制到Navicat或命令行执行即可:
CREATE DATABASE IF NOT EXISTS student_ms DEFAULT CHARSET utf8mb4; USE student_ms; CREATE TABLE student ( student_id VARCHAR(20) PRIMARY KEY COMMENT '学号', name VARCHAR(50) NOT NULL COMMENT '姓名', gender CHAR(1) DEFAULT '男' COMMENT '性别', class_name VARCHAR(50) COMMENT '班级', phone VARCHAR(11) COMMENT '手机号', email VARCHAR(100) COMMENT '邮箱', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB COMMENT '学生表'; CREATE TABLE course ( course_id VARCHAR(20) PRIMARY KEY COMMENT '课程编号', course_name VARCHAR(100) NOT NULL COMMENT '课程名称', credit DECIMAL(3,1) DEFAULT 2.0 COMMENT '学分', teacher VARCHAR(50) COMMENT '授课教师' ) ENGINE=InnoDB COMMENT '课程表'; CREATE TABLE score ( id INT AUTO_INCREMENT PRIMARY KEY, student_id VARCHAR(20) NOT NULL, course_id VARCHAR(20) NOT NULL, score DECIMAL(5,2) COMMENT '成绩', UNIQUE KEY uk_stu_course (student_id, course_id), CONSTRAINT fk_score_student FOREIGN KEY (student_id) REFERENCES student(student_id), CONSTRAINT fk_score_course FOREIGN KEY (course_id) REFERENCES course(course_id) ) ENGINE=InnoDB COMMENT '成绩表'; CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(64) NOT NULL COMMENT 'MD5加盐后密文', real_name VARCHAR(50) COMMENT '操作员姓名' ) ENGINE=InnoDB COMMENT '登录用户表';设计说明:为什么学号不用自增ID?因为教务系统里的学号本身就是稳定的自然键,用它做关联查询可以少一次JOIN。成绩表里的UNIQUE合并索引是很妙的一笔,它能防止一条肮脏的数据破坏统计结果;外键虽然有时影响性能,但在这种小规模管理系统中,它换来的数据完整性,对验收很有帮助。此外,密码别用明文,MD5加盐虽然不算强加密,但足以表明你具备“不能存明文”的安全意识,这在答辩中是个加分项。
2.4 包结构与关键工具类:DBUtil和统一Servlet的写法
在写具体功能之前,先把地基打好。第一块地基是数据库连接工具类,使用C3P0连接池。连接池为什么会是这套系统的关键优化点?因为传统的DriverManager写法是一次请求、一次连接、一次关闭,高并发下数据库会很快被拖垮,而连接池能让连接重复使用。
C3P0的配置写在src/main/resources/c3p0-config.xml中:
<?xml version="1.0" encoding="UTF-8"?> <c3p0-config> <default-config> <property name="driverClass">com.mysql.jdbc.Driver</property> <property name="jdbcUrl">jdbc:mysql://localhost:3306/student_ms?useSSL=false&characterEncoding=utf8</property> <property name="user">root</property> <property name="password">123456</property> <property name="initialPoolSize">5</property> <property name="minPoolSize">5</property> <property name="maxPoolSize">20</property> <property name="maxIdleTime">60</property> </default-config> </c3p0-config>配套的DBUtil工具类:
package com.example.util; import com.mchange.v2.c3p0.ComboPooledDataSource; import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; public class DBUtil { // 私有静态连接池,进程内复用,不会每次new private static final DataSource dataSource = new ComboPooledDataSource(); public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这里说明两个重点参数:initialPoolSize是系统启动时预先创建的连接数,设为5意味着第一个请求进来不用等待建立连接;maxPoolSize是池子上限,超过就排队等待。生产价值的体现集中在释放连接的地方——你的DAO层用完连接后必须在finally块里调用close(),Connection对象调close()在连接池模式下实际上是把连接归还给池子,不是真正关闭。这一点踩坑率极高,稍后避坑章节会展开讲。
第二块地基是BaseServlet的设计。原始Servlet开发里,一个功能一个Servlet类,动不动建十几个类。更优雅的做法是一个模块一个Servlet,前端用action参数区分操作。定义一个BaseServlet继承HttpServlet,重写service方法,根据参数反射调用对应方法:
package com.example.servlet; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.lang.reflect.Method; public class BaseServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null || action.trim().isEmpty()) { resp.sendError(400, "缺少action参数"); return; } try { Method method = this.getClass().getMethod(action, HttpServletRequest.class, HttpServletResponse.class); String result = (String) method.invoke(this, req, resp); if (result != null) { if (result.startsWith("redirect:")) { resp.sendRedirect(req.getContextPath() + result.substring(9)); } else { req.getRequestDispatcher(result).forward(req, resp); } } } catch (NoSuchMethodException e) { resp.sendError(404, "未知的action: " + action); } catch (Exception e) { throw new ServletException("调用方法异常: " + action, e); } } }这个设计带来的好处,在后面写StudentServlet时就能体会——删改查都集中在同一个类里,URL统一为student?action=list这种形式,既整洁又方便权限控制Filter统一拦截。你完全可以把这个BaseServlet的说明写进设计文档,作为“可扩展性”论点的论据。
3. 核心功能模块实现:登录拦截、学生信息CRUD、分页查询与模糊搜索的完整落地
3.1 登录模块与拦截器:Session会话和Filter过滤器怎么配合
登录是所有管理系统的入口,也是最检验细节的部分。登录流程由LoginServlet和UserDao共同完成,步骤是:
- 用户提交JSP表单中的username与password
- Servlet从请求中取出参数,对密码做MD5加盐哈希处理
- 调用UserDao的findByUsernameAndPassword方法查询数据库
- 查到则setAttribute存入Session,重定向到主页面;查不到则转发回登录页并显示错误
核心代码如下:
@WebServlet("/login") public class LoginServlet extends BaseServlet { private UserDao userDao = new UserDao(); public String doLogin(HttpServletRequest req, HttpServletResponse resp) throws Exception { String username = req.getParameter("username"); String password = req.getParameter("password"); if (username == null || password == null || username.trim().isEmpty() || password.trim().isEmpty()) { req.setAttribute("errorMsg", "用户名和密码不能为空"); return "/login.jsp"; } String salted = MD5Util.md5(password + SALT); User user = userDao.findByUsernameAndPassword(username, salted); if (user == null) { req.setAttribute("errorMsg", "用户名或密码错误"); return "/login.jsp"; } req.getSession().setAttribute("loginUser", user); return "redirect:/index.jsp"; } public String logout(HttpServletRequest req, HttpServletResponse resp) throws Exception { req.getSession().invalidate(); return "redirect:/login.jsp"; } }注意一个细节,UserDao.findByUsernameAndPassword里用的SQL要写成:
SELECT id, username, password, real_name FROM user WHERE username = ? AND password = ?为什么用PreparedStatement而不直接拼接字符串?最直接的理由是SQL注入防护。比如用户在用户名字段输入' OR '1'='1,字符串拼接直接改变SQL逻辑,而PreparedStatement会把整个输入当作一个字符串参数对待。验证方法很简单,你可以在登录页试一次:用户名填' OR '1'='1,密码随便填,拼接SQL的系统会直接瘫痪,而PreparedStatement的方案会提示“用户名或密码错误”。
接下来是Filter。登录后会访问所有页面,如果只靠每个页面检查Session,代码会重复倒数。一个登录拦截过滤器解决所有问题,LoginFilter检查请求路径下的Session状态:
@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 path = req.getRequestURI(); if (path.endsWith("/login") || path.endsWith("/login.jsp") || path.contains("static")) { chain.doFilter(request, response); return; } if (req.getSession().getAttribute("loginUser") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }这里有一个隐藏细节:Filter配置用/*,而JSP里有访问CSS和图片的需求,一旦拦截了静态资源,页面会瞬间“裸奔”。解决方式是放行含有static关键字的路径,所以我在webapp目录里建了一个static文件夹放CSS和JS文件。代码里我写的path.contains("static")逻辑在真实项目中更严谨的做法是专门给静态目录起独立前缀。另外,重定向时要带req.getContextPath(),这是容器部署路径的前缀,不加的话假设你部署在/sms下,重定向到/login.jsp会直接404,这是血泪经验。
3.2 学生信息管理:CRUD操作从界面到数据库的完整链路
学生模块是管理系统的老大哥,功能包括:学生列表、新增学生、修改学生、删除学生、条件查询。由于使用了BaseServlet,StudentServlet只需要专注于业务方法:
@WebServlet("/student") public class StudentServlet extends BaseServlet { private StudentService studentService = new StudentService(); // 列表 + 分页 + 模糊查询 public String list(HttpServletRequest req, HttpServletResponse resp) throws Exception { String pageNumStr = req.getParameter("pageNum"); String keyword = req.getParameter("keyword"); if (keyword == null) keyword = ""; int pageNum = 1; try { pageNum = Integer.parseInt(pageNumStr); } catch (NumberFormatException e) { /* 保持默认值 */ } PageBean<Student> pageBean = studentService.queryPage(pageNum, keyword); req.setAttribute("pageBean", pageBean); req.setAttribute("keyword", keyword); return "/student/list.jsp"; } public String toAdd(HttpServletRequest req, HttpServletResponse resp) { return "/student/add.jsp"; } public String add(HttpServletRequest req, HttpServletResponse resp) throws Exception { Student student = assembleStudent(req); if (studentService.addStudent(student)) { return "redirect:/student?action=list"; } req.setAttribute("errorMsg", "学号已存在或参数不合法"); return "/student/add.jsp"; } private Student assembleStudent(HttpServletRequest req) { Student s = new Student(); s.setStudentId(req.getParameter("studentId")); s.setName(req.getParameter("name")); s.setGender(req.getParameter("gender")); s.setClassName(req.getParameter("className")); s.setPhone(req.getParameter("phone")); s.setEmail(req.getParameter("email")); return s; } }纯粹的数据表单向Servlet提交的“新增”有玄学:学生的学号来自用户输入,而不是自增,如果数据库里已经有这个学号,插入会抛主键冲突异常。最稳妥是在Service层先通过findById做一次预查询,存在就返回false,前端提示“重复”。这个预查询放在DAO层的实现也很简单:
@Override public boolean addStudent(Student student) throws SQLException { String checkSql = "SELECT COUNT(*) FROM student WHERE student_id = ?"; String insertSql = "INSERT INTO student (student_id, name, gender, class_name, phone, email) VALUES (?,?,?,?,?,?)"; try (Connection conn = DBUtil.getConnection()) { conn.setAutoCommit(false); // 开启事务 try (PreparedStatement ps = conn.prepareStatement(checkSql)) { ps.setString(1, student.getStudentId()); ResultSet rs = ps.executeQuery(); if (rs.next() && rs.getInt(1) > 0) { return false; } } try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setString(1, student.getStudentId()); ps.setString(2, student.getName()); ps.setString(3, student.getGender()); ps.setString(4, student.getClassName()); ps.setString(5, student.getPhone()); ps.setString(6, student.getEmail()); ps.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { e.printStackTrace(); // 这里应该回滚 return false; } }注意conn.setAutoCommit(false)这一步,它把检查学号是否重复和插入学生这两步操作放进了一个事务,保证要么都执行要么都不执行。防止并发请求下多个重复学号在同一瞬间都被插入,这种黑匣子问题在课设答辩时讲出来,老师会觉得你对“事务隔离性”有概念。
界面上,JSP列表页核心代码是这样一个表格,配合EL表达式和JSTL标签遍历:
<table class="table table-bordered"> <thead> <tr> <th>学号</th><th>姓名</th><th>性别</th><th>班级</th> <th>手机</th><th>邮箱</th><th>操作</th> </tr> </thead> <tbody> <c:forEach items="${pageBean.list}" var="stu"> <tr> <td>${stu.studentId}</td> <td>${stu.name}</td> <td>${stu.gender}</td> <td>${stu.className}</td> <td>${stu.phone}</td> <td>${stu.email}</td> <td> <a href="student?action=toEdit&studentId=${stu.studentId}">编辑</a> <button class="btn-del">document.querySelectorAll('.btn-del').forEach(btn => { btn.addEventListener('click', function () { if (confirm('确定删除该学生吗?删除后不可恢复')) { fetch('student?action=delete&studentId=' + this.dataset.id, { method: 'POST' }) .then(() => location.reload()); } }); });3.3 分页查询与模糊搜索:PageBean的设计和SQL的LIMIT参数
列表数据随着测试数据变多会无限膨胀,所以分页是每个管理系统的标配。这套系统的分页绝对值不能漏,不然一次把几百行全部渲染出来,JSP页面会明显卡顿。我的设计是PageBean通用分页类:
package com.example.util; import java.util.List; public class PageBean<T> { private int pageNum; // 当前页码 private int pageSize = 10; // 每页条数 private int totalCount; // 总记录数 private int totalPages; // 总页数 private List<T> list; // 当前页数据 private int startRow; // 起始行偏移量,SQL限制用 // 构造时自动计算startRow和totalPages }在DAO层的查询方法里,模糊查询和分页是紧耦合的。很多同学死记硬背LIMIT offset, pageSize,但总淘神在offset该从0开始还是从1开始。正确逻辑是:
SELECT * FROM student WHERE name LIKE ? OR student_id LIKE ? OR class_name LIKE ? ORDER BY create_time DESC LIMIT ?, ?第一条?传keyword包装成的%关键词%值,可以在Java里拼好:
String likePattern = "%" + keyword + "%";第二条?传startRow,值为(pageNum-1) * pageSize,第三条传pageSize。因为SQL中LIMIT的偏移量从0开始,第一页的startRow就是0,第二页就是10,由此推导出(pageNum-1)*pageSize。
查询总条数与查询当前页数据的SQL可以拆成两次独立查询,在计算总页数时加一点防御逻辑:
totalPages = (totalCount + pageSize - 1) / pageSize; if (totalPages < 1) totalPages = 1;这个公式比totalCount / pageSize的向上取整写法更直接,totalCount=0时也能兜底。在做demo阶段你也许会想“不装分页了”,但冲刺答辩时老师大概率会问“数据量上来了怎么办”,到那时再补分页,重构成本远超现在。
3.4 成绩与课程模块:多表关联查询时JDBC怎么封装ResultSet
成绩模块的业务是:某个学生选修了某门课程,获得一个分数。页面上的成绩列表往往是多表查询的结果,显示学生姓名、课程名称而不是裸ID。这时DAO层的SQL就变成JOIN:
public List<ScoreVO> findScorePage(int offset, int pageSize, String keyword) throws SQLException { String sql = "SELECT s.student_id, s.name AS student_name, c.course_id, c.course_name, " + "sc.score FROM score sc " + "JOIN student s ON sc.student_id = s.student_id " + "JOIN course c ON sc.course_id = c.course_id " + "WHERE s.name LIKE ? OR c.course_name LIKE ? " + "ORDER BY sc.id DESC LIMIT ?, ?"; List<ScoreVO> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String keywordPattern = "%" + keyword + "%"; ps.setString(1, keywordPattern); ps.setString(2, keywordPattern); ps.setInt(3, offset); ps.setInt(4, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { ScoreVO vo = new ScoreVO(); vo.setStudentId(rs.getString("student_id")); vo.setStudentName(rs.getString("student_name")); vo.setCourseId(rs.getString("course_id")); vo.setCourseName(rs.getString("course_name")); vo.setScore(rs.getDouble("score")); list.add(vo); } } } return list; }这里取别名是必须规范的点。student.name和c.course_name如果不取别名,在ResultSet编码返回值时你拿到的是同一列名,取值只会取到第一个匹配列,成绩列表就会“学生名和课程名永远一样”,这个翻车案例非常适合拿到避坑章节。规范的写法是先给列起别名,再以别名getString("student_name")取值。
成绩录入的页面核心是三个下拉框。学生和课程的选项从数据库动态加载,这样录入的人没法选到不存在的选项,数据的脏率大幅降低:
<select name="studentId"> <c:forEach items="${studentList}" var="stu"> <option value="${stu.studentId}">${stu.studentId} - ${stu.name}</option> </c:forEach> </select>3.5 项目工程化:数据库连接配置、日志与SQL防注入的规范建议
除了功能代码,工程化的细节分为三个方面:
第一,所有SQL参数一律使用PreparedStatement。只要看到字符串拼接SQL的地方,就是需要审查的漏洞点。你可以做一个简单的代码搜索,把Statement挑出来全部改掉,同时把这个防止SQL注入的考量,写进设计文档的“安全性”段落。
第二,配置外置。数据库密码不要写在DAO类里,而是放到c3p0-config.xml中,将来部署到别的环境,运维只需要修改配置而不动Java源码。这是一个“你懂实践”的信号,答辩中可以提一句。
第三,统一异常处理。Servlet中不应当catch所有异常然后吞掉,至少把printStackTrace()和日志框架结合。仅做课程设计的话,拿System.err输出也能跑,但更好的一个习惯是集中跳转到一个错误页面,而不是让默认的堆栈信息直接裸露在浏览器里,观感很差。
4. 高频翻车点排查:JSP+Servlet项目最容易踩的五个坑及修复方法
4.1 页面中文乱码:设置编码的地方有一处疏忽,全文动态显示都会出问题
现象:页面上的中文、写入MySQL的中文显示为“???”,或者JSP页面头部正常但Servlet写入的数据乱码。
原因:链路中至少需要两级编码一致。第一级,JSP页面本身要声明UTF-8;第二级,Servlet读取参数和写回响应要UTF-8;第三级,数据库表和JDBC连接URL要UTF-8。很多同学只设置了pageEncoding,却漏了request.setCharacterEncoding和数据库connector的characterEncoding=utf8。
解决:在代码里用一个全局的CharacterEncodingFilter,在web.xml中配置为第一个Filter,保证每个请求在进入业务逻辑之前就被设置为UTF-8:
@WebFilter("/*") public class CharacterEncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } }4.2 同一用户对同一课程录了两次成绩,统计报表直接翻车
现象:成绩表里出现两行student_id和course_id完全相同的数据,总分和平均分都算不对,像是闹了鬼。
原因:录入界面的学生、课程两个下拉框全部可选,用户手一滑,同一学生选了两次同一门课,数据库没有业务约束拦下重复插入。
解决:建表阶段就该在score表上加联合唯一约束UNIQUE KEY uk_stu_course(student_id, course_id)。如果表已建好,可以后补执行:
ALTER TABLE score ADD UNIQUE KEY uk_stu_course(student_id, course_id);4.3 部署到Tomcat后,页面点击“删除”直接暴露错误堆栈
现象:本地IDEA运行得好好的,打包成war包放进某个实验室的Tomcat后,删除按钮一点,页面直接显现一个500或405,后台显示MethodNotAllowed。
原因:多半是列表页的删除操作用了a标签或普通form的GET分支。我用Java Web的一个常识来解释:HTTP的GET请求对应用户可见,还可能被浏览器的预加载机制提取;DELETE这种幂等操作本来不该由GET触发。Tomcat默认的JspServlet会拦截对JSP的POST请求,但如果是“链接+GET”,就相对更容易因为参数不合规而抛出异常。合理的方式是用form表单模拟POST,例如在列表页把每一个按钮包在一个表单里:
<form action="student" method="POST" style="display:inline"> <input type="hidden" name="action" value="delete"> <input type="hidden" name="studentId" value="${stu.studentId}"> <button type="submit" onclick="return confirm('确定删除?')">删除</button> </form>4.4 登录拦截Filter把静态资源拦死,整个页面只有文字没有样式
现象:用户登录成功后,首页所有CSS、JS、图片全部失效,浏览器F12的控制台看到大量静态资源“blocked”或404。
原因:Filter路径写的是/*,后端把样式表、图片等静态文件也当成受保护资源拦截了,Session验证不过的全被重定向到登录页。
解决:建立约定的目录结构,把所有资源放在/static/下,并在Filter的放行逻辑中加上这一条:
if (path.contains("/static/") || path.endsWith(".css") || path.endsWith(".js") || path.endsWith(".png")) { chain.doFilter(request, response); return; }4.5 密码明文入库或SQL拼接导致注入:简单的系统也要有安全意识
现象:数据库表中user的password字段显示为123456,写PPT时被其他同学看到,脸面挂不住;或者搜索框输入一个特殊字符,服务端输出SQL报错。
原因:一是怕麻烦没用哈希,二是图省事用了Statement拼接。
解决:即使只是个简单的学生管理系统,也一律用MD5加盐。MD5Util工具可以自己写,很简单:
public class MD5Util { public static String md5(String text) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(text.getBytes("UTF-8")); StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (Exception e) { throw new RuntimeException(e); } } }对所有查询一律换成PreparedStatement。这两件事做完,你再在项目文档里写“本项目避免了SQL注入风险”,就是真话而不是口号。
5. 方案验收与答辩技巧:项目能不能拿优秀,就看这几个环节
5.1 自测验收的标准清单
每次新功能写完就手动测一遍。这里给一份可以直接照用的验收清单,我建议你按这个顺序走一遍流程,比最后期末突击强无数倍。如果所有项都通过,这份方案就已经具备提交条件:
| 验收项 | 测试动作 | 预期结果 |
|---|---|---|
| 登录页访问 | 启动Tomcat访问登录URL | 页面完整显示,无500错误 |
| 正常登录 | 输入正确账号密码 | 跳转到首页,右上角显示当前用户姓名 |
| 错误登录 | 输入错误密码 | 停留在登录页,显示错误提示 |
| 越权访问 | 未登录状态下直接访问/student页面 | 自动跳转到登录页 |
| 新增学生 | 录入合法数据 | 列表出现新记录,无报错 |
| 重复学号 | 新增加已存在学号 | 页面提示“学号已存在” |
| 模糊查询 | 姓名输入“张” | 只显示姓张的学生 |
| 分页跳转 | 造15条数据翻到第二页 | URL携带pageNum变化,列表显示5条 |
| 删除确认 | 点击删除按钮 | 弹确认框,确认后记录消失 |
| 中文显示 | 检查所有页面的中文 | 无乱码 |
| 成绩录入 | 选择学生课程输入分数 | 列表多一条JOIN后的完整数据 |
| 重复成绩 | 再次录入同一学生同一课程 | 数据库层报错,页面被捕获拦截 |
5.2 答辩陈述路径:从“我会增删改查”到“我做了完整设计”
答辩最常见的翻车现场是:老师问“为什么用三层架构”,答“别人都这么用”;问“分页怎么做”,答“写了limit”。通常的应对策略是:把项目的陈述节奏安排成“问题→设计→验证”,这样让老师的提问始终顺着你的思路走。
第一分钟讲核心问题:管理系统面对的核心问题是数据的快速维护与准确统计。用一句话还原业务场景,然后引到技术选型。
第二分钟讲架构:展示工程包结构截图,说明控制层、Service层、DAO层的分层,带出BaseServlet和统一Filter的设计,强调为什么从控制到数据访问都是单一依赖方向。
第三分钟讲亮点:两个亮点二选一——连接池的使用与原因;防止重复成绩的数据库唯一约束。挑你最有把握的讲深讲透。
第四分钟留出时间给老师提问:主动问“老师要不要看一下数据库结构”,或者“我这里做了一个防止重复录成绩的约束,我可以演示一下”。演示总是比纸上谈兵更信服。
5.3 几个被问概率极高的难点问题,最好提前准备答案
问题一:为什么不直接用JDBC连接,要引连接池?
标准回答:数据库连接的创建和销毁都比较耗时,在高并发的场景下,每次请求都新建连接会产生明显延迟,严重时可能耗尽数据库连接数。连接池在系统启动时预创建一批连接复用,用完归还。我配置了initialPoolSize=5、maxPoolSize=20,既满足并发需求,也能防止连接耗尽。
问题二:filter和interceptor有什么区别?
标准回答:Filter是Servlet规范里的,作用于Web容器层,拦截所有请求,无法调用Spring管理的方法;Interceptor是框架层概念,可以对Controller内部的方法做横切。我这个项目不用Spring,所以用Filter实现登录校验与编码处理更符合Servlet规范,迁移到Spring MVC后则可以平滑替换成拦截器。
问题三:分页为什么不用PageHelper这类插件?
标准回答:课设的核心目标是验证基础原理,PageHelper自动生成的count查询与分页SQL隐藏了实现过程。手写LIMIT参数并配合PageBean封装,能够自己掌控查询过程,在内容量大的情况下也能通过修改pageSize适配不同场景。
三句话讲完这些问题,你的理论分就拿到了。
5.4 交付物整理与复盘
最后整理提交材料时,通常的做法是从这几个维度打包,方便自己和别人双层验收:
- 源码工程:Maven目录完整,包含src、pom.xml、.idea(可选)
- 数据库脚本文档:建库建表语句、初始化测试数据语句
- 运行说明:JDK/Tomcat/MySQL的版本,启动步骤,默认账号密码
- 设计文档:包括需求分析、数据库设计、系统架构图和核心流程说明
- 答辩PPT:控制在10页,展示页面截图、核心技术、总结亮点即可
从那以后,我每做完一个课设级别的小系统,都强制自己走一遍“验收清单→答辩问题预演→材料打包”,不把“跑通”当成终点。这套习惯后来在准备更正式的项目评审时,也帮我节省了大量临时补文档的时间。希望这份方案能帮你把JSP+Servlet的学生信息管理系统做得既完整又好讲,交出一份能让自己满意的课程设计。
本文还有配套的精品资源,点击获取