☰
Java Web员工管理系统实战:Servlet+JSP+JDBC从表设计到分页事务
2026/9/30 5:06:48 网站建设 项目流程

简介:基于Java的企业员工信息管理系统设计与实现文档,面向计算机相关专业毕业生、Java Web初学者及课程设计在校学生。文档按毕业设计规范结构展开,覆盖需求分析、系统概要设计、系统功能实现与系统测试等环节,可帮助读者理顺从业务调研到系统落地的完整流程。系统采用B/S架构,使用MyEclipse编写程序,前台基于JSP,数据库使用MySQL,服务器采用Tomcat;功能按管理员与普通员工两类角色划分,管理员可操作系统全部功能,涵盖部门管理、员工信息管理、出勤管理、工资管理及请假审核,普通员工仅能查看工资和请假,兼顾功能完整性与安全性。资源包共1个文件,为docx格式文档,大小约369KB,内含中英文摘要、目录及详细正文章节,结构清晰便于对照修改。目前已有500人学习下载,适合需要梳理系统设计思路、撰写论文或搭建同类员工信息管理系统的读者。

1. 基于Java企业员工信息管理系统:代码之外的第一个决定

每当在课程设计题目里看到“基于Java企业员工信息管理系统”这几个字,我就知道又有一个同学要开始跟 Tomcat 和 MySQL 搏斗了。这个系统几乎是 Java Web 课程设计的常青树:登录会话、部门与员工一对多、分页搜索、权限区分,每个点都是日后面试常考的基础。把它吃透,比背十道 java 面试八股文更有用。它适合三类人:正在赶课程设计的学生、想练手 Java Web 的转行者,以及要给公司内部做小工具的一线开发。我不建议你一开始就扑向 Spring Boot,先把 Servlet + JSP + JDBC 这条老路走通,你才算真正见过 Java Web 的底层长相。这个标题真正考验你的不是会不会写“增删改查”,而是能不能把数据关系理清楚、把代码分层分明白。

2. 选型与设计:为什么我建议用 Servlet + JSP + JDBC 而不是一上来就 Spring Boot

2.1 课程设计与生产项目的分界线:先看你的交付物是什么

标题里只写了“基于Java”,没指定框架,这是很多人的第一个自由选择题。常见的做法有三套:JSP + Servlet + JDBC、SSM(Spring MVC + MyBatis)、Spring Boot + MyBatis/JPA。不同方案的学习曲线和交付痕迹差别很大,先看下表:

方案学习曲线部署难度答辩友好度简历价值
JSP + Servlet + JDBC陡,但要手写请求/响应链路简单,WAR包丢进Tomcat高,能讲清底层原理中等
SSM中等,配置多中等中,但配置文件容易背锅高
Spring Boot平缓,自动配置简单,内嵌Tomcat低,封装太黑容易一问三不知高

我的建议很直接:如果课程设计评分标准里有“必须使用 JSP”,或者答辩老师喜欢追着原理问,那就乖乖用 JSP + Servlet。与其用 Spring Boot 写一个你讲不清楚启动流程的项目,不如用传统方案把它做透。如果没有任何框架限制,而且你想把它作为找工作时的项目写进简历,那用 Spring Boot 更合适,但这类方案不在标题默认的“基于Java”语境里,也不是本文展开的重点。

这里先立住一个原则:这个系统无论用什么框架,数据关系是核心。我在同类项目里见过太多人把部门名称直接写在员工表里,导致部门改名时只能批量 update。正确做法是拆表,让数据关系本身成为业务的约束。下面就开始做这件事。

2.2 数据库表设计:五张表撑起整个员工管理系统

员工管理系统不是只有“员工”一张表。我会拆出 sys_user、dept、emp、job、attendance 这五张表,分别管登录用户、部门、员工、职位、考勤。员工和部门是多对一,员工和职位是多对一,用户和员工是逻辑上的 1 对 1,也就是说员工表里不放登录密码。

建表语句是整个系统的地基,下面这段 SQL 可以直接执行:

CREATE DATABASE employee_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL COMMENT '存MD5或BCrypt后的值', real_name VARCHAR(50), role VARCHAR(20) DEFAULT 'EMPLOYEE' COMMENT 'ADMIN/EMPLOYEE' ); CREATE TABLE dept ( id INT PRIMARY KEY AUTO_INCREMENT, dept_name VARCHAR(50) NOT NULL, manager_id INT COMMENT '部门经理员工ID', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE job ( id INT PRIMARY KEY AUTO_INCREMENT, job_name VARCHAR(50) NOT NULL, job_level INT COMMENT '职级,用于排序' ); CREATE TABLE emp ( id INT PRIMARY KEY AUTO_INCREMENT, emp_no VARCHAR(20) UNIQUE COMMENT '工号,业务上唯一', name VARCHAR(50) NOT NULL, gender CHAR(1), phone VARCHAR(20), dept_id INT, job_id INT, hire_date DATE, salary DECIMAL(10,2), status TINYINT DEFAULT 1 COMMENT '1在职,0离职', CONSTRAINT fk_emp_dept FOREIGN KEY (dept_id) REFERENCES dept(id), CONSTRAINT fk_emp_job FOREIGN KEY (job_id) REFERENCES job(id) ); CREATE TABLE attendance ( id INT PRIMARY KEY AUTO_INCREMENT, emp_id INT, work_date DATE, check_in TIME, check_out TIME, CONSTRAINT fk_att_emp FOREIGN KEY (emp_id) REFERENCES emp(id) );

这段 DDL 里三个容易被忽略的点。第一,字符集用 utf8mb4 而不是 utf8,因为 MySQL 的 utf8 最多只能存 3 字节,员工备注里万一有个生僻字或者表情符号,插入直接报错。第二,外键一定要建,答辩时老师问“数据一致性怎么保证”,外键是你能拿得出的第一个理由。第三,工号 emp_no 加了 UNIQUE,业务上员工编号必须唯一,不要拿自增主键当工号用,否则换系统或者数据迁移时你会后悔。

可能有人会问:为什么不把部门名称冗余到 emp 表里,查询时少一次 JOIN?确实,读列表时 JOIN 会多一点代码,但换来的是一致性和可维护性。部门名称只在 dept 表维护一处,员工表只存 dept_id,这是经典范式设计。等到查列表时,用 LEFT JOIN 把 dept_name 带出来即可,反正 SQL 不复杂。下面这段查询是列表页最常用的:

SELECT e.*, d.dept_name FROM emp e LEFT JOIN dept d ON e.dept_id = d.id WHERE e.status = 1 ORDER BY e.id DESC

我用 LEFT JOIN 而不是 INNER JOIN,是因为员工可能还没分配部门,但列表里依然要把这个人显示出来。答辩时你能主动说出这个 JOIN 的选择理由,比被老师问出来再解释要好得多。

2.3 数据模型与Java实体类的映射:字段类型、包装类、时间精度

数据库表建好后,接着就是把它映射成 Java 实体类。这里有个非常高频的坑:数据库的 INT 对应 Java 的 int,但当查询结果里这个字段是 NULL 时,基本类型 int 会自动拆箱,直接抛空指针。所以实体类里我都用 Integer、Long、BigDecimal 这类包装类,配合 ResultSet 的 getObject() 或者 getInt() 都能安全处理 null。

时间字段也是一样的逻辑。别再用 java.util.Date 和 SimpleDateFormat 了,Java 8 开始有 java.time 包,LocalDate 对应 DATE,LocalDateTime 对应 DATETIME。一旦用上 LocalDate,格式化就交给 DateTimeFormatter,不再有线程安全问题,代码也干净得多。员工实体的典型写法如下:

public class Employee { private Integer id; private String empNo; private String name; private String gender; private String phone; private Integer deptId; private Integer jobId; private LocalDate hireDate; private BigDecimal salary; private Integer status; private String deptName; // 联查出来的冗余字段,只读 // 省略getter/setter }

额外加了一个 deptName 字段,它不是数据库表中的列,而是列表页联查后展示用的。这种“实体类里带一个只读冗余字段”的做法,在小型管理系统里很常见,可以少写一大段对象组装代码。如果你追求更规范的分层,可以单独建一个 EmployeeVO,把 deptName 放进去,但课程设计阶段没必要把简单问题复杂化。

再展开一个更进阶的细节:Java 实体对象的复制。如果你要给编辑页回显数据,直接把对象塞给表单,有时候你需要一份“旧值”做对比。这时候用对象引用的浅拷贝会出问题——修改副本时原对象也会变。我一般不用 clone,因为深浅拷贝的规则本身就很绕,更稳的做法是手动 new 一个 DTO,把需要的字段一个个传过去。像员工信息这种字段不多的小对象,手工拷贝反而最可靠,这就是 java 对象深度拷贝在实际写码时最常见的替代方案。

另外,写这类系统时你会大量用到 Java 容器。列表查询结果用 ArrayList 没问题,但构造动态查询条件时,我更偏向用 LinkedHashMap 保存参数和值的键值对,因为它能保证参数顺序。JDBC 的 PreparedStatement 占位符是按下标 1、2、3 来的,如果你用 HashMap,遍历顺序不受控,一旦参数顺序和 SQL 里的问号顺序不一致,数据就会写进错误的字段,这是真实发生过的翻车现场。养成这个习惯后,后面写动态 SQL 会顺很多。

3. 环境与项目骨架:从JDK配置到跑起第一个查询

3.1 JDK、Tomcat、MySQL版本搭配:别让环境变量成为第一道坎

代码写得再好,环境跑不起来也白搭。根据我的经验,2025 年的当前环境下,最稳的组合是 JDK 8 或 11 + Tomcat 9 + MySQL 8.0 + mysql-connector-java 8.x。这个组合的 JSP/Servlet 包名还是 javax.servlet,网上绝大多数资料都能直接用,踩坑成本最低。

但如果你手一抖装了 JDK 17,又配了 Tomcat 10,那就容易掉进大坑:Tomcat 10 把 javax.servlet 改名成了 jakarta.servlet,代码里所有 import javax.servlet.* 都会编译报错。这时要么把 Tomcat 降回 9,要么把所有 javax 改成 jakarta,工作量说大不大,但卡住新手一小时很轻松。优先使用 JDK 8 + Tomcat 9,这是很多一线开发仍在用的保守配置。

再来看 java 环境变量配置。很多人装了多个 JDK,命令行里 java -version 永远显示旧版本,原因多半是 PATH 里有一个 C:\Program Files\Common Files\Oracle\Java\javapath 条目,它排在配置的 JAVA_HOME\bin 前面,把真实版本盖住了。解决办法是把那个条目从 PATH 中删掉,或者把 JAVA_HOME\bin 移到最前面。还有一点要提醒:JDK 1.5 以后根本不需要配置 CLASSPATH,网上有些老教程还在教配置 classpath,那是写给九十年代的 JDK 看的,千万别跟着配,配了反而可能干扰。

3.2 用Maven还是手动导包:课程设计最稳的项目结构

课程设计的传统做法是手动下载一堆 jar 包,再在 IDEA 里逐个 Add as Library,最后 Web/WEB-INF/lib 下面挤满了版本各异的依赖。我不推荐这么做,因为依赖冲突会让你排查到怀疑人生。更稳的做法是用 Maven,哪怕老师不要求,Maven 也能帮你把依赖树理顺。

只需要在 pom.xml 里声明依赖,Maven 会自动下载并传递依赖:

<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>

这里有两个关键点,新手很容易踩。第一,servlet-api 和 jsp-api 的 scope 必须是 provided,因为 Tomcat 自带这两个 jar,如果你把 scope 设成默认的 compile,打成 WAR 包时会连 Tomcat 自带类一起打包进去,启动直接报 NoSuchMethodError 这类莫名错误。第二,mysql-connector-java 8.x 的驱动类是 com.mysql.cj.jdbc.Driver,不再是老版本的 com.mysql.jdbc.Driver,虽然旧类名也能用,但会打一条红色日志,看着难受。

Maven 还会帮你规避一个常见问题:不同版本的 jar 包冲突。比如你手动下载了一个 commons-dbutils 1.6,又下载了一个 1.7,类路径里存在两份,运行时加载到哪个版本全看运气。这类“玄学报错”在 Maven 项目里几乎不会出现。

3.3 数据库连接池:为什么要用Druid而不是DriverManager.getConnection

我见过很多课程设计的代码长这样:每个 DAO 方法里都写一次 DriverManager.getConnection(),用完再 close()。这种写法在小系统里勉强能跑,但只要用户稍微多点,页面刷新频繁些,MySQL 就会报“Too many connections”。原因很简单:每次创建连接都要经过 TCP 握手、MySQL 权限校验、会话初始化,开销很大,而且连接用完就销毁,一进一出全是成本。

解决方向是连接池。连接池本质上是一个 Java 容器,里面提前放着若干个已经建立好的连接对象,谁用谁借,用完归还。这里我选 Druid,阿里巴巴开源的项目,配置简单还带监控面板,比手写一个连接池靠谱得多。

在 src/main/resources 下创建 druid.properties:

driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/employee_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username=root password=123456 initialSize=5 maxActive=20 minIdle=5 maxWait=10000 filters=stat

注意 url 里必须有 serverTimezone=Asia/Shanghai,MySQL 8.0 的驱动默认取系统时区,如果你在中国,不指定这个参数,连接会直接报 CST 时区错误。initialSize=5 表示启动时创建 5 个连接,maxActive=20 是最大连接数,maxWait=10000 是拿连接的最长等待时间,超过 10 秒直接抛异常,避免线程无限等下去。

Druid 的 filters=stat 能开启 SQL 监控。在 WEB-INF/web.xml 里配置一个 DruidServlet,浏览器访问 /druid/index.html 就能看到每条 SQL 的执行次数、平均耗时、慢查询列表。这个功能在答辩时特别好用,老师就算不问,你也可以主动展示一条慢查询是怎么定位的。

3.4 第一个JDBC查询:从Class.forName到ResultSet的完整闭环

连接池配好了,接下来看怎么用。先写一个 JdbcUtils 工具类,类加载时初始化 DruidDataSource:

public class JdbcUtils { private static DruidDataSource dataSource; static { try { Properties props = new Properties(); props.load(new FileInputStream("src/main/resources/druid.properties")); dataSource = (DruidDataSource) DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }

注意 JdbcUtils 里没有写关闭方法,因为我会在所有 DAO 里用 try-with-resources 自动关闭。很多老教程里写的关闭工具类是手动 close 三层资源,其实在 JDK 8之后已经没必要手写了,try-with-resources 更安全。

下面是一个完整的按 ID 查员工方法:

public Employee findById(Integer id) { String sql = "SELECT e.*, d.dept_name FROM emp e " + "LEFT JOIN dept d ON e.dept_id = d.id WHERE e.id = ?"; try (Connection conn = JdbcUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { Employee emp = new Employee(); emp.setId(rs.getInt("id")); emp.setName(rs.getString("name")); emp.setDeptName(rs.getString("dept_name")); // 其他字段按同样方式赋值 return emp; } } } catch (SQLException e) { e.printStackTrace(); } return null; }

第一,PreparedStatement 的占位符下标从 1 开始,不是 0,写错会报 Parameter index out of range。第二,try-with-resources 的变量声明括号里,Connection 和 PreparedStatement 的关闭顺序是自动处理的,完全不用管。第三,rs.getXxx 的列名要和 SQL 里的别名一致,比如 dept_name 来自别名,不能写成 deptName,JDBC 不认驼峰。

到这里,环境、连接池、第一个查询都通了,后面写 DAO、Servlet、JSP 就是循环往复的体力活。但正是这个“能跑通”的起点,决定了你后面是顺利推进还是天天跟异常搏斗。

4. 核心功能实现:登录、员工CRUD、分页搜索的完整代码路径

4.1 登录与Session:密码加密和会话失效一个都不能少

登录是所有管理系统的门面。先立一个底线:密码不能明文存数据库。课程设计答辩时老师只要瞄一眼数据库,看到明文密码,印象分会大打折扣。最省事的做法是 MD5 加盐——虽然 MD5 在安全要求高的场景里已经不够看了,但比明文强一个量级。注册时用 MD5Util.md5(password + salt),登录时把用户输入的内容加同样的盐再比对。

登录逻辑本身不复杂,但有两个细节需要注意:

@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); UserDao dao = new UserDao(); User user = dao.findByUsername(username); if (user != null && MD5Util.verify(password, user.getPassword())) { HttpSession session = req.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); resp.sendRedirect(req.getContextPath() + "/employee/list"); } else { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } }

第一个细节是 session.setMaxInactiveInterval(30 * 60),这里的单位是秒,等于 30 分钟无人操作后会话自动失效。虽然 Tomcat 默认也是 30 分钟,但显式写出来能让老师知道你有会话管理意识。第二个细节是登录成功后用 sendRedirect 而不是 forward,因为 forward 只是服务器内部跳转,浏览器地址栏不变,用户按 F5 刷新会重复提交表单,可能造成重复登录的副作用。

会话失效后怎么拦截未登录用户?常见做法是写一个 Filter,拦截除了 login 之外的所有 URL,检查 session 里有没有 loginUser,没有就跳回登录页。这个 Filter 代码很固定,相信你能写出来,这里就不贴了。需要提醒的是,别把 Filter 底下的请求路径写死,用 req.getRequestURI() 判断一下是否包含 .css/.js/.png,否则页面样式会加载不出来。

4.2 员工列表分页:用PageBean把SQL和前端解耦

员工数量一旦超过几十条,不分页的页面就像一坨长长的流水账,浏览器渲染也会卡。分页的 SQL 核心是 LIMIT offset, pageSize,配合一个 PageBean 把分页信息封装起来。

PageBean 的简化版:

public class PageBean<T> { private int pageNum; private int pageSize; private long total; private List<T> rows; }

DAO 层先查 total,再查 rows:

public PageBean<Employee> findPage(int pageNum, int pageSize, String name, Integer deptId) { PageBean<Employee> pb = new PageBean<>(); pb.setPageNum(pageNum); pb.setPageSize(pageSize); String countSql = "SELECT COUNT(*) FROM emp e WHERE 1=1"; // 动态追加 and 条件,同查询SQL // ... String listSql = "SELECT e.*, d.dept_name FROM emp e LEFT JOIN dept d ON e.dept_id = d.id WHERE 1=1"; // 追加条件、ORDER BY e.id DESC LIMIT ?, ? // 设置参数:先设置业务参数,最后设置 offset 和 pageSize // ... return pb; }

这条路径里最容易踩的坑是参数顺序。如果前面有动态条件,那么 PreparedStatement 的 set 顺序必须和 SQL 里的占位符顺序严格一致。我一般用一个 List 收集所有参数值,最后统一设置,避免手滑。offset 的计算是 (pageNum - 1) * pageSize,第一页 offset = 0,不要写成 pageNum * pageSize,那是典型的差一错误。

前端列表底部分页导航,用 JSTL 标签循环生成页码即可。这里有个思维点:PageBean 把分页状态和业务数据封装在一起,Controller 只需要往 request 里塞一个 pb,JSP 页面用 ${pb.rows} 遍历数据、用 ${pb.total} 显示总条数。这样前后端各司其职,不会出现 JSP 里写大量 Java 脚本块的情况。

4.3 新增和修改:如何用同一个JSP页面兜住两个场景

很多入门项目会把新增和编辑写成两个 JSP 页面,导致重复的 HTML 代码量大,维护起来也累。更常见的做法是共用 employee_form.jsp,通过一个隐藏的 id 字段区分动作:id 为空就是新增,id 有值就是编辑。

后端 Servlet 收参的写法如下:

Integer id = null; if (req.getParameter("id") != null && !req.getParameter("id").isEmpty()) { id = Integer.parseInt(req.getParameter("id")); } String name = req.getParameter("name"); String gender = req.getParameter("gender"); String deptIdStr = req.getParameter("deptId"); if (deptIdStr != null && !deptIdStr.isEmpty()) { emp.setDeptId(Integer.parseInt(deptIdStr)); } // ... if (id == null) { dao.insert(emp); } else { emp.setId(id); dao.update(emp); }

新手最容易在这里翻一个大跟头:前端页面如果只回显了部分字段,比如编辑员工时下拉框没有设置选中项,那么提交上来的 deptId 是空字符串,后端 parseInt 就会抛 NumberFormatException。所以在取值时要先判断空串。另外,我强烈建议在 JSP 中为隐藏 id 的 value 做空判断:

<input type="hidden" name="id" value="${employee.id != null ? employee.id : ''}"/>

如果直接用 ${employee.id},当 employee 对象为 null 时,EL 表达式会输出字符串 “null”,提交到后端后 Integer.parseInt("null") 直接报错。这种问题很难一眼看出,但只要你记住“EL 输出前必须判空”这条铁律,就能避免。

4.4 模糊搜索与部门筛选:SQL拼接的三种边界情况

列表页通常有一个主搜索框和一个部门筛选下拉框。动态 SQL 是逃不掉的。这里的核心安全原则是:所有用户输入都必须交给 PreparedStatement 占位符处理,绝不能用字符串拼接。

我惯用的写法如下:

StringBuilder sql = new StringBuilder("SELECT e.* FROM emp e WHERE 1=1"); List<Object> params = new ArrayList<>(); if (name != null && !name.trim().isEmpty()) { sql.append(" AND e.name LIKE ?"); params.add("%" + name.trim() + "%"); } if (deptId != null && deptId > 0) { sql.append(" AND e.dept_id = ?"); params.add(deptId); } if (status != null) { sql.append(" AND e.status = ?"); params.add(status); } // 最后执行时遍历 params 依次 setObject(i+1, params.get(i))

这里三个边界情况必须说清。第一,WHERE 1=1 虽然看起来有点“野”,但它让后面每个条件都能无脑追加 AND,不需要判断是否是第一个条件,代码复杂度瞬间降下来。第二,LIKE 的 % 位置决定了匹配语义:“%” + name + “%” 是包含匹配,“%” 加在右侧是前缀匹配。业务上搜“张”应该匹配“张三”和“老张”,所以要左右都加。第三,deptId 从页面过来时可能是个空字符串,后端要先转成 Integer 判断是否 > 0,否则把 0 拼进 SQL 会查出所有 dept_id 为 0 的数据,而实际上没有 id=0 的部门,导致列表一片空白。

动态 SQL 写好后,列表、新增、修改、删除这几个核心流程就能串成一条完整的链路。到这里系统已经能跑了,但距离“稳定”还差一步,很多坑得等你自己踩过一次才能长记性。下面我直接把高频的五个坑说出来,避免你重复踩。

5. 常见问题与避坑:数据不一致、中文乱码、Tomcat重启卡死

5.1 插入中文变“??”:不是MySQL字符集没设utf8那么简单

现象:数据库表、连接串、页面编码都写了 UTF-8,可插入的中文在库里变成了问号。

原因:第一层是建库时字符集没指定 utf8mb4,默认 latin1 存不了中文。第二层是 MySQL Connector/J 8.x 对连接参数更严格,url 里 characterEncoding=utf8 经常不够,你还需要在 MySQL 服务端把 character_set_server 改成 utf8mb4。第三层是 JSP 页面本身没声明 contentType,导致浏览器提交中文时按 ISO-8859-1 解码。

解决:按顺序做三件事。建库时显式写 DEFAULT CHARACTER SET utf8mb4;JSP 顶部写 <%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8"%>;在 web.xml 里配置一个 CharacterEncodingFilter,把所有请求的编码统一成 UTF-8。最后别忘检查 Tomcat 的 server.xml 中 Connector 是否设置了 URIEncoding="UTF-8",这个对 GET 请求的乱码至关重要。按我经验,80% 的乱码问题都出在 Tomcat 的 URIEncoding 上,而不是代码里。

5.2 更新员工时部门被清空:埋点/调试/回滚的排查顺序

现象:编辑员工时只改了手机号,保存后部门反而变成空白。

原因:前端表单里根本没有 dept_id 的输入项,或者 JSP 下拉框没设置选中态,导致提交的 deptId 为空。后端拿到 null 后直接 set 到 Employee 对象里,再 update 整条记录,未提交的旧值就被 null 覆盖了。这是最典型的“持久层更新覆盖旧值”问题。

解决:排查时不要一上来就加日志。先看浏览器开发者工具里提交的 Form Data 有哪些字段,确认 deptId 是否出现。如果没出现,就在编辑页表单里补上部门下拉框。但更稳妥的办法是:后端只允许更新表单里出现的非空字段,而不是把整个 Employee 对象序列化成 update SQL。具体实现可以做成“字段非空才拼接 set 子句”,虽然 SQL 写得麻烦一点,但能避免很多覆盖问题。另外,如果表单里确实需要回显旧值,记得在 JSP 的 option 标签里加上 selected="${emp.deptId == dept.id}" 这样的判断。

5.3 Tomcat热部署卡死:注意静态资源占用与线程泄漏

现象:IDEA 里改一下 JSP,点 Redeploy 后 Tomcat 直接卡死,控制台毫无输出,只能杀进程。

原因:大部分发生在 Windows 系统下。Tomcat 在删除旧 webapp 目录时,某些文件被进程占用,比如浏览器还开着页面导致 JS/CSS 文件句柄未释放,或者 Druid 连接池没有在 contextDestroyed 时关闭连接,线程没退干净。

解决:针对连接池泄漏,在 web.xml 里配置一个实现了 ServletContextListener 的监听器,在 contextDestroyed() 里调用 dataSource.close()。针对文件占用,只能先杀 Tomcat 进程再重启。最省心的做法是:开发阶段不要依赖热部署,改完代码直接重启 Tomcat,几秒钟的事。课程设计阶段本来就没什么超长启动流程,别为了省那几秒把时间耗在卡死上。

5.4 连接池连接耗尽:finally里释放资源为什么还会泄漏

现象:系统跑一段时间后,页面加载报“无法获取连接,等待超时”,Druid 监控显示 active 连接数满。

原因:最直接的泄漏是你根本没在 finally 里 close Connection。但还有一种隐蔽情况:finally 块里先关闭了 ResultSet,如果 ResultSet 是 null,rs.close() 会抛 NullPointerException,导致后续 Statement 和 Connection 的关闭代码根本执行不到。这个我想特别提醒你,因为有很多老教程的关闭模板是三层嵌套 try-catch,一不小心就把后面的关闭吞了。

解决:使用 try-with-resources,代替手写 finally。这是 JDK 7 以后的语言特性,也是目前最不容易出错的写法。前面 JDBC 查询代码已经展示过,不再重复。你需要注意一个细节:Connection 和 PreparedStatement 必须在同一个 try 的括号里声明,这样 close 顺序才能保证 PreparedStatement 先关闭,否则连接归还后 PreparedStatement 还握着底层 socket,时间长了照样泄漏。

5.5 分页页码溢出:前端传参攻击和后端校验

现象:把 URL 参数 pageNum 改成 -1 或 999,页面要么报 SQL 异常,要么返回空白。

原因:后端没做边界校验。pageNum 小于 1,offset 变成负数,MySQL 直接报错;pageNum 大于总页数,虽然 LIMIT 不减,但会扫描大量无效行,查询变慢,同时前端展示空页显然不合理。

解决:在 Servlet 入口处统一校验。拿到 pageNum 字符串后先 parseInt,失败就默认第一页;然后判断是否小于 1,小于 1 就重置为 1;最后和总页数比较,超过总页数就强制落到最后一页。这段代码比较固定,建议写在 Controller 工具类里,每个分页接口复用。另外,你还可以给分页接口加一个最大 pageSize 限制,比如每页最多 100 条,防止有人把 pageSize 改成 999999 直接把整个表拉出来。

6. 进阶与验证:从“能跑”到“敢写进简历”

6.1 用事务保证数据一致性:删除部门时员工表的外键策略

员工和部门存在外键约束,直接删除一个还有员工的部门会被 MySQL 拒绝。业务上合理的做法是:删除前检查部门下员工数,为 0 才允许删除。这个检查与删除之间可能会插入新数据,所以必须放进同一个事务。

Connection conn = JdbcUtils.getConnection(); try { conn.setAutoCommit(false); int count = empDao.countByDeptId(conn, deptId); if (count > 0) { throw new BizException("部门下存在员工,不能删除"); } deptDao.deleteById(conn, deptId); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); }

这段代码的核心是让同一个 Connection 贯穿检查和删除两个 DAO 方法。所以 DAO 方法要设计成可以接收 Connection 参数的重载版本,而不是各自去 getConnection()。如果你在两处各拿一个连接,事务等于白设——不同连接各自有独立的隔离级别,根本做不到原子性。

6.2 日志、统一异常与try-with-resources的落地写法

课程设计阶段可以 e.printStackTrace(),但如果想让它出现在简历的项目描述里,请把打印异常换成日志。不需要引入庞大的 log4j2,JDK 自带的 java.util.logging 足够小型项目使用:

private static final Logger LOGGER = Logger.getLogger(EmployeeService.class.getName()); LOGGER.warning("删除部门失败: deptId=" + deptId);

同时建议在 web.xml 里配置错误页,把 500、404 分别指向友好的 JSP,而不是让容器默认输出一大片英文异常栈。统一异常处理其实不复杂,但很多课程设计都没做,你做了就是加分项。

最后我会走一遍自测清单:登录失败和成功分别验证;Session 过期后访问列表页是否跳回登录;分页首页、末页、越界三种情况;搜索框输入空格和 SQL 片段是否安全;编辑页面回显、取消后数据是否不变;删除一个被引用的部门是否提示友好;直接访问 /employee/edit?id=999 是否返回友好页面而不是白屏。这一套走完,这个基于 Java 的企业员工信息管理系统就可以放心交出去。我自己做第一个系统时,就是靠这条清单避免了答辩现场翻车——把用户乱输入当成最大的敌人,而不是功能有没有写完。这个习惯让我少改很多 bug,希望帮到你。

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

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

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

立即咨询