尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot人事管理系统:数据模型、薪资批算与并发控制

Spring Boot人事管理系统:数据模型、薪资批算与并发控制 简介本资源为一份基于Java的人事管理系统设计与实现毕业论文文档面向高校计算机相关专业的本科生、课程设计者及需要参考完整开发流程的初学者。文档围绕工作人员的统一管理展开涵盖录入、查询、删除、修改等核心操作并采用Java语言配合Microsoft Access数据库完成开发。压缩包共包含1个doc文件约982KB内容为完整的论文正文含绪论、需求分析、系统设计、数据库设计、详细设计、系统调试与总结等章节涉及Java语言特性、MyEclipse开发环境、功能结构图、E-R图以及登录界面、基础信息管理、人员调动、人员考核、劳资管理等模块的具体实现描述可作为论文写作与系统开发的参照模板。目前已有681人学习下载适合用于梳理从需求分析到测试上线的整体思路并借鉴其模块划分与数据库设计方法。1. 为什么一份人事管理系统需求最后都会落到 Java 后端很多团队接人事管理系统的活第一反应是先画十几个页面等页面画完才发现员工、组织、岗位、合同、薪资之间的关系根本理不清。人事管理系统真正的难点不在界面而在于「同一个人在不同时间点属于不同部门、拿不同薪资、走不同审批流」这种带时间维度的数据一致性才是决定系统能不能上线的分水岭。Java 生态在这类场景里的家底足够厚Spring Boot 负责装配MyBatis 或 JPA 负责持久化MySQL 存主数据Redis 扛会话和字典缓存JDK 自带的LocalDate、BigDecimal、CompletableFuture又刚好覆盖日期、金额和批量计算这三个高频痛点。对刚补完 java 基础的同学这套组合是能照着跑通的对写了几年业务的后端边界在哪、坑在哪也都有迹可循。下面按数据模型、核心模块、并发与一致性、上线验证四段推进每段都给能直接抄的代码和参数。2. 人事管理系统的数据模型怎么定员工主档、组织树与任职记录模型定错了后面写多少 CRUD 都是白干。人事系统最常见的返工是把「人」和「岗位」直接绑死在一张表里结果员工一调岗历史薪资和考勤全跟着变。先把概念拆开再落表结构。2.1 先分清「人」「岗」「任职」三张概念表把员工person当成一个自然人主档工号、姓名、证件、入职日期这些属性一辈子基本不变岗位position是组织里的编制位任职记录employment才是把人和岗位连起来的那根线并且带起止时间。三者拆开之后「张三 2023 年在研发部做后端、2024 年调到架构组」就是两条任职记录而不是一次覆盖写。表名承载什么关键字段是否随时间变化person自然人主档emp_no、name、id_card_hash、hire_date、status基本不变department组织节点id、name、parent_id、path、leader_id会调整position岗位编制id、dept_id、title、grade会调整employment任职关系person_id、dept_id、position_id、start_date、end_date核心时间维度salary_record薪资结果person_id、month、gross、net、batch_id按月固化这张表里最关键的一条经验是任何「会随时间变化的关系」都不要写成主档上的一个字段否则历史数据永远对不上账。2.2 员工主档与组织树的建表 SQL下面这段是在 MySQL 8 上能直接执行的最小结构字符集统一 utf8mb4金额字段一律DECIMAL绝不用FLOAT。CREATE TABLE person ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, emp_no VARCHAR(32) NOT NULL COMMENT 工号全局唯一, name VARCHAR(64) NOT NULL COMMENT 姓名, id_card_hash CHAR(64) NOT NULL COMMENT 证件号哈希避免明文落库, hire_date DATE NOT NULL COMMENT 首次入职日期, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在职 2离职 3待入职, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除标记, 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_hire_date (hire_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT员工主档; CREATE TABLE employment ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, person_id BIGINT UNSIGNED NOT NULL, dept_id BIGINT UNSIGNED NOT NULL, position_id BIGINT UNSIGNED NOT NULL, start_date DATE NOT NULL COMMENT 任职生效日, end_date DATE NOT NULL DEFAULT 9999-12-31 COMMENT 失效日用极大值代替 NULL, primary_flag TINYINT NOT NULL DEFAULT 1 COMMENT 1主职 0兼职, PRIMARY KEY (id), KEY idx_person_period (person_id, start_date, end_date), KEY idx_dept (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT任职记录;end_date不给 NULL 而给9999-12-31是为了让区间查询统一写成start_date d AND end_date d不用到处判空BETWEEN也用不了 NULL。id_card_hash存哈希而不是明文是合规底线真要做校验再加一层加盐。2.3 组织树查询邻接表 递归 CTE 够不够用组织树 90% 的场景只有两种查询查某个部门的所有下级、查某人所在部门的完整路径。邻接表只存parent_id配合 MySQL 8 的递归 CTE 就能扛住千级节点。2.3.1 递归 CTE 的标准写法WITH RECURSIVE dept_tree AS ( SELECT id, name, parent_id, 1 AS lvl FROM department WHERE parent_id 0 -- 根节点按实际约定填 UNION ALL SELECT d.id, d.name, d.parent_id, t.lvl 1 FROM department d JOIN dept_tree t ON d.parent_id t.id ) SELECT id, name, lvl FROM dept_tree ORDER BY lvl, id;lvl是层级方便前端渲染缩进UNION ALL不能写UNION否则会去重排序几千节点时差距很明显。注意 MySQL 默认cte_max_recursion_depth是 1000组织层级一般不超过 10但如果你的代码里出现了自引用死循环比如 A 的父节点是 B、B 的父节点是 A这条 SQL 会一直递归到报错所以维护部门时必须在服务层校验「新父节点不能是自己的子孙」。2.3.2 什么时候该换成闭包表或路径枚举如果需求里出现「按层级统计人数、按任意祖先筛选」递归 CTE 就有点吃力了这时候加一张闭包表dept_closure(ancestor_id, descendant_id, depth)插入时同步维护查询变成一次普通 JOIN。取舍参考下表。方案查询上级查询下级移动部门适用规模邻接表递归 CTE递归 CTE只改一行千级节点路径枚举pathLIKE a/b/%LIKE前缀批量改子节点 path百级节点闭包表一次 JOIN一次 JOIN删插若干行万级节点、读多写少多数人事系统停在邻接表就够了别为了「看起来专业」提前上闭包表维护成本会翻倍。2.4 任职记录为什么要带生效区间薪资和考勤都是按月跑批的跑批时必须能回答「这个月这个人属于哪个部门、哪个岗位」。有了start_date和end_date查询就是一句SELECT e.person_id, e.dept_id, e.position_id FROM employment e WHERE e.person_id #{personId} AND e.start_date #{monthEnd} AND e.end_date #{monthStart} ORDER BY e.primary_flag DESC, e.start_date DESC LIMIT 1;同一个人同一时间段理论上只应有一条主职记录这条约束别只写在文档里用一条唯一索引近似实现在employment上加UNIQUE KEY uk_person_start (person_id, start_date, primary_flag)能挡住大部分重复提交剩下的跨区间重叠交给服务层校验。2.5 逻辑删除与唯一索引的冲突怎么解deleted标记加UNIQUE KEY (emp_no)会打架员工离职后重新入职工号复用就插不进去。常见做法有两种一是把唯一索引改成(emp_no, deleted)但deleted只有 0/1只能允许一条删除记录二是删除时把emp_no改写成原工号_时间戳保留原始值到另一个字段。我一般选第二种因为离职返聘在制造业和零售业是常态工号复用是刚需第一种方案第二次复用就会撞唯一键。3. 基于 Spring Boot 的人事管理系统核心模块实现模型确定后编码阶段的目标是接口能跑、参数能校验、权限能兜底、规则能扩展。以下按骨架、CRUD、数据权限、薪资规则四块展开。3.1 用 start.spring.io 拉一个能跑的最小骨架JDK 装好后先确认java -version和JAVA_HOME环境变量没配对后面 Maven 编译报的错会一路迷惑你。骨架直接用官方初始化器拉比手写 pom 少踩一半依赖坑。# 版本号按本地 JDK 环境替换JDK 17 对应 Spring Boot 3.x curl -s https://start.spring.io/starter.zip \ -d typemaven-project \ -d languagejava \ -d javaVersion17 \ -d groupIdcom.example \ -d artifactIdhrms \ -d dependenciesweb,mybatis,mysql,validation,lombok \ -o hrms.zip unzip hrms.zip -d hrms cd hrms ./mvnw spring-boot:run关键依赖四个web提供 RESTmybatis做持久化mysql是驱动validation管参数校验。启动类上记得加MapperScan(com.example.hrms.mapper)不加的话 MyBatis 的接口不会被扫描启动时不报错调接口时才抛Invalid bound statement这个错排查起来最浪费时间。application.yml里连接池参数给一组起步值spring: datasource: url: jdbc:mysql://127.0.0.1:3306/hrms?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghairewriteBatchedStatementstrue username: hrms password: ${DB_PASSWORD} hikari: maximum-pool-size: 20 # 按 QPS 估一般 10~20 够用 minimum-idle: 5 connection-timeout: 3000 max-lifetime: 1800000 # 略小于 MySQL wait_timeoutrewriteBatchedStatementstrue这条一定要加它让批处理 INSERT 合并成多值语句跑薪资初始化数据时性能差好几倍。3.2 员工创建接口参数校验别交给前端服务端校验是最后一道闸缺了它脏数据会一路流到薪资表里。RestController RequestMapping(/api/employees) Validated public class EmployeeController { private final EmployeeService employeeService; public EmployeeController(EmployeeService employeeService) { this.employeeService employeeService; } PostMapping public ResultLong create(RequestBody Valid EmployeeCreateReq req) { // 业务级校验入职日期不允许晚于今天 if (req.getHireDate().isAfter(LocalDate.now())) { throw new BizException(入职日期不能晚于今天); } return Result.ok(employeeService.create(req)); } } Data public class EmployeeCreateReq { NotBlank(message 工号不能为空) Pattern(regexp ^[A-Z0-9]{4,32}$, message 工号格式不合法) private String empNo; NotBlank(message 姓名不能为空) Size(max 64) private String name; NotNull(message 入职日期不能为空) private LocalDate hireDate; NotNull(message 部门不能为空) private Long deptId; }校验分两层注解层负责格式和空值Valid触发业务层负责跨字段和跨表规则比如工号是否已存在、部门是否存在。两层职责别混注解里写不进数据库查询。日期和金额用java.time.LocalDate与BigDecimal这两个常用类前者避开SimpleDateFormat的线程安全问题后者避开double的精度丢失。3.3 MyBatis 分页与动态条件列表查询最忌讳SELECT *加一串if拼 SQL。MyBatis 的动态标签配合理赔参数对象更清爽。select idpageQuery resultTypecom.example.hrms.vo.EmployeeVO SELECT p.id, p.emp_no, p.name, p.hire_date, p.status, d.name AS deptName, pos.title AS positionTitle FROM person p LEFT JOIN employment e ON e.person_id p.id AND e.end_date CURDATE() !-- 只取当前生效的任职 -- LEFT JOIN department d ON d.id e.dept_id LEFT JOIN position pos ON pos.id e.position_id WHERE p.deleted 0 if testname ! null and name ! AND p.name LIKE CONCAT(%, #{name}, %) /if if testdeptId ! null AND e.dept_id IN foreach collectionsubDeptIds itemid open( separator, close) #{id} /foreach /if ORDER BY p.hire_date DESC /select两个要点一是任职表用ON里的区间条件做过滤别搬到WHERE否则LEFT JOIN会退化成INNER JOIN没任职记录的员工直接查不出来二是部门筛选走subDeptIds由服务层先查出所有下级部门 id 再传进来不让 SQL 里写递归。3.4 数据权限用 AOP 切面底层就是 JDK 动态代理HR 系统里「部门主管只能看本部门及下级员工」是最常被面试和实战双重拷打的点。实现方式我一般用注解加切面把数据范围塞进ThreadLocalMyBatis 拦截器再读出来拼 SQL。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DataScope { String deptAlias() default e; } Aspect Component public class DataScopeAspect { Around(annotation(dataScope)) public Object around(ProceedingJoinPoint pjp, DataScope dataScope) throws Throwable { LoginUser user CurrentUserHolder.get(); // 超级管理员不加范围普通用户限定到自己所在部门的子树 if (!user.isAdmin()) { ListLong scope deptService.listSubDeptIds(user.getDeptId()); DataScopeContext.set(scope); } try { return pjp.proceed(); } finally { DataScopeContext.clear(); // 必须清理线程池复用会串上下文 } } }Spring AOP 对实现了接口的 Bean 默认走 JDK 动态代理对没有接口的类退化成 CGLIB 子类代理。这带来两个实际约束被切的方法不能是private或final同类内部方法直接调用也不会触发切面。ThreadLocal一定要在finally里清Tomcat 线程是复用的不清就等于把上一个请求的可见范围泄给下一个请求这是权限系统里最隐蔽的事故。3.5 薪资规则用策略模式替代 if-else固定薪、时薪、计件、提成一开始都是 if-else加两条规则后就没法看了。把每条规则做成一个实现类用Map收集。public interface SalaryRule { String type(); BigDecimal calc(SalaryContext ctx); } Component public class HourlySalaryRule implements SalaryRule { public String type() { return HOURLY; } public BigDecimal calc(SalaryContext ctx) { // 月计薪天数 21.75日工作 8 小时 BigDecimal hourly ctx.getBase() .divide(new BigDecimal(21.75), 4, RoundingMode.HALF_UP) .divide(new BigDecimal(8), 4, RoundingMode.HALF_UP); return hourly.multiply(BigDecimal.valueOf(ctx.getWorkHours())) .setScale(2, RoundingMode.HALF_UP); } } Component public class SalaryRuleRegistry { private final MapString, SalaryRule rules new HashMap(); public SalaryRuleRegistry(ListSalaryRule ruleList) { ruleList.forEach(r - rules.put(r.type(), r)); } public BigDecimal calc(String type, SalaryContext ctx) { SalaryRule rule rules.get(type); if (rule null) throw new BizException(未知薪资规则: type); return rule.calc(ctx); } }新增规则只需要加一个Component类不动任何已有代码这就是开闭原则的落地方式。所有除法统一指定精度和舍入模式BigDecimal不指定RoundingMode遇到无限小数会直接抛ArithmeticException。4. 考勤汇总、薪资批算与并发控制怎么做到了跑批环节性能和数据一致性同时压过来。这一章处理四件事批量写、并发取数、批次幂等、缓存正确性。4.1 批量更新为什么不能循环单条 update一千人循环update就是一千次网络往返加一千次事务日志刷盘。正确的做法是分批拼接配合前面开的rewriteBatchedStatements。Transactional(rollbackFor Exception.class) public void batchSaveSalary(ListSalaryRecord records) { int batchSize 500; for (int i 0; i records.size(); i batchSize) { ListSalaryRecord sub records.subList(i, Math.min(i batchSize, records.size())); salaryMapper.batchInsert(sub); // 单条 INSERT ... VALUES (...),(...) } }批大小给 500 是个折中太小网络往返多太大单条 SQL 超过max_allowed_packet默认 4MB会直接报错而且锁持有时间变长。分批之后整体事务不要太大几千条以上建议拆到多个事务失败重跑依赖 4.3 的幂等设计。4.2 CompletableFuture 并发汇总考勤等全部完成后合并考勤汇总要调多个数据源或者多次查库串行跑一千人可能要几十秒。可以把每人一个任务丢进线程池用allOf等全部完成后统一取结果。private final ExecutorService ioPool new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(200), // 有界队列防止任务堆积 OOM new ThreadFactoryBuilder().setNameFormat(att-sum-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy()); // 满了让提交线程自己跑起到削峰作用 public ListAttendanceSummary summarize(Long month, ListLong personIds) { ListCompletableFutureAttendanceSummary futures personIds.stream() .map(id - CompletableFuture .supplyAsync(() - attendanceService.summary(id, month), ioPool) .exceptionally(ex - AttendanceSummary.empty(id, month))) // 单人失败不拖垮整批 .toList(); // 阻塞直到全部任务结束allOf 完成后 join 不会再次阻塞 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); return futures.stream() .map(CompletableFuture::join) .toList(); }allOf(...).join()是等的核心它只在全部完成后返回后续join()取的是已完成结果不会阻塞。exceptionally必须加否则一个员工的数据异常会让整批 join 抛CompletionException前面成功的部分全白跑。线程池参数建议参数建议值依据corePoolSize8任务以查库为主IO 等待占比高maxPoolSize16峰值不超过数据库连接池的 80%queueCapacity200有界队列拒绝时走调用方线程keepAliveTime60s跑批是周期性峰值空闲收缩拒绝策略CallerRunsPolicy不丢任务反向压住提交速度线程池大小别拍脑袋超过 Hikari 的maximum-pool-size否则任务全在等连接线程数再多也没用。4.3 薪资批次状态机与幂等键跑批最怕的是重复执行第二次跑出来的金额覆盖了第一次人工调过的数据。用一个显式的批次状态机管住流程。状态含义允许流转到说明DRAFT草稿可调参数CALCULATING允许改薪资规则CALCULATING计算中DONE / FAILED加分布式锁防并发触发DONE计算完成待复核CONFIRMED / DRAFT复核驳回可退回草稿CONFIRMED已确认PAID已确认金额不再允许脚本覆盖PAID已发放—终态同时给salary_record加UNIQUE KEY uk_person_month (person_id, month, batch_id)重复跑批时用INSERT ... ON DUPLICATE KEY UPDATE只更新未确认的数据已CONFIRMED的记录在 SQL 的WHERE里直接排除。这样即使运维手动重跑也不会动到已发放的账。4.4 字典缓存用 Redis重点在防穿透和过期策略部门树、岗位列表这类字典读多写少适合放 Redis。但缓存写不对反而制造 bug穿透、雪崩、脏读三个里最容易踩的是穿透。public Department getDept(Long id) { String key hr:dept: id; String cached redis.opsForValue().get(key); if (cached ! null) { // 空字符串是穿透占位符直接返回 null 不再查库 return cached.isEmpty() ? null : JSON.parseObject(cached, Department.class); } Department dept deptMapper.selectById(id); if (dept null) { // 不存在的 key 也缓存但过期时间要短防止脏数据长期占用 redis.opsForValue().set(key, , 60, TimeUnit.SECONDS); return null; } // 加随机偏移避免同一时刻批量过期造成雪崩 long ttl 30 * 60 ThreadLocalRandom.current().nextInt(300); redis.opsForValue().set(key, JSON.toJSONString(dept), ttl, TimeUnit.SECONDS); return dept; }过期时间加随机偏移这条经常被忽略部门字典如果整点统一重建整批 key 会在同一秒失效请求全砸到数据库。写操作更新缓存时用「先更新库、再删缓存」而不是「更新缓存」删除失败比更新成旧值更好修。4.5 事务失效的三个高频坑第一个是同类内部方法调用this.batchSave()走不到代理事务注解形同虚设要拆到别的 Bean 或者注入自己。第二个是异常被吞掉catch完没重新抛Transactional默认只对RuntimeException回滚受检异常要显式写rollbackFor Exception.class。第三个是Transactional方法里开了新线程子线程的事务和主线程完全无关主线程回滚不会带走子线程已经提交的数据所以 4.2 里的批处理必须保证每个任务是幂等的不能指望外层事务兜底。5. 上线前的验证初始化数据、慢 SQL 与容器化部署代码跑通只算一半交付前有几件事必须过一遍否则上线当天最容易翻车。5.1 初始化数据与冒烟脚本交付给客户的环境第一条 SQL 不是业务数据而是基础字典和第一个管理员账号。# 1. 建库建表 mysql -h127.0.0.1 -uroot -p hrms schema.sql # 2. 灌字典和根部门 mysql -h127.0.0.1 -uroot -p hrms init_data.sql # 3. 冒烟建一个员工再查出来验证写读链路 curl -s -X POST http://127.0.0.1:8080/api/employees \ -H Content-Type: application/json \ -d {empNo:E10001,name:测试员工,hireDate:2024-01-02,deptId:1} curl -s http://127.0.0.1:8080/api/employees/page?pageNum1pageSize10冒烟脚本要进 CI别只在自己机器上手工点两下。返回值里code不是 0 就中断比人工看日志可靠。5.2 盯住三条必查的慢 SQL人事系统里最容易慢的不是列表而是跨表统计。列表超过 1 秒先EXPLAIN看三处-- 1. 员工列表确认走了 uk_emp_no 或 idx_hire_date不要出现 typeALL EXPLAIN SELECT p.id FROM person p WHERE p.deleted 0 ORDER BY p.hire_date DESC LIMIT 20; -- 2. 部门人数统计确认 idx_dept 生效避免全表扫 employment EXPLAIN SELECT dept_id, COUNT(*) FROM employment WHERE end_date CURDATE() GROUP BY dept_id; -- 3. 薪资区间查询确认走 (person_id, month) 复合索引 EXPLAIN SELECT * FROM salary_record WHERE person_id 10086 AND month BETWEEN 2024-01 AND 2024-06;看到Using filesort且行数上万就补索引看到typeALL就检查是不是条件里对索引列做了函数运算比如DATE_FORMAT(hire_date,%Y) 2024这种写法会让索引直接失效改成hire_date 2024-01-01 AND hire_date 2025-01-01。5.3 容器化部署与最后的交付细节容器里跑 Java 应用有两个参数必须显式给否则容器看到的是宿主机内存JVM 会按物理机内存开堆。FROM eclipse-temurin:17-jre WORKDIR /app COPY target/hrms.jar app.jar # 让 JVM 感知容器内存上限堆占容器上限的 75% ENTRYPOINT [java, -XX:MaxRAMPercentage75.0, \ -XX:HeapDumpOnOutOfMemoryError, \ -Duser.timezoneAsia/Shanghai, \ -jar, app.jar]-Duser.timezone这条务必加容器默认 UTC考勤跨天判定会在凌晨出错这个 bug 上线后极难复现。交付前最后两件事一是把数据库账号密码走环境变量而不是写进 yml二是如果交付的是可反编译的 jar评估一下是否需要字节码混淆至少把连接串和许可校验相关的常量处理掉别裸奔。本文还有配套的精品资源点击获取
返回列表