简介:这是一份面向Java Web课程设计与毕业设计的人事管理系统项目,基于JSP+Servlet+MySQL实现,整合员工信息维护、管理员登录与权限校验、部门岗位管理等功能,并借鉴ERP人力资源模块的设计思路,适合计算机及相关专业学生参考完整项目结构、编码规范与答辩演示流程。资源包共108个文件,约2.04MB,包含JSP视图页面、Java源码与编译后的class文件、SQL数据库脚本、JAR依赖库、HTML/CSS/JS前端样式,以及多张jpg、png图片,数据库文件单独置于lib包下,便于导入与按目录定位。系统采用DAO、Service、Servlet分层设计,从内容预览可判断包含数据访问层、业务层、控制器及单元测试类,能够帮助读者理解从页面请求、业务处理到数据持久化的完整调用链。已有1447人学习下载,适合需要快速搭建演示环境、完善毕设文档或系统梳理JSP+MySQL项目实现思路的读者。
1. 为什么说 JSP+Mysql 是人事管理系统毕设里最稳的组合
每年到了毕设季,总能看到有人在群里问“人事管理系统用什么写比较好”。如果你只有两三个月的时间,还要兼顾论文和答辩PPT,那我一般会直接建议:别碰前后端分离,别上微服务,用 JSP+Servlet+Mysql 把传统 JavaWeb 三板斧打好就够了。这个组合在人事管理系统这个场景下,几乎是容错率最高的方案——技术栈老但生态成熟,网上资料多到你根本看不完,随便搜一个报错信息都能找到前人的踩坑记录。
人事管理系统本身要解决的业务问题很明确:公司里谁在哪个部门、工资怎么发、请假找谁批、考勤怎么算。这些功能听起来简单,但真做起来涉及用户登录、权限区分(管理员和普通员工看到的界面完全不一样)、数据的增删改查,还有部门与员工之间的关联关系。用 JSP 做页面展示、Servlet 做请求控制、Mysql 存数据,正好把每层的职责切得清清楚楚,答辩的时候老师问“你这个三层架构是怎么设计的”,你能答得明明白白。
这篇不是给你贴一个完整源码包了事,而是把从建表到部署的全过程拆开讲清楚。新手照着走能跑起来,已经在做的能对照着查漏补缺。我不打算绕弯子,直接进入正题。
2. 先搭骨架:项目结构、依赖与数据库连接方式
2.1 选型理由:为什么不用框架,或者用了框架会怎么样
我知道你肯定纠结过:都 2025 年了,Spring Boot 都出到 3.x 了,为什么还要用 JSP?原因很现实:第一,很多学校的毕设题目库和开题报告模板还停留在 JSP 时代,你换了框架反而不一定符合要求;第二,JSP+Servlet 能让你把 HTTP 请求怎么走、Session 怎么存、JDBC 怎么连这些底层问题搞清楚,答辩时老师就爱问这些;第三,这个方案不需要 Maven 拉几百兆依赖,你拿一个 Tomcat 加一个 Mysql 就能跑,部署简单,演示的时候翻车概率小。
当然我也见过用 Spring Boot 做人事系统的,好处是配置少、起步快,但坏处也明显——自动配置把太多细节藏起来了,老师问“你的事务是怎么控制的”“你的数据库连接是怎么管理的”,答不上来反而扣分。我的建议是:如果你的开题报告已经写了 Spring Boot,那就继续做;如果还没开题,老老实实用 JSP+Servlet,稳。
2.2 项目目录结构:Eclipse / IDEA 里怎么建,包怎么分
打开你的 IDE,新建一个 Dynamic Web Project(Eclipse)或者普通 Java Web 项目(IDEA),在 src 下按照下面的结构建包。这个分包方式是多数同类项目的约定俗成,我一般直接照这个来:
src/main/java ├── com.pms.dao # 数据访问层,JDBC 操作 ├── com.pms.entity # 实体类,对应数据库表 ├── com.pms.service # 业务逻辑层(可省,简单项目可并入 servlet) ├── com.pms.servlet # 控制器层,接收页面请求 ├── com.pms.util # DBUtil 数据库连接工具 src/main/webapp # JSP 页面与静态资源 ├── WEB-INF │ ├── web.xml │ └── lib # mysql-connector-java JAR 包 ├── index.jsp ├── login.jsp ├── admin/ # 管理员页面 └── employee/ # 员工页面说一下这样分的原因:entity 层和数据库表一一对应,字段名尽量保持一致减少手写映射的错误;dao 层只负责 SQL 执行,不写业务判断;servlet 层接收前端传来的参数,调用 dao 拿结果,再转发给 JSP 渲染。三层之间单向依赖,不要出现 servlet 直接访问 entity 以外的类去操作数据库的情况。
注意把 Mysql 驱动 JAR 放到 WEB-INF/lib 下,不是放到项目 classpath 就完事了。Tomcat 运行 webapp 时只会加载 WEB-INF/lib 里的 JAR 和 classes 目录下的 class 文件,这一步少了驱动,运行时报 ClassNotFoundException:com.mysql.jdbc.Driver,开局就翻车。
2.3 DBUtil 连接池与手写 JDBC 的取舍
老项目里最常见的连接方式是每次新建连接、用完关闭。这种做法代码简单,但有个隐患:高并发场景下每一个请求都经历 DriverManager.getConnection,数据库资源开销大、响应慢,而且连接池满的时候报表页面直接卡死。
我建议你直接用 Druid 连接池(阿里巴巴开源的那个),它的配置方式对新手友好,而且答辩时也能说“我用了连接池管理数据库资源”。把 druid-1.2.8.jar 放进 WEB-INF/lib,然后在项目里加一个 druid.properties 配置文件,内容如下:
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/pms_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username=root password=123456 initialSize=5 maxActive=20 minIdle=5 maxWait=60000然后写一个 DBUtil 工具类,负责从配置里读参数并初始化连接池:
package com.pms.util; 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 DBUtil { private static DataSource dataSource; static { try (InputStream in = DBUtil.class.getClassLoader().getResourceAsStream("druid.properties")) { Properties props = new Properties(); props.load(in); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这里的核心是 DruidDataSourceFactory 从 Properties 里读取所有以druid.** 开头的配置项(如果没有前缀,可以直接裸写属性名)。initialSize=5代表启动时建立 5 个初始连接,maxActive=20代表最大同时活跃连接数,maxWait=60000是拿不到连接时的最长等待时间,超过后抛出异常。allowPublicKeyRetrieval=true这个参数是 Mysql 8.0 之后特有的,不加的话本地连接可能直接报 Public Key Retrieval is not allowed 错误,第一次遇到的人会懵半天。
如果你非要用原生 JDBC,那至少把获取连接的代码封装成一个工具方法,不要在每个 DAO 里重复写 Class.forName。不过我还是那句话:既然毕设迟早要面对并发问题,不如一开始就用连接池,后面写批量导入功能时不会卡死。
2.4 web.xml 与 Tomcat 版本匹配的注意事项
这一步极其容易踩坑。用 Dynamic Web Project 创建时,web.xml 的版本号默认可能很高(比如 5.0),但你的 Tomcat 如果是 8.x 或 9.x,启动时会直接报错。我见过同学因为 eclipse 默认 web-app 版本是 5.0,配的 Tomcat 8.5 装的是 JDK8,运行时报 404 找不到 index.jsp,后来发现是 web.xml 头声明的问题。
一个经过验证的稳妥配置是 Tomcat 9.0 + JDK 8 或 11,web.xml 用 4.0 版本声明:
<?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>Personnel Management System</display-name> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> </web-app>注意 welcome-file 这里我写的是 index.jsp,如果你希望输入 http://localhost:8080/pms/ 直接跳转到登录页,也可以写成 login.jsp,但要保证这个文件存在。很多项目卡在第一眼就觉得代码有问题,其实只是 Tomcat 版本和 web.xml 版本对不上,优先级极低但极其常见,先检查这里再查代码。
3. 数据库设计:人事管理系统的核心是表和表之间的关系
3.1 应该建几张表:部门表、员工表、考勤表、薪资表的字段设计
人事管理系统的表设计,我见过最基础的是两张表(部门和员工)就能跑,但为了答辩更丰满,通常要加考勤、薪资、请假或公告。这里我给出一个大多数人会接受的表结构方案,既能覆盖业务逻辑,又不至于让你写太多代码。
先建部门表和员工表,这两张是一切的基础。员工表必须冗余部门名称吗?不需要,存 departmentId 外键即可,查询时 JOIN 拿名字。千万别在员工表里直接存部门名字字符串,否则部门改名时你得写一条 UPDATE 把所有员工记录都扫一遍。
另外,员工表里要区分“账号”和“员工基本信息”。通常做法是员工表同时承担登录账号的角色,字段包含 username、password、role(管理员/普通员工),这样可以少建一张 user 表。但如果你觉得“表多显得工作量足”,也可以拆成 user 表和 employee_info 表,user 表存登录凭证,employee_info 存姓名、部门、职位、入职日期、手机号等。两个方案都行,只要你在论文里把 E-R 图画清楚,老师不会说什么。
下面是建表 SQL,字符集统一用 utf8mb4,因为姓名或者备注里可能存 emoji 或特殊符号,utf8 存不下:
CREATE DATABASE pms_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE pms_db; CREATE TABLE department ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE, manager VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE employee ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, role ENUM('admin', 'staff') DEFAULT 'staff', name VARCHAR(20) NOT NULL, gender ENUM('男', '女'), email VARCHAR(50), phone VARCHAR(20), department_id INT, position VARCHAR(30), hire_date DATE, salary DECIMAL(10,2), status ENUM('在职', '离职') DEFAULT '在职', FOREIGN KEY (department_id) REFERENCES department(id) ) ENGINE=InnoDB; CREATE TABLE attendance ( id INT AUTO_INCREMENT PRIMARY KEY, employee_id INT NOT NULL, check_date DATE NOT NULL, check_in_time TIME, check_out_time TIME, status ENUM('正常', '迟到', '早退', '缺勤') DEFAULT '正常', FOREIGN KEY (employee_id) REFERENCES employee(id) ) ENGINE=InnoDB;字段说明里,salary用 DECIMAL(10,2) 而不是 FLOAT 或 DOUBLE,是因为浮点类型算工资、做汇总时会出现精度丢失。status用 ENUM 比用 TINYINT 更直观,查数据时直接看到“在职/离职”而不是 0/1,答辩演示时也更友好。role也是同理,虽然是字符串类型,但只有两个合法值,用 ENUM 能防止非法数据写入。
3.2 外键到底要不要用:先想清楚这五个问题
外键约束我建议你建。理由比“完整性”更实在:第一,答辩时老师会看你的 E-R 图和表结构,外键能直接证明你理解表关联;第二,删除部门的时候,如果还有员工挂在这个部门下,数据库会拒绝操作,这恰好能引出你“做逻辑删除而非物理删除”的设计,是一个加分回答点。但也有一个痛点:如果代码里先删主表、再删子表的顺序写反了,外键约束直接报错,新手排查很费时间。
你可以在 MySQL 里用ON DELETE RESTRICT默认行为,防止误删有员工的部门。同时要注意,外键字段类型必须和主表主键类型完全一致,INT 就是 INT,BIGINT 就是 BIGINT,否则在建表时报 1215 Cannot add foreign key constraint。这是 MySQL 里最常见的报错之一,原因经常是类型不匹配或者两张表的字符集不一致。
如果确实不想用外键,那就必须在 service 层里自己写校验:删除部门前先查 employee 表里 department_id 有没有记录。代码写在 DAO 里也行,但容易漏,所以我个人更相信数据库层面的约束,毕竟毕设代码量不大,外键对性能的影响可以忽略。
3.3 用索引让列表查询不卡顿:三个必加和两个可选
人事管理系统最核心的页面是员工列表和管理员列表,数据量到几千条时,不加索引的全表扫描会有明显的延迟。以下字段我认为必须加索引:
ALTER TABLE employee ADD INDEX idx_emp_department (department_id); ALTER TABLE employee ADD INDEX idx_emp_status (status); ALTER TABLE attendance ADD INDEX idx_att_emp_date (employee_id, check_date);索引的作用不用多说,关键在于为什么加了这几个:department_id是员工列表筛选部门时的高频条件,status是统计在职/离职人数时用的,attendance表联合索引覆盖“查某个员工某天的考勤”这个查询条件,能直接走索引避免回表。如果你写了薪资表,employee_id 也加索引。username因为有 UNIQUE 约束,MySQL 会自动建索引,不需要手动再加。
一个容易忽略的坑:联合索引(employee_id, check_date)在查询时只能命中“employee_id 为条件”或“employee_id 和 check_date 同时为条件”的前缀,如果你单独用 check_date 查某一天所有考勤,这个索引是走不上的。所以如果考试勤报表是高频操作,额外再加一个idx_att_date (check_date)。
3.4 初始化数据:登录账号和测试数据要怎么造
系统刚跑起来时,admin 账号需要手动插数据。用 SQL 直接插入,不要用工具界面点来点去,方便你论文里写“系统初始化脚本”:
INSERT INTO department (name, manager) VALUES ('技术部', '张伟'), ('人事部', '李芳'); INSERT INTO employee (username, password, role, name, department_id, position, hire_date, salary, status) VALUES ('admin', MD5('admin123'), 'admin', '系统管理员', 2, '管理员', '2024-01-01', 10000, '在职'), ('zhangsan', MD5('123456'), 'staff', '张三', 1, 'Java开发', '2024-03-15', 8500, '在职');密码用MD5()函数存摘要,不要明文。虽然 MD5 已经不够安全,但毕设选题在这里不用专门去上 BCrypt,答辩中被问到了就实说“毕设场景下用 MD5,生产环境会升级为 BCrypt”,这个回答比硬吹安全要真诚。
测试数据的数量建议:员工表插 20 条以上,考勤表插 100 条以上。数据太少时分页功能看不出效果,数据太多时(十万级)你的 SQL 可能扛不住。20-100 条足够演示所有功能了。
4. 从登录到员工管理:核心功能的落地代码与参数说明
4.1 登录功能:ServletRequest 转发与重定向怎么选
人事管理系统的第一个页面就是登录,这里藏着整个系统的基础逻辑:校验用户名密码、保存会话、区分角色跳转。先写 JSP 页面的表单,再写处理登录的 Servlet。
login.jsp 的核心部分长这样,注意 form 的 action 指向 Servlet 路径而不是 JSP:
<form action="${pageContext.request.contextPath}/login" method="post"> <div class="form-group"> <label>用户名</label> <input type="text" name="username" required> </div> <div class="form-group"> <label>密码</label> <input type="password" name="password" required> </div> <button type="submit">登 录</button> <span style="color:red;font-size:12px;">${errorMsg}</span> </form>${pageContext.request.contextPath}是 EL 表达式,取当前 webapp 的上下文路径,这样即使你的项目部署名不是 pms 也能正确拼接 URL。${errorMsg}是Servlet转发过来时存在 request 域里的提示信息。
LoginServlet 的代码是这个系统里最核心的一段:
package com.pms.servlet; import com.pms.dao.EmployeeDAO; import com.pms.entity.Employee; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; @WebServlet("/login") public class LoginServlet extends HttpServlet { private final EmployeeDAO employeeDAO = new EmployeeDAO(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); // 基础校验:空值直接打回 if (username == null || password == null || username.trim().isEmpty() || password.trim().isEmpty()) { req.setAttribute("errorMsg", "用户名和密码不能为空"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } Employee emp = employeeDAO.findByUsernameAndPassword(username, password); if (emp == null) { req.setAttribute("errorMsg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(); session.setAttribute("loginUser", emp); session.setMaxInactiveInterval(30 * 60); // 角色不同,跳转不同页面 if ("admin".equals(emp.getRole())) { resp.sendRedirect(req.getContextPath() + "/admin/home.jsp"); } else { resp.sendRedirect(req.getContextPath() + "/employee/home.jsp"); } } }这里我特意用了resp.sendRedirect而不是forward。区别在于:登录成功后浏览器地址栏变成 admin/home.jsp,用户在刷新页面时不会重复提交表单;错误时用 forward 把 errorMsg 带回 login.jsp,地址栏仍保持 /login,不会因为刷新导致重复登录。这个细节在答辩问“你的登录成功后浏览器地址栏是怎么变化的”时非常站得住脚。
session.setMaxInactiveInterval(30 * 60)把会话超时设为 30 分钟。如果不设置,Tomcat 默认是 30 分钟,但显式写出来显得你懂 Session 的有效期控制。这里有个安全逻辑题:如果用户勾选了“记住我”,应该用 Cookie 存账号密码明文吗?答案是不应该,最多存用户名,密码绝不能出现在 Cookie 里,最好用 token 机制,但毕设不需要做到这一步。
密码校验的 SQL 在 DAO 层实现,注意用 MD5 函数和输入的字符串拼接而不是先查询再在 Java 里比对:
public Employee findByUsernameAndPassword(String username, String password) { String sql = "SELECT * FROM employee WHERE username = ? AND password = MD5(?)"; // 使用 PreparedStatement 执行 }为什么这么写?把MD5(?)放进 SQL 里让数据库计算,而不是 Java 先算好 MD5 再拼进来。从执行结果上看一样,但你能少引一个 Java 的 MessageDigest 代码段。不过你需要确认你没在 Java 侧二次加密,否则密码对不上。
4.2 员工增删改查:PreparedStatement 的占位符顺序就是你的救星
员工管理是最典型的 CRUD,代码套路固定。但有几个细节,新手翻车的概率极高。
EmployeeDAO 里增加员工的代码:
public boolean addEmployee(Employee emp) { String sql = "INSERT INTO employee (username, password, role, name, gender, email, phone, department_id, position, hire_date, salary, status) " + "VALUES (?, MD5(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, emp.getUsername()); ps.setString(2, emp.getPassword()); // MD5 交给 SQL 处理 ps.setString(3, emp.getRole()); ps.setString(4, emp.getName()); ps.setString(5, emp.getGender()); ps.setString(6, emp.getEmail()); ps.setString(7, emp.getPhone()); ps.setInt(8, emp.getDepartmentId()); ps.setString(9, emp.getPosition()); ps.setDate(10, emp.getHireDate()); ps.setBigDecimal(11, emp.getSalary()); ps.setString(12, emp.getStatus()); return ps.executeUpdate() > 0; } catch (SQLException e) { e.printStackTrace(); return false; } }几个要点:一是使用try-with-resources,代码执行完自动关闭连接和 PreparedStatement,不用手写 finally 块去 close;二是所有字段通过占位符?传入,不用字符串拼接 SQL,从源头上杜绝 SQL 注入;三是setDate接收的是java.sql.Date,如果你在 entity 层用的java.util.Date,需要new java.sql.Date(emp.getHireDate().getTime())转换,这一步类型不匹配会直接编译报错。
新增员工时密码怎么处理?后台新增员工时通常是给定初始密码,建议在 Servlet 层判断:如果页面密码框留空,则默认设置 "123456",然后用上面的 SQL 插入。修改密码时,单拎出一个修改密码的页面,要求输入旧密码、新密码、确认密码,三个都匹配才执行 UPDATE。注意用 MD5 把旧密码的明文和数据库里的摘要比较,而不是明文直接 where 条件。
4.3 考勤功能:打卡时间记录与状态自动判定的思路
考勤模块是人事系统里最能体现业务逻辑的部分,也是答辩时老师容易追问的。最简单的设计是:员工登录后点“上班打卡”和“下班打卡”,系统记录时间。判断迟到、早退的逻辑不在数据库,而在 Java 代码里。
打卡 Servlet 里的一段核心逻辑:
// 获取当前时间和日期 LocalDate today = LocalDate.now(); LocalTime now = LocalTime.now(); // 查今天的记录是否存在 Attendance att = attendanceDAO.findByEmployeeIdAndDate(empId, today); if (att == null) { // 第一次打卡 = 上班打卡 Attendance newAtt = new Attendance(); newAtt.setEmployeeId(empId); newAtt.setCheckDate(today); newAtt.setCheckInTime(now); // 上班时间 9:00,晚于 9:00 算迟到 if (now.isAfter(LocalTime.of(9, 0))) { newAtt.setStatus("迟到"); } else { newAtt.setStatus("正常"); } attendanceDAO.insert(newAtt); } else if (att.getCheckOutTime() == null) { // 第二次打卡 = 下班打卡 att.setCheckOutTime(now); // 下班时间 18:00,早于 18:00 算早退 if (now.isBefore(LocalTime.of(18, 0))) { att.setStatus("早退"); } else { att.setStatus("正常"); } attendanceDAO.update(att); } else { // 当天已打过两次卡,提示重复操作 resp.setAttribute("errorMsg", "今天已完成上下班打卡"); }使用LocalDate和LocalTime是 Java 8 之后的推荐方式,比 Date 和 SimpleDateFormat 安全得多,不需要手动处理时区。这里判断状态用的是“先修改状态再覆盖更新”的方式,也就是说如果上下班都打了卡,最后状态可能是“迟到”或“早退”,覆盖掉之前记录的值。如果你希望保留每一次判定,需要额外加一个 status 字段存储“迟到但早退”等组合状态,可以用字符串拼接方式,但不建议搞得太复杂。
考勤表的check_in_time和check_out_time用 TIME 类型,配合 JDBC 的 setTime 传给java.sql.Time类型。查询考勤报表时,按日期范围查询和按员工查询是最常见的两个入口,SQL 里建议用BETWEEN ? AND ?来做日期范围过滤。
4.4 权限控制:Filter 拦截器和 Session 校验的双重保险
人事管理系统里有管理员和普通员工两种角色,权限控制必须做。最粗鲁的做法是在每个 JSP 页面开头写<% if(session.getAttribute("loginUser")==null) { response.sendRedirect("login.jsp"); } %>,这样能挡住没登录的人,但挡不住员工访问 admin 目录下的页面。
推荐做法是写一个 Filter,统一拦截所有请求,检查 Session 中的用户,再根据请求路径判断是否允许访问:
@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(); String ctxPath = request.getContextPath(); String path = uri.substring(ctxPath.length()); // 放行登录页面、登录接口和静态资源 if (path.equals("/login.jsp") || path.equals("/login") || path.startsWith("/static/")) { chain.doFilter(req, resp); return; } HttpSession session = request.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { response.sendRedirect(ctxPath + "/login.jsp"); return; } // admin 目录下的资源只允许管理员访问 if (path.startsWith("/admin/")) { Employee emp = (Employee) session.getAttribute("loginUser"); if (!"admin".equals(emp.getRole())) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return; } } chain.doFilter(req, resp); } }关键点:request.getSession(false)传了 false,表示如果 Session 不存在就直接返回 null,而不会新创建一个空 Session 再交给 Filter,避免未登录状态下也无端生成 Session 对象,减轻服务器压力。/static/放行是因为静态资源不需要登录也能加载(虽然毕设项目通常没有独立静态目录,但留着是良好的扩展习惯)。
Filter 的拦截范围是/*,如果你用的是注解配置方式(上面这段用的是@WebFilter,Servlet 3.0 以后支持),就不用在 web.xml 里配置 Filter 了。但为了稳妥,我还建议在 web.xml 里声明一遍,防止部分老版本容器对注解处理不稳定。只要 class 存在,两处配置都写也能正常工作。
5. 部署与运行时的高频坑:现象、原因与解决方案
5.1 每次启动 Tomcat 都看到类的静态初始化错误
现象:Tomcat 启动日志里出现Exception loading module或com.mysql.cj.jdbc.Driver相关的 ClassNotFoundException,而且经常是启动到一半就失败。原因基本是 mysql-connector-java 的 JAR 没真正放进 WEB-INF/lib,或者在编译时引用了但部署时没有一起带过去。解决方式:确认 IDE 里 Build Path 加入了 JAR,同时检查部署目录——在 Eclipse 的 Servers 视图里右键 Tomcat,看Deployment Assembly里有没有添加 webapp 的依赖。IDEA 则在 Artifacts 设置里看Available Elements是否把 JAR 打进了WEB-INF/lib。
5.2 页面中文全是问号:字符编码不统一的排查顺序
现象:JSP 页面能打开,标题正常,但数据库里查出来的中文数据全部显示为???。原因:项目里至少有四个地方需要统一编码,缺一不可。检查顺序是:MySQL 数据库和表字符集是否为 utf8mb4;连接 URL 是否带useUnicode=true&characterEncoding=utf8;JSP 页面头部是否写了<%@ page contentType="text/html;charset=UTF-8" %>;Tomcat 的 server.xml 里 Connector 是否配置了 URIEncoding="UTF-8"。大多数情况下,JSP 头没加或者 MySQL 建表时用默认的 latin1 是最容易被忽略的。如果表已经建好但字符集不对,用ALTER TABLE employee CONVERT TO CHARACTER SET utf8mb4;转换。
5.3 删部门时提示外键约束失败:处理顺序和逻辑删除的设计
现象:在管理员页面试图删除一个部门,页面报错Cannot delete or update a parent row。原因:employee 表里还有员工记录引用了这个部门的 id,外键约束拒绝了删除。解决方式:在删除部门前,先查询该部门下员工数量,如果大于 0 则提示“该部门下存在员工,不能删除”并返回。如果你确实需要支持删除并同时清除员工记录,应该把删除员工和删除部门放在同一个事务里执行,而不是先删部门再删员工。这里我建议在 DepartmentDAO 里写一个事务方法:先删除该部门下的员工(或者先把员工的 department_id 置为 null),再删除部门,整个操作包在事务里,任何一步失败都回滚。
5.4 登录成功后页面跳转 404,地址栏的 URL 是乱码的路径
现象:点击登录后浏览器地址栏变成类似http://localhost:8080/pms/admin/home.jsp的结果,页面没有渲染,显示 404。原因大部分不是代码逻辑问题,而是目录结构里 admin 文件夹不在 webapp 根目录下,或者 home.jsp 文件名写错(比如大写字母不一致、没加 .jsp 后缀)。先打开项目的 webapp 目录,对照浏览器里的路径一级一级看,40% 是新手的拼写问题,30% 是 JSP 文件放错目录,还有 30% 是响应重定向时路径多了一个或少了一个斜杠。应对措施:把所有页面跳转都写req.getContextPath() + "/...",不要写相对路径,这是治本。
5.5 系统能跑但非常慢,CPU 占用率高
如果每次点击列表页都卡一两秒,检查一下是不是在循环里高频调用 DBUtil.getConnection。常见错误写法是在 JSP 页面写的 Java 脚本片段里反复查询数据库,比如在 forEach 标签里嵌套 DAO 查询。解决方式:先查询出部门列表,再和员工列表做一次性 JOIN 查询,或者在内层循环减少数据库访问。如果确认是 SQL 本身慢,用EXPLAIN SELECT ...查看索引是否命中。另一个隐蔽开销是 DriverManager.getConnection,如果你用了原生 JDBC 且没有连接池,页面和页面之间的连接无法复用,一旦点击多了 CPU 就会飙升,这时候换成 Druid 效果立竿见影。这些坑我都是逐个踩过才记住的,尤其是 5.4 那一类,当时调试了一下午才发现是文件名少拼了一个字母。
6. 把系统打磨到可以答辩:分页、Excel 导出与操作日志
6.1 分页查询的正确写法:limit 与 count 分开执行
列表页数据超过 20 条后,展示所有数据会让页面又长又慢。每页 10 条或者 15 条是常见的默认值。分页查询的核心是两个 SQL,一个查总数、一个查当前页数据,先执行 count 获取总页数,再查数据。以下是 EmployeeDAO 中分页查询的标准实现:
public List<Employee> findPage(int pageNum, int pageSize) { String countSql = "SELECT COUNT(*) FROM employee"; String dataSql = "SELECT * FROM employee ORDER BY id LIMIT ? OFFSET ?"; // 先执行 countSql 得到总记录数 total // 再执行 dataSql,limit 表示每页条数,offset 表示跳过的条数 // offset = (pageNum - 1) * pageSize }这里要说明:LIMIT ? OFFSET ?的第二个参数是偏移量,不是页码。第一次写分页的同学经常把 OFFSET 直接填页码数字,导致第一页是 1 到 10 没问题,第二页直接把 1 到 10 翻到第 1 条到第 10 条,完全没翻页。正确的 offset 计算公式是(pageNum - 1) * pageSize。还有一种写法LIMIT pageSize, pageNum,这里 LIMIT 的第一个参数是 offset,顺序别搞反。
前端页面的分页导航条用 JSP 里判断当前页码,高亮显示,配合“上一页/下一页”的链接,链接里带pageNum参数。在 Servlet 里读取req.getParameter("pageNum"),为空的时候默认取 1,字符串转数字时注意 NumberFormatException,用 try-catch 包一层。
6.2 导出员工列表到 Excel:响应 Excel 文件与浏览器下载框出现
答辩时如果系统能一键导出 Excel,观感会好很多。用纯 Java 操作 Excel,常见的是 Apache POI。把 poi.jar 放进 WEB-INF/lib,然后写一个 ExportExcelServlet,核心代码如下:
// 创建 Excel 工作簿 XSSFWorkbook workbook = new XSSFWorkbook(); XSSFSheet sheet = workbook.createSheet("员工信息"); // 创建表头 String[] headers = {"姓名", "部门", "职位", "入职日期", "薪资"}; Row headerRow = sheet.createRow(0); for (int i = 0; i < headers.length; i++) { headerRow.createCell(i).setCellValue(headers[i]); } // 填充数据行 int rowIdx = 1; for (Employee e : empList) { Row row = sheet.createRow(rowIdx++); row.createCell(0).setCellValue(e.getName()); row.createCell(1).setCellValue(e.getDepartmentName()); row.createCell(2).setCellValue(e.getPosition()); row.createCell(3).setCellValue(e.getHireDate().toString()); row.createCell(4).setCellValue(e.getSalary().doubleValue()); } // 设置响应头,告诉浏览器这是个需要下载的 Excel resp.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); resp.setHeader("Content-Disposition", "attachment;filename=employee_list.xlsx"); // 把 workbook 写入响应流 workbook.write(resp.getOutputStream());关键点在Content-Disposition,如果不设置这个响应头,浏览器会直接尝试在页面里展示二进制乱码,而不是弹下载框。filename需要注意中文文件名时浏览器可能显示乱码,建议用英文文件名,或者在文件名里拼接时间戳避免相同文件名覆盖下载记录。workbook.write(resp.getOutputStream())之后要调用workbook.close()和流关闭,不然 Tomcat 会报文件占用警告。导出大量数据时(超过 6 万行),XSSFWorkbook 会内存溢出,因为它是把整个工作簿放在内存里的,这时要切换到 SXSSFWorkbook 流式写入,不过毕设数据量基本到不了那个规模。
6.3 操作日志表的设计与埋点思路:拦截器还是手动记录
如果老师问“系统有没有操作日志”,你说没有会减分,说“有”就需要写出来。操作日志表很简单,字段包括 id、operator_id、action、detail、create_time。用一个 LogDAO 保存记录。埋点思路有两种:手动在增删改查的 Servlet 方法开头和结尾调用日志方法,简单直观,但容易遗漏;用拦截器统一记录是更优雅的方案,但需要配合自定义注解,对毕设来说会一下子把代码复杂度提起来。
我的建议是折中:只在关键操作上手动埋点,比如新增员工、删除员工、修改工资、导入数据,这些操作统一走一个 LogUtil 工具类,传入操作者 id 和操作描述。列表查询和详情查询不记日志,因为噪音太大。这说明你在设计时区分了“重要操作”和“普通操作”,比一刀切全部记录更懂业务。日志表在界面上给管理员一个查看入口,如果时间不够,直接在数据库里查也是可以的。
6.4 提前演练演示环境:一个没人注意但决定命运的习惯
最后说一个经验教训。我遇到过一个同学,答辩前一晚部署好一切,第二天演示时系统却打不开,排查了半天发现是 MySQL 服务没启动。后来我养成了一个习惯——演示前必须完整走一遍“冷启动流程”:电脑重启后,先启动 MySQL、再启动 Tomcat,然后从登录一直点到导出 Excel,确认每个功能都正常。这篇文章写的是 JSP+Mysql 的人事管理系统,老实说这个方案不算新潮,但它胜在足够基础、足够稳。把数据库设计干净、代码分层清晰、权限控制和分页做到位,就已经能把大多数同学的水平甩开一截。希望帮到你,祝答辩顺利。
本文还有配套的精品资源,点击获取