我接过不少类似的内部管理系统这次这个项目从标题就能看出它是典型的“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.18Spring Boot 3.xJDK 要求8 及以上17 起步包命名空间javaxjakarta老库兼容性好需要逐一适配适合场景企业内网、维护成本敏感全新项目、团队技术较新如果你是新项目且团队已经全部切到 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、返回统一结构RT。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。LambdaQueryWrapperEmpArchive 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); PageEmpArchive 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 RT { 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对于扫描件根本不够。我在配置里调整到了 20MBspring: 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_dateScheduled(cron 0 0 7 * * ?) public void remindContractExpiring() { LocalDate today LocalDate.now(); LocalDate deadline today.plusDays(30); ListEmpContract list contractService.list(Wrappers.EmpContractlambdaQuery() .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 RVoid 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 RVoid 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: GMT8 datasource: url: jdbc:mysql://localhost:3306/hr_system?serverTimezoneAsia/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.activeprod生产环境的数据库连接、文件上传路径、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 这套稳扎稳打的方案先把地基打牢再去谈花哨的功能。最后一个小建议做这类系统时把所有敏感操作日志和权限校验留在后端不要依赖前端按钮隐藏来控制安全性。按钮隐藏只是体验真正能保护数据的永远是服务端的校验逻辑。把这条想清楚你就离“能上线”不远了。