月初刚给一家做跨境电商的客户交付了一套基于Spring Boot的人力资源管理系统,前后从需求梳理到上线差不多六周。这段时间后台陆续有同行问我,这类系统从零开始做,技术选型怎么做、数据库表怎么设计、哪些功能模块优先级最高,踩坑点又集中在哪。今天就拿这套HR系统当引子,把我实际走的流程、做的取舍、趟过的坑全部摊开来讲,希望能给你一个可以直接抄作业的参考。
先说清楚这套系统解决了什么。对于中小型公司来说,人事管理最头疼的往往不是招不到人,而是日常事务太碎:员工档案散落在Excel里、考勤统计要手工算半天、请假调休靠微信记录、工资表月底对来对去总差几块钱。这套基于Spring Boot的人力资源管理系统,就是把员工信息、部门组织、考勤打卡、请假审批、薪资管理、公告通知这些日常人事事务,统一收到一个Web应用里来管。角色权限上分成管理员、HR、部门主管、普通员工四种,各角色看到的菜单和操作按钮完全不同,数据也都是按部门和职级隔离的。
这套项目的技术栈非常标准,但如果没人帮你把每一步捋清楚,新手照着网上的零散教程写,很容易在数据库关联、权限控制、事务处理这些地方卡住。这篇文章我不会只贴代码,而是重点讲清楚每个设计背后的原因,以及我在实际开发中验证过的最优做法。
1. 项目立项与需求拆解
1.1 一个HR系统到底要解决什么事
接手这个项目前,客户给的原话是"我们就是想把人事那些Excel表搬到网页上"。需求听起来简单,但真搬到网页上,难点全在细节里。比如员工的入职日期要不要精确到天?离职员工的档案是物理删除还是逻辑删除?考勤迟到怎么判定,弹性打卡怎么算?这些业务规则如果不提前敲定,代码写着写着就会返工。
我在立项阶段习惯做一件事:把客户口头提的需求整理成一张功能清单,再按"必须做、应该做、可以不做"排个优先级。这套HR系统的核心优先级是这样的:
- P0(第一版必须上线):员工档案管理、部门管理、用户登录与权限控制、员工增删改查、基本数据统计
- P1(第二版迭代):考勤打卡管理、请假审批流程、薪资管理、公告发布
- P2(有条件再做):绩效评分、培训记录、招聘流程跟踪、报表导出
这个优先级排序是有讲究的。员工档案是数据根基,登录权限是系统安全底线,这两块不做好,后面全白搭。考勤和薪资虽然业务上很痛,但涉及复杂的规则运算,放到第二版做,可以先把基础档案的数据准确性养起来。很多新手一上来就想做薪资计算,结果员工基础数据乱七八糟,算出来的工资根本没人敢用。
1.2 角色权限的划分逻辑
权限设计的核心不是"谁能登录",而是"谁能看什么数据、操作什么功能"。这套系统我做了四类角色:
| 角色 | 核心权限 | 数据可见范围 |
|---|---|---|
| 管理员 | 全部功能,含用户授权、系统配置 | 全部数据 |
| HR专员 | 员工档案、考勤、薪资、报表 | 全部数据,但无权限管理功能 |
| 部门主管 | 审批下属请假、查看本部门考勤 | 仅本部门数据 |
| 普通员工 | 查看个人信息、提交请假、打卡 | 仅本人数据 |
这四类角色对应到代码里,就是经典的RBAC(基于角色的访问控制)模型。用户表、角色表、权限表三张基础表,再加两张关联表,用Spring Security或Shiro做拦截过滤。这里有个经验之谈:不要一上来就把权限做到菜单按钮级别,先做到"菜单可见 + API可访问"两层就够了。按钮级别的细粒度控制,等第一版业务跑顺了再按需加,不然光配权限就要熬几个通宵。
我在权限这块踩过最大的坑是部门数据隔离。一开始只用角色控制可访问的菜单,结果发现HR能看全部数据、部门主管也能看全部数据,这就出事了。后来引入了"数据权限范围"的概念,在查询语句里根据当前用户的角色动态拼接部门过滤条件,主管角色只能查自己部门那棵子树的员工。这个逻辑想清楚后,用MyBatis-Plus的拦截器或者手动拼接SQL都能实现,建议手动拼,逻辑透明好排查。
2. 技术选型与底座搭建
2.1 为什么锁定Spring Boot这个底座
这套系统后端选型没有太多悬念,就是Spring Boot。原因很直接:Spring Boot的自动配置机制把Spring家族那些复杂的XML配置全部变成了约定,你只要引依赖、写application.yml,就能把环境跑起来。对于HR系统这种典型的CRUD密集型业务,Spring Boot的成熟度和小生态足够覆盖,社区案例多,招人也好招。
版本上我选的是Spring Boot 2.7.x,不是最新的3.x。原因有两个:一是3.x从javax包迁移到了jakarta包,很多老教程和老依赖不兼容,新手上手容易卡在包名报错上;二是作为交付项目,稳定压倒一切,2.7还在主流维护期内,网上能搜到的解决方案也最多。如果你是自用学习,直接上3.x没问题,但如果是承接交付项目,优先选2.7。
配套的JDK用的1.8,这在2024年的眼光看稍微有点老,但对于这套系统,JDK 8配合Spring Boot 2.7,运行稳定、内存占用合理,部署到客户的windows服务器上也不会出幺蛾子。没必要为了新特性去升级基础设施,能用、稳定才是第一位的。
2.2 数据访问层:MyBatis-Plus提升效率的关键
持久层我选了MyBatis-Plus,不是原生MyBatis,也不是Spring Data JPA。
MyBatis-Plus说白了就是MyBatis的增强插件,它最实用的三把刷子:通用Mapper(单表CRUD不用写SQL)、分页插件(一个拦截器搞定分页)、条件构造器(LambdaQueryWrapper)。HR系统的业务大部分是单表查询和简单的多表关联,用MyBatis-Plus可以让代码量减少至少三成。
举个小例子,员工列表的分页查询,原生MyBatis你得写SQL、写count查询、写Page类,用MyBatis-Plus就是一个LambdaQueryWrapper的事:
// 员工分页查询,带部门过滤和关键字搜索 public Page<EmployeeVO> pageQuery(int pageNum, int pageSize, String deptId, String keyword) { Page<Employee> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Employee> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Employee::getName, keyword) .eq(StringUtils.hasText(deptId), Employee::getDeptId, deptId) .orderByDesc(Employee::getCreateTime); IPage<Employee> result = employeeMapper.selectPage(page, wrapper); // 再循环组装部门名称等VO字段 ... }如果要做复杂的报表统计、多表联查、分组聚合,MyBatis-Plus也支持在Mapper里自定义XML SQL,两种模式混着用,灵活度足够。
2.3 前端渲染方案:模板引擎还是前后端分离
这是很多做单体应用的人纠结的问题。我的选择是:第一版用Thymeleaf服务端渲染,第二版再考虑上Vue做前后端分离。
为什么这么选?因为HR系统是典型的内部管理系统,用户量就几十上百人,没必要为了"技术时髦"引入Node、Vite、Axios一整套前端工程链。Thymeleaf直接跟着Spring Boot的模版目录走,页面用HTML加th:标签写,后端ModelAndView一返回,数据就填进页面了,开发效率极高,学习成本也很低。
需要注意,Thymeleaf页面默认放在src/main/resources/templates下,静态资源放static下。用模板引擎时有个很常见的坑:页面的公共部分(侧边栏、头部导航)最好用th:fragment抽取成公共片段,不然改一次菜单要翻十几个HTML文件,能改到怀疑人生。我在这个项目里把侧边栏做成了fragment,后续加菜单只要改一个地方就行。
<!-- 公共侧边栏片段,放在common/sidebar.html --> <nav th:fragment="sidebar"> <a th:each="menu : ${session.userMenus}" th:href="${menu.url}" th:text="${menu.name}" class="menu-item"></a> </nav> <!-- 其他页面引入 --> <div th:replace="~{common/sidebar :: sidebar}"></div>至于前后端分离,等你业务量真上来了、需要专职前端配合时再迁移不迟。内部管理系统用Thymeleaf,省掉的工程量绝对超出你的想象。
3. 数据库设计:整个系统的地基工程
3.1 核心表结构设计
数据库设计是这套系统里决定成败的环节。表设计得烂,后面写多复杂的代码都救不回来。我梳理的核心表如下:
sys_user——登录用户表,存用户名、密码(加密)、手机号、状态、关联员工IDsys_role——角色表,存角色编码和名称sys_permission——权限表,菜单和按钮都在这张表sys_user_role/sys_role_permission——用户角色、角色权限关联表emp_employee——员工档案表,存姓名、性别、身份证、手机、职位、入职时间、部门IDorg_department——部门表,带parent_id支持树形层级att_attendance——考勤记录表,存打卡上下班时间、日期、状态lea_leave——请假申请表,存开始结束时间、类型、原因、流程状态sal_salary——薪资记录表,存基本工资、绩效、补贴、应发实发等sys_notice——公告表
员工表是体系的核心,字段规划上我建议把"容易变化的属性"和"相对固定的属性"分开。比如岗位名称属于易变字段,部门层级也可能调整,但身份证号、姓名、入职日期这些是相对固定的。字段设计时多用字典编码(比如性别用0/1、状态用枚举值),不直接存中文。
3.2 两个容易犯的设计错误
第一个错误:员工表直接存部门名称,不存部门ID。这会引发连锁问题——部门改名了,员工表里的旧名字就成了僵尸数据;查某部门所有员工时,还得拿部门名称去模糊匹配,数据一多必然乱。正确做法是员工表存dept_id,再关联org_department表,获取部门名称用联查或单独填充。
第二个错误:用物理删除处理离职员工。HR系统的员工数据带有审计属性,离职员工的信息要留档,工资历史要可追溯。所以普遍做法是增加一个status字段(0-在职,1-离职,2-停用),所有查询默认带上status条件。逻辑删除还有个好处,误操作可以随时恢复。MyBatis-Plus提供了@TableLogic注解做逻辑删除,配好后delete接口自动变成update操作。
3.3 数据字典与枚举的联动
我在项目中把员工性别、婚姻状态、学历、合同类型、请假类型这些"几选一"的字段,统一做成sys_dict数据字典表。好处是前端下拉框数据不用写死在后端代码里,HR在界面上自己维护字典项,加个"事假类型"不用重新发布版本。另外在Java侧,对应字段建议用枚举类接收,避免魔法值乱飞。比如请假类型,用枚举定义事假、病假、年假、调休,前端传数值,后端转枚举,代码里只认枚举,逻辑清晰不易出错。
数据库字符集一定记得用utf8mb4,不要用utf8。原因很简单,utf8在MySQL里最多存3个字节的字符,而emoji和部分生僻字需要4个字节,你不想用户把手机号备注输成表情后入库报错吧?
4. 核心功能模块落地实现
4.1 登录认证与权限拦截
登录这块我用的Spring Security加JWT的方案。为什么不用传统的Session?因为这套系统后续希望能分离前端,提前用无状态的JWT,未来扩展移动端或小程序时认证逻辑不用重构。JWT的实施要点是:登录成功后签发一个token,前端每次请求在Header里带Authorization: Bearer <token>,后端用拦截器解析并放入当前用户上下文。
// JWT工具类核心方法 public String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }这个方案的坑点也很明显:JWT有效期设置太短用户要老是重新登录,太长又有安全风险。实践中我把token有效期设置为8小时,再加一个refresh_token来刷新,复杂度偏高。如果你求稳,用Spring Security的Session方案完全够用,JWT不是必须的。这里要特别强调,密码存储千万别用明文,也别用MD5,用BCryptPasswordEncoder,每次校验时把用户输入的密码和数据库里的hash值做匹配。这一点很多培训项目都不讲,但真实交付时安全审计会直接亮红灯。
4.2 员工档案管理
员工模块是最典型的CRUD,但我在做的时候给它加了三个实用功能:导入导出、附件管理、变更记录。
导入导出用的是EasyExcel,不再推荐POI原生了,原生的API写起来太繁琐。EasyExcel封装好读和写的监听器,支持批量导入五万条以内的数据很轻松。员工导入模板里我做了校验规则:身份证号要能通过18位格式校验、手机号要符合11位规范、部门名称必须存在于部门表。导入时逐行校验,不符合的记录下来生成错误报告提示用户下载,这个细节得到了客户HR的高度好评,因为Excel操作出错是必然的,能不能告诉用户哪错了一行错哪了才是关键。
附件管理主要是员工的合同扫描件、学历证书照片、身份证复印件。文件不能直接存数据库,数据库里只存文件的存储路径或对象地址,文件本身放本地磁盘的指定目录,或者云存储的bucket里。上传接口要做好文件类型白名单校验和大小限制,避免别人把exe文件传到你的服务器上。
4.3 考勤与请假审批
考勤这块客户内部用的是钉钉打卡,数据能导出Excel,所以我们的方案不是自己做打卡功能,而是做一个导入功能:HR把钉钉导出的考勤记录Excel上传到系统,系统解析后和排班表比对,自动判断正常、迟到、早退、缺勤等状态。
这个设计要分享一个很关键的教训:考勤比对不能拿"打卡时间"直接和"上班时间"比,除非公司上下班时间全市统一。很多公司是弹性工作制,不同部门排班不同,甚至同一部门不同日子班次也不同。所以我把排班表做成了独立的一张表,员工绑定班次,再根据日期规则判断工作日。考勤自动判定时,先查当天该员工的班次设定,再拿实际打卡时间和班次上下班时间去比对。
请假审批流程我实现了一个简单的工作流:员工提交请假单,状态为待审批,部门主管看到待审批列表,同意或驳回,通知HR,最后同步到考勤模块自动算扣除。这套流程用状态机实现就够了,订单场景下用Activity工作流太重,反而把简单问题复杂化。核心就是一张请假表加一个状态字段流转:
// 审批状态流转 PENDING -> APPROVED / REJECTED APPROVED -> CANCELED(申请人取消) APPROVED -> SYNCED(同步考勤和薪资)4.4 薪资管理与统计报表
薪资模块的计算逻辑是客户最敏感的,错了任何一分钱都会炸锅。我的处理方式是:薪资公式不硬编码在Java里,而是做成工资项配置表。基本工资、岗位工资、绩效系数、加班费、餐补、社保扣款、个税专项扣除这些都作为可配置的工资项,每个工资项绑定计算规则(固定金额、按比例、按考勤天数折算)。月底生成工资时,遍历每个员工的在职期间的考勤、请假、绩效结果,套用配置规则逐项计算,最后汇总成工资条。
报表这块用了ECharts,后端用SQL聚合出数据,前端渲染成柱状图、饼图。实际用下来,HR最常看三张报表:各部门人数分布、月度入离职趋势、考勤异常汇总。这些SQL都不复杂,关键是要提前做好统计口径的定义。比如"离职率"到底是用期初人数做分母还是期初期末平均人数做分母,不同口径算出来差很多,一定要和客户当面确认清楚,做成参数放到配置里。
5. 打包部署与项目瘦身
5.1 环境配置分离
项目开发和生产的数据库连接、日志级别、文件路径肯定都不一样。我通过Maven的Profile机制做了三套环境配置:dev(开发)、test(测试)、prod(生产)。Spring Boot会根据启动参数spring.profiles.active来加载对应环境的application-xxx.yml。
# 主配置 application.yml spring: profiles: active: @profile.active@<!-- Maven pom.xml 配置多环境 --> <profiles> <profile> <id>dev</id> <properties><profile.active>dev</profile.active></properties> </profile> <profile> <id>prod</id> <properties><profile.active>prod</profile.active></properties> </profile> </profiles>打包的时候用mvn clean package -Pprod,自然的把生产环境配置打包进去。这样开发机、测试机、生产机的配置互不干扰,不会出现开发本地连生产库的悲剧。
5.2 常见打包陷阱
打包阶段新手最容易碰到的问题是Spring Boot Maven插件的配置。如果你只引了spring-boot-starter-parent,但没配spring-boot-maven-plugin,打出来的jar包运行时会直接报"没有主清单属性"。这个插件的作用是把依赖和工程的类一起打成一个可执行的fat jar,缺了它Spring Boot应用就跑不起来。
打出来的jar包默认比较大,动辄七八十兆。如果客户服务器带宽有限,可以用spring-boot-maven-plugin的includes配置把依赖打到一个单独的lib目录里,jar和lib分离部署,以后只要没改依赖,升级只传一个小jar包就行了。不过这个优化看部署环境,如果内网部署带宽无压力,直接fat jar省心。
6. 常见问题与排查实录
6.1 MySQL连接与时区问题
这个坑我相信每个Spring Boot新手都遇过。用MySQL 8.0时,驱动类从com.mysql.jdbc.Driver变成了com.mysql.cj.jdbc.Driver,同时URL必须加时区参数serverTimezone=Asia/Shanghai,不然启动或首次查询时报时区错误。这个问题的本质是MySQL 8.0的驱动默认使用的是服务器的UTC时区,和国内东八区差了8小时,导致时间字段全部偏差。
spring: datasource: url: jdbc:mysql://localhost:3306/hr_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai另一个时间深坑是后端返回日期格式。Java的LocalDateTime默认序列化成yyyy-MM-dd'T'HH:mm:ss.SSS这种带T的格式,前端Thymeleaf显示出来非常丑。解决办法是全局配置Jackson的日期序列化格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+86.2 MyBatis-Plus分页不生效的经典难题
MyBatis-Plus的分页插件光引入依赖还不够,必须注册一个PaginationInnerInterceptor的Bean,很多人忘记这一步,导致分页查询返回全部数据但不报错。分页插件没注册时,selectPage实际上执行的是普通查询,数据全量查出来再内存里"假分页",数据量一大页面直接卡死。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }排查这类问题有个思路分享给你:遇到"能跑但行为不对"的,先去看SQL日志。在application.yml里打开mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,控制台会打印每一条实际执行的SQL和参数。分页不生效时,看看SQL末尾有没有LIMIT,一眼就能定位问题。
6.3 拦截器的放行路径配置
Spring Security或自定义拦截器登录校验时,最容易被静态资源挡住。Thymeleaf页面依赖的CSS、JS、图片都在static目录下,这些请求不应该被拦截。我一开始配置拦截器时只放行了login路径,结果登录页加载出来样式全丢,翻浏览器控制台才发现css请求被302重定向了。
配置拦截器放行时,除了登录接口,还要放行静态资源:
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login", "/css/**", "/js/**", "/images/**", "/error"); }这个配置还有另一个隐蔽问题:/**会拦截URL中包含/druid或某些监控页面,如果你要保留后端管理面板,也要按需放行。
6.4 事务失效的三个原因
薪资生成这类批量操作一定是事务性的,要么全部成功,要么全部回滚。事务失效是业务系统最常见的隐蔽bug,常见原因有三类:
第一类是方法自调用。同一个类里一个方法调用另一个方法,Spring的@Transactional是基于AOP的,AOP代理只对外部调用生效,内部this.method()调用会绕过代理,事务就失效了。第二类是方法不是public的,Spring的事务只对public方法生效。第三类是抛出的异常不是RuntimeException,默认情况下Spring事务只在遇到RuntimeException或Error时回滚,受检异常不会触发回滚。如果业务方法抛的是Exception子类,必须写@Transactional(rollbackFor = Exception.class),否则数据就悄悄提交了。
排查事务问题有个复盘技巧:在测试环境故意往数据里写一条中间状态,看失败后数据库是否回滚。很多时候代码组件本身功能正常,恰恰是这些基础设施性质的细节决定了项目交付后的稳定性。
7. 一套可复用的数据库初始化脚本参考
最后给出一套我在本项目中使用过的数据库核心建表脚本,供你快速起步。这里做了一定精简,实际项目中的字段会更多,但骨架一致。
-- 部门表 CREATE TABLE `org_department` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `parent_id` BIGINT DEFAULT NULL COMMENT '父部门ID', `dept_name` VARCHAR(50) NOT NULL COMMENT '部门名称', `leader_id` BIGINT DEFAULT NULL COMMENT '负责人员工ID', `status` TINYINT DEFAULT 1 COMMENT '1-启用 0-停用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表'; -- 员工档案表 CREATE TABLE `emp_employee` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `dept_id` BIGINT DEFAULT NULL, `emp_no` VARCHAR(20) NOT NULL COMMENT '工号', `name` VARCHAR(50) NOT NULL, `gender` TINYINT DEFAULT 0 COMMENT '0-男 1-女 2-未知', `id_card` VARCHAR(18) DEFAULT NULL, `phone` VARCHAR(11) DEFAULT NULL, `position` VARCHAR(50) DEFAULT NULL COMMENT '职位', `hire_date` DATE DEFAULT NULL COMMENT '入职日期', `status` TINYINT DEFAULT 0 COMMENT '0-在职 1-离职 2-停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_emp_no` (`emp_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工档案表';建表时统一加created_time和updated_time这两个审计字段。Spring Boot项目可以配合MetaObjectHandler自动填充,这习惯一旦养成,以后不管做什么系统,数据审查、问题排查都会省很多力。
做完这套系统,我最大的体会是:人力资源管理系统技术上不花哨,但它是典型的"业务逻辑复杂度大于技术复杂度"的项目。真正花时间的不是那个CRUD接口,而是那些需要和客户来回确认的业务规则。所以如果你正准备做类似的系统,我给你的建议很简单:设计阶段多和真实的HR聊一小时,比开发阶段少加七天班有用得多。把员工数据规则、考勤判定逻辑、薪资计算口径这些前置问题全部敲定,写代码就是水到渠成的事。