拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java企业人事管理系统实战:从数据库设计到Spring Boot开发

Java企业人事管理系统实战:从数据库设计到Spring Boot开发

简介:基于Java的企业人事管理系统毕业设计文档,面向计算机相关专业学生、Java Web开发学习者和毕业设计选题人员。文档从人事管理系统的现实意义谈起,强调企业人力资源数字化带来的效率提升,随后完整展开系统分析、总体设计、详细设计与功能模块划分。设计基于MyEclipse集成开发环境,采用JSP动态页面技术与SqlServer2008数据库,覆盖人员档案、培训、职称评定、奖惩、人员调动等业务模块,并提供可行性分析、非功能性需求、实体属性图及模块设计说明,可帮助读者快速理解从需求到实现的关键链路。资源共1个doc文件,压缩包大小48KB,虽然体量精简,但内容结构完整,适合作为毕业设计论文框架或小型人事管理项目的参考资料。文档中对各功能模块的划分与数据库设计思路清晰,特别适合在缺少完整项目参考时作为起步模板。已有80人学习下载,可用于课程设计、答辩准备或系统开发前的需求梳理。

1. “基于java企业人事管理系统.doc”到底在解决什么问题

“基于java企业人事管理系统.doc”这个标题,我在课程设计、毕业设计题目里见过很多次,也在一些小企业的项目需求文档里看到过。它指向的不是高并发中间件或复杂算法,而是一套面向人事专员的信息化工具:员工档案能录、能改、能查,部门结构能调整,考勤和薪资能按规则计算,合同到期能提醒,最后还要能把结果导成 Word 或 Excel 报表。标题里的“.doc”很常见,因为这类项目交作业或交项目时,习惯把需求分析、数据库设计、核心接口说明写进一份 Word 文档,代码和文档一起交付。适合三类人:准备课程设计或毕业设计的 java 学习者,要给小团队做内部系统的一线开发者,以及正被“java事务、权限、数据一致性”这些问题问住、想用真实系统验证一遍的转行者。下面按一套最常见也最可靠的方案拆开讲,目标是让读者能照着建表、写接口、跑起来。

2. 先别写代码:把人事领域拆成表,数据库设计定成败

2.1 技术栈选型:Spring Boot + MyBatis 为什么是这类系统的主流答案

这类系统我不建议一上来就上微服务,也不建议把 Redis 当成核心存储用。人事管理系统的人数级通常就是几十到几千人,业务量不大,但字段多、状态多、报表条件多,真正要命的是数据一致性,而不是性能。Spring Boot 把配置收敛成少数几个文件,内置 Web 服务器,最后交付物就是一个 jar 包,小公司没有专职运维也能跑。MyBatis 虽然要写 XML,但人事系统里的统计 SQL 基本都是动态条件,手写 SQL 比 JPA 的派生查询更容易调优、更容易给后来的维护者解释。

前端如果只给人事专员用,服务端渲染的 Thymeleaf 足够;如果还要让员工自己提交请假、查看工资条,再拆出一个 Vue 项目也不迟,但这不是人事系统的必需品。数据库选 MySQL,云厂商 RDS 和自建都默认支持,网上能查到的踩坑记录也最多。还有一件事容易被新人卡住:java 环境变量配置。JDK 装完不配 JAVA_HOME 和 PATH,Maven 编译时直接报“java 不是内部或外部命令”,这不是题目难点,但会消耗一小时。先把环境确认好,再往下做。

2.2 核心表结构与主外键关系:从部门、员工到考勤、薪资

行业里做这类系统,最少需要这几类表:账号权限、组织架构、员工档案、考勤、薪资,以及一套流程记录。下面是我常用的核心表结构:

表名关键字段关系与业务规则
departmentid, parent_id, name, manager_id, status自关联树形结构,部门允许停用但不允许物理删除
employeeid, dept_id, name, gender, id_card, phone, hire_date, contract_end_date, status, deleted员工与部门多对一,员工表保留逻辑删除标记
sys_userid, employee_id, username, password_hash, role_id登录账号与员工一对一,密码只存哈希
sys_roleid, role_code, role_name角色与权限点通过 sys_role_menu 关联
attendanceid, employee_id, work_date, first_in_time, last_out_time, status, overtime_hours每个员工每天一条,用 employee_id + work_date 做唯一键
salaryid, employee_id, salary_month, base_salary, allowance, bonus, deduction, net_salary, status, version每个员工每月一条,用 employee_id + salary_month 做唯一键

design_dept 的 parent_id 自关联可以表达公司、部门、小组三级结构;employee 里不要直接存上级经理姓名,应该存 manager_id 指向 employee.id,这样汇报关系才能跟着组织调整走。考勤和薪资表都要有状态字段,考勤至少分正常、迟到、早退、缺卡、请假,薪资至少分草稿、已确认、已发放,否则报表里对不上账。

2.3 初始化 SQL 脚本:建库建表与关键参数说明

下面是建库和四张核心表的初始化 SQL,字段做了裁剪,但保留了最容易踩坑的参数设置。

CREATE DATABASE hr_system DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE hr_system; CREATE TABLE department ( id BIGINT AUTO_INCREMENT PRIMARY KEY, parent_id BIGINT NOT NULL DEFAULT 0, name VARCHAR(64) NOT NULL, manager_id BIGINT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_parent_id (parent_id) ) ENGINE = InnoDB COMMENT '部门表'; CREATE TABLE employee ( id BIGINT AUTO_INCREMENT PRIMARY KEY, dept_id BIGINT NOT NULL, name VARCHAR(32) NOT NULL, id_card VARCHAR(18) NULL, phone VARCHAR(20) NULL, hire_date DATE NULL, contract_end_date DATE NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1在职 2离职', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1删除', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_dept_id (dept_id), KEY idx_phone (phone), CONSTRAINT fk_emp_dept FOREIGN KEY (dept_id) REFERENCES department (id) ) ENGINE = InnoDB COMMENT '员工表'; CREATE TABLE attendance ( id BIGINT AUTO_INCREMENT PRIMARY KEY, employee_id BIGINT NOT NULL, work_date DATE NOT NULL, first_in_time DATETIME NULL, last_out_time DATETIME NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1迟到 2早退 3缺卡 4请假', overtime_hours DECIMAL(4,1) NOT NULL DEFAULT 0, UNIQUE KEY uk_emp_date (employee_id, work_date), CONSTRAINT fk_att_emp FOREIGN KEY (employee_id) REFERENCES employee (id) ) ENGINE = InnoDB COMMENT '考勤日表'; CREATE TABLE salary ( id BIGINT AUTO_INCREMENT PRIMARY KEY, employee_id BIGINT NOT NULL, salary_month CHAR(7) NOT NULL COMMENT '格式 2025-06', base_salary DECIMAL(10,2) NOT NULL DEFAULT 0, allowance DECIMAL(10,2) NOT NULL DEFAULT 0, bonus DECIMAL(10,2) NOT NULL DEFAULT 0, deduction DECIMAL(10,2) NOT NULL DEFAULT 0, net_salary DECIMAL(10,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已确认 2已发放', version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_emp_month (employee_id, salary_month), CONSTRAINT fk_sal_emp FOREIGN KEY (employee_id) REFERENCES employee (id) ) ENGINE = InnoDB COMMENT '薪资表';

这里有几个参数值得单独说。字符集用 utf8mb4 而不是 utf8,是因为员工姓名和备注里可能出现生僻字或 emoji,utf8 在 MySQL 里最多存三个字节,遇到四字节字符会直接报错。COLLATE 用 general_ci 还是 utf8mb4_unicode_ci 影响排序和中文等值比较,企业内部系统选 general_ci 已经够用,性能上差不了多少。DECIMAL 是给所有金额字段用的,这是人事系统里不能商量的事情,用 DOUBLE 存工资会在累计计算时出现 0.1+0.2 不等于 0.3 的问题。attendance 和 salary 的唯一键是防重复计算的第一道保险,后面谈数据一致性时还会再提到。

3. 把后端骨架搭起来:登录认证、员工档案与组织管理

3.1 Maven 工程结构与依赖:搭一个能启动的最小骨架

一个标准的 Spring Boot 工程,目录上先保持 maven 的约定:src/main/java、src/main/resources、src/main/java 下面按 controller、service、mapper、entity、config 分包。下面是我常用的 pom.xml 精简版,版本号不写死,交给父 pom 或 BOM 统一管理。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency> </dependencies>

Spring Boot 2.7.18 是 2.x 这条线里很稳的一个版本,JDK 8 到 17 都能跑;如果你们公司强制 JDK 17 以上,直接换 Spring Boot 3.x 也是同一套代码,只是 jakarta 包名需要同步改。POI 5.2.5 用于后面导出 Excel 和 Word,这个依赖会在第 4 章和第 6 章真正用到。jjwt 三个包拆开是 API、实现和 JSON 序列化,写登录模块时缺一个都会在运行期抛 ClassNotFoundException。

配置文件 application.yml 里我习惯把数据源参数分开写,方便排查现场问题:

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/hr_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

url 里的 serverTimezone 必须和数据库所在时区一致。国内服务器一般直接写 Asia/Shanghai,不写的话,LocalDateTime 存入 DATETIME 字段后查询出来可能相差 8 小时,这是人事系统里最常见的“数据不对”的玄学来源。allowPublicKeyRetrieval=true 是在 MySQL 8 使用 caching_sha2_password 认证时避免连接失败的常用参数,本地开发可以开,生产环境建议配合白名单使用。map-underscore-to-camel-case 打开后,数据库的 create_time 可以直接映射到实体的 createTime,不用每个字段写 @TableField 注解。

3.2 用 JWT 做登录认证:把“谁能看哪个部门”在接口层收敛

登录认证是人事系统所有接口的前置条件。常见做法是用 token 保存登录态,JWT 只是其中一种实现,好处是后端不用维护 session,坏处是 token 无法主动失效,所以密码修改后要强制 Redis 记录黑名单。人事系统中我更看重的是“行级权限”,也就是一个分公司的人事专员登录后,只能看到自己分公司和下级部门的员工,不能全表看。行级权限不是靠前端隐藏按钮,而是靠 SQL 条件。

下面是一个极简的登录接口,密码用 BCrypt 哈希校验,登录成功后生成 JWT,并把用户可见的部门范围放进 token 或缓存中。

@RestController @RequestMapping("/api/auth") public class AuthController { private final LoginService loginService; public AuthController(LoginService loginService) { this.loginService = loginService; } @PostMapping("/login") public Result login(@RequestBody LoginRequest req) { LoginResult result = loginService.login(req.getUsername(), req.getPassword()); return Result.success(result); } }
@Service public class LoginService { @Autowired private SysUserMapper userMapper; @Autowired private DeptScopeService deptScopeService; public LoginResult login(String username, String rawPassword) { SysUser user = userMapper.findByUsername(username); if (user == null) { throw new BusinessException("用户名或密码错误"); } if (!BCrypt.checkpw(rawPassword, user.getPasswordHash())) { throw new BusinessException("用户名或密码错误"); } // 查出该用户能看到哪些部门,生成权限范围 List<Long> deptScope = deptScopeService.getVisibleDeptIds(user.getRoleId()); String token = JwtUtil.createToken(user.getId(), user.getRoleCode(), deptScope); return new LoginResult(token, user.getUsername(), user.getRoleCode()); } }

逻辑说明:BCrypt.checkpw 是对明文密码做校验,用户表里永远不要存原始密码。token 里塞部门范围有个前提,就是部门调整后旧 token 可能带旧范围,所以我的习惯是 token 里只放 userId 和 roleId,每次请求时从 Redis 读一次权限范围,权限变更后能立刻生效。LoginRequest 和 LoginResult 是两个普通 DTO,这里省略了字段定义,实际项目里它们就是 getter/setter 的 POJO。

参数上有一个容易忽略的点:JWT 过期时间不要只设一天,人事系统里专员常常几天不退出登录,我一般设 7 天,并要求服务端每隔一天自动续签一次。续签接口要放到认证拦截器的白名单里,否则前端拿过期 token 换新 token 会被自己的过滤器拦死。

3.3 员工档案管理:Controller-Service-Mapper 三层实现

员工档案是人事系统的“主数据”,部门、考勤、薪资全都围绕它展开。下面是一个带行级权限的查询接口,核心在 Mapper XML 的查询条件上。

@RestController @RequestMapping("/api/employee") public class EmployeeController { @Autowired private EmployeeService employeeService; @GetMapping("/page") public Result page(@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String keyword, @RequestAttribute("deptScope") List<Long> deptScope) { PageResult<EmployeeVO> result = employeeService.page(pageNum, pageSize, keyword, deptScope); return Result.success(result); } }
<select id="page" resultType="com.example.hr.entity.EmployeeVO"> SELECT e.id, e.name, e.phone, e.hire_date, e.contract_end_date, d.name AS dept_name FROM employee e LEFT JOIN department d ON e.dept_id = d.id WHERE e.deleted = 0 AND e.dept_id IN <foreach collection="deptScope" item="deptId" open="(" separator="," close=")"> #{deptId} </foreach> <if test="keyword != null and keyword != ''"> AND (e.name LIKE CONCAT('%', #{keyword}, '%') OR e.phone LIKE CONCAT('%', #{keyword}, '%')) </if> ORDER BY e.hire_date DESC LIMIT #{offset}, #{pageSize} </select>

deptScope 是拦截器从当前登录人的权限范围里取出来放进请求属性的,SQL 里用 foreach 展开成 IN 条件,这不是可选条件,是强制条件。哪怕登录的人是超级管理员,deptScope 里也要显式放入所有部门 ID,避免代码分支里“管理员不走权限”这种写法留下遗漏。LIMIT #{offset}, #{pageSize} 是手动分页,offset 的计算在 service 层做:offset = (pageNum - 1) * pageSize。如果 list 用 PageHelper,最外层用 PageHelper.startPage(pageNum, pageSize),原理一样,只是帮你拼好了 limit。

Service 层我一般把更新和删除包进事务里。删除员工不能真的 DELETE,而是 update deleted=1,否则考勤表、薪资表的外键会大面积报错,历史报表也全部对不上。另一个坑是修改员工时如果用 BeanUtils.copyProperties 把整个 DTO 拷贝到实体上,包含的 List 子对象会被浅拷贝共享引用,改一个对象的子列表另一处也变了。对深层嵌套对象,要么自己写 clone,要么重新查库再赋值。这就是 java 面试里常问的“对象深度拷贝”在真实业务里的体现,不是八股文,是真会翻车。

4. 考勤与薪资:人事系统的业务核心,别做成单纯 CRUD

4.1 考勤的“日汇总—月统计”结构:为什么月度表必须单独建

很多新人会认为考勤只要一张表,每天往里插打卡记录就行。实际上人事专员要的不是流水,是“某人这个月迟到几次、缺卡几次、加班几小时”。如果把月统计结果靠程序临时算,每当考勤日期跨月、请假状态被修正、漏打卡补录时,报表就乱了。所以在 attendance 日表之上,我习惯再加一条 ATTENDANCE_MONTH 月统计表,字段包括 employee_id、month、late_count、early_leave_count、absent_days、overtime_hours,唯一键是 employee_id + month。

月汇总可以由日表定时聚合生成。下面 SQL 是一句典型生成逻辑:

INSERT INTO attendance_month ( employee_id, month, late_count, early_leave_count, absent_days, overtime_hours, update_time ) SELECT employee_id, DATE_FORMAT(work_date, '%Y-%m') AS month, SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS late_count, SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS early_leave_count, SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS absent_days, SUM(overtime_hours) AS overtime_hours, NOW() FROM attendance WHERE work_date BETWEEN '2025-06-01' AND '2025-06-30' GROUP BY employee_id, DATE_FORMAT(work_date, '%Y-%m') ON DUPLICATE KEY UPDATE late_count = VALUES(late_count), early_leave_count = VALUES(early_leave_count), absent_days = VALUES(absent_days), overtime_hours = VALUES(overtime_hours), update_time = NOW();

ON DUPLICATE KEY UPDATE 依赖 attendance_month 表上的唯一键 employee_id + month,所以重复执行这个 SQL 不会产生两条记录,只会刷新统计值。这就解决了“考勤补录后重新统计”的问题。要注意的一点是,这条 SQL 只适合跑当月数据,不能把三个月前的数据重新整体洗一遍,因为历史月份可能已确认,状态字段不在这个 SQL 里更新,确认过的月份应该走单独的重算接口。

4.2 薪资计算:BigDecimal、事务与幂等控制

薪资计算是人事系统里最不能含糊的模块。计算规则看起来简单:基本工资 + 补贴 + 奖金 - 扣款,但涉及的数值必须用 BigDecimal。java 的 double 和 float 在二进制里无法精确表示 0.1,累加几次后出现 8.999999 这种数字,工资条上就说不清。BigDecimal 也不允许直接 new BigDecimal(0.1),因为 0.1 的二进制近似值已经被算进去了,正确姿势是 BigDecimal.valueOf(0.1) 或 new BigDecimal("0.1")。

下面是薪资计算的 service 层核心片段:

@Service public class SalaryService { @Autowired private SalaryMapper salaryMapper; @Transactional(rollbackFor = Exception.class) public void calcOneMonth(String salaryMonth, Long employeeId) { // 1. 先查是否已经存在,存在且状态已确认则不允许覆盖 Salary salary = salaryMapper.findByEmployeeIdAndMonth(employeeId, salaryMonth); if (salary != null && salary.getStatus() >= 1) { throw new BusinessException("该月薪资已确认,不能重复计算"); } // 2. 查考勤月表,算出扣款和加班费 AttendanceMonth att = attendanceMonthMapper.findByEmployeeIdAndMonth(employeeId, salaryMonth); BigDecimal deduction = BigDecimal.ZERO; if (att != null) { deduction = att.getLateCount() == 0 ? BigDecimal.ZERO : BigDecimal.valueOf(att.getLateCount()).multiply(new BigDecimal("20")); } BigDecimal netSalary = BigDecimal.valueOf(6000) .add(BigDecimal.valueOf(500)) .subtract(deduction); // 3. 不存在则插入,存在但状态为草稿则更新 if (salary == null) { salary = new Salary(); salary.setEmployeeId(employeeId); salary.setSalaryMonth(salaryMonth); salary.setBaseSalary(BigDecimal.valueOf(6000)); salary.setAllowance(BigDecimal.valueOf(500)); salary.setDeduction(deduction); salary.setNetSalary(netSalary); salary.setStatus(0); salary.setVersion(0); salaryMapper.insert(salary); } else { salary.setDeduction(deduction); salary.setNetSalary(netSalary); salaryMapper.updateById(salary); } } }

@Transactional 保证“查重、计算、插入”三步要么全部成功,要么全部回滚。这里还有一个更隐蔽的问题:两个管理员同时在页面上点击“计算薪资”,事务只能保证数据库操作原子性,不能阻止两个请求读到同样状态后各自插入。所以在 salary 表里有唯一键 uk_emp_month 和 version 字段。唯一键保证重复插入时数据库直接报错,version 则用于更新时乐观锁:

UPDATE salary SET net_salary = #{netSalary}, version = version + 1 WHERE id = #{id} AND version = #{oldVersion}

如果更新返回行数为 0,说明有另外的请求已经改过这条薪资,当前请求要重新加载再算或者直接报错。salary 的 status 字段从草稿到已确认到已发放,每一步都往前走,不允许从“已发放”回退到“草稿”,这是防止“算错了重新覆盖”的最后一道闸。万不得已要更正,也要新建一条负数冲抵记录再另算一条,对账才有痕迹。

4.3 用 Java POI 把薪资数据导成 Excel:样式、字段类型和行数限制

人事系统里导出工资条是刚需,POI 是 java 生态里最常用的办公文档库。这里先讲 Excel 导出,因为工资条、考勤统计用 Excel 比 Word 更合适,Word 报表放到第 6 章讲。Excel 导出不要用 XSSFWorkbook 把全部数据写进内存,员工过万时很容易 OutOfMemory。常见做法是用 SXSSFWorkbook,它内部用临时文件落盘,只保留窗口期内可见的行。

@GetMapping("/export/salary") public void exportSalary(HttpServletResponse response, @RequestParam String salaryMonth) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment;filename=salary_" + salaryMonth + ".xlsx"); try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { Sheet 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 pageNum = 1; int pageSize = 1000; List<SalaryVO> list; do { list = salaryMapper.pageSalary(salaryMonth, pageNum, pageSize); int startRow = (pageNum - 1) * pageSize + 1; for (int i = 0; i < list.size(); i++) { Row row = sheet.createRow(startRow + i); SalaryVO vo = list.get(i); row.createCell(0).setCellValue(String.valueOf(vo.getEmployeeId())); row.createCell(1).setCellValue(vo.getEmployeeName()); row.createCell(2).setCellValue(vo.getBaseSalary().doubleValue()); row.createCell(3).setCellValue(vo.getAllowance().doubleValue()); row.createCell(4).setCellValue(vo.getBonus().doubleValue()); row.createCell(5).setCellValue(vo.getDeduction().doubleValue()); row.createCell(6).setCellValue(vo.getNetSalary().doubleValue()); } pageNum++; } while (list.size() == pageSize); workbook.write(response.getOutputStream()); } }

逻辑说明:SXSSFWorkbook 构造参数 100 表示内存中最多保留 100 行,超过部分写入磁盘临时文件,适合大数据量导出。这里分页查询是为了不让 List 一次装几万条,页面每翻一次查 1000 条,写完后继续查。金额字段在这里用 doubleValue() 转成数字,只是为了 Excel 单元格显示和求和方便;doubleValue 本身会有精度风险,但只是导出发送,不是再次用于计算,可以接受。如果连显示都不允许有 0.1 偏差,就把单元格设成字符串,用 vo.getBaseSalary().toPlainString() 写入,代价是不能直接在 Excel 里求和。

这一类导出最容易翻车的是长数字列,比如手机号、身份证号。它们如果按数值写入 Excel,会被自动转成科学计数法,手机号还会丢尾数。解决办法是把这些列统一用 row.createCell(i).setCellValue(vo.getPhone()) 字符串写入,千万别图省事转 Long。POI 导出的内容不是自己打开看一眼没问题就完事,还要关注 Excel 的“单元格格式”那一列到底存的是数值还是文本。

5. 避坑与常见问题排查:从部署到使用的 5 个血泪经验

5.1 日期时间慢 8 小时:页面显示与数据库不一致

现象:员工入职时间录的是 2025-06-01 09:00,数据库里存成 2025-06-01 01:00,前端页面查出来又变回 09:00。原因:MySQL 连接串没指定 serverTimezone,驱动和数据库会话时区各用一套。解决:在连接池 url 里固定加上 serverTimezone=Asia/Shanghai,并把数据库实例的 time_zone 也改掉,命令是 SET GLOBAL time_zone = '+08:00'。改了之后重启应用,再查历史数据如果仍偏移,说明写入时已经错了,只能对异常月份的数据做一次批量补偿。

5.2 Excel 导出手机号变成 1.39E+10 和身份证后四位变成 0000

现象:导出的员工通讯录里,手机号列显示 1.39E+10,身份证后四位全变成 0。原因:POI 把手机号当作 double 写入单元格,Excel 数值精度只有 15 位有效数字,而身份证是 18 位,超出的位数全被丢弃。解决:把这类长数字列全部用字符串写入,导出前统一格式化,用 cell.setCellValue(String.valueOf(vo.getIdCard()))。这是最容易复现也最尴尬的坑,通常发生在交付前一刻。

5.3 导出报表时内存溢出:几万员工一起查出来

现象:点击“导出全员通讯录”,页面卡死,后台日志出现 java.lang.OutOfMemoryError: Java heap space。原因:导出代码直接 select * from employee,再在循环里取 department 名称,几万条记录和嵌套对象全堆在 List 里。解决:分页读取并流式写入,代码见 4.3 的 SXSSFWorkbook 例子。另外把 JVM 堆内存设大一点只是治标,根本办法是“查一批、写一批、释放一批”,不要让导出接口和普通查询接口共用同一套大数据量方法。

5.4 删除部门或员工时外键报错:历史数据被物理删除

现象:删除一个已经离职三年的员工时,数据库抛 Cannot delete or update a parent row: a foreign key constraint fails。原因:员工表被考勤表、薪资表外键引用,物理删除员工等于把历史对账依据一并删了。解决:员工表加 deleted 字段,所有查询默认带上 deleted=0;离职走“状态改为离职”而不是删除;部门删除时先检查下是否存在在职员工,有就不允许删,只能停用。外键在业务上相当于一个强约束,反过来也要求代码里不能有绕过约束的物理删除路径。

5.5 薪资被重复发放:事务和唯一键缺一不可

现象:同一月份的薪资被算了两次,salary 表出现两条相同 employee_id 和 salary_month 的记录,或者一个管理员在计算时另一个管理员同步修改,最终工资条对不上。原因:@Transactional 只能保证单次事务内一致性,不能防止两个并发请求读到同样状态后一起插入。解决:数据库唯一键 uk_emp_month 守住插入,代码里的乐观锁版本字段 version 守住更新,业务代码里再增加状态判断,status 为非草稿时禁止重算。这三层少一层,最终都会以“这月工资多了 500”的形式出现在老板面前。这也是 java 面试里“怎么保证数据一致性”最接地气的答案:事务、唯一索引、乐观锁,按场景三层配合。

6. 最后一步:用导出的 Word 报表把“doc”落到实际业务里

6.1 模板填充还是代码生成:先分清楚再动手

很多标题带 .doc 的人事系统,最后都要交付一份像样的 Word 报表。常见做法有两种:一种是用 Word 做一个模板,在占位符处填充数据,适合“格式固定、每周都出”的员工花名册;另一种是全程用代码生成段落和表格,适合“表头和数据列不固定”的临时报表。实际项目里我做新人入职登记表时用模板,做全公司组织架构图时用代码生成,因为树形结构每层行数不可控。POI 的 XWPF 用来生成 .docx 是成熟的,但旧 .doc 格式的生成在 POI 里只有 HWPF,接口少、维护慢,所以真落地的项目里基本都是生成 docx,再由终端的 Word 打开另存为 .doc 交付,不要硬扛二进制后缀。

6.2 用 XWPF 生成员工信息 Word 报表的最小代码

下面是一个生成员工信息 Word 表格的最小示例,表头固定,数据行动态增加:

import org.apache.poi.xwpf.usermodel.*; try (XWPFDocument doc = new XWPFDocument(); FileOutputStream out = new FileOutputStream("employee_report.docx")) { // 标题段落 XWPFParagraph title = doc.createParagraph(); XWPFRun titleRun = title.createRun(); titleRun.setText("员工信息报表"); titleRun.setBold(true); titleRun.setFontSize(16); // 创建 1 行 4 列表格,表头 XWPFTable table = doc.createTable(1, 4); table.getRow(0).getCell(0).setText("姓名"); table.getRow(0).getCell(1).setText("部门"); table.getRow(0).getCell(2).setText("入职日期"); table.getRow(0).getCell(3).setText("合同到期日"); // 动态追加数据行 for (EmployeeVO vo : employeeList) { XWPFTableRow row = table.createRow(); row.getCell(0).setText(vo.getName()); row.getCell(1).setText(vo.getDeptName()); row.getCell(2).setText(String.valueOf(vo.getHireDate())); row.getCell(3).setText(String.valueOf(vo.getContractEndDate())); } doc.write(out); }

table.createRow() 会在表格末尾追加行,并自动补齐与表头一样的列数。这里 employeeList 建议控制在几百条以内,超过千行时 Word 表格本身的渲染会变慢,更合理的做法是按部门拆成多个 Word 文档或直接转 PDF 再合并。标题段落的 setBold 和 setFontSize 只作用于当前 run,如果之后又往同一段落加 run,样式要重新设一遍,这是新人最容易忽略的细节。

6.3 验证方法:别让导出的文档变成黑匣子

导出 Word 后不要只双击打开看格式,我自己的习惯是写一个简易校验方法,用 POI 重新读回生成的文件,断言标题段落存在、表格行数等于员工数加一。这样可以避免导出时静默截断、字符集错乱这类问题在交付现场才暴露。生成的 docx 本质是一个 zip 包,也可以用 unzip 打开后直接查看 word/document.xml 的文本内容,判断占位符是否被正确替换。这套系统的最后一段路,往往不是写业务代码,而是把结果文档的每个细节都验证一遍。希望这些从建表到导出的习惯能帮到你,至少让你在下次接到“java 企业人事管理系统”这类题目时,不用再从零开始踩一遍我当年踩过的坑。

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

返回列表