简介:这套基于JSP+Servlet与MySQL实现的人事管理系统,面向高校计算机相关专业的课程设计与毕业设计场景,也可供Java Web入门者练习分层开发与数据库集成。系统围绕企业人力资源管理中的管理员登录校验、注册、员工信息维护等基础业务展开,采用DAO、Service与实体类分层组织代码,便于读者理解请求处理、数据访问和页面展示之间的协作关系。zip压缩包共108个文件,约2.04MB,主要包括JSP动态页面、Java源码及编译后的class文件、HTML/CSS/JavaScript前端资源、SQL数据库脚本、JAR依赖和界面图片素材,数据库文件位于lib包下,目录结构较完整。已有1448人学习下载,适合用来对照课程设计需求梳理功能模块、参考项目分层与数据库连接方式,并在此基础上完成二次开发或答辩准备。
1. 人事管理系统 JSP+MySQL:答辩前一晚还在改的那套 JavaWeb 基础
「系统在我电脑上是好的」——这句话在 JavaWeb 答辩现场基本等于自曝。A 同学把人事管理系统(JSP+MySQL)拷到答辩机上,登录能进,点开员工列表直接 500:工程里的 classes 是三天前编译的,JDBC 驱动 jar 还留在他本地 D 盘,没进 WEB-INF/lib。
这套系统是典型的 Servlet+JSP+JDBC 三层结构:JSP 渲染页面,Servlet 接请求做分发,DAO 用 JDBC 直连 MySQL,没有 Spring、没有 MyBatis,也没有 Maven 依赖树。它要解决的事很朴素——员工档案增删改查、部门岗位维护、考勤录入、请假审批、工资条生成,加一套登录与角色隔离。适合赶毕业设计的、交课程设计的,以及想弄明白「一个请求从浏览器到数据库到底过了哪几层」的人。链路短是它最大的好处:每层都能改、能打断点、也能被追问。
2. 环境与工程骨架:JDK、Tomcat、MySQL 怎么摆才不返工
拿到这种 JavaWeb 工程,第一件事不是读代码,是确认三件套版本和目录归属。经验上,八成「跑不起来」既不出在业务逻辑,也不出在 SQL 写错,而出在编译产物没部署、驱动 jar 位置不对、web.xml 和注解冲突这三处。下面按「先定环境、再定路由、再定表、最后打通一条 JDBC 链路」的顺序推进,每一步都能单独验证,出错时也容易定位在哪一层。
2.1 版本组合与目录摆放:先把环境定下来再动手
老 JSP 工程最容易出问题的地方是版本跨度。Servlet 规范在 Tomcat 10 之后把javax.servlet.*整体换成了jakarta.servlet.*,你手里这份工程如果 import 还是javax.servlet,放到 Tomcat 10 上会直接编译不过,报「程序包 javax.servlet 不存在」。这不是代码问题,是运行环境选错了。
| 组件 | 建议版本 | 关键说明 |
|---|---|---|
| JDK | 8 | 老工程在 JDK 8 上最稳,高版本要额外处理模块与编译参数 |
| Tomcat | 8.5 或 9 | 仍是javax.servlet包名,Servlet 3.1 规范 |
| MySQL | 5.7 或 8.0 | 8.0 默认sql_mode更严格,聚合写法要跟着改 |
| JDBC 驱动 | mysql-connector-java 8.x | 驱动类名是com.mysql.cj.jdbc.Driver |
| IDE | Eclipse 或 IDEA | 无所谓,关键是能配「编译输出目录」 |
不建议为一套毕设工程硬上 Maven。答辩机经常是离线的,mvn clean package拉不到依赖就直接卡死;而这个体量的工程一共也就四五个 jar,手动放进WEB-INF/lib反而最可控。真正要盯住的是目录结构——很多人把源码放对了,编译输出却指到了项目根目录下的build/classes,部署时只拷了WebContent,于是 Tomcat 里根本没有 class 文件,页面一访问就是 404 或 500。
hrms/ ├── src/ │ └── com/hrms/ │ ├── entity/ Employee.java Department.java User.java │ ├── dao/ EmployeeDao.java DeptDao.java │ ├── service/ EmployeeService.java LeaveService.java │ ├── servlet/ EmployeeServlet.java LoginServlet.java │ ├── filter/ EncodingFilter.java AuthFilter.java │ └── util/ DBUtil.java Page.java ├── WebContent/ │ ├── WEB-INF/ │ │ ├── web.xml │ │ ├── lib/mysql-connector-java-8.x.jar │ │ └── views/employee_list.jsp │ ├── css/ js/ images/ │ └── login.jsp └── build/classes/ ← 编译输出,必须随 WebContent 一起进 Tomcat这段目录树里有两个位置是硬性约束,不能改着玩。WEB-INF/lib是 Web 应用自己的类路径,jar 放在这儿,Tomcat 启动时才会加载;放到项目根的lib或者 IDE 的全局库目录,本机能跑、换机器就ClassNotFoundException。WEB-INF/views下的 JSP 外部无法直接访问,只能通过 Servlet 转发进去,这正好可以用来做权限拦截——外部请求/views/employee_list.jsp是 404 的。
部署时把WebContent整个目录(含WEB-INF)和build/classes的内容一起拷到TOMCAT_HOME/webapps/hrms/下,或者用 IDE 配一个 exploded 部署,指向同一个输出目录。这一步做对了,后面所有问题都好排查。
2.2 web.xml 与 @WebServlet:路由到底谁说了算
Servlet 3.0 之后有两种注册方式:web.xml里写<servlet>,或者类上打@WebServlet("/employee")。两种混用不会报错,但会出现「改了注解不生效」的怪异现象,因为容器先按web.xml建映射,注解只补充没被占用的路径。更隐蔽的是<web-app>上的metadata-complete="true",这个属性一旦写上,容器会完全跳过注解扫描,所有@WebServlet、@WebFilter集体失效,页面全 404,而你翻遍代码也找不到错。
我的习惯是:Servlet 用注解,Filter 用 web.xml。原因是 Filter 的执行顺序由web.xml中<filter-mapping>的书写顺序决定,注解注册的 Filter 顺序由容器实现决定、不可控;而编码过滤器必须排在鉴权过滤器之前,否则鉴权过滤器里取不到正确编码的参数。
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1" metadata-complete="false"> <display-name>hrms</display-name> <!-- 编码过滤器:必须排第一,后面的 Filter 才能拿到正常中文 --> <filter> <filter-name>encodingFilter</filter-name> <filter-class>com.hrms.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 鉴权过滤器:排除登录页与静态资源,内部自行判断 --> <filter> <filter-name>authFilter</filter-name> <filter-class>com.hrms.filter.AuthFilter</filter-class> </filter> <filter-mapping> <filter-name>authFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> <session-config> <session-timeout>30</session-timeout> </session-config> </web-app>metadata-complete="false"明确允许注解扫描,等价于默认行为,但写出来能避免后续有人复制粘贴时改错。两个<filter-mapping>的先后就是执行顺序:请求进来先过encodingFilter调request.setCharacterEncoding("UTF-8"),再过authFilter判断登录态。session-timeout单位是分钟,30 表示半小时无操作会话失效,答辩演示时不容易中途掉线。
<url-pattern>/*</url-pattern>会拦下包括.css、.js在内的所有请求,所以AuthFilter内部必须自己做白名单放行,不然登录页会陷入「跳转到登录页又被拦、再跳转」的死循环。这一点在第 4 章给具体代码。
2.3 建表:字符集、外键和字段类型一次定对
数据库设计是这套系统里返工成本最高的部分。表建完再改字段类型,DAO 和 JSP 全要跟着动。下面三张是核心表,另外还有attendance(考勤)、leave_apply(请假申请)、salary(工资单)、sys_user(登录账号)四张,结构类似,不重复贴。
CREATE DATABASE hrms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE hrms; CREATE TABLE department ( dept_id INT PRIMARY KEY AUTO_INCREMENT, dept_no VARCHAR(20) NOT NULL UNIQUE COMMENT '部门编号', dept_name VARCHAR(50) NOT NULL UNIQUE COMMENT '部门名称', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE position ( pos_id INT PRIMARY KEY AUTO_INCREMENT, pos_name VARCHAR(50) NOT NULL COMMENT '岗位名称', dept_id INT NOT NULL, CONSTRAINT fk_pos_dept FOREIGN KEY (dept_id) REFERENCES department(dept_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE employee ( emp_id INT PRIMARY KEY AUTO_INCREMENT, emp_no VARCHAR(20) NOT NULL UNIQUE COMMENT '工号', emp_name VARCHAR(30) NOT NULL, gender TINYINT NOT NULL DEFAULT 1 COMMENT '1男 2女', birthday DATE, phone VARCHAR(20), email VARCHAR(64), dept_id INT, pos_id INT, hire_date DATE NOT NULL COMMENT '入职日期', base_salary DECIMAL(10,2) DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1 COMMENT '1在职 2离职', KEY idx_dept (dept_id), KEY idx_name (emp_name), CONSTRAINT fk_emp_dept FOREIGN KEY (dept_id) REFERENCES department(dept_id), CONSTRAINT fk_emp_pos FOREIGN KEY (pos_id) REFERENCES position(pos_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个类型选择的理由值得说清。字符集用utf8mb4而不是utf8,后者在 MySQL 里是三个字节的残缺实现,存不了生僻字和部分符号,员工姓名里一个生僻字就能让你整条插入失败。薪资用DECIMAL(10,2)而不是DOUBLE,浮点数做金额累加会出现0.30000000000000004这种结果,工资条一算就露馅。性别、状态这类有限枚举用TINYINT而不是VARCHAR,省空间也更方便做条件判断和索引。
外键要建,但要想清楚删除策略。默认的RESTRICT意味着「部门下还有员工就不许删部门」,这其实是业务上正确的行为,问题只在于异常没被捕获时会变成一张 500 白页。第 5 章会给具体的兜底写法。emp_no上的UNIQUE不是可选项,工号重复在人事系统里是数据事故,让数据库兜住比在 Java 里if判断更可靠。
2.4 从 DBUtil 到 DAO:打通一条 JDBC 链路
在写任何业务之前,先写一个DBUtil,然后用它查一条数据出来打印到控制台。这条链路通了,后面所有模块都只是换 SQL 和换 JSP。
package com.hrms.util; import java.sql.*; public class DBUtil { // useUnicode + characterEncoding 解决中文;serverTimezone 解决 8.x 驱动时区报错 private static final String URL = "jdbc:mysql://127.0.0.1:3306/hrms?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"; private static final String USER = "hrms_user"; private static final String PWD = "Hrms@2024"; static { try { // 8.x 驱动类是 com.mysql.cj.jdbc.Driver,5.x 是 com.mysql.jdbc.Driver Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("驱动没进 WEB-INF/lib: " + e.getMessage()); } } public static Connection getConn() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } // 统一关闭,顺序必须是 ResultSet -> Statement -> Connection public static void close(ResultSet rs, Statement st, Connection conn) { try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (st != null) st.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} } }Class.forName放在static块里,类一加载就执行一次,且只执行一次;放在getConn()里每取一次连接就反射一次,纯属浪费。serverTimezone=Asia/Shanghai是 MySQL 8.x 驱动的必需项,不写会抛The server time zone value ... is unrecognized,这是新手遇到最多的一类异常。allowPublicKeyRetrieval=true是配合useSSL=false使用的,8.x 驱动在关闭 SSL 后默认不允许取公钥,密码校验会直接失败。
DAO 层拿连接的标准写法:
public Employee findById(int empId) { String sql = "SELECT emp_id, emp_no, emp_name, gender, phone, hire_date, " + "dept_id, pos_id, base_salary, status " + "FROM employee WHERE emp_id = ?"; Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConn(); ps = conn.prepareStatement(sql); ps.setInt(1, empId); // 参数下标从 1 开始,不是 0 rs = ps.executeQuery(); return rs.next() ? mapRow(rs) : null; } catch (SQLException e) { throw new RuntimeException("查询员工失败, empId=" + empId, e); } finally { DBUtil.close(rs, ps, conn); } }PreparedStatement的占位符下标从 1 开始,setInt(1, ...)对应第一个问号;写成 0 会抛Parameter index out of range。mapRow是逐列取值转实体的私有方法,用rs.getDate("hire_date")取日期、rs.getBigDecimal("base_salary")取金额,别用getString再new BigDecimal(...),空值会炸。异常统一包成RuntimeException往上抛,Servlet 层统一捕获转到错误页,比每个 DAO 方法都写一遍 try-catch 干净得多。
3. 核心业务实现:员工档案、分页与审批状态机
骨架通了之后,真正花时间的是业务细节。人事系统的重头戏不是「增删改查」四个字,而是列表页的分页与多条件筛选、表单里的日期和金额校验、以及审批流里「一条申请只能被审一次」的并发安全。这三处是最容易在答辩时被追问「如果两个人同时点通过会怎样」的地方。
3.1 分层约定:Servlet 只做三件事
很多毕设工程写着写着就变成「一个 Servlet 里塞两千行」,最后自己都不敢改。约定一个底线:Servlet 只做参数接收与校验、调用 Service、选择转发或重定向;SQL 只在 DAO 里出现;事务边界放在 Service。
@WebServlet("/employee") public class EmployeeServlet extends HttpServlet { private final EmployeeService service = new EmployeeService(); @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": toEdit(req, resp); break; case "delete": delete(req, resp); break; default: resp.sendError(HttpServletResponse.SC_NOT_FOUND); } } private void list(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String keyword = req.getParameter("keyword"); Integer deptId = parseIntOrNull(req.getParameter("deptId")); int pageNo = parseIntOrDefault(req.getParameter("pageNo"), 1); Page<Employee> page = service.query(keyword, deptId, pageNo, 10); req.setAttribute("page", page); req.setAttribute("deptList", service.allDepts()); req.getRequestDispatcher("/WEB-INF/views/employee_list.jsp").forward(req, resp); } }用一个action参数做二级分发,是这类项目最省事的做法,比每个功能写一个 Servlet 类要好维护。parseIntOrNull和parseIntOrDefault两个小工具方法要自己写,作用是「参数缺失或非法时给默认值」,否则用户手改 URL 里的pageNo=abc就会抛NumberFormatException。转发用getRequestDispatcher(...).forward(),地址栏不变、请求域数据能带到 JSP;如果是增删改之后要跳列表页,必须用sendRedirect,否则刷新页面会重复提交。
3.2 分页:总数、越界与 LIMIT 计算
分页看起来简单,坑集中在三个地方:总数查询和列表查询的条件必须一致、页码越界要有兜底、LIMIT的两个参数含义别搞反。
-- 列表:带部门岗位名称,按工号倒序 SELECT e.emp_id, e.emp_no, e.emp_name, e.gender, e.phone, e.hire_date, e.status, d.dept_name, p.pos_name FROM employee e LEFT JOIN department d ON e.dept_id = d.dept_id LEFT JOIN position p ON e.pos_id = p.pos_id WHERE 1 = 1 ORDER BY e.emp_id DESC LIMIT ?, ?; -- 第一个 ? 是偏移量 offset,第二个 ? 是每页条数 pageSizeLIMIT offset, size里 offset 是「跳过多少条」而不是「第几页」,写反了第一页就空。用LEFT JOIN而不是INNER JOIN,是因为员工可能还没分配部门岗位,内连接会把这些记录直接过滤掉,列表看着少人却查不出原因。
总数查询必须复用同一套WHERE条件,做法是把条件拼装抽成一个方法:
public Page<Employee> query(String keyword, Integer deptId, int pageNo, int pageSize) { StringBuilder where = new StringBuilder(" WHERE 1=1 "); List<Object> args = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { where.append(" AND (e.emp_name LIKE ? OR e.emp_no LIKE ?) "); String like = "%" + keyword.trim() + "%"; args.add(like); args.add(like); } if (deptId != null && deptId > 0) { where.append(" AND e.dept_id = ? "); args.add(deptId); } int total = dao.count(where.toString(), args.toArray()); int totalPages = Math.max(1, (total + pageSize - 1) / pageSize); // 页码兜底:小于 1 归 1,超过总页数归最后一页 pageNo = Math.min(Math.max(pageNo, 1), totalPages); int offset = (pageNo - 1) * pageSize; List<Employee> rows = dao.page(where.toString(), args.toArray(), offset, pageSize); Page<Employee> p = new Page<>(); p.setRows(rows); p.setPageNo(pageNo); p.setTotal(total); p.setTotalPages(totalPages); return p; }(total + pageSize - 1) / pageSize是向上取整的整数写法,等价于Math.ceil(total / pageSize),但避免了浮点误差。Math.max(1, ...)保证即使一条数据都没有,页码也显示 1 而不是 0,JSP 里就不需要额外判空。参数用Object[]传而不是字符串拼接,是为了保住PreparedStatement的预编译能力,like的通配符放在参数值里拼,不是拼进 SQL——这一点在第 6 章验证注入时会再提。
3.3 表单校验:日期、金额与空值
JSP 表单提交上来的全是字符串,request.getParameter拿不到类型信息,直接new BigDecimal(str)遇到空串就抛NumberFormatException,页面直接白屏。合格的做法是在 Servlet 里做一次集中转换,非法输入给出明确提示,而不是让异常栈暴露给用户。
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private LocalDate parseDate(String s, String label) { if (s == null || s.trim().isEmpty()) return null; try { return LocalDate.parse(s.trim(), DATE_FMT); } catch (DateTimeParseException e) { throw new IllegalArgumentException(label + " 格式应为 yyyy-MM-dd,实际收到:" + s); } } private BigDecimal parseMoney(String s, String label) { if (s == null || s.trim().isEmpty()) return BigDecimal.ZERO; BigDecimal v; try { v = new BigDecimal(s.trim()); } catch (NumberFormatException e) { throw new IllegalArgumentException(label + " 必须是数字,实际收到:" + s); } if (v.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException(label + " 不能为负数"); } return v.setScale(2, RoundingMode.HALF_UP); // 统一保留两位,四舍五入 }用DateTimeFormatter而不是SimpleDateFormat,因为后者不是线程安全的,Servlet 是多线程环境,用静态的SimpleDateFormat实例在高并发下会解析出乱七八糟的日期。金额比较必须用compareTo,BigDecimal的equals会连精度一起比,0.0和0.00返回 false,这个坑我在对账逻辑里踩过一次。
写回数据库时用ps.setDate(idx, java.sql.Date.valueOf(localDate))和ps.setBigDecimal(idx, money);java.sql.Date和java.util.Date是两个类,传错了编译期就报错,反而好排查。入职日期这类字段还要加业务校验:不能晚于今天,不能早于员工生日加 16 年——这种校验放 Service 层,DAO 只管存取。
3.4 请假审批:用状态条件更新防止重复审批
请假审批是这套系统里唯一带「流程」味道的模块。表结构大致是leave_apply(apply_id, emp_id, leave_type, start_date, end_date, reason, status, approve_by, approve_time),status取值 1 待审批、2 已通过、3 已驳回。
新手写法是先SELECT查状态,判断等于 1 再UPDATE。两个管理员同时打开待审列表,各自点「通过」,两条请求都能通过状态判断,最终同一条申请被审两次,approve_by被后一个覆盖,日志对不上。正确做法是把状态判断塞进UPDATE的WHERE里,靠数据库的行锁保证原子性:
UPDATE leave_apply SET status = ?, approve_by = ?, approve_time = NOW(), approve_remark = ? WHERE apply_id = ? AND status = 1;Java 侧看影响行数:
public boolean approve(int applyId, int status, int approveBy, String remark) { String sql = "UPDATE leave_apply SET status=?, approve_by=?, " + "approve_time=NOW(), approve_remark=? " + "WHERE apply_id=? AND status=1"; try (Connection conn = DBUtil.getConn(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, status); ps.setInt(2, approveBy); ps.setString(3, remark); ps.setInt(4, applyId); return ps.executeUpdate() == 1; // 1 表示审批成功,0 表示已被处理 } catch (SQLException e) { throw new RuntimeException("审批失败, applyId=" + applyId, e); } }返回 false 时给前端提示「该申请已被处理,请刷新列表」,而不是笼统报「操作失败」。这里的try-with-resources比finally里手动关更稳,资源释放顺序由编译器保证,也不怕中间抛异常。注意不要在同一个事务里既查又改又不加锁,SELECT ... FOR UPDATE也能解决,但对这个体量的项目属于过度设计,一条带条件的UPDATE足够。
4. 登录、会话与权限:把越权挡在 Filter 里
人事系统存的身份证号、手机号、薪资,都是不该被普通员工看到的字段。答辩时老师最爱试的一件事,就是登录普通员工账号,然后手改地址栏里的empId,看能不能翻出别人的工资条。权限做不做得好,往往就决定这一项是加分还是扣分。
4.1 登录流程与密码存储
登录的本质是「用提交的账号查一条用户记录,比对密码,一致就把用户对象放进 Session」。问题出在密码怎么存。明文存库里,只要数据库文件被拷走,所有人的密码一览无余,答辩时被问一句「密码是明文吗」就很难看。
public static String md5(String raw, String salt) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest((raw + salt).getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); // 转成两位小写十六进制 } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("MD5 不可用", e); } }salt每个用户独立生成(比如随机 8 位字符串),和密码哈希一起存进sys_user表。这样即使两个人密码相同,库里的哈希也不同,彩虹表直接失效。要说清楚的是:MD5 加盐只够应付课程设计和毕设的「不能明文」要求,真实系统应该用 BCrypt 或 PBKDF2 这类带工作因子的慢哈希,MD5 本身已被证明抗碰撞性不足。这一点主动说出来,比被追问时支支吾吾强。
登录 Servlet 的关键逻辑:
String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userService.login(username, password); // 内部做 哈希(密码+盐) 比对 if (user == null) { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } // 登录成功:换新 Session,防会话固定攻击 req.getSession().invalidate(); HttpSession session = req.getSession(true); session.setAttribute("loginUser", user); resp.sendRedirect(req.getContextPath() + "/employee?action=list");失败提示统一为「用户名或密码错误」,不要区分「用户不存在」和「密码错误」——那等于免费送给对方一个账号枚举接口。invalidate()再getSession(true)是为了防会话固定:攻击者先拿到一个 SessionID,诱导你用它登录,之后他就能复用这个 ID。这个细节在毕设里几乎没人写,但成本只有两行。
4.2 Filter 链:编码、鉴权与白名单
编码过滤器必须是链上第一个:
public class EncodingFilter implements Filter { private String encoding = "UTF-8"; @Override public void init(FilterConfig cfg) { String v = cfg.getInitParameter("encoding"); if (v != null && !v.trim().isEmpty()) encoding = v.trim(); } @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(encoding); // 只对 POST 请求体有效 resp.setCharacterEncoding(encoding); resp.setContentType("text/html;charset=" + encoding); chain.doFilter(req, resp); } }setCharacterEncoding对 GET 请求的查询串无效——GET 参数在 URL 里,由 Tomcat 的Connector决定解码方式。Tomcat 8 及以后默认 URI 编码就是 UTF-8,所以不用改;更早的版本需要在server.xml的<Connector>上加URIEncoding="UTF-8"。搞不清这一点的典型表现是:表单提交的中文正常,地址栏带的中文参数乱码。
鉴权过滤器把「未登录」和「越权」两件事一起处理:
public class AuthFilter implements Filter { // 白名单:登录相关页面与静态资源,注意用前缀匹配 private static final String[] WHITE = { "/login.jsp", "/login", "/css/", "/js/", "/images/" }; @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().substring(req.getContextPath().length()); for (String w : WHITE) { if (path.startsWith(w)) { chain.doFilter(request, response); return; } } HttpSession session = req.getSession(false); // 没有就别新建 User user = (session == null) ? null : (User) session.getAttribute("loginUser"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } // 管理员 role=1,普通员工 role=2;/admin 前缀只给管理员 if (path.startsWith("/admin") && user.getRole() != 1) { resp.sendError(HttpServletResponse.SC_FORBIDDEN); return; } chain.doFilter(request, response); } }req.getSession(false)是关键,传 false 表示「有就拿、没有返回 null」,不会顺手创建一个新 Session。传 true 或不传,每个未登录请求都会新建 Session,内存里堆一堆垃圾,也干扰会话统计。白名单用startsWith前缀匹配,这样/css/能覆盖目录下所有文件,不用一条条列。
4.3 角色隔离:别相信 URL 里的 ID
最常见的越权漏洞就是把权限判断寄托在「页面上没显示这个链接」。用户手敲 URL 一样能访问。典型错误写法:
// 错误:完全信任前端传来的 empId int empId = Integer.parseInt(req.getParameter("empId")); Employee emp = employeeService.findById(empId); BigDecimal salary = salaryService.ofMonth(empId, month);正确写法是:管理员才允许用参数里的empId,普通员工强制用 Session 里的empId:
User loginUser = (User) req.getSession().getAttribute("loginUser"); int empId; if (loginUser.getRole() == 1) { // 管理员:可以看任何人,但仍要校验参数合法性 empId = Integer.parseInt(req.getParameter("empId")); } else { // 普通员工:忽略参数,只能看自己 empId = loginUser.getEmpId(); }判断依据必须是服务端的 Session,不能是页面传的role隐藏域。顺带一提,离职员工(status=2)在登录时就该被拦下,提示「账号已停用」,而不是让他登进去看到一片空数据。
5. 避坑排查:五个让系统在演示现场崩溃的问题
下面这五条不是理论推演,是这类工程里出现频率最高的故障。每条按「现象 → 原因 → 解决」写,症状描述尽量贴近实际看到的报错文本,方便对照搜索。
5.1 中文乱码:表单提交的姓名变成问号
现象:新增员工时填「张三」,列表页显示「??」;控制台打印insert into employee(emp_name) values('??')。
原因:三个环节任一处的字符集不是 UTF-8 都会出问题——数据库/表/列的字符集、JDBC 连接串的characterEncoding、以及request.setCharacterEncoding是否在取参数之前调用。最常见的组合错误是连接串写了characterEncoding=utf8但表建成了latin1,驱动转换时按表结构来,一样乱。
解决:三层一起查。库和表用utf8mb4(建表语句里显式写DEFAULT CHARSET=utf8mb4);连接串加useUnicode=true&characterEncoding=utf8;Filter 里在chain.doFilter之前调setCharacterEncoding。验证方法很直接:SHOW VARIABLES LIKE 'character_set_%';看character_set_database和character_set_server是不是 utf8mb4。另外,JSP 页面顶部必须有<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>,pageEncoding管的是文件本身的编码,缺了它 JSP 编译出来的 Servlet 就已经乱码了。
5.2 驱动报错三连:时区、公钥与 SSL
现象:换机器后启动,控制台刷The server time zone value '中国标准时间' is unrecognized or represents more than one time zone;或者Public Key Retrieval is not allowed;或者一堆警告Establishing SSL connection without server's identity verification is not recommended。
原因:MySQL 8.x 的 JDBC 驱动默认要求明确时区,因为TIMESTAMP需要知道服务端按哪个时区解释;而关闭 SSL 之后驱动出于安全考虑,默认不允许从服务端获取 RSA 公钥来加密密码传输。
解决:连接串补三个参数——serverTimezone=Asia/Shanghai(不要写成GMT%2B8里的%2B,那是 URL 编码,直接写GMT+8会被解析错)、allowPublicKeyRetrieval=true、useSSL=false。另外ClassNotFoundException: com.mysql.cj.jdbc.Driver十有八九是 jar 没放进WEB-INF/lib,或者放的是 5.x 的 jar 却写了 8.x 的类名,两者要对应上。建议把连接串和账号密码抽到db.properties里读,改环境不用重编译。
5.3 部门统计报错:GROUP BY 里的字段不全
现象:打开首页统计图表时 500,日志里是Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'hrms.e.emp_name' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by。
原因:MySQL 5.7 之后sql_mode默认包含ONLY_FULL_GROUP_BY,SELECT dept_id, emp_name, COUNT(*) FROM employee GROUP BY dept_id这种写法被判定为语义不明——一个部门有多个人,凭什么显示某一个的姓名。这条规则是对的,只是老代码没跟上。
解决:优先改 SQL,把非聚合字段从SELECT里去掉,或者把所有非聚合字段都写进GROUP BY。确实需要取一个代表值时用聚合函数包一下,比如MAX(emp_name),或者ANY_VALUE(emp_name)(MySQL 5.7+ 提供)。不建议直接SET GLOBAL sql_mode='...'把这条规则关掉,答辩机重启后配置失效,问题照样在,而且掩盖了真实的语义错误。统计类 SQL 建议先在命令行里跑通再粘进 DAO。
5.4 删除部门报 500:外键约束引发的白页
现象:在部门列表点「删除」,浏览器跳出一张 Tomcat 的异常页,里面写着Cannot delete or update a parent row: a foreign key constraint fails (hrms.position, CONSTRAINT fk_pos_dept FOREIGN KEY (dept_id) REFERENCES department (dept_id))。
原因:position和employee表都有指向department的外键,且是默认的RESTRICT,部门下挂着数据就不允许删。数据库的行为完全正确,错的是 Java 侧没捕获这个SQLIntegrityConstraintViolationException,让它一路冒到了容器。
解决:删之前先查引用计数,给用户明确反馈:
public String deleteDept(int deptId) { int empCount = deptDao.countEmployees(deptId); if (empCount > 0) { return "该部门下还有 " + empCount + " 名员工,请先调岗"; } int posCount = deptDao.countPositions(deptId); if (posCount > 0) { return "该部门下还有 " + posCount + " 个岗位,请先删除岗位"; } deptDao.delete(deptId); return null; // null 表示成功 }返回字符串给前端展示,比抛异常友好得多。真要物理删除时,Dao 层仍然要 try-catchSQLIntegrityConstraintViolationException,防止两个人同时操作时的竞态导致白页。
5.5 连接泄漏:跑十几分钟就卡住不动
现象:刚启动时一切正常,反复点列表页十几次以后,页面开始转圈,最后报Communications link failure或Too many connections,重启 Tomcat 又好了。
原因:DAO 里Connection只在正常路径关闭,异常分支直接return或throw,连接就漏了。DriverManager.getConnection每次都新建物理连接,MySQL 默认max_connections一百多,漏上几十个就满。还有一种隐蔽情况:ResultSet没关,某些驱动实现下会一直持有 Statement,进而拖住 Connection。
解决:全量换成try-with-resources,顺序按「后开先关」自动处理:
String sql = "SELECT COUNT(*) FROM employee WHERE dept_id = ?"; try (Connection conn = DBUtil.getConn(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, deptId); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getInt(1) : 0; } } catch (SQLException e) { throw new RuntimeException("统计部门人数失败", e); }排查阶段可以在DBUtil.getConn()里打一行new Exception("连接从这里申请").printStackTrace(),跑一轮页面就能看到哪条路径没还。另外,这种小项目上一个连接池(Druid、HikariCP 都行)能显著降低连接创建开销,但前提是连接归还正确,池子只会把泄漏问题放大得更明显。
6. 交付前自检:三个验证动作和一份清单
答辩前一周,我会把整个系统按「异常路径」重新走一遍,而不是按功能点走。下面这份清单是我每次交付前都强制过一遍的,五分钟能筛掉大半现场翻车的可能。
| 检查项 | 验证动作 | 期望结果 |
|---|---|---|
| 部署完整性 | 把WebContent+build/classes拷到干净目录,用命令行startup.bat启动 | 无ClassNotFoundException,首页能出 |
| JSP 改动生效 | 改一行 JSP 文字后直接刷新(不重启) | 内容立即变化 |
| SQL 注入 | 列表页关键词输入' OR '1'='1,或 URL 加 `? |
本文还有配套的精品资源,点击获取