Spring Boot人事系统源码拆解:从数据建模到部署实战
简介在Java企业级应用开发中Spring Boot凭借自动配置与丰富的Starter生态成为构建后台管理系统的首选框架。人事管理作为典型业务场景涵盖了组织架构、流程审批、敏感数据保护等核心需求。理解其底层原理如基于MyBatis-Plus的数据建模、JWT无状态鉴权、部门树的递归与内存组装、审批状态机的迁移设计能帮助开发者快速上手并规避常见陷阱。这些技术不仅适用于人事系统也可迁移至通用后台权限模块。面对源码包从工程结构核对、依赖版本管理到启动配置项逐一排查再到二次开发中的缓存加速与安全加固是Java工程师从能运行到会优化的必经之路。本文以一套可落地的Spring Boot人事系统为例拆解其关键技术点为毕设选题、老旧系统维护或架构设计提供参考。1. Spring Boot 人事系统源码包它到底值不值得打开在网盘和 GitHub 上搜“人事管理系统源码”十个结果里七八个写着 Spring Boot这个现象本身已经说明了选型倾向Spring Boot 的自动配置和 Starter 生态能让一套包含员工档案、部门树、考勤记录、请假审批、薪资计算的后台系统在两周内跑通。但真把 zip 压缩包解压出来之后常见的尴尬是 README 只有一句话数据库脚本和实体类字段对不上启动类一跑就报数据源错误。人事系统做过一遍之后你会发现它的技术难点从来不在 CRUD而在三件事组织架构的层级关系怎么建模、审批状态怎么迁移、身份证和薪资这类敏感数据怎么防泄露。这篇文章就从这三条线把源码拆开讲顺带把部署到二次开发的坑填平。适合准备毕设、接手老旧内部系统或者想借鉴组织架构和审批流设计的 Java 工程师。2. 拆开源码包Spring Boot 人事系统的工程结构与数据建模拿到源码包先别急着启动。用 IDEA 的 Import Project 把 pom.xml 导进来之后第一件事是核对目录结构是否完整controller/service/mapper/entity四层有没有缺失resources下的application.yml和mapper/*.xml是否在正确位置。很多网上流传的压缩包解压后缺少.mvn目录或者把 SQL 脚本放在src/main/resources里本质上不影响编译但会影响你对项目认知的完整性。2.1 先看两张核心表部门表和用户表的字段设计人事系统的地基是组织架构。绝大多数源码先建sys_dept和sys_user两张表下面是一份常见的建表思路。CREATE TABLE sys_dept ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 部门ID, parent_id BIGINT DEFAULT 0 COMMENT 上级部门ID0表示顶级, dept_name VARCHAR(64) NOT NULL COMMENT 部门名称, ancestors VARCHAR(255) DEFAULT COMMENT 祖级列表例如 0,1,10, order_num INT DEFAULT 0 COMMENT 排序号, del_flag TINYINT DEFAULT 0 COMMENT 删除标志 0存在 2删除, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, KEY idx_parent_id (parent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT部门表; CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, dept_id BIGINT COMMENT 所属部门ID, username VARCHAR(32) NOT NULL COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT BCrypt哈希后的密码, real_name VARCHAR(32) COMMENT 姓名, id_card VARCHAR(64) COMMENT 身份证号建议AES加密后存储, phone VARCHAR(20) COMMENT 手机号, status TINYINT DEFAULT 1 COMMENT 状态 1正常 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, UNIQUE KEY uk_username (username), KEY idx_dept_id (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户/员工表;两个设计细节值得注意。ancestors字段是若依风格的做法写入0,1,10这样的祖级路径查询某个部门及其所有子部门时用FIND_IN_SET(#{deptId}, ancestors)一条 SQL 就能拿到整棵子树不用递归。而id_card字段的长度给到 64不是 18是因为生产环境通常对身份证做 AES 加密后落库密文长度远大于明文如果源码里id_card是 VARCHAR(18) 且明文存储接手后第一件事应该是改为加密存储。薪资、银行账号这类更敏感的数据通常不会直接塞进sys_user而是拆一张employee_ext扩展表用user_id一对一关联。这样查询基础列表时不需要触碰敏感字段只有打开详情页才需要去扩展表读取。2.2 pom.xml 里关键依赖怎么核对依赖声明直接决定项目能不能编译。一份典型的人事系统 pom.xml 核心片段如下。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency /dependencies版本选择上需要注意两点。Spring Boot 2.7.18 是 2.x 的最后一个版本兼容 Java 8适合手上只有 JDK 8 的服务器如果本地环境是 JDK 17 或 21直接换成 Spring Boot 3.x对应的 MyBatis-Plus 也要升到 3.5.5 以上版本。jjwt 的依赖拆成 api、impl、jackson 三个impl 和 jackson 声明为 runtime 是因为编译期只需要 API 接口运行时才需要具体实现漏掉 runtime 这两个依赖编译能过但启动时会报JwtParserBuilder找不到实现的错。2.3 MyBatis-Plus 还是 JPA人事系统的报表场景怎么取舍人事系统比普通 CRUD 项目多的是一类需求按部门汇总人数、按月统计考勤、查询某段时间的入职离职。这种多维统计场景里MyBatis-Plus 和 Spring Data JPA 的差距会被明显放大。维度MyBatis-PlusSpring Data JPA复杂多表联查直接写 SQL可控性强需要 JPQL 或 Specification学习成本高分页查询内置分页插件方言自动识别分页规范统一但跨表聚合较繁琐逻辑删除全局配置自动拼接条件需要手动在查询里带条件人事报表 SQL适合相对吃力常见人事系统占比约七成三成左右结论很直接如果目标是自己维护且后面要加报表选 MyBatis-Plus如果团队已经深度使用 JPA 且愿意为领域模型买单JPA 也没问题但不要在同一个实体上混用两套 ORM 的注解。封装通用 BaseEntity 时把create_time、update_time、del_flag三个字段放进去用TableField(fill FieldFill.INSERT)自动填充能省掉大半重复代码。3. 读透关键代码登录鉴权、部门树与状态流转源码包里最能看出水平的三个地方登录怎么做的、部门树怎么查的、审批状态怎么存的。这三个点也是 Spring Boot 面试题里最爱追问的场景把实现看懂比背八股文有用得多。3.1 Spring Security JWT 的登录鉴权骨架无状态鉴权是人事系统的默认选择。核心是一个过滤器拦截请求并解析 JWT把用户信息塞进SecurityContextHolder。Component public class JwtAuthFilter extends OncePerRequestFilter { private final SecretKey secretKey; private final UserDetailsService userDetailsService; public JwtAuthFilter(Value(${jwt.secret}) String secret, UserDetailsService userDetailsService) { // 生产环境 secret 必须来自环境变量或配置中心不能硬编码在 yml byte[] keyBytes Decoders.BASE64.decode(secret); this.secretKey Keys.hmacShaKeyFor(keyBytes); this.userDetailsService userDetailsService; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token resolveToken(request); if (token ! null SecurityContextHolder.getContext().getAuthentication() null) { try { Claims claims Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); String username claims.getSubject(); UserDetails user userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken auth new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(auth); } catch (JwtException | IllegalArgumentException e) { // 令牌过期或签名不对放行交给匿名过滤器处理 } } chain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (bearer ! null bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }这里有几个边界要说清楚。OncePerRequestFilter保证一次请求只被过滤一次不会因为转发多跑几遍。JWT 里只放subject即用户名不能放身份证、薪资这些敏感字段因为 JWT 的 payload 只是 Base64 编码不是加密任何人抓到 token 都能解码看内容。密码校验用的是 BCryptpasswordEncoder.matches(rawPassword, encodedPassword)BCrypt 自带盐且不可反解数据库里永远存的是哈希值这也是为什么建表时password字段要 VARCHAR(100)。3.2 部门树查询一次查出全量再内存组装部门层级一般不深两三百条数据递归查库会很笨。常见做法是一次查全表在内存里组装成树。public ListDeptVO buildDeptTree() { // 一次性查全表内存组装避免每层一次SQL ListSysDept all deptMapper.selectList( new LambdaQueryWrapperSysDept() .orderByAsc(SysDept::getOrderNum)); MapLong, DeptVO map new LinkedHashMap(); for (SysDept dept : all) { map.put(dept.getId(), DeptVO.from(dept)); } ListDeptVO roots new ArrayList(); for (DeptVO node : map.values()) { if (node.getParentId() 0L) { roots.add(node); } else { DeptVO parent map.get(node.getParentId()); if (parent ! null) { parent.getChildren().add(node); } else { // 父级被逻辑删除或数据异常时的兜底避免节点丢失 roots.add(node); } } } return roots; }这段代码的隐藏点是LinkedHashMap。它保证遍历顺序和插入顺序一致这样树节点在children里的顺序就是 SQL 里order_num排序后的结果。如果误用HashMap部门顺序会乱。兜底逻辑也关键当某个部门的parent_id指向的父部门被逻辑删除后如果不加 else 分支这个节点会直接从树里消失前端渲染就会出现“有数据但看不到人”的诡异问题。部门表数据量到五万以上时内存组装依然可用但每次全量查询的耗时开始显现这时再考虑引入 Redis 缓存或改用物化路径。3.3 请假审批的状态机用枚举集中管理迁移人事系统里审批流是最容易写乱的部分。最常见的坏味道是到处if (1.equals(status))改一处漏三处。正确做法是用枚举把状态和可迁移路径集中起来。public enum LeaveStatus { DRAFT(0, 草稿), PENDING(1, 审批中), APPROVED(2, 已通过), REJECTED(3, 已驳回), CANCELLED(4, 已撤销); private final int code; private final String desc; private static final SetString TRANSITIONS Set.of( 0-1, 1-2, 1-3, 0-3, 0-4, 1-4 ); public boolean canTransitionTo(LeaveStatus target) { return TRANSITIONS.contains(this.code - target.code); } }迁移路径解释一下草稿可以提交0-1审批中可以通过或驳回1-2、1-3草稿和审批中都可以撤销0-4、1-4审批人也可以直接驳回草稿0-3。不允许的迁移比如已通过再驳回调用canTransitionTo会直接返回 false。数据库里只存 code 数字不存名称。这样以后把“审批中”改名叫“审核中”只改枚举的 desc不需要动历史数据。审批日志单独建一张leave_audit_log每次迁移都记录操作人、目标状态、操作时间这张表同时也是考勤争议时的主要依据。4. 启动与部署 Spring Boot 人事项目配置项、打包命令与排查方向代码看得懂之后下一步是把项目跑起来。这个环节的坑密度最高绝大多数源码被放弃是因为卡在启动阶段。核心就三件事配置核对、打包方式、报错定位。4.1 application.yml 必须核对的五个参数server: port: 8080 servlet: context-path: /hr spring: datasource: url: jdbc:mysql://localhost:3306/hr_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD:root} driver-class-name: com.mysql.cj.jdbc.Driver jackson: time-zone: Asia/Shanghai date-format: yyyy-MM-dd HH:mm:ss mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: delFlag logic-delete-value: 2 logic-not-delete-value: 0 jwt: secret: ${JWT_SECRET:hr-system-jwt-secret-key-at-least-32-bytes} expire-hours: 8参数逐个说明。context-path: /hr给所有接口加了一层前缀多个系统部署在同一域名下时避免路由冲突。serverTimezoneAsia/Shanghai是连接 MySQL 8 的必需品不加这个参数日期字段整体偏早 8 小时考勤统计必然出错。map-underscore-to-camel-case开启后dept_name自动映射到deptName不需要手写 resultMap。logic-delete-field: delFlag是 MyBatis-Plus 的逻辑删除全局配置删除操作自动变成UPDATE ... SET del_flag 2查询自动追加del_flag 0条件。log-impl只建议在开发环境打开生产环境务必移除否则每次查询都在控制台刷 SQL性能损耗可以忽略但日志文件会快速膨胀。jwt.secret用环境变量${JWT_SECRET:默认值}是双保险本地没有环境变量时用默认值生产环境通过环境变量注入强密钥避免密钥跟着源码走。4.2 Maven 打包的常见三种方式# 方式一直接启动适合开发调试 mvn spring-boot:run -Dspring-boot.run.profilesdev # 方式二打成可执行 JAR跳过测试 mvn clean package -DskipTests # 方式三指定生产环境配置启动 java -jar target/hr-system-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod # 生产部署时建议带上 JVM 参数 java -Xms512m -Xmx1024m -XX:HeapDumpOnOutOfMemoryError \ -jar hr-system.jar --server.port8080-DskipTests只跳过测试执行测试代码仍然编译-Dmaven.test.skiptrue连测试代码都跳过编译能省一点时间但建议只在确实不需要测试类时使用。打包机如果是 JDK 11目标服务器是 JDK 8要检查 pom 里java.version是否和目标环境一致否则会出现UnsupportedClassVersionError。HeapDumpOnOutOfMemoryError让 JVM 在内存溢出时自动 dump 现场人事系统在导出全量花名册时最容易触发这参数能在事后复盘时省很多事。4.3 启动报错的排查思路报错信息原因处理方式Unable to start web server端口被占用lsof -i:8080查看进程kill 或改端口Failed to configure a DataSource数据源配置错误核对 url、用户名、密码、驱动四个参数error read zip archive源码压缩包下载不完整删掉重新下载比对文件大小或 MD5 校验值Access denied for user rootlocalhostMySQL 账号或权限问题用客户端工具直接连库验证Jackson 解析时间字段异常后端返回格式与前端不一致检查spring.jackson.date-format是否生效排查思路只有一个原则先看控制台完整异常的Caused by部分而不是盯着第一行。曾经碰过一个案例数据库密码里带了#符号写在 yml 里被当成注释截断报错信息却是断断续续的连接超时。花了两小时检查网络最后发现是 YAML 解析问题。密码含特殊字符时用${DB_PASSWORD}环境变量注入是最稳的这也是上面配置里password: ${DB_PASSWORD:root}存在的原因之一。5. 二次开发与安全加固人事系统的真实扩展清单系统能跑通只是起点。人事系统上线后真正的维护重点在两个方向性能和敏感数据。以下三个扩展点建议按顺序做。5.1 用 Redis 缓存部门树部门树是典型的读多写少数据几百人规模的系统每次查询都全表扫一遍完全没必要。Cacheable(cacheNames deptTree, key 1) public ListDeptVO getDeptTreeCached() { return buildDeptTree(); } CacheEvict(cacheNames deptTree, key 1) public void updateDept(SysDept dept) { deptMapper.updateById(dept); }注意CacheEvict必须放在更新、删除、新增三个入口上漏掉任何一个缓存里都会残留旧结构。部门树崩溃的典型现象是“新增部门后页面不显示”八成就是 evict 没加全。5.2 heapdump 端点引发的敏感信息泄露Spring Boot Actuator 的heapdump端点暴露公网是个隐患。Java 堆内存里包含正在处理的请求参数、数据库连接信息、JWT secretheapdump文件下载下来用 MAT 分析能直接读取内存中的明文密码。源码包里如果打开了所有端点生产环境一定要收口management: endpoints: web: exposure: include: health,info将端点暴露范围限制为health,info是最低要求。如果需要用 heapdump 排查线上 OOM也不建议直接暴露公网改为内网访问或临时开启用完立即关闭。这套处置同样适用于spring-boot-devtools它默认开启远程热部署能力生产环境千万不能带。5.3 导出全量花名册用 SXSSFWorkbook普通 XSSFWorkbook 把整个工作簿放在内存里两千人、单行六十个字段的导出就能吃满 1G 内存直接 OOM。用 SXSSFWorkbook 改成流式写法是更稳妥的选择。GetMapping(/export) public void export(HttpServletResponse response) throws IOException { response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenameemployees.xlsx); try (SXSSFWorkbook workbook new SXSSFWorkbook(100)) { Sheet sheet workbook.createSheet(员工); // 写表头 Row header sheet.createRow(0); header.createCell(0).setCellValue(姓名); header.createCell(1).setCellValue(部门); // 从数据库流式读取员工列表逐行写入 workbook.write(response.getOutputStream()); } }SXSSFWorkbook(100)里的窗格参数表示内存中只保留最近 100 行更早的行会被刷到磁盘临时文件写完后由workbook.dispose()清理。这个阈值按报表实际列宽调整单行字段越多阈值调低一些建议 100 到 200 之间比较稳妥。本文还有配套的精品资源点击获取