JSP+MySQL人事管理系统:JavaWeb三层架构与部署避坑
简介这套基于JSPServlet与MySQL实现的人事管理系统面向高校计算机相关专业的课程设计与毕业设计场景也可供Java Web入门者练习分层开发与数据库集成。系统围绕企业人力资源管理中的管理员登录校验、注册、员工信息维护等基础业务展开采用DAO、Service与实体类分层组织代码便于读者理解请求处理、数据访问和页面展示之间的协作关系。zip压缩包共108个文件约2.04MB主要包括JSP动态页面、Java源码及编译后的class文件、HTML/CSS/JavaScript前端资源、SQL数据库脚本、JAR依赖和界面图片素材数据库文件位于lib包下目录结构较完整。已有1448人学习下载适合用来对照课程设计需求梳理功能模块、参考项目分层与数据库连接方式并在此基础上完成二次开发或答辩准备。1. 人事管理系统 JSPMySQL答辩前一晚还在改的那套 JavaWeb 基础「系统在我电脑上是好的」——这句话在 JavaWeb 答辩现场基本等于自曝。A 同学把人事管理系统JSPMySQL拷到答辩机上登录能进点开员工列表直接 500工程里的 classes 是三天前编译的JDBC 驱动 jar 还留在他本地 D 盘没进 WEB-INF/lib。这套系统是典型的 ServletJSPJDBC 三层结构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 不存在」。这不是代码问题是运行环境选错了。组件建议版本关键说明JDK8老工程在 JDK 8 上最稳高版本要额外处理模块与编译参数Tomcat8.5 或 9仍是javax.servlet包名Servlet 3.1 规范MySQL5.7 或 8.08.0 默认sql_mode更严格聚合写法要跟着改JDBC 驱动mysql-connector-java 8.x驱动类名是com.mysql.cj.jdbc.DriverIDEEclipse 或 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-completetrue这个属性一旦写上容器会完全跳过注解扫描所有WebServlet、WebFilter集体失效页面全 404而你翻遍代码也找不到错。我的习惯是Servlet 用注解Filter 用 web.xml。原因是 Filter 的执行顺序由web.xml中filter-mapping的书写顺序决定注解注册的 Filter 顺序由容器实现决定、不可控而编码过滤器必须排在鉴权过滤器之前否则鉴权过滤器里取不到正确编码的参数。web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version3.1 metadata-completefalse display-namehrms/display-name !-- 编码过滤器必须排第一后面的 Filter 才能拿到正常中文 -- filter filter-nameencodingFilter/filter-name filter-classcom.hrms.filter.EncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping !-- 鉴权过滤器排除登录页与静态资源内部自行判断 -- filter filter-nameauthFilter/filter-name filter-classcom.hrms.filter.AuthFilter/filter-class /filter filter-mapping filter-nameauthFilter/filter-name url-pattern/*/url-pattern /filter-mapping welcome-file-list welcome-filelogin.jsp/welcome-file /welcome-file-list session-config session-timeout30/session-timeout /session-config /web-appmetadata-completefalse明确允许注解扫描等价于默认行为但写出来能避免后续有人复制粘贴时改错。两个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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个类型选择的理由值得说清。字符集用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?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue; private static final String USER hrms_user; private static final String PWD Hrms2024; static { try { // 8.x 驱动类是 com.mysql.cj.jdbc.Driver5.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()里每取一次连接就反射一次纯属浪费。serverTimezoneAsia/Shanghai是 MySQL 8.x 驱动的必需项不写会抛The server time zone value ... is unrecognized这是新手遇到最多的一类异常。allowPublicKeyRetrievaltrue是配合useSSLfalse使用的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); PageEmployee 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 里的pageNoabc就会抛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 PageEmployee query(String keyword, Integer deptId, int pageNo, int pageSize) { StringBuilder where new StringBuilder( WHERE 11 ); ListObject 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; ListEmployee rows dao.page(where.toString(), args.toArray(), offset, pageSize); PageEmployee 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 而不是 0JSP 里就不需要额外判空。参数用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实例在高并发下会解析出乱七八糟的日期。金额比较必须用compareToBigDecimal的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_timeNOW(), approve_remark? WHERE apply_id? AND status1; 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?actionlist);失败提示统一为「用户名或密码错误」不要区分「用户不存在」和「密码错误」——那等于免费送给对方一个账号枚举接口。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上加URIEncodingUTF-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; } // 管理员 role1普通员工 role2/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 里的empIdUser loginUser (User) req.getSession().getAttribute(loginUser); int empId; if (loginUser.getRole() 1) { // 管理员可以看任何人但仍要校验参数合法性 empId Integer.parseInt(req.getParameter(empId)); } else { // 普通员工忽略参数只能看自己 empId loginUser.getEmpId(); }判断依据必须是服务端的 Session不能是页面传的role隐藏域。顺带一提离职员工status2在登录时就该被拦下提示「账号已停用」而不是让他登进去看到一片空数据。5. 避坑排查五个让系统在演示现场崩溃的问题下面这五条不是理论推演是这类工程里出现频率最高的故障。每条按「现象 → 原因 → 解决」写症状描述尽量贴近实际看到的报错文本方便对照搜索。5.1 中文乱码表单提交的姓名变成问号现象新增员工时填「张三」列表页显示「??」控制台打印insert into employee(emp_name) values(??)。原因三个环节任一处的字符集不是 UTF-8 都会出问题——数据库/表/列的字符集、JDBC 连接串的characterEncoding、以及request.setCharacterEncoding是否在取参数之前调用。最常见的组合错误是连接串写了characterEncodingutf8但表建成了latin1驱动转换时按表结构来一样乱。解决三层一起查。库和表用utf8mb4建表语句里显式写DEFAULT CHARSETutf8mb4连接串加useUnicodetruecharacterEncodingutf8Filter 里在chain.doFilter之前调setCharacterEncoding。验证方法很直接SHOW VARIABLES LIKE character_set_%;看character_set_database和character_set_server是不是 utf8mb4。另外JSP 页面顶部必须有% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-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 servers identity verification is not recommended。原因MySQL 8.x 的 JDBC 驱动默认要求明确时区因为TIMESTAMP需要知道服务端按哪个时区解释而关闭 SSL 之后驱动出于安全考虑默认不允许从服务端获取 RSA 公钥来加密密码传输。解决连接串补三个参数——serverTimezoneAsia/Shanghai不要写成GMT%2B8里的%2B那是 URL 编码直接写GMT8会被解析错、allowPublicKeyRetrievaltrue、useSSLfalse。另外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_modeonly_full_group_by。原因MySQL 5.7 之后sql_mode默认包含ONLY_FULL_GROUP_BYSELECT 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. 交付前自检三个验证动作和一份清单答辩前一周我会把整个系统按「异常路径」重新走一遍而不是按功能点走。下面这份清单是我每次交付前都强制过一遍的五分钟能筛掉大半现场翻车的可能。检查项验证动作期望结果部署完整性把WebContentbuild/classes拷到干净目录用命令行startup.bat启动无ClassNotFoundException首页能出JSP 改动生效改一行 JSP 文字后直接刷新不重启内容立即变化SQL 注入列表页关键词输入 OR 11或 URL 加 ?本文还有配套的精品资源点击获取