我接过不少类似的内部管理系统,这次这个项目从标题就能看出它是典型的“Spring Boot + Java 人事档案管理系统”,项目代号叫 4o6be1e2。很多开发者拿到这种需求的第一反应是“不就是增删改查吗”,但真正做完就会发现,人事档案系统里最难的从来不是 CRUD,而是档案数据的组织方式、敏感信息的保护、合同到期的提醒、权限边界的控制这些细节。这篇文章我会把完整的设计和实现过程捋一遍,重点是讲清楚每一步为什么这么选,以及在开发过程中实际踩过的坑。
如果你正要开始写一个企业人事管理系统,或者你在做档案、合同、人员信息这类后台项目,这篇文章可以直接拿来当参考。我用的技术组合是 Spring Boot + MyBatis-Plus + MySQL + Vue3,整体偏保守,不追新版本,目的就是稳定、好维护、上线不出幺蛾子。
1. 为什么要把人事档案从 Excel 里搬出来
1.1 需求到底来自哪里
大多数中小企业的人事档案管理,早期基本都靠 Excel 和纸质材料。HR 手里一堆表格,员工基本信息、合同扫描件、学历证书、身份证复印件分散在各个文件夹,甚至还有不少人直接在微信上传来传去。表面上看是“有归档”,实际上是“找不着”。
我接到这个需求的时候,对方提了几个很具体的问题:新员工入职后信息要填好几遍;员工离职两年后想调原始合同,翻半天找不到;合同到期没人提醒,导致有人已经在公司干了大半年合同还是过期的;部门主管能随意看到全公司的工资和身份证号,但又不好管。这些问题一旦汇总,结论就非常清晰:需要一个统一的人事档案平台,把“人员信息”和“附件原件”串起来。
所以这个项目不是从“我要做一个管理系统”出发的,而是从“如何让 HR 少加班”出发的。所有功能设计都要围绕档案数据的完整性、可追溯性和安全性来展开。
1.2 系统边界画在哪里
做管理系统的第一个大忌是需求无限膨胀。人事档案管理系统听起来和相关业务强相关,容易让人把考勤、薪资、绩效、招聘都塞进来。一旦塞进来,项目周期和技术复杂度都会失控。
我这次画系统边界时只保留了和“档案”强相关的核心模块:
| 模块 | 主要功能 |
|---|---|
| 员工档案 | 员工基本信息新增、编辑、查询、离职归档 |
| 合同管理 | 合同信息登记、附件上传、到期提醒 |
| 档案附件 | 身份证、学历证书、劳动合同等文件的统一上传下载 |
| 部门与岗位 | 部门树维护、岗位信息维护 |
| 系统管理 | 用户、角色、权限、操作日志 |
| 数据导入导出 | Excel 批量导入和导出 |
考勤、薪资、招聘这些业务,如果后续需要,可以做成独立系统再对接,而不是硬塞进当前项目。这个边界的好处是开发工作量可控,权限模型也不会因为一堆业务规则而变得纠缠不清。
1.3 角色权限不能只分“管理员”和“普通用户”
人事档案里包含身份证号、手机号、家庭住址这些敏感信息,所以权限模型一定要设计得稍微细一点。这个系统里我分了四类角色:
- 超级管理员:管理系统配置、用户角色、查看全部档案;
- HR 专员:维护员工档案、合同、附件,可导出数据;
- 部门管理员:只能维护本部门人员档案;
- 普通员工:只能查看自己的档案和合同。
这种划分对应到系统实现上,就不仅仅是“能不能进菜单”的问题,还涉及数据行级权限。比如部门管理员登录后,即使知道接口地址,也不能直接查其他部门的员工,这就需要后面的数据权限过滤来实现。
2. 技术选型:我选了保守但好维护的方案
2.1 Spring Boot 版本为什么选 2.7 而不是 3.x
现在 Spring Boot 3.x 已经很成熟了,Java 17 也普及了,但很多企业内网生产环境的 JDK 还停留在 8 或 11。如果强行上 3.x,需要面对几个实际成本:javax 包名全部要改成 jakarta、部分老库的兼容性问题、团队的学习成本。
我这次最终选择的是 Spring Boot 2.7.18 + JDK 8。2.7 是最后一个官方支持 JDK 8 的版本,兼容性非常稳,后期也拿到了较长的维护周期。对于人事档案这种业务逻辑不复杂、但对稳定性要求极高的系统,用新不如用稳。
| 对比项 | Spring Boot 2.7.18 | Spring Boot 3.x |
|---|---|---|
| JDK 要求 | 8 及以上 | 17 起步 |
| 包命名空间 | javax | jakarta |
| 老库兼容性 | 好 | 需要逐一适配 |
| 适合场景 | 企业内网、维护成本敏感 | 全新项目、团队技术较新 |
如果你是新项目且团队已经全部切到 JDK 17,那直接用 3.x 也没问题。但如果你要部署到客户内网、无法控制服务器环境,2.7 是更保险的选择。
2.2 ORM 选 MyBatis-Plus 的原因
持久层框架我选的是 MyBatis-Plus,而不是 JPA,也不是原生 MyBatis。理由很简单:人事档案系统的查询条件比较灵活,人员姓名、工号、身份证、部门、状态这些条件经常自由组合,MyBatis 体系对复杂 SQL 的控制力更强。而 MyBatis-Plus 在保留 SQL 控制能力的基础上,把单表 CRUD 的模板代码省掉了,分页查询也内置了插件,开发效率明显提高。
这个项目里我用到了它三个核心能力:
- LambdaQueryWrapper 构造动态查询条件;
- 分页插件无需手写 PageHelper 配置;
- 逻辑删除注解一键实现“离职档案不物理删除”。
遇到连表统计、自定义报表之类的需求,我依然会写 XML SQL,不会因为用了 Plus 就把手写 SQL 丢掉。
2.3 前端选择:前后端分离但打包到一起
前端我用了 Vue3 + Element Plus + Vite,但最终构建产物会放到 Spring Boot 的src/main/resources/static目录下面,打成单个 Jar 包运行。
这样做是综合考虑了企业内网部署环境。很多公司 IT 环境不允许随便装 Node 服务、Nginx 也未必有人会维护,如果前端后端分别部署,运维成本会高一截。把 Vue 构建出的静态文件直接被 Spring Boot 托管,一行java -jar就能起整个系统,对使用方非常友好。
前端路由我特意用的 Hash 模式。SPA 用 History 模式在刷新二级页面时需要后端或 Nginx 配合重写到 index.html,如果部署在 Jar 包内,Hash 模式可以直接规避这个问题。
2.4 后端工程目录是怎么组织的
项目采用经典的分层结构,每个层次的职责很明确:
com.example.hr ├── common # 统一返回结果、全局异常、常量 ├── config # 安全配置、跨域配置、分页插件 ├── controller # 接收参数、返回结果 ├── service # 业务逻辑 ├── mapper # MyBatis-Plus 数据操作 ├── entity # 数据库实体 ├── dto # 接收前端参数 ├── vo # 返回前端结构 └── util # JWT、脱敏、文件等工具类Controller 里不写业务逻辑,只负责参数绑定、调用 Service、返回统一结构R<T>。Service 处理业务时,还会区分“当前登录用户是谁”“他有没有权限操作这部门的数据”,而不是无脑执行 SQL。
3. 数据库设计:档案表其实是一张“易变但又要留痕”的表
3.1 核心表不要贪多,够用且不混乱
我初期把表设计得特别多,比如员工教育经历单独一张表、家庭关系单独一张表,结果实现的时候发现很多表的数据量并不大,反而让联查复杂度上去了。最后我收敛成下面几类核心表:
sys_user:登录账号、密码、关联员工或管理账号;sys_role与sys_user_role:用户与角色关系;sys_dept:部门树;emp_archive:员工档案主表;emp_contract:员工合同表;emp_attachment:档案附件表;sys_operation_log:操作日志表。
emp_archive是系统的绝对核心,字段包括工号、姓名、性别、身份证号、手机号、邮箱、学历、毕业院校、入职日期、转正日期、部门、岗位、在职状态、紧急联系人等。身份证号会在接口层脱敏,但数据库里存的仍是明文,因为导出员工信息时 HR 需要原始数据。
3.2 为什么合同表和附件表不能塞进主表
我见过不少项目把合同编号、合同开始日期、合同结束日期直接做成员工表里的几个字段。这么设计在开发阶段很省事,但真实业务里员工会续签合同,一份员工可能同时有多份历史合同,旧合同不能覆盖掉。
所以我把合同拆成了独立的emp_contract表,每次续签都新增一条记录。查当前合同时取status = '有效'且结束日期最大的一条;查历史合同时按照签订时间倒序即可。这样后续接合同到期提醒的定时任务,也能直接在合同表里按end_date字段扫描。
附件同理。身份证扫描件、学历证书、劳动合同,这些都是“一个员工多份文件”的关系,单独建emp_attachment表,通过emp_id关联。文件本身存在磁盘上,数据库只存相对路径、文件大小、文件类型、上传人、上传时间。这样数据库不会因为文件数据膨胀到无法备份。
3.3 员工离职要“留痕”而不是“删除”
人事档案最大的特点是:即使员工离职了,档案依然要保留很多年。直接物理删除是完全错误的操作。
我的方案是给emp_archive加一个status字段,值包括待入职、在职、离职,再用 MyBatis-Plus 的逻辑删除功能把数据库删除操作转换为update修改deleted字段。这样 HR 在列表页看到的是正常在职人员,但在“已离职”筛选下依然能查到完整历史档案。审计时也能追溯这个人当年在这个部门的完整变动链。
4. 登录认证与档案查询的实现细节
4.1 JWT 访问令牌的完整流程
系统前端是单独打包的 SPA,后端提供纯 JSON 接口,因此登录认证采用 JWT 模式比较合适。流程是这样:
用户提交用户名密码后,后端用 BCrypt 校验密码,通过后签发一个访问令牌,默认有效期 30 分钟。前端每次请求都会在请求头带上Authorization: Bearer <token>,后端通过过滤器解析令牌,并把当前用户信息放到请求上下文。
实际生成令牌的核心代码大概这样:
String token = Jwts.builder() .setSubject(userId.toString()) .claim("username", user.getUsername()) .claim("roleCode", role.getRoleCode()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expireMillis)) .signWith(Keys.hmacShaKeyFor(secret.getBytes()), SignatureAlgorithm.HS256) .compact();这里有几个细节值得注意:
secret不能太短,否则Keys.hmacShaKeyFor会报错,至少要 32 字节以上;- 不要把身份证号、手机号这种大字段塞进 JWT,令牌体积会变大,而且这些信息本就不需要每次请求都带着;
- 令牌过期后前端拦截 401 响应,跳回登录页,不要求做复杂的刷新令牌机制,内部系统 30 分钟过期是完全可以接受的。
4.2 员工档案的分页模糊查询
档案列表是使用频率最高的功能,HR 一般会按姓名、工号、手机号、部门、在职状态这几个条件组合查询。我用 MyBatis-Plus 的 LambdaQueryWrapper 实现,避免在 Java 代码里手动拼接字符串 SQL。
LambdaQueryWrapper<EmpArchive> wrapper = Wrappers.lambdaQuery(); wrapper.and(StringUtils.hasText(keyword), w -> w .like(EmpArchive::getName, keyword) .or().like(EmpArchive::getEmpNo, keyword) .or().like(EmpArchive::getMobile, keyword)) .eq(StringUtils.hasText(deptId), EmpArchive::getDeptId, deptId) .eq(StringUtils.hasText(status), EmpArchive::getStatus, status) .orderByDesc(EmpArchive::getUpdateTime); Page<EmpArchive> page = empArchiveMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);这里最关键的是and(w -> ...)这层括号。如果我们不加这一层,直接把like(name).or().like(empNo)跟在eq(deptId)后面,生成的 SQL 会变成dept_id = ? AND name LIKE ? OR emp_no LIKE ?,OR会破坏前面的AND条件,导致部门过滤失效,出现越权查询。这个坑我后面还会专门讲。
4.3 统一返回结构和全局异常处理
所有接口我都返回同一个结构:
public class R<T> { private int code; private String msg; private T data; }正常返回时code = 200,业务异常时code = 500或业务错误码,前端根据code判断是否弹出错误提示。这个统一结构的好处是,前端 axios 响应拦截器只需要处理一种数据格式,不需要每个接口单独判断。
全局异常处理用@RestControllerAdvice,把参数校验异常、业务异常、未知异常分别处理。这里有一点要特别强调:未知异常一定要打印完整堆栈,但返回给前端的msg只能是“系统异常,请稍后重试”,不要直接把 SQL 异常信息暴露出去。否则用户看到一堆数据库字段报错,既危险又难看。
4.4 档案详情要组合多张表再返回
档案详情页面需要同时展示基本信息和当前有效合同、附件列表。Controller 层不会直接返回EmpArchive实体,而是组装成一个EmpArchiveDetailVO,里面包含员工信息、当前合同 DTO、附件 DTO 列表。
之所以用 VO 而不是直接把实体返回,是因为实体里包含身份证号、家庭住址这些敏感字段,直接序列化返回给前端不安全。VO 层的idCard字段会经过脱敏处理,列表页和详情页看到的都是打了马赛克的版本,只有经过授权的导出接口才使用原始数据。
5. 文件上传、Excel 导入导出与合同到期提醒
5.1 文件上传与下载的安全处理
人事档案的附件涉及身份证和合同扫描件,这都是敏感文件。上传功能我做了四层限制:
第一,限制文件大小。Spring Boot 默认上传文件大小是 1MB,对于扫描件根本不够。我在配置里调整到了 20MB:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB第二,限制文件类型。不能只相信前端传来的文件名后缀,还要判断扩展名是否在白名单里。允许的扩展名就是 jpg、png、pdf、doc、docx 这些常见格式,其它一律拒绝。
第三,存储路径不能用户可控。文件上传后重新命名为 UUID,防止因为文件名包含特殊字符导致路径穿越,也防止同名文件互相覆盖。实际文件存储在服务器的/data/hr-files/目录,数据库里只保存相对路径。
String ext = StringUtils.getFilenameExtension(file.getOriginalFilename()); String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext; Path targetDir = Paths.get(uploadDir).toAbsolutePath().normalize(); Files.createDirectories(targetDir); file.transferTo(targetDir.resolve(fileName).toFile());第四,下载接口必须校验权限。文件下载不是直接把路径返回给前端,而是走后端接口:当前登录用户必须对这条附件记录有访问权限,后端才把文件以流的方式写到响应里。这能防止有人把文件的绝对路径复制出来任意传播。
5.2 用 EasyExcel 搞定批量导入导出
HR 手里有大量历史 Excel 数据,系统上线时不可能让 HR 一条一条录进去。批量导入是刚需。
我选择的是阿里 EasyExcel,而不是直接操作 Apache POI。EasyExcel 的优势是低内存占用,而且通过注解可以直接把 Excel 列映射到实体字段:
@ExcelProperty("姓名") private String name; @ExcelProperty("身份证号") private String idCard;导入的核心思路是:先用 EasyExcel 把数据读成EmpImportDTO列表,然后逐条校验必填字段、手机号格式、身份证号格式,校验失败就记录行号和错误原因,最后返回一个导入结果列表给前端。这里不能读一行插一行,因为如果 Excel 中间某行格式错了,前面已经插入的数据会导致“半成功”状态,回滚非常麻烦。正确做法是先全部校验,全部通过后再批量插入。
导出功能类似,HR 勾选筛选条件后,后端查询数据并写到 Excel 响应流。导出接口属于敏感操作,因为会包含身份证号等原始信息,所以我加了操作日志和权限校验,只有 HR 专员及以上角色可以调用。
5.3 合同到期提醒是怎么做的
合同到期提醒是整个需求中业务价值最高的功能之一。实现不复杂,核心就是每天定时扫描合同表里的end_date:
@Scheduled(cron = "0 0 7 * * ?") public void remindContractExpiring() { LocalDate today = LocalDate.now(); LocalDate deadline = today.plusDays(30); List<EmpContract> list = contractService.list(Wrappers.<EmpContract>lambdaQuery() .eq(EmpContract::getStatus, "有效") .le(EmpContract::getEndDate, deadline) .ge(EmpContract::getEndDate, today)); // 为每个合同生成待办提醒,同时发送邮件通知 HR }定时任务我用的是 Spring 自带的@Scheduled,没有引入 Quartz。原因还是系统规模有限,单实例部署已经完全够用。这里有个前提:如果你的系统会部署多台实例,@Scheduled会在每台实例都执行一次,造成重复提醒。解决方式也很简单,要么用分布式锁,要么在配置文件里用开关控制只有一台机器启用任务。
6. 权限模型、数据脱敏与审计日志
6.1 RBAC 权限模型怎么落到接口上
我采用的是经典 RBAC 模型:用户关联角色,角色关联菜单和权限标识。后端在接口上通过 Spring Security 的方法级注解做控制,比如:
@PreAuthorize("hasRole('HR')") @PostMapping("/archive") public R<Void> createArchive(@RequestBody @Valid EmpArchiveDTO dto) { ... }使用之前需要在启动类或配置类上开启方法级安全:
@EnableGlobalMethodSecurity(prePostEnabled = true)这样能做到“注解即权限”,每一个 Controller 接口是开放的还是受限的,一眼就能看出来。
6.2 数据权限:接口权限还不够
接口权限只能控制“谁能访问这个按钮”,但控制不了“谁能看这一行数据”。部门管理员可以访问员工列表接口,但他只能看到自己部门的人。这部分我在 Service 层做了一层数据过滤:
boolean deptAdmin = SecurityUtils.hasRole("DEPT_ADMIN"); if (deptAdmin) { Long deptId = SecurityUtils.getCurrentUser().getDeptId(); wrapper.eq(EmpArchive::getDeptId, deptId); }这里容易犯的一个错误是把数据权限写死在 SQL 字符串里,比如硬编码一个常量。正确做法是把当前登录用户的部门 ID 和角色信息从安全上下文取出来,动态加到查询条件中。最后再看接口效果,我做了个测试:部门管理员用接口工具直接传别人的部门 ID,后端同样会忽略掉,因为他根本没有那个部门的数据权限。
6.3 敏感字段脱敏怎么做
身份证号、手机号、家庭住址这些信息在前端页面默认要脱敏。我写了一个工具类,核心逻辑很简单:身份证保留前 3 位和后 4 位,中间用星号补齐;手机号保留前 3 位和后 4 位。
public String maskIdCard(String idCard) { if (idCard == null || idCard.length() < 10) { return idCard; } return idCard.substring(0, 3) + "**********" + idCard.substring(idCard.length() - 4); }这个脱敏操作一定是在 VO 层做,而不是把实体脱敏。原因前面提过:数据库原始数据必须完整,而展示层可以脱敏。如果数据库直接存脱敏数据,那 HR 导出身份证原件时反而要二次还原,既麻烦又容易出错。
6.4 操作日志如何埋点不脏代码
档案系统的审计要求很高,谁在什么时候删除了一个员工档案,谁导出了身份证信息,都需要留痕。如果每个方法里手动写日志代码,太容易漏写,而且会让业务代码变脏。
我用的是自定义注解加 AOP 切面的方式。先定义一个注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperationLog { String value() default ""; }然后在需要记录日志的 Controller 方法上打上注解:
@OperationLog("删除员工档案") @DeleteMapping("/archive/{id}") public R<Void> deleteArchive(@PathVariable Long id) { ... }切面会拦截这些方法,把当前用户、操作类型、请求参数、响应结果、IP 地址、执行时间统一写入sys_operation_log表。这样业务代码里一行日志代码都不用写,审计功能也不会被人为绕过。
7. 开发中最容易翻车的几个细节
7.1 MyBatis-Plus 的 and/or 条件覆盖问题
这个坑我在前面提到过,再展开讲一下。当时写员工列表的时候,我以为只要把条件一个一个串起来就行,于是写出了这样的代码:
wrapper.like(EmpArchive::getName, keyword) .or().like(EmpArchive::getEmpNo, keyword) .eq(EmpArchive::getDeptId, deptId);最终生成的 SQL 条件变成了:
WHERE name LIKE ? OR emp_no LIKE ? AND dept_id = ?由于 SQL 里AND的优先级比OR高,部门过滤条件只作用在emp_no LIKE这一项上。前端传一个带name关键字但不带部门上下文时,结果就可能把别的部门数据查出来。部门管理员看到其它部门员工,这就是严重的数据越权。
正确做法是用and(...)把一组OR条件包裹起来:
wrapper.and(w -> w.like(EmpArchive::getName, keyword) .or().like(EmpArchive::getEmpNo, keyword)) .eq(EmpArchive::getDeptId, deptId);生成的条件就是(name LIKE ? OR emp_no LIKE ?) AND dept_id = ?。这里给所有用 MyBatis-Plus 写动态条件的朋友一个建议:只要OR参与查询,就一定要检查它是否被括号正确隔离,否则很容易出现“接口能用、数据不对”的隐性 bug。
7.2 分页插件没注册导致假分页
MyBatis-Plus 的分页查询不是内置直接可用的,需要注册分页插件,否则调用selectPage时,虽然接口返回了 Page 对象,但实际数据并不会分页,而是把全表所有数据查出来然后在内存里假分页。这样接口响应看起来是正常的,但数据库性能迟早会被打爆。
注册方法是在配置类里加入:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }排查这个问题的思路很简单:给 SQL 日志打开,或者看耗时。如果列表接口动辄几百毫秒,而数据量明明只有几百条,那就很有可能是全表查询了。
7.3 日期序列化出现时区偏差
前端页面显示时间比数据库时间早 8 个小时,这类问题在 Spring Boot 项目里很常见。原因有两个层面:JDBC 连接串没有指定时区,Jackson 序列化时又使用了默认时区。
我最终在application.yml里做了三处设置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 datasource: url: jdbc:mysql://localhost:3306/hr_system?serverTimezone=Asia/Shanghai这里还需要注意,如果数据库字段用了datetime类型,而实体里用的是LocalDateTime,序列化出来默认一般是带T的 ISO 格式。如果不希望前端看到那个T,必须在全局配置里指定date-format。
7.4 事务注解“没生效”的真实原因
我在批量导入员工时,要求“要么全部成功,要么全部回滚”,于是给 Service 方法加了@Transactional注解。结果测试发现前几条插入了,后面校验失败,前面的数据居然还在。
排查后发现问题出在“同类调用”。Controller 调用的是 Service 的importBatch方法,这个方法内部调用了同类另一个saveOne方法,而@Transactional只加在了saveOne上。由于 Spring 事务是通过代理实现的,同类内部调用不会经过代理,@Transactional自然完全失效。
正确做法是把事务注解加在外部入口方法上,或者把内部方法放到另一个 Service 中调用。这里建议优先考虑“事务边界放在最外层”原则,避免一个事务里调用了一堆同类私有方法,结果注解全部失效还不自知。
7.5 开发环境的跨域配置
前后端分离开发时,我前端跑在 8080 端口,后端跑在 8081 端口,前端访问后端必然遇到跨域。生产环境由于前后端打包到一起,基本不存在跨域,只影响开发阶段。
我在后端做了一个CorsConfigurationSource配置,开放本地开发域名并允许携带 Header。这里注意不要为了一时方便直接allowedOriginPatterns("*")配合allowCredentials(true),生产环境如果也放开,会让登录令牌有被第三方站点恶意调用的风险。正确做法是区分 profile,开发环境放开,生产环境走同源或只允许指定域名。
8. 打包发布与上线后的维护建议
8.1 构建配置与启动方式
后端 Maven 打包时把前端构建产物提前拷贝到static目录,然后执行:
mvn clean package -DskipTests java -jar hr-system.jar --spring.profiles.active=prod生产环境的数据库连接、文件上传路径、JWT 密钥都放在application-prod.yml里,不在源码里写死。JWT 密钥在配置文件中用环境变量方式引用,比如${JWT_SECRET},这样即使项目代码被人拿去,也拿不到真实密钥。
文件上传目录我在生产环境固定放在/data/hr-files/,这个路径必须提前创建好,并且启动用户要有写权限。如果忘记创建,系统启动后第一次上传文件就会报目录不存在的异常。
8.2 nginx 反向代理与上传大小限制
虽然前端被 Spring Boot 托管了,但生产环境中我依然建议前面套一层 Nginx,主要目的是做 HTTPS 证书卸载、请求日志、静态文件缓存。Nginx 配置里比较容易被忽略的一个参数是上传大小限制:
client_max_body_size 50m;如果不设置这个值,Nginx 默认限制是 1MB,用户上传大一点的文件扫描件会被 Nginx 直接拦截,返回 413,而后端根本没有收到请求,排查起来容易懵。
8.3 索引设计与慢查询优化
人事档案系统的数据量不会特别大,但列表查询条件组合多了,索引设计仍然重要。我上线前给核心表加了几组关键索引:
ALTER TABLE emp_archive ADD INDEX idx_emp_name (name); ALTER TABLE emp_archive ADD INDEX idx_emp_dept_status (dept_id, status); ALTER TABLE emp_contract ADD INDEX idx_contract_end (end_date); ALTER TABLE emp_attachment ADD INDEX idx_attachment_emp (emp_id);这部分设计的原则是:查询条件里频繁出现的字段要建索引,联合索引要遵循最左前缀原则。像dept_id + status这种组合条件,查询频次高,适合建联合索引。合同到期提醒定时任务每天晚上扫描end_date,在这个字段上建索引能避免全表扫描。
8.4 数据库备份策略
人事档案属于长期保存的高价值数据,备份不能停。我给的备份方案是每天凌晨执行一次逻辑备份:
mysqldump -u root -p hr_system --single-transaction --routines | gzip > /backup/hr_backup_$(date +%Y%m%d).sql.gz--single-transaction参数能够保证备份期间不锁表,业务可以继续写入。备份文件保留最近 30 天,同时每周做一次异地复制。文件上传目录里的附件也同样需要备份,最好用 rsync 同步到另一台机器,光备份数据库而丢了附件,档案还是不完整。
项目上线稳定运行以后,我最大的体会是:这种系统决定成败的往往不是写代码的能力,而是需求边界和细节控制。权限有没有漏,敏感信息有没有脱敏,合同到期能不能提醒到人,文件路径会不会被传出去,这些才是人事档案管理系统真正的核心。如果再让我做一遍,我依然会选择 Spring Boot 2.7 这套稳扎稳打的方案,先把地基打牢,再去谈花哨的功能。
最后一个小建议:做这类系统时,把所有敏感操作日志和权限校验留在后端,不要依赖前端按钮隐藏来控制安全性。按钮隐藏只是体验,真正能保护数据的,永远是服务端的校验逻辑。把这条想清楚,你就离“能上线”不远了。