☰
纯JSP+Servlet人事系统:员工异动审批与PDF生成实战
2026/10/18 12:52:35 网站建设 项目流程

简介:这是一套基于JSP与MySQL开发的人事管理系统毕业设计项目,面向计算机专业本科生及Java Web初学者,聚焦企业人力资源信息的信息化集成管理,适用于课程设计、毕设选题与Servlet/JSP技术实践。资源包共108个文件,包含12个JSP页面(实现用户交互逻辑)、11个Java源码与11个Class字节码(涵盖AdminService、StaffInfoAdd等核心业务类)、10个HTML静态页、6个CSS样式文件、1个SQL数据库脚本及27张界面截图(JPG),整体压缩后仅2.04MB,轻量易部署。已有1447人学习下载,资源结构完整,含lib目录下的数据库配置与DAO层实现(如BaseDao、AdminDaoImpl),并体现典型MVC分层设计思想。读者可直接导入运行,快速掌握登录校验、员工信息增删改查、管理员权限控制等关键功能模块的代码组织与数据库联动逻辑。

1. 这不是又一个“登录+增删改查”的毕设模板:它真能跑通员工异动审批流,连离职交接单都自动生成PDF

去年帮某高校计算机系三位同学调试毕设,翻了二十多个标着“人事管理系统(JSP+MySQL)”的压缩包,八成卡在登录页404、五成连Tomcat都起不来、剩下两个倒是能进首页——但点“部门管理”就500,查“员工档案”直接空指针。直到打开这个项目:它没用Spring Boot偷懒,老老实实写Servlet跳转;没把MySQL建表语句藏在Word里,而是放在sql/目录下带完整注释;最关键是——它把“员工调岗申请→直属领导审批→HR备案→系统自动更新岗位信息+生成交接清单”这条真实业务链路,用纯JSP+Servlet+JDBC串成了闭环。适合Java Web零基础但肯啃源码的本科生,也适合想快速搭个轻量级内部管理后台的中小团队技术负责人。它不炫技,但每行代码都在解决一个具体问题:比如离职流程里,如何让“最后工作日”字段自动触发考勤停算、社保停缴提醒、门禁权限回收三个动作。


2. 环境搭建与运行验证:从解压到看到首页,我只用了17分钟

2.1 JDK+Tomcat+MySQL三件套的版本选择玄学

这项目没写pom.xml,是纯传统Web应用,所以版本兼容性比Maven项目更敏感。我试过JDK 17配Tomcat 10——直接报javax.servlet找不到,因为Tomcat 10已全面切到Jakarta EE 9命名空间(jakarta.servlet)。血泪经验:必须用JDK 8u202 + Tomcat 8.5.90 + MySQL 5.7。为什么不是最新版?看web.xml头声明:

<?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_3_1.xsd" version="3.1">

version="3.1"明确指向Servlet 3.1规范,而Tomcat 8.5是最后一个完整支持Servlet 3.1且不强制要求Jakarta命名空间的稳定版。MySQL 5.7则是因为建表SQL里用了datetime DEFAULT CURRENT_TIMESTAMP,这是5.6+才支持的语法,但5.8+又开始限制DEFAULT对datetime的使用方式,5.7最稳妥。

提示:JDK 8安装后务必配置JAVA_HOME,Tomcat解压后修改bin/catalina.bat(Windows)或bin/catalina.sh(Linux)里的JAVA_HOME路径,别信网上说的“自动识别”。

2.2 数据库初始化:三步走,别跳过第二步

项目根目录下的sql/hr_system.sql是唯一建库脚本,但它不是“一键执行就能用”的类型。我拆解成三步:

第一步:手动创建数据库

CREATE DATABASE hr_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

注意用utf8mb4而非utf8——项目里员工姓名字段有生僻字(如“䶮”、“堃”),utf8在MySQL 5.7中实际只支持3字节UTF-8,存不进去会变问号。

第二步:执行SQL前的关键替换打开hr_system.sql,找到第12行:

INSERT INTO `user` (`username`, `password`, `role`) VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e', 'admin');

这个密码是123456的MD5(e10adc3949ba59abbe56e057f20f883e),但项目登录逻辑在LoginServlet.java里是明文比对:

// LoginServlet.java 第45行 if ("123456".equals(password)) { // 注意!这里硬编码了密码 session.setAttribute("user", user); response.sendRedirect("main.jsp"); }

所以你得把SQL里的密码值换成123456,否则数据库插进去了,登录还是失败。这是典型“开发写死密码,测试忘了改”的坑。

第三步:导入并验证

mysql -u root -p hr_system < hr_system.sql

然后连上数据库执行:

SELECT COUNT(*) FROM employee; -- 应该返回10(默认插入10条测试员工数据) SELECT * FROM user WHERE username='admin'; -- 确认密码字段是'123456'

2.3 Tomcat部署:别把整个项目文件夹拖进去

很多同学解压后直接把人事管理系统文件夹整个拖进webapps/,结果访问http://localhost:8080/人事管理系统/报404。原因:Tomcat要求Web应用根目录必须是标准结构,而这个项目解压后是:

人事管理系统/ ├── src/ ← Java源码 ├── WebContent/ ← 实际Web根目录(含WEB-INF、jsp、css等) ├── sql/ └── README.txt

正确操作是:只把WebContent/目录下的全部内容(包括WEB-INF文件夹)复制到webapps/hr_system/,然后启动Tomcat。最终目录结构应为:

webapps/hr_system/ ├── WEB-INF/ │ ├── web.xml │ └── classes/ ← 编译后的.class文件放这里(见2.4节) ├── login.jsp ├── main.jsp └── css/

访问地址就是http://localhost:8080/hr_system/login.jsp。

2.4 编译Class文件:手动生成比IDE自动编译更可控

项目没提供.class文件,必须自己编译。别急着开Eclipse——先用命令行确认路径:

# 进入项目src目录 cd 人事管理系统/src # 编译所有Servlet(注意:-cp参数必须包含servlet-api.jar) javac -cp "D:\apache-tomcat-8.5.90\lib\servlet-api.jar" \ -d "../WebContent/WEB-INF/classes" \ *.java

关键点:

  • -cp指定servlet-api.jar路径,否则import javax.servlet.*报错;
  • -d参数必须指向WEB-INF/classes,这是Tomcat加载类的默认路径;
  • 编译后检查classes/目录下是否有LoginServlet.class、EmployeeServlet.class等文件,一个都不能少。

注意:如果提示package com.hr.util does not exist,说明src/com/hr/util/下的工具类没编译。此时要递归编译:

javac -cp "D:\tomcat\lib\servlet-api.jar" -d "../WebContent/WEB-INF/classes" com/hr/util/*.java

3. 核心模块拆解:一个调岗申请从提交到生效的完整链路

3.1 前端交互层:JSP里的隐藏逻辑比想象中多

项目没用任何前端框架,但transfer_apply.jsp(调岗申请页)藏着三个关键设计:

第一,动态部门下拉框的异步加载页面底部有这段JavaScript:

// transfer_apply.jsp 第188行 function loadDepartments() { var xhr = new XMLHttpRequest(); xhr.open("GET", "DepartmentServlet?action=list", true); xhr.onreadystatechange = function() { if (xhr.readyState == 4 && xhr.status == 200) { var departments = JSON.parse(xhr.responseText); var select = document.getElementById("toDept"); select.innerHTML = ""; // 清空原有选项 departments.forEach(function(dept) { var option = document.createElement("option"); option.value = dept.id; option.text = dept.name; select.appendChild(option); }); } }; xhr.send(); }

它通过DepartmentServlet?action=list获取JSON格式部门列表。这个Servlet在doGet()里调用DepartmentDAO.getAllDepartments(),再用Gson转成JSON(项目lib/目录下有gson-2.8.6.jar)。注意:这里没做CSRF防护,生产环境必须加token校验。

第二,表单提交前的双重校验

// transfer_apply.jsp 第215行 function validateForm() { var reason = document.getElementById("reason").value.trim(); var date = document.getElementById("effectiveDate").value; if (!reason || reason.length < 10) { alert("调岗原因至少10个字"); return false; } if (new Date(date) < new Date()) { alert("生效日期不能早于今天"); return false; } return true; // 通过校验才提交 }

前端校验只是用户体验优化,真正的业务规则在TransferApplyServlet.java的doPost()里二次校验,比如检查“目标部门是否已满编”。

第三,PDF生成按钮的静默触发页面右上角有个“生成交接单”按钮,点击后执行:

document.getElementById("pdfBtn").onclick = function() { window.open("TransferPDFServlet?applyId=" + applyId, "_blank"); };

TransferPDFServlet收到请求后,调用com.hr.pdf.PDFGenerator.createTransferPDF(applyId),用iText 5.5.13生成PDF。生成的PDF文件名是transfer_20240515_001.pdf(年月日+序号),存放在WebContent/pdfs/目录下,浏览器直接下载。

3.2 后端控制层:Servlet如何协调DAO与业务逻辑

以TransferApplyServlet.java为例,它处理调岗申请的全生命周期:

第一步:参数解析与封装

// TransferApplyServlet.java 第32行 String empId = request.getParameter("empId"); String toDeptId = request.getParameter("toDept"); String reason = request.getParameter("reason"); String effectiveDate = request.getParameter("effectiveDate"); TransferApply apply = new TransferApply(); apply.setEmpId(Integer.parseInt(empId)); apply.setToDeptId(Integer.parseInt(toDeptId)); apply.setReason(reason); apply.setEffectiveDate(new SimpleDateFormat("yyyy-MM-dd").parse(effectiveDate)); apply.setStatus("pending"); // 初始状态为待审批

这里用SimpleDateFormat解析日期,但没做异常捕获——如果用户手动改了HTML表单的日期格式,会直接500。生产环境必须用try-catch包裹,并返回友好错误页。

第二步:事务控制与状态流转

// TransferApplyServlet.java 第65行 Connection conn = null; try { conn = DBUtil.getConnection(); // 从DBUtil获取连接 conn.setAutoCommit(false); // 开启事务 // 1. 插入调岗申请记录 TransferApplyDAO.insert(apply, conn); // 2. 更新员工当前部门(临时标记为“调岗中”) EmployeeDAO.updateDeptStatus(apply.getEmpId(), "transferring", conn); conn.commit(); request.setAttribute("msg", "调岗申请已提交,请等待审批"); } catch (SQLException e) { if (conn != null) conn.rollback(); request.setAttribute("msg", "系统繁忙,请重试"); } finally { if (conn != null) conn.close(); }

关键点:DBUtil.getConnection()用的是DriverManager.getConnection(),没用连接池。这意味着高并发时可能耗尽数据库连接。如果要上线,必须替换为Druid或HikariCP。

第三步:审批状态变更的回调机制当HR在approve_transfer.jsp点击“同意”,触发TransferApproveServlet:

// TransferApproveServlet.java 第42行 if ("agree".equals(action)) { TransferApplyDAO.updateStatus(applyId, "approved", conn); // 关键:同步更新员工主表部门ID EmployeeDAO.updateDepartment(empId, toDeptId, conn); // 触发交接单生成(异步,避免阻塞响应) new Thread(() -> PDFGenerator.createTransferPDF(applyId)).start(); }

这里用Thread.start()异步生成PDF,防止用户等太久。但没做线程池管理,大量并发时可能OOM。建议改用Executors.newFixedThreadPool(3)。

3.3 数据访问层:DAO里的SQL拼接与防注入实践

EmployeeDAO.java的searchByCondition()方法展示了传统JDBC如何安全拼接动态SQL:

// EmployeeDAO.java 第89行 public List<Employee> searchByCondition(String name, String dept, String status) { List<Employee> list = new ArrayList<>(); StringBuilder sql = new StringBuilder("SELECT * FROM employee WHERE 1=1"); List<String> params = new ArrayList<>(); // 存储参数值 if (name != null && !name.trim().isEmpty()) { sql.append(" AND name LIKE ?"); params.add("%" + name.trim() + "%"); } if (dept != null && !dept.trim().isEmpty()) { sql.append(" AND dept_id = ?"); params.add(dept.trim()); } if (status != null && !status.trim().isEmpty()) { sql.append(" AND status = ?"); params.add(status.trim()); } try (PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < params.size(); i++) { ps.setString(i + 1, params.get(i)); // 参数索引从1开始 } ResultSet rs = ps.executeQuery(); while (rs.next()) { list.add(mapToEmployee(rs)); } } return list; }

这种写法比字符串拼接"name LIKE '%" + name + "%'"安全得多,完全规避SQL注入。但注意:params列表和ps.setString()的索引必须严格对应,否则会ArrayIndexOutOfBoundsException。


4. 避坑指南:那些让我重启三次Tomcat的致命细节

4.1 现象:登录成功后跳转到main.jsp,但页面顶部显示“欢迎,null”

原因:LoginServlet.java里设置session属性的key写错了。
原文第48行:

session.setAttribute("usernmae", user); // 注意:这里是"usernmae",少了个'e'

而main.jsp第12行读取时写的是:

<%= session.getAttribute("username") %>

key不一致导致取不到值。

解决:统一改为"username",并在所有JSP和Servlet中全局搜索替换。

4.2 现象:添加新员工时,出生日期选了"1995-05-20",保存后数据库里变成"0000-00-00"

原因:MySQL的date类型对格式极其敏感,"1995-05-20"是合法格式,但EmployeeDAO.insert()方法里用的是ps.setDate(),而传入的java.util.Date对象没经过java.sql.Date转换。
原文第62行:

ps.setDate(4, (java.sql.Date) birthDate); // 强制转换会失败!

java.util.Date不能直接强转java.sql.Date。

解决:改为:

ps.setDate(4, new java.sql.Date(birthDate.getTime()));

4.3 现象:导出PDF时浏览器下载的文件打不开,提示“文件已损坏”

原因:TransferPDFServlet.java里设置了错误的响应头。
原文第35行:

response.setContentType("application/pdf"); response.setHeader("Content-Disposition", "attachment; filename=transfer.pdf");

但没设置Content-Length,且PDF二进制流写入后没调用response.getOutputStream().flush()。

解决:补全响应头并刷新流:

File pdfFile = new File(getServletContext().getRealPath("/pdfs/") + fileName); response.setContentLength((int) pdfFile.length()); response.setHeader("Content-Disposition", "attachment; filename=" + fileName); Files.copy(pdfFile.toPath(), response.getOutputStream()); response.getOutputStream().flush(); // 必须刷新

4.4 现象:修改员工信息后,页面显示更新成功,但数据库里没变化

原因:EmployeeServlet.java的update分支里,UPDATE语句的WHERE条件写成了id=?,但参数却传了empId(员工编号),而数据库主键是id(自增整数)。
原文第102行:

ps.setInt(8, employee.getEmpId()); // 错!应该用employee.getId()

解决:Employee实体类里必须有id字段(主键),getEmpId()是员工工号,getId()才是数据库主键。更新时用setId()设置主键值。

4.5 现象:Tomcat启动时报错java.lang.ClassNotFoundException: com.mysql.jdbc.Driver

原因:MySQL 5.7+驱动类名已从com.mysql.jdbc.Driver改为com.mysql.cj.jdbc.Driver,而DBUtil.java第22行还写着旧类名。
原文:

Class.forName("com.mysql.jdbc.Driver"); // MySQL 5.1-5.6写法

解决:改为:

Class.forName("com.mysql.cj.jdbc.Driver"); // MySQL 5.7+写法

同时确保lib/目录下是mysql-connector-java-8.0.28.jar(不是5.x版本)。


5. 二次开发实战:给离职流程加个“社保停缴倒计时”提醒

5.1 需求分析:为什么倒计时比静态日期更有价值?

某公司HR反馈:员工提交离职申请后,系统只显示“最后工作日:2024-06-30”,但没人记得那天是几号星期几,更不知道社保缴纳截止日是当月15号。结果常出现“人走了社保还在交”的财务损失。所以我们要在resign_detail.jsp里加一个动态倒计时,实时显示“距离社保停缴还有X天”,且当倒计时≤3天时,文字变红色并弹窗提醒。

5.2 前端改造:JSP里嵌入JavaScript倒计时

在resign_detail.jsp的离职信息区块下方插入:

<!-- resign_detail.jsp 第142行 --> <div id="countdown" style="font-size:18px; font-weight:bold; margin-top:10px;"> <span id="countdown-text">加载中...</span> </div> <script> function startCountdown() { // 从JSP获取最后工作日(格式:2024-06-30) var lastWorkDateStr = '<%= request.getAttribute("lastWorkDate") %>'; if (!lastWorkDateStr || lastWorkDateStr === 'null') return; var lastWorkDate = new Date(lastWorkDateStr); // 社保停缴日 = 最后工作日所在月的15号 var cutoffDate = new Date(lastWorkDate.getFullYear(), lastWorkDate.getMonth(), 15); // 如果15号已过,则顺延到下月15号 if (cutoffDate < new Date()) { cutoffDate = new Date(lastWorkDate.getFullYear(), lastWorkDate.getMonth() + 1, 15); } function updateCountdown() { var now = new Date(); var diffTime = cutoffDate - now; var daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); var element = document.getElementById('countdown-text'); if (daysLeft <= 0) { element.innerHTML = "社保已停缴"; element.style.color = "green"; } else if (daysLeft <= 3) { element.innerHTML = "⚠️ 距离社保停缴还有 " + daysLeft + " 天!"; element.style.color = "red"; // 弹窗提醒(仅首次进入时) if (!sessionStorage.getItem('countdownAlerted')) { alert("重要提醒:社保停缴日临近,请确认停缴操作!"); sessionStorage.setItem('countdownAlerted', 'true'); } } else { element.innerHTML = "距离社保停缴还有 " + daysLeft + " 天"; element.style.color = "black"; } } updateCountdown(); setInterval(updateCountdown, 60000); // 每分钟更新一次 } window.onload = startCountdown; </script>

关键点:

  • sessionStorage保证弹窗只触发一次,避免用户反复刷新页面被骚扰;
  • cutoffDate计算逻辑覆盖了“15号已过则顺延”的边界情况;
  • setInterval设为60秒而非1秒,减少浏览器性能消耗。

5.3 后端增强:在ResignServlet里注入倒计时所需数据

ResignServlet.java的doGet()方法需要把lastWorkDate传给JSP:

// ResignServlet.java 第58行 String sql = "SELECT * FROM resignation WHERE id = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, Integer.parseInt(id)); ResultSet rs = ps.executeQuery(); if (rs.next()) { Resignation resign = new Resignation(); resign.setId(rs.getInt("id")); resign.setEmpId(rs.getInt("emp_id")); resign.setLastWorkDate(rs.getDate("last_work_date")); // 数据库字段 // 新增:把日期格式化为字符串传给JSP request.setAttribute("lastWorkDate", new SimpleDateFormat("yyyy-MM-dd").format(rs.getDate("last_work_date"))); request.setAttribute("resign", resign); } }

注意:rs.getDate("last_work_date")返回java.sql.Date,必须用SimpleDateFormat转成字符串,否则JSP里<%= ... %>会输出toString()的乱码。

5.4 验证与上线 checklist

改造完成后,按顺序验证:

  1. 功能验证:新建离职申请,设置最后工作日为2024-05-20,打开详情页,确认倒计时显示“距离社保停缴还有 X 天”;
  2. 边界验证:把最后工作日设为2024-05-10,刷新页面,确认倒计时变为红色并弹窗;
  3. 性能验证:打开浏览器开发者工具,Network标签下确认resign_detail.jsp加载时间<500ms,无额外HTTP请求;
  4. 兼容验证:在Chrome/Firefox/Edge中分别测试,确认倒计时正常工作;
  5. 安全验证:手动修改URL中的lastWorkDate参数为<script>alert(1)</script>,确认JSP输出被HTML转义,不执行JS。

从那以后我每次改JSP里的JavaScript,都强制走一遍“修改→编译→重启Tomcat→清浏览器缓存→F5刷新”全流程。因为JSP的热加载在Tomcat 8.5里经常失效,缓存的旧JS会让你以为代码没生效,其实只是浏览器在耍赖。希望帮到你

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

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

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

立即咨询