简介:这份资源是面向计算机专业学生与Java Web初学者的人事管理系统毕业设计文档,围绕企业人事部门日常的档案、薪酬、绩效等信息管理需求,给出从系统分析到详细设计的完整实现思路。压缩包内共1个doc文件,约48KB,内容涵盖绪论、系统分析、总体设计与详细设计等章节,并配有功能模块划分、系统模块设计图与实体属性图等图示说明。文档以Java语言为基础,依托MyEclipse平台,结合JSP技术与SqlServer2008数据库展开论述,重点讲解人员档案管理、培训管理、职称评定管理、奖惩管理、人员调动等模块的设计方法,同时涉及系统功能要求、可行性分析与非功能性需求等分析环节。目前已有80人浏览学习,适合需要参考同类课题结构、撰写开题报告或课程设计的学生,也可为准备搭建基础人事管理系统的开发者提供模块划分与数据库设计的思路借鉴。
1. 从一份 .doc 需求文档到能跑的人事系统:Java 企业级项目落地全流程
很多做 Java 后端的同学都遇到过这种场景:产品或者行政丢过来一份 Word 文档,标题写着「企业人事管理系统需求说明」,里面是几十条功能点,从员工档案、考勤、薪资到组织架构,密密麻麻。你打开一看,需求是中文的,但怎么把它变成一套能跑起来、能演示、能交付的 Java 系统,文档里一个字没提。这份「基于 Java 企业人事管理系统.doc」本质上就是这类需求文档的典型代表,它描述的是业务,不是技术方案。真正要落地,需要你自己把文档里的业务语言翻译成数据库表、Spring Boot 接口、权限模型和前端页面。这篇文章面向的是拿到类似需求文档、需要独立完成一套企业人事管理系统的 Java 开发者,不管你是要交课程设计、做公司内部工具,还是准备面试时拿一个完整项目练手,下面这套从需求拆解到部署上线的路径都能直接照着走。我会把选型理由、建表脚本、核心接口代码、参数配置和踩过的坑都摊开讲,新手能跟步骤复现,熟手能直接拿去改。
2. 需求文档怎么拆成数据库表和接口:先定边界再动手
2.1 从 .doc 里提取实体和关系的实操方法
拿到一份人事管理系统的需求文档,第一件事不是打开 IDEA 建工程,而是拿一张纸或者一个在线表格,把文档里出现的名词全部圈出来。员工、部门、职位、考勤记录、请假单、薪资条、社保、合同、培训记录,这些名词大概率就是你要建的实体。动词则是你要暴露的接口,比如「录入员工信息」「审批请假」「计算月度薪资」「导出考勤报表」。
我一般会按下面这个顺序做拆解:
- 通读文档,把所有业务名词列成一张清单,不要漏掉任何出现过的名词。
- 对每个名词判断它是不是独立实体。比如「员工姓名」不是实体,它属于员工这个实体;「部门名称」属于部门实体。
- 确定实体之间的关系。一个部门有多个员工,一个员工属于一个部门,这是典型的一对多。一个员工可以有多条考勤记录,也是一对多。请假单和审批人之间可能是多对一。
- 把每个实体的关键属性列出来,标注哪些是必填、哪些是可选、哪些需要唯一约束。
- 最后再回头看文档里的动词,每个动词对应一个或一组接口。
这个过程不需要任何工具,但一定要在写代码之前完成。我见过太多人直接打开 Navicat 就开始建表,建到一半发现部门层级没设计好,又回头改,改完发现员工表的外键要跟着动,连锁反应能把人逼疯。
2.2 人事系统核心表结构设计与建表 SQL
下面这套表结构是我在多个中小型企业人事系统里反复用过的版本,覆盖了员工、部门、职位、考勤、请假、薪资六个核心模块。字段类型以 MySQL 8.0 为准,其他数据库自行调整。
-- 部门表:支持树形结构,parent_id 为 0 表示顶级部门 CREATE TABLE `sys_department` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `parent_id` BIGINT NOT NULL DEFAULT 0 COMMENT '父部门ID,0为顶级', `dept_name` VARCHAR(64) NOT NULL COMMENT '部门名称', `dept_code` VARCHAR(32) NOT NULL COMMENT '部门编码,唯一', `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_dept_code` (`dept_code`), KEY `idx_parent_id` (`parent_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表'; -- 职位表:和部门关联,一个部门有多个职位 CREATE TABLE `sys_position` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `dept_id` BIGINT NOT NULL COMMENT '所属部门ID', `position_name` VARCHAR(64) NOT NULL COMMENT '职位名称', `position_level` TINYINT NOT NULL DEFAULT 1 COMMENT '职级:1初级 2中级 3高级 4管理', `base_salary` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '基础薪资', `status` TINYINT NOT NULL DEFAULT 1, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_dept_id` (`dept_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='职位表'; -- 员工表:核心表,关联部门和职位 CREATE TABLE `sys_employee` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `emp_no` VARCHAR(32) NOT NULL COMMENT '工号,唯一', `real_name` VARCHAR(64) NOT NULL COMMENT '真实姓名', `gender` TINYINT NOT NULL DEFAULT 1 COMMENT '性别:1男 2女', `birth_date` DATE DEFAULT NULL COMMENT '出生日期', `id_card` VARCHAR(18) DEFAULT NULL COMMENT '身份证号', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', `email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱', `dept_id` BIGINT NOT NULL COMMENT '部门ID', `position_id` BIGINT NOT NULL COMMENT '职位ID', `hire_date` DATE NOT NULL COMMENT '入职日期', `leave_date` DATE DEFAULT NULL COMMENT '离职日期', `emp_status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1在职 2试用 3离职', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_emp_no` (`emp_no`), KEY `idx_dept_id` (`dept_id`), KEY `idx_position_id` (`position_id`), KEY `idx_emp_status` (`emp_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工表'; -- 考勤记录表:每天每人一条 CREATE TABLE `att_attendance` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `emp_id` BIGINT NOT NULL COMMENT '员工ID', `attend_date` DATE NOT NULL COMMENT '考勤日期', `check_in_time` DATETIME DEFAULT NULL COMMENT '上班打卡时间', `check_out_time` DATETIME DEFAULT NULL COMMENT '下班打卡时间', `attend_status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 2迟到 3早退 4缺勤 5请假', `work_hours` DECIMAL(4,1) DEFAULT NULL COMMENT '工作时长', `remark` VARCHAR(255) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_emp_date` (`emp_id`, `attend_date`), KEY `idx_attend_date` (`attend_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤记录表'; -- 请假单表 CREATE TABLE `att_leave` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `emp_id` BIGINT NOT NULL COMMENT '申请人ID', `leave_type` TINYINT NOT NULL COMMENT '1事假 2病假 3年假 4调休', `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `leave_days` DECIMAL(4,1) NOT NULL COMMENT '请假天数', `reason` VARCHAR(500) DEFAULT NULL, `approve_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审批 1通过 2驳回', `approver_id` BIGINT DEFAULT NULL COMMENT '审批人ID', `approve_time` DATETIME DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_emp_id` (`emp_id`), KEY `idx_approve_status` (`approve_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='请假单表'; -- 薪资记录表:按月生成 CREATE TABLE `sal_salary` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `emp_id` BIGINT NOT NULL, `salary_month` VARCHAR(7) NOT NULL COMMENT '薪资月份,格式2026-01', `base_salary` DECIMAL(12,2) NOT NULL DEFAULT 0.00, `performance_salary` DECIMAL(12,2) NOT NULL DEFAULT 0.00, `deduction` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '扣款', `social_security` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '社保个人部分', `actual_salary` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '实发薪资', `pay_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0未发放 1已发放', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_emp_month` (`emp_id`, `salary_month`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='薪资记录表';这套表结构有几个设计点需要说明。部门表用parent_id实现树形结构,查询某个部门及其所有子部门的员工时,要么用递归 CTE,要么在应用层做递归组装,MySQL 8.0 支持WITH RECURSIVE,可以直接在 SQL 里搞定。员工表的emp_status字段区分在职、试用、离职,离职员工不删除记录,只改状态,这样历史考勤和薪资数据不会断链。考勤表的uk_emp_date唯一索引保证一个人一天只有一条考勤记录,避免重复打卡导致数据混乱。薪资表的uk_emp_month同理,一个人一个月只有一条薪资记录。
2.3 Spring Boot 项目分层与接口定义
表建好之后,项目结构按经典的三层架构来搭:Controller 层负责接收请求和参数校验,Service 层写业务逻辑,Mapper 层做数据库操作。用 Spring Boot + MyBatis-Plus 的组合,省去大量手写 CRUD 的时间。
// EmployeeController.java @RestController @RequestMapping("/api/employee") @Validated public class EmployeeController { @Autowired private EmployeeService employeeService; // 分页查询员工列表,支持按部门、状态、关键字筛选 @GetMapping("/page") public Result<Page<EmployeeVO>> page( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) Long deptId, @RequestParam(required = false) Integer empStatus, @RequestParam(required = false) String keyword) { return Result.ok(employeeService.pageQuery(pageNum, pageSize, deptId, empStatus, keyword)); } // 新增员工 @PostMapping("/add") public Result<Long> add(@RequestBody @Valid EmployeeAddDTO dto) { return Result.ok(employeeService.addEmployee(dto)); } // 修改员工信息 @PutMapping("/update") public Result<Void> update(@RequestBody @Valid EmployeeUpdateDTO dto) { employeeService.updateEmployee(dto); return Result.ok(); } // 员工离职:只改状态,不删记录 @PutMapping("/leave/{empId}") public Result<Void> leave(@PathVariable Long empId, @RequestParam @NotBlank String leaveDate) { employeeService.leaveEmployee(empId, leaveDate); return Result.ok(); } }Controller 层只做参数接收和返回封装,不写业务逻辑。@Validated和@Valid配合 DTO 上的注解做参数校验,比如@NotBlank、@NotNull、@Pattern这些,能在进入 Service 之前就把非法参数拦掉。返回结果统一用Result<T>包装,前端拿到code、message、data三个字段,处理起来一致。
Service 层的核心方法是pageQuery和addEmployee。pageQuery用 MyBatis-Plus 的LambdaQueryWrapper拼条件,关键字搜索对real_name和emp_no做like匹配。addEmployee需要做几件事:校验工号是否重复、校验部门是否存在、插入员工记录、如果开启了试用期还要自动计算转正日期。这些逻辑写在 Service 里,Controller 不感知。
@Service public class EmployeeServiceImpl implements EmployeeService { @Autowired private EmployeeMapper employeeMapper; @Autowired private DepartmentMapper departmentMapper; @Override public Page<EmployeeVO> pageQuery(Integer pageNum, Integer pageSize, Long deptId, Integer empStatus, String keyword) { LambdaQueryWrapper<Employee> wrapper = new LambdaQueryWrapper<>(); if (deptId != null) { wrapper.eq(Employee::getDeptId, deptId); } if (empStatus != null) { wrapper.eq(Employee::getEmpStatus, empStatus); } if (StringUtils.hasText(keyword)) { wrapper.and(w -> w.like(Employee::getRealName, keyword) .or().like(Employee::getEmpNo, keyword)); } wrapper.orderByDesc(Employee::getCreateTime); Page<Employee> page = employeeMapper.selectPage(new Page<>(pageNum, pageSize), wrapper); return page.convert(this::toVO); } @Override @Transactional(rollbackFor = Exception.class) public Long addEmployee(EmployeeAddDTO dto) { // 工号唯一性校验 Long count = employeeMapper.selectCount( new LambdaQueryWrapper<Employee>().eq(Employee::getEmpNo, dto.getEmpNo())); if (count > 0) { throw new BizException("工号已存在"); } // 部门存在性校验 Department dept = departmentMapper.selectById(dto.getDeptId()); if (dept == null) { throw new BizException("部门不存在"); } Employee employee = new Employee(); BeanUtils.copyProperties(dto, employee); employee.setEmpStatus(1); employeeMapper.insert(employee); return employee.getId(); } }@Transactional(rollbackFor = Exception.class)保证异常时回滚,rollbackFor指定Exception而不是默认的RuntimeException,是因为业务里可能抛受检异常,不指定的话不会回滚。工号唯一性校验放在插入之前,但高并发下仍可能重复,所以数据库层面uk_emp_no唯一索引是最后一道防线,捕获DuplicateKeyException再转成友好提示。
3. 权限控制与数据隔离:人事系统最容易翻车的地方
3.1 基于角色的接口权限设计
人事系统的权限比普通业务系统复杂,因为不同角色能看到的数据范围完全不同。普通员工只能看自己的考勤和薪资,部门主管能看本部门所有员工的数据,HR 能看全公司,系统管理员能改配置但不能看薪资明细。这种「功能权限 + 数据权限」的双层控制,是人事系统最容易出问题的地方。
功能权限用 Spring Security 或者 Sa-Token 都能做,核心是给每个接口打上权限标识,用户登录后查出他拥有的权限集合,请求进来时比对。下面是一个基于注解的权限校验示例:
// 自定义权限注解 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequirePermission { String value(); // 权限标识,如 employee:view, salary:edit } // AOP 切面校验权限 @Aspect @Component public class PermissionAspect { @Autowired private PermissionService permissionService; @Around("@annotation(requirePermission)") public Object check(ProceedingJoinPoint joinPoint, RequirePermission requirePermission) throws Throwable { Long userId = UserContext.getCurrentUserId(); if (userId == null) { throw new BizException("未登录"); } boolean has = permissionService.hasPermission(userId, requirePermission.value()); if (!has) { throw new BizException("无权限访问"); } return joinPoint.proceed(); } }接口上直接标注@RequirePermission("salary:view"),切面在方法执行前校验。权限数据存在sys_permission表里,角色和权限多对多,用户和角色多对多,这是标准的 RBAC 模型。
3.2 行级数据权限:让主管只能看本部门数据
功能权限解决「能不能访问这个接口」,行级权限解决「能看哪些数据」。人事系统里,部门主管调/api/employee/page时,不应该看到其他部门的员工。实现方式是在 Service 层拼查询条件时,根据当前用户的角色动态追加dept_id过滤。
// 数据权限拦截器:在 MyBatis 查询前追加部门条件 @Component public class DataScopeInterceptor implements InnerInterceptor { @Override public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { // 从当前登录用户获取数据权限范围 DataScope dataScope = UserContext.getDataScope(); if (dataScope == null || dataScope.isAll()) { return; // 全公司权限,不加限制 } // 获取原始 SQL,追加 dept_id 条件 String originalSql = boundSql.getSql(); String newSql = "SELECT * FROM (" + originalSql + ") tmp WHERE tmp.dept_id IN (" + dataScope.getDeptIds() + ")"; // 通过反射修改 BoundSql 的 sql 字段 ReflectUtil.setFieldValue(boundSql, "sql", newSql); } }这段代码用 MyBatis-Plus 的InnerInterceptor在 SQL 执行前改写语句,把原始查询包一层子查询,外层加上dept_id IN (...)的条件。UserContext里存的是当前登录用户的角色和数据范围,HR 角色isAll()返回 true 不加限制,主管角色返回他管理的部门 ID 列表。
注意:这种 SQL 改写方式对复杂查询(比如带 UNION、子查询、GROUP BY 的语句)可能出问题,包一层子查询后外层再过滤,性能会下降。更稳妥的做法是在 Service 层显式拼条件,而不是在拦截器里改 SQL。我一般只在简单列表查询上用拦截器,复杂报表查询手动控制。
3.3 登录认证与密码安全
登录接口用 Spring Security 的AuthenticationManager或者自己写一个简单的 Token 方案。密码存储必须用 BCrypt,绝对不能在数据库里存明文或者 MD5。BCrypt 每次加密结果不同,但校验时能匹配上,这是它的设计特性。
// 密码加密与校验 @Configuration public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(10); } } // 登录逻辑 public String login(String username, String rawPassword) { SysUser user = userMapper.selectByUsername(username); if (user == null) { throw new BizException("用户名或密码错误"); } if (!passwordEncoder.matches(rawPassword, user.getPassword())) { throw new BizException("用户名或密码错误"); } // 生成 Token,存入 Redis,设置过期时间 String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set("token:" + token, user.getId(), 2, TimeUnit.HOURS); return token; }BCryptPasswordEncoder(10)里的 10 是强度参数,值越大加密越慢,10 是性能和安全的平衡点。Token 存 Redis 并设置 2 小时过期,前端每次请求带在 Header 里,后端拦截器校验 Redis 里是否存在。用户登出时删除 Redis 里的 Token 即可。
4. 考勤与薪资计算:业务逻辑最密集的两个模块
4.1 考勤打卡记录的处理与异常判定
考勤模块的核心是每天定时跑批,把打卡记录和排班规则比对,判定正常、迟到、早退、缺勤。假设公司规定 9:00 上班、18:00 下班,打卡时间在 9:00 之后算迟到,18:00 之前算早退。
// 考勤判定逻辑 public void judgeAttendance(Long empId, LocalDate date) { Attendance att = attendanceMapper.selectByEmpAndDate(empId, date); if (att == null || att.getCheckInTime() == null) { // 没有打卡记录,判定缺勤 saveOrUpdateStatus(empId, date, 4); return; } LocalTime standardIn = LocalTime.of(9, 0); LocalTime standardOut = LocalTime.of(18, 0); LocalTime actualIn = att.getCheckInTime().toLocalTime(); LocalTime actualOut = att.getCheckOutTime() != null ? att.getCheckOutTime().toLocalTime() : null; int status = 1; // 默认正常 if (actualIn.isAfter(standardIn)) { status = 2; // 迟到 } if (actualOut != null && actualOut.isBefore(standardOut)) { status = 3; // 早退 } // 工作时长计算 if (att.getCheckInTime() != null && att.getCheckOutTime() != null) { long minutes = Duration.between(att.getCheckInTime(), att.getCheckOutTime()).toMinutes(); att.setWorkHours(BigDecimal.valueOf(minutes).divide(BigDecimal.valueOf(60), 1, RoundingMode.HALF_UP)); } att.setAttendStatus(status); attendanceMapper.updateById(att); }这段逻辑每天凌晨跑一次,遍历前一天所有在职员工。实际项目中还要考虑弹性工作制、加班、外勤打卡等情况,但核心判定逻辑就是时间比对。work_hours用BigDecimal保留一位小数,避免浮点精度问题。
4.2 月度薪资计算:从考勤和请假数据汇总
薪资计算是人事系统里逻辑最复杂的部分,因为它依赖考勤、请假、社保、个税等多个数据源。我一般把薪资计算拆成三步:先算应发,再算扣款,最后算实发。
// 月度薪资计算 @Transactional(rollbackFor = Exception.class) public void calculateSalary(String salaryMonth) { // 1. 查出当月所有在职员工 List<Employee> employees = employeeMapper.selectList( new LambdaQueryWrapper<Employee>().in(Employee::getEmpStatus, 1, 2)); for (Employee emp : employees) { // 2. 获取基础薪资和绩效薪资 Position position = positionMapper.selectById(emp.getPositionId()); BigDecimal baseSalary = position.getBaseSalary(); BigDecimal performance = getPerformanceSalary(emp.getId(), salaryMonth); // 3. 统计考勤扣款:迟到早退各扣50,缺勤扣日薪 BigDecimal attendanceDeduction = calcAttendanceDeduction(emp.getId(), salaryMonth); // 4. 统计请假扣款:事假扣日薪,病假扣50%日薪 BigDecimal leaveDeduction = calcLeaveDeduction(emp.getId(), salaryMonth); // 5. 社保个人部分(简化按固定比例) BigDecimal socialSecurity = baseSalary.multiply(new BigDecimal("0.105")) .setScale(2, RoundingMode.HALF_UP); // 6. 实发 = 基础 + 绩效 - 考勤扣款 - 请假扣款 - 社保 BigDecimal actual = baseSalary.add(performance) .subtract(attendanceDeduction) .subtract(leaveDeduction) .subtract(socialSecurity); // 7. 写入薪资表 Salary salary = new Salary(); salary.setEmpId(emp.getId()); salary.setSalaryMonth(salaryMonth); salary.setBaseSalary(baseSalary); salary.setPerformanceSalary(performance); salary.setDeduction(attendanceDeduction.add(leaveDeduction)); salary.setSocialSecurity(socialSecurity); salary.setActualSalary(actual); salaryMapper.insert(salary); } }日薪的计算方式是baseSalary / 21.75,21.75 是法定月计薪天数。事假扣一天日薪,病假扣半天。迟到早退每次扣 50 元,缺勤扣一天日薪。这些规则每个公司不同,实际项目中应该做成可配置的,存在sys_config表里,而不是硬编码。
提示:薪资计算涉及金额,所有
BigDecimal运算必须指定精度和舍入模式,setScale(2, RoundingMode.HALF_UP)是标准做法。不要用double做金额计算,浮点误差会导致对不上账。
4.3 定时任务与批量处理
考勤判定和薪资计算都是定时任务,用 Spring 的@Scheduled或者 Quartz 都能做。@Scheduled简单够用,Quartz 适合需要动态调整执行时间的场景。
@Component public class ScheduleTask { @Autowired private AttendanceService attendanceService; @Autowired private SalaryService salaryService; // 每天凌晨1点跑前一天考勤判定 @Scheduled(cron = "0 0 1 * * ?") public void dailyAttendance() { LocalDate yesterday = LocalDate.now().minusDays(1); attendanceService.judgeAll(yesterday); } // 每月1号凌晨2点算上个月薪资 @Scheduled(cron = "0 0 2 1 * ?") public void monthlySalary() { String lastMonth = YearMonth.now().minusMonths(1).toString(); salaryService.calculateSalary(lastMonth); } }cron表达式0 0 1 * * ?表示每天 1:00:00 执行,0 0 2 1 * ?表示每月 1 号 2:00:00 执行。批量处理时要注意事务边界,如果一次处理几千条数据,不要把所有操作放在一个大事务里,分批提交,每批 500 条,避免长事务锁表。
5. 部署上线与常见问题排查
5.1 从本地到服务器的部署清单
项目开发完,部署到服务器上需要准备这些东西:JDK 17、MySQL 8.0、Redis、Nginx。Spring Boot 打成 jar 包,用nohup java -jar启动,或者用 systemd 做成服务。
# 打包 mvn clean package -DskipTests # 启动 nohup java -jar hr-system.jar \ --spring.profiles.active=prod \ --server.port=8080 \ > /var/log/hr-system.log 2>&1 & # 查看启动日志 tail -f /var/log/hr-system.log--spring.profiles.active=prod指定生产环境配置,数据库连接、Redis 地址这些敏感信息放在application-prod.yml里,不要提交到代码仓库。Nginx 做反向代理,把 80 端口的请求转发到 8080,同时配置静态资源缓存。
5.2 人事系统上线后最容易踩的五个坑
坑一:员工工号重复插入导致 500 错误。现象是前端提交新增员工时偶尔报「系统异常」,日志里是DuplicateKeyException。原因是并发请求下两个线程同时通过了工号唯一性校验,都执行了 insert。解决方式是在 Service 层捕获DuplicateKeyException,转成「工号已存在」的友好提示,同时数据库唯一索引必须保留。
坑二:考勤定时任务跑完后数据对不上。现象是某些员工明明打了卡却显示缺勤。原因是打卡记录的时间字段有时区问题,服务器用 UTC 时间,数据库存的是本地时间,比对时差了 8 小时。解决方式是在 JDBC 连接串里加serverTimezone=Asia/Shanghai,所有时间字段统一用LocalDateTime,不要用Date。
坑三:薪资计算重复执行导致数据翻倍。现象是某个月薪资表里同一个员工有两条记录。原因是定时任务被触发了两次,或者手动执行时没有做幂等。解决方式是在sal_salary表的uk_emp_month唯一索引基础上,计算前先删除当月已有记录,或者用INSERT ... ON DUPLICATE KEY UPDATE。
坑四:部门主管能看到其他部门数据。现象是行级权限拦截器没生效。原因是UserContext里的数据范围在异步线程里丢失了,ThreadLocal没有传递到子线程。解决方式是用TransmittableThreadLocal替代ThreadLocal,或者在异步任务里手动设置上下文。
坑五:导出 Excel 时内存溢出。现象是导出全公司几千条考勤记录时服务卡死。原因是用了 POI 的XSSFWorkbook把整个工作簿加载到内存。解决方式是换成 EasyExcel 的流式导出,或者分页查询、分批写入。
6. 把 .doc 变成可演示项目的最后一步:接口文档与联调技巧
项目能跑起来之后,最后一步是让前端或者测试能顺利联调。我一般用 Knife4j 或者 SpringDoc 自动生成接口文档,省去手写文档的时间。在pom.xml里加依赖,启动类上加注解,所有 Controller 的接口就会自动出现在/doc.html页面上。
<!-- Knife4j 依赖 --> <dependency> <groupId>com.github.xiaoymin</groupId> <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId> <version>4.4.0</version> </dependency>@Configuration public class Knife4jConfig { @Bean public OpenAPI openAPI() { return new OpenAPI() .info(new Info() .title("企业人事管理系统接口文档") .version("1.0") .description("员工、考勤、请假、薪资模块接口")); } }联调时最容易扯皮的是参数格式。日期用yyyy-MM-dd还是时间戳,金额用字符串还是数字,分页从 0 开始还是从 1 开始,这些必须在接口文档里写清楚。我的习惯是在 DTO 的字段上加@Schema(description = "入职日期,格式yyyy-MM-dd"),前端一看就明白。
还有一个技巧是准备一套测试数据。写一个data.sql,插入几个部门、十几个员工、一个月的考勤和薪资记录,每次重建数据库后自动执行。这样前端联调时不用手动造数据,直接就能看到效果。
-- data.sql 测试数据示例 INSERT INTO sys_department (parent_id, dept_name, dept_code, sort_order, status) VALUES (0, '技术部', 'TECH', 1, 1), (0, '人事部', 'HR', 2, 1), (1, '后端组', 'TECH_BACKEND', 1, 1); INSERT INTO sys_position (dept_id, position_name, position_level, base_salary, status) VALUES (1, 'Java开发工程师', 2, 15000.00, 1), (1, '前端开发工程师', 2, 13000.00, 1), (2, 'HR专员', 1, 8000.00, 1); INSERT INTO sys_employee (emp_no, real_name, gender, dept_id, position_id, hire_date, emp_status) VALUES ('E001', '张三', 1, 1, 1, '2024-03-01', 1), ('E002', '李四', 2, 1, 2, '2024-05-15', 1), ('E003', '王五', 1, 2, 3, '2023-11-20', 1);这套测试数据配合spring.sql.init.mode=always配置,每次启动都会执行,开发阶段非常方便。生产环境记得关掉这个配置,改成never。
最后说一个我自己的习惯:每次改完代码,不管多小的改动,都先跑一遍完整的流程——登录、查员工列表、新增员工、审批请假、算薪资、导出报表。这六个动作走通,基本能覆盖 90% 的回归场景。人事系统的业务逻辑不复杂,但数据关联多,一个字段改错可能影响好几个模块。我在这上面翻过车,改完员工表结构忘了同步改薪资计算的 SQL,结果算出来的薪资全是错的,被财务追着问了一下午。从那以后,改表结构必查所有引用该表的 SQL,这个习惯帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取