1. 项目整体设计与思路拆解
1.1 为什么还要做一套人力资源管理系统
很多同学在选毕业设计课题或者练手项目时,第一反应是“HR系统是不是做烂了”。这话不假,市面上开源的人力资源管理系统一把一把的,但恰恰因为烂大街,反而说明这个题目有它的合理之处:需求足够经典、业务边界足够清晰、技术栈足够匹配企业真实开发场景。我见过太多人上来就选什么“基于微服务的电商平台”“基于区块链的溯源系统”,结果做了一半发现能力跟不上,代码全是Ctrl+C和Ctrl+V,答辩的时候连自己项目里的核心流程都讲不清楚。
人力资源管理系统恰好卡在一个非常适合全栈练手的复杂度区间。你说它简单,它有员工信息管理、组织架构管理、考勤排班、薪资计算、招聘流程、权限控制这些业务模块,逻辑链条完整;你说它复杂,它又不像电商系统那样需要处理高并发秒杀、分布式事务、消息队列削峰这些高难度问题。换句话说,这个选题的技术挑战核心不在“高并发”,而在“业务建模的清晰度”和“前后端协作的规范度”,这两个能力恰恰是刚入行的开发者最需要建立的职业素养。
从实际复用价值来看,这套系统做完之后可以改造成企业内部的管理后台、社团管理平台、甚至是一套通用的CRUD权限脚手架。我见过不少开发者把自己做的HR系统经过简单改造直接变成了公司的内部后台基座,因为员工管理、角色权限、操作日志这些模块几乎是所有管理系统的公共底座。
1.2 技术选型背后的真实考量
这次项目采用的技术栈是Spring Boot + Vue + ElementUI,后端2.x版本搭配前端2.x版本,看起来是市面上最常见的组合,但每个技术选型背后都有实际的理由。
Spring Boot作为后端框架,最大的价值不是它比SSH省了多少XML配置,而是它带来了“约定大于配置”的开发节奏。对于一个人要写完前后端的场景来说,Spring Boot能让你把注意力集中在业务代码本身,而不是耗费大量时间在框架配置上。配合MyBatis Plus使用,单表CRUD几乎不需要手写SQL,连分页都帮你封装好了。如果这个项目的目标是快速成型、稳定运行,这个组合是最不容易出意外的。
Vue 2 + ElementUI的组合放在今天看来不算最新,但胜在生态极其成熟。ElementUI的表格组件、表单组件、弹窗组件、树形控件直接覆盖了HR系统的全部界面需求,你不需要花一个下午去研究某个组件怎么实现“编辑弹窗表单回显”这种功能,人家早就帮你做好了。而且网上针对这两个框架的资料多到溢出,遇到任何问题都能快速找到解决方案。这套组合还有一个隐含的优势:团队协作成本低。如果后续有同学加入项目,Vue 2的学习曲线比Vue 3组合式API要缓和不少,沟通成本低,上手速度快。
我没有选择把后端拆分成微服务,也没有选择用Redis做缓存、用RabbitMQ做异步消息。原因很简单:HR系统本身的并发量并不高,一个几百人规模的企业,每日活跃操作撑死几千次,单体架构完全扛得住。引入微服务和消息队列只会增加部署复杂度,对系统性能的提升却微乎其微,属于典型的技术过度设计。我在实际开发中一条原则就是:系统复杂度必须匹配业务复杂度,宁可留出扩展空间,也不要为了技术而技术。
1.3 模块规划与功能边界划分
整个系统我规划为六大核心功能模块,外加一个系统管理模块作为公共底座:
- 员工管理模块:员工信息档案、入职登记、离职办理、员工调动记录
- 组织管理模块:部门结构树、岗位信息、编制管理
- 考勤管理模块:排班管理、打卡记录、请假审批、加班登记
- 薪资管理模块:薪资结构设置、工资条生成、社保公积金计算
- 招聘管理模块:职位发布、简历管理、面试流程跟踪
- 系统管理模块:用户管理、角色管理、菜单权限、操作日志
每个模块都不是孤立的。员工档案会关联到部门树上的节点,考勤数据会汇总进入薪资计算流程,招聘通过审批的员工需要一键转入员工管理模块,系统管理中的角色菜单权限决定每个用户能看到的操作按钮。这些模块之间的数据流转关系,构成了整个系统最核心的业务骨架。
在功能边界划分上,我刻意没有把“培训管理”“绩效管理”塞进第一版。原因很简单——这两块业务弹性太大,需求定义稍有不清晰就会导致数据库设计反复返工。绩效管理的考核指标体系在不同的企业可能是完全不同的两套玩法,培训管理的课程体系、签到统计、效果评估做起来工作量也不小。做项目最忌讳的是一开始就把范围铺太大,先把核心闭环打通,再逐步迭代扩展,这才是做项目的基本节奏。
2. 数据库设计与权限模型构建
2.1 数据库表结构设计思路
数据库设计是整个系统的地基,这块如果做得烂,后面写业务代码的时候会处处难受。我这次采用的是MySQL 5.7数据库,InnoDB存储引擎,utf8mb4字符集。表结构设计遵循了三范式,但在具体字段的设计上做了适度冗余,目的很简单:减少多表联查的频率,提升查询性能。
核心数据表分为几组:
第一组是组织架构相关的表,部门表和岗位表。部门表的核心字段包括部门ID、部门名称、父部门ID、负责人ID、排序号、状态。这里有个关键设计——通过父部门ID字段实现树形结构,而不是单独建一张层级关系表。对于HR系统这个业务场景来说,部门层级一般不会超过四层,直接用parent_id字段维护树形关系就足够了,查询子部门时递归查询,逻辑简单直观。
第二组是员工相关的主数据和从表。员工主表存储工号、姓名、性别、出生日期、身份证号、手机号、邮箱、入职日期、员工状态、部门ID、岗位ID等核心字段。这里特别要注意的是员工状态字段,我设置了试用期、正式、离职、停薪留职四种状态,后续很多业务流程都会根据这个字段做条件过滤。除了主表之外,还单独建了员工教育经历表和员工工作经历表,都属于典型的1对N从表设计。
第三组是考勤和薪资相关的业务表。考勤记录表是最核心的一张表,字段包括员工ID、考勤日期、上班打卡时间、下班打卡时间、考勤状态(正常、迟到、早退、缺勤、请假、加班)。薪资表的设计要复杂一些,分为薪资结构表和工资条明细表。薪资结构表定义每个岗位的基本工资和岗位工资,工资条明细表则是每个月实际生成的工资单,记录实际发放的各项金额。
第四组就是系统管理相关的表:用户表、角色表、菜单表、用户角色关联表、角色菜单关联表、操作日志表。用户表在员工表是外键关联的,但不是强制一对一绑定。我在这里做的设计是:用户表独立存在,user_id关联员工表emp_id,允许为空。这样设计的好处是系统可以存在部分“非员工用户”,比如系统维护人员、外部审计人员等,灵活度更高。
2.2 核心表结构的关键SQL参考
部门表结构设计如下:
CREATE TABLE `sys_dept` ( `dept_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '部门ID', `parent_id` bigint(20) DEFAULT '0' COMMENT '父部门ID', `ancestors` varchar(50) DEFAULT '' COMMENT '祖级列表', `dept_name` varchar(50) NOT NULL COMMENT '部门名称', `order_num` int(4) DEFAULT '0' COMMENT '显示顺序', `leader` varchar(20) DEFAULT NULL COMMENT '负责人', `phone` varchar(11) DEFAULT NULL COMMENT '联系电话', `email` varchar(50) DEFAULT NULL COMMENT '邮箱', `status` char(1) DEFAULT '0' COMMENT '部门状态(0正常 1停用)', `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0存在 2删除)', `create_by` varchar(64) DEFAULT '' COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_by` varchar(64) DEFAULT '' COMMENT '更新者', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`dept_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='部门表';员工主表是整张系统中数据量最大、字段最多的表,这里给出一个精简后的核心版本:
CREATE TABLE `emp_employee` ( `emp_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '员工ID', `emp_no` varchar(20) NOT NULL COMMENT '工号', `emp_name` varchar(30) NOT NULL COMMENT '姓名', `gender` char(1) DEFAULT '0' COMMENT '性别(0男 1女)', `birth_date` date DEFAULT NULL COMMENT '出生日期', `id_card` varchar(18) DEFAULT NULL COMMENT '身份证号', `phone` varchar(11) DEFAULT NULL COMMENT '手机号', `email` varchar(50) DEFAULT NULL COMMENT '邮箱', `dept_id` bigint(20) DEFAULT NULL COMMENT '部门ID', `post_id` bigint(20) DEFAULT NULL COMMENT '岗位ID', `education` varchar(20) DEFAULT NULL COMMENT '学历', `entry_date` date DEFAULT NULL COMMENT '入职日期', `emp_status` char(1) DEFAULT '0' COMMENT '员工状态(0试用期 1正式 2离职 3停薪留职)', `create_by` varchar(64) DEFAULT '' COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_by` varchar(64) DEFAULT '' COMMENT '更新者', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`emp_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='员工信息表';关于表结构的经验之谈:一定要给所有业务表都加上create_by、create_time、update_by、update_time这四个公共审计字段。很多刚入行的开发者在设计数据库时容易忽略这一点,结果系统运行一段时间后出现问题想追溯数据来源,发现完全没有线索,只能干瞪眼。加上审计字段对业务逻辑没有任何负担,但对后期的数据维护和问题排查帮助巨大。
del_flag这个逻辑删除标志位也值得展开说。我在所有主表上都设计了逻辑删除,而不是物理删除。因为HR系统涉及的数据带有很强的历史追溯属性,员工离职后他的档案不能直接删掉,考勤记录更是不能丢。逻辑删除的好处是数据不真正消失,只是查询条件默认过滤掉,一旦需要审计或者追溯历史,数据仍然完好存在。
2.3 权限模型与RBAC设计
权限模型采用的是经典RBAC(基于角色的访问控制)模型,拆成五张核心表:用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。用户在系统中的权限能力完全由“用户-角色-菜单”这条链路决定。
菜单表中有一类比较特殊的数据——按钮权限。我在设计时把页面上每个操作按钮都纳入权限控制范围,比如“新增员工”按钮、“删除员工”按钮、“导出工资条”按钮。每个按钮对应菜单表里的一条记录,type字段标识是目录、菜单还是按钮。这样前端可以根据用户拥有的按钮权限,动态控制页面上的按钮渲染。没有“删除员工”权限的用户,界面上根本不出现删除按钮,而不是出现按钮后点击时后端返回错误。
登录认证这块我用的是Spring Security + JWT的方案。登录成功后由后端生成一个JWT令牌,前端通过Axios拦截器把令牌Token附加到每个请求的请求头中,后端过滤器对请求进行校验。选择JWT而不是传统Session方案,最核心的原因是前端和后端完全分离部署,JWT的无状态特性天然适合这种架构。
这里有一个需要注意的坑:JWT令牌是无状态的,服务端没法主动让某个令牌失效。如果用户的角色权限在令牌有效期内发生了变化,旧令牌仍然能访问旧权限范围内的接口,直到令牌过期。解决方式有两种,一种是把令牌有效期设短一点,比如30分钟,需要刷新机制配合;另一种是在Redis中维护一张令牌黑名单或者权限版本号。我这次采用的是把令牌有效期设为2小时,同时提供刷新令牌的接口,权限变更的频率在这个场景下远低于2小时一次,问题不突出。
2.4 数据库设计的三个避坑建议
第一,字段类型选择上,金额字段一律使用DECIMAL而不是FLOAT或DOUBLE。这个教训我是在薪资计算模块里吃过的。浮点数在计算机中本身就是近似值,员工工资这种东西精确到分,累计计算几次之后误差就会累加出来。DECIMAL(10,2)是金额字段的标准配置。
第二,手机号、身份证号这类字段在数据库里建议按字符串存储,而不是数值类型。很多新手喜欢把手机号设置成bigint,结果遇到手机号前几位是0的情况,数据直接丢失。身份证号更不用说,18位数字在bigint范围内能存下,但未来如果涉及X结尾的号码,数值类型就完全存不了。
第三,简单查询场景不要过度设计索引。我给employee表的emp_no字段建了唯一索引,给dept_id、post_id、emp_status这些高频查询字段建了普通索引。但其他的组合索引一律没建,因为系统的数据量也就是千级别,索引建多了反而拖慢写入速度、浪费存储空间,属于典型的画蛇添足。
3. 后端核心功能实现与技术要点
3.1 Spring Boot项目工程结构
后端的项目结构按功能模块分包,不是按技术层次分包。这个组织方式的区别很重要。按技术层次分包是controller、service、mapper各建一个包,所有业务模块的Controller全部塞在一起。按功能分包则是employee、attendance、salary各建一个顶层包,每个包下面再分出controller、service、mapper子包。
我这次使用的是按功能分包的方式,结构大概是这样的:
com.company.hrms ├── common // 通用工具类、常量、异常处理 │ ├── constant │ ├── exception │ └── utils ├── config // 配置类(Security配置、MyBatisPlus配置、跨域配置) ├── system // 系统管理模块(用户、角色、菜单) │ ├── controller │ ├── service │ └── mapper ├── employee // 员工管理模块 │ ├── controller │ ├── service │ ├── mapper │ └── domain ├── attendance // 考勤管理模块 ├── salary // 薪资管理模块 └── recruit // 招聘管理模块这种组织方式在功能模块越来越多时优势会非常明显。一个模块的功能涉及到的代码文件彼此靠近,改起来不需要在十几个包之间来回跳转,代码审查时按模块审查也很清晰。
后端统一返回结果类设计为Result对象,包含code、msg、data三个字段,所有接口都返回这个统一格式。前端Axios响应拦截器统一判断code值,成功才进入业务逻辑,失败时直接弹出错误提示。这个规范看着不起眼,但对前后端协作效率的提升是巨大的,沟通成本直线下降——不需要每次联调都去问“这个接口失败时返回什么格式”,所有接口都是一个约定。
3.2 员工管理模块的核心业务逻辑
员工管理模块是HR系统的“主数据”模块,其他模块都在围绕它转,这个模块我做的是最扎实的。
员工信息的CRUD接口中,复杂度最高的不是增删改查本身,而是工号生成逻辑和员工状态流转。
工号生成规则是“部门编码+入职年份+流水号”:比如部门编码是D04,入职年份是2025,流水号从001递增,生成结果就是D042025001。这个逻辑必须在后端生成,不能用前端传值。我通过查询当前部门当年已有员工数再加一作为流水号,同时为了保证并发下工号不重复,在数据库层面对emp_no加了唯一索引,双重保险。
员工状态流转这块有几个约束:试用期员工只能转正式、办理离职或停薪留职,正式员工可以转停薪留职或办理离职,离职员工不能重新变回正式状态,只能走“重新入职”流程。这些状态流转规则我用一个独立的状态机类维护,不在Service层散写代码判断。状态机的优势是新增一种状态时只需修改状态机定义,不会遗漏某个分支逻辑。
员工查询接口支持多条件组合筛选:员工姓名模糊查询、部门精确查询、状态精确查询、入职日期范围查询。这个接口的分页查询使用MyBatis Plus的LambdaQueryWrapper实现条件构造,没有手写XML SQL。MyBatis Plus在这个场景下效率确实高,但如果查询逻辑特别复杂、涉及多表嵌套查询,还是建议写XML SQL更可控。我的经验是单表条件查询用Wrapper,多表关联查询用XML自定义SQL,两条线并行互不干扰。
删除员工的逻辑需要注意,这里我实现的是逻辑删除而不是物理删除,主要原因在上一节已经详细说明了。但逻辑删除有个连锁问题需要处理——员工关联的考勤记录、工资记录、合同记录都需要一并做逻辑删除或者置为失效状态,保持数据一致性。
3.3 考勤与薪资模块的算法设计
考勤模块的核心是考勤状态判定算法。我的实现思路是:员工在考勤规则表里维护一个排班计划,比如工作日9点上班、18点下班。打卡记录表保存每天的打点记录,系统在每晚定时任务中根据当天上班打卡时间和排班计划进行比对,自动生成考勤状态:
- 上班打卡时间不超过规定时间9点整的判定为正常
- 打卡时间超过9点但在9点30分之前的判定为迟到
- 超过9点30分判定为缺勤
同样的逻辑应用在下班时间。这里要处理的一个边界场景是跨天排班,比如晚班是晚上22点到第二天早上6点。排班计划的字段设计上要能表达这种跨天场景,我通过在排班计划表中增加shift_type字段来标识早晚班类型,考勤判定逻辑里对晚班和早班采用不同的比对逻辑。
自动生成考勤状态我建议用定时任务实现,Spring Boot中自带的@Scheduled注解就够用了。需要注意的是,定时任务逻辑要写成幂等的——重复执行不会产生重复数据。做法是在考勤记录表中对“员工ID+考勤日期”建唯一索引,插入时如果冲突则进行更新操作,而不是重复插入。
薪资计算模块是最容易出错的部分。工资条的计算公式为:
实发工资 = 基本工资 + 岗位工资 + 绩效奖金 + 加班费 - 应扣社保 - 应扣公积金 - 个人所得税社保和公积金的计算基准是缴费基数,这个基数的确定规则通常是取员工上年度月平均工资,但这个值有上下限限制,我设计时在薪资结构表中维护了一个缴费基数上下限的配置项,避免算出来的社保金额不合理。个人所得税的计算使用最新的累进税率表,这一块涉及的数据比较敏感,计算逻辑一定要写单元测试覆盖各档税率边界值。
工资条生成后往工资条明细表中写入数据,同时可以生成PDF文件推送给员工的账户。这里的推送功能不是核心需求,我采用了简单的邮件方式发送PDF附件,通过JavaMailSender实现。这套流程跑通之后,从月初考勤统计到工资条发放的自动化闭环就完全建成了。
3.4 系统管理与操作日志设计
系统管理模块中的登录功能用了Spring Security框架。登录接口不做自定义Controller处理,而是通过Spring Security的过滤器链机制实现Token的签发和校验。用户输入用户名密码,认证管理器校验通过后,创建JWT令牌返回。
权限控制的核心是@PreAuthorize注解。在每个需要权限的Controller接口方法上标注权限标识符,比如:
@PreAuthorize("@ss.hasPermission('employee:add')") @PostMapping("/employee") public Result addEmployee(@RequestBody EmployeeDTO dto) { // 业务逻辑 }@ss.hasPermission方法会从当前登录用户的权限列表中查询是否包含指定的权限标识。这个权限标识在菜单表中配置,对应页面上按钮的权限标记,实现前后端权限控制的统一。
操作日志模块使用Spring AOP实现,自定义了一个@Log注解。在核心业务接口上标注该注解后,AOP切面会在接口执行前后自动记录操作信息。日志内容包含操作人、操作模块、操作类型、请求参数、请求耗时、IP地址、操作结果。参数序列化时要做到脱敏处理,比如身份证号和手机号字段在日志中做掩码显示,这是数据安全合规的基本要求。
3.5 关键接口一览与请求响应示例
后端接口设计遵循RESTful风格,以下是部分核心接口的约定:
| 接口地址 | 请求方式 | 功能说明 | 权限标识 |
|---|---|---|---|
| /api/employee/page | GET | 分页查询员工列表 | employee:list |
| /api/employee/{id} | GET | 查询员工详情 | employee:query |
| /api/employee | POST | 新增员工 | employee:add |
| /api/employee | PUT | 修改员工信息 | employee:edit |
| /api/employee/{id} | DELETE | 删除员工 | employee:remove |
| /api/attendance/record/page | GET | 分页查询考勤记录 | attendance:list |
| /api/attendance/record/generate | POST | 手动触发考勤记录生成 | attendance:generate |
| /api/salary/detail/page | GET | 分页查询工资条 | salary:list |
| /api/salary/calculate | POST | 触发薪资计算 | salary:calculate |
| /api/system/login | POST | 用户登录 | 无 |
| /api/system/logout | POST | 退出登录 | 无 |
接口返回统一结果格式的JSON示例:
{ "code": 200, "msg": "操作成功", "data": { "records": [ { "empId": 1001, "empNo": "D042025001", "empName": "张三", "deptName": "技术部", "postName": "Java开发工程师", "empStatus": "1", "entryDate": "2025-03-15" } ], "total": 26, "size": 10, "current": 1 } }4. 前端页面实现与前后端联调
4.1 Vue项目的工程化搭建
前端项目使用Vue CLI脚手架创建,先配置好基础依赖:Vue Router负责前端路由,Vuex负责全局状态管理,Axios负责HTTP请求,ElementUI负责UI组件。这套工具链是Vue 2时代最标准的全家桶组合。
前端工程结构分为几个核心目录:
src ├── api // 接口请求封装 │ ├── employee.js │ ├── attendance.js │ ├── salary.js │ └── system.js ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── store // Vuex状态 ├── utils // 工具函数(request封装,auth工具) ├── views // 页面视图 │ ├── employee │ ├── attendance │ ├── salary │ ├── recruit │ └── system └── main.jsapi目录下的每个文件对应一个后端模块,里面是细分到每个接口的请求函数。比如employee.js中会定义getEmployeePage、addEmployee、updateEmployee、deleteEmployee、getEmployeeDetail五个函数。view页面中不直接调用Axios,而是通过调用这些api函数发起请求,好处是页面代码中看不到任何请求URL字符串,修改接口地址只需改一处。
路由配置需要配合后端返回的菜单权限动态生成。用户登录成功后,系统根据用户的角色权限动态生成可访问的路由表,然后通过router.addRoutes方法添加到前端路由中。这样做的好处是无权限的页面即使通过URL强制访问也会被前端路由拦截,搭配后端接口权限校验形成双重防护。
4.2 ElementUI核心组件的实战用法
前端页面中最常用的ElementUI组件是表格组件、表单组件、弹窗组件、树形组件、分页组件。这里挑几个最关键的实战用法来说。
员工列表页是最典型的“搜索区+表格区+分页区”三段式布局。搜索区是表单组件,表格区是Table组件,分页区是Pagination组件。搜索条件变动时重新请求第一页数据,分页信息变化时重新请求对应页的数据。表格操作列中根据用户的按钮权限渲染编辑和删除按钮,权限判断通过Vuex中存储的用户按钮标识列表组合完成。
表单弹窗是员工新增和编辑共用的一个弹窗组件,通过传入的formData对象区分是新增还是编辑模式。表单校验规则在el-form-item上通过rules属性声明,对必填字段、手机号格式、邮箱格式等进行前端校验。这里有一个经验之谈——前端校验只能提升用户体验,真正防非法数据必须依赖后端校验。我在后端对每个DTO对象都加了@NotNull、@Pattern等校验注解,前端传过来的数据在后端Controller入口就被拦截掉了,根本不会进入业务逻辑层。
部门树组件在员工管理页的左侧栏使用,点击树节点时把当前部门ID传入列表查询条件,实现按部门筛选员工的功能。树的节点数据从后端部门接口异步加载,一次加载全部部门数据后在内存中构建树,因为部门数据量本身不大,不需要做懒加载优化。
4.3 Vuex状态管理与登录状态保持
Vuex中主要存储三类全局状态:用户信息、Token令牌、权限标识列表。登录成功后把这三类数据写入Vuex,同时存到LocalStorage中,浏览器页面刷新后从LocalStorage恢复Vuex状态,保证刷新页面不会丢失登录态。
Axios封装的核心在utils/request.js中,这里实现了两个关键的拦截器逻辑:
请求拦截器从LocalStorage中取出Token,加到请求头的Authorization字段上。如果Token不存在,直接跳转登录页。响应拦截器判断HTTP状态码和后端Result对象中的code值。code值等于401时说明Token过期,清除本地登录态并跳转登录页。code值为200时进入正常业务逻辑,其他code值弹出错误提示框。
这里有一个排坑经验要分享:后端接口在处理业务异常时不要返回HTTP 500状态码,而是返回HTTP 200加上业务错误码。这样前后端联调时HTTP层的错误和业务层的错误可以清晰区分,Axios拦截器处理起来逻辑也简单很多。自定义业务异常统一抛出BusinessException,由全局异常处理器转换为对应的Result格式。
4.4 前后端联调的关键配置
前后端分离开发时最常遇到的问题是跨域请求。开发环境下Vue项目运行在8080端口,后端运行在8081端口,前端请求后端接口必然存在跨域问题。解决方式有两种:后端配置CORS跨域规则,或者前端通过Vue CLI的devServer配置代理。我这次使用的是前端代理方案,在vue.config.js中配置:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } } }这样配置之后前端请求/api/employee/page时,开发服务器会把请求转发到http://localhost:8081/api/employee/page,浏览器的请求是发给同源的开发服务器,不存在跨域问题。生产部署时再用Nginx做反向代理统一转发。这个方案比后端配置CORS更优雅,同时避免了预检请求带来的额外网络开销。
Axios请求超时时间建议设置为15秒,太短容易误报超时,太长则交互卡顿体验不好。我同时在后端设置了一个全局的接口响应时间监控日志,打印出每个接口的处理耗时,方便定位慢接口。
4.5 前端页面核心代码参考
员工列表页模板的核心结构长这样:
<template> <div class="app-container"> <!-- 搜索区 --> <el-form :model="queryParams" inline> <el-form-item label="员工姓名"> <el-input v-model="queryParams.empName" placeholder="请输入员工姓名" clearable /> </el-form-item> <el-form-item label="部门"> <el-select v-model="queryParams.deptId" placeholder="请选择部门" clearable> <el-option v-for="dept in deptOptions" :key="dept.deptId" :label="dept.deptName" :value="dept.deptId" /> </el-select> </el-form-item> <el-form-item> <el-button type="primary" @click="handleQuery">搜索</el-button> <el-button @click="resetQuery">重置</el-button> </el-form-item> </el-form> <!-- 表格区 --> <el-table :data="tableData" v-loading="loading"> <el-table-column prop="empNo" label="工号" /> <el-table-column prop="empName" label="姓名" /> <el-table-column prop="deptName" label="部门" /> <el-table-column prop="postName" label="岗位" /> <el-table-column prop="phone" label="手机号" /> <el-table-column label="操作" width="240"> <template slot-scope="scope"> <el-button type="text" @click="handleEdit(scope.row)">编辑</el-button> <el-button type="text" @click="handleDelete(scope.row)">删除</el-button> </template> </el-table-column> </el-table> <!-- 分页区 --> <el-pagination v-show="total > 0" :total="total" :current-page.sync="queryParams.pageNum" :page-size.sync="queryParams.pageSize" :page-sizes="[10, 20, 30, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="handleQuery" @current-change="handleQuery" /> </div> </template>这段代码看起来简单,实际开发时有两个容易踩坑的点。第一是分页组件的current-page.sync和page-size.sync修饰符,这要求queryParams对象中的对应字段必须初始化好,否则首次渲染时组件会警告。第二是搜索区的表单数据对象queryParams必须包含分页参数pageNum和pageSize,因为handleQuery方法会把整个queryParams作为查询参数传给后端接口。
5. 系统测试与性能调优实录
5.1 功能测试要点与测试用例设计
系统测试阶段我采用了“功能测试优先、性能测试兜底”的策略。功能测试覆盖核心业务闭环流程,具体包括:
员工入职测试场景,涉及新增员工、分配部门岗位、创建系统账号、分配角色权限四个环节。测试数据准备两个边界情况:工号已有但姓名不同的员工,系统给出提示;手机号重复的员工,系统给出格式校验。都把边界数据跑了一遍确认不会产生脏数据。
请假审批流程测试,走的是员工提交请假、直属主管审批、考勤管理员备案三个环节。测试中重点验证不同角色登录后看到的操作按钮是否正确——员工只能提交请假单,主管只能审批本部门员工的请假单,考勤管理员能查看全部备案记录。角色权限的判定逻辑也是在这轮测试中修了不少Bug。
薪资计算测试是最需要细心的一环。计算逻辑设计为可配置,测试时分别跑了五组不同缴费基数、不同入离职月份的员工数据,和Excel手工计算的结果逐一核对。第一轮测试跑出了误差,排查后发现是社保上限配置没有生效,修完之后第二轮测试结果完全一致。薪资这类钱相关的模块绝对不能“差不多就行”,必须数字精确一分不差。
5.2 性能测试中发现的问题与优化
系统并发量不高,但我还是用JMeter简单跑了一轮性能测试,模拟30个并发用户连续操作5分钟,主要关注接口响应时间和错误率。
测试发现最慢的接口是员工列表查询接口,平均响应时间在800毫秒左右。原因是员工查询接口走了逻辑删除过滤条件加多个查询条件组合,SQL的WHERE条件过多导致索引未能完全命中。优化方案是重新分析查询字段的组合情况,把“部门ID+员工状态”组合索引加上,同时给员工姓名字段增加了前缀索引。优化后平均响应时间降到了150毫秒左右,效果显著。
对于一个CRUD系统来说,200毫秒以内的接口响应时间属于正常水平,300毫秒以上就值得关注了。性能测试的价值在于提前发现这些隐患,而不是等到上线后用户来反馈“系统有点卡”。
5.3 部署环境与上线流程
系统部署采用前后端分离部署方案。后端的Spring Boot项目打成JAR包,部署在服务器上运行;前端项目通过打包命令生成dist目录下的静态文件,由Nginx进行托管。
后端部署时配置了多环境Profile,区分开发环境、测试环境、生产环境。生产环境的数据库连接信息、Redis连接信息等敏感配置不写在代码中,而是通过环境变量注入。这个习惯很重要,很多项目代码泄露导致的数据安全事故,根源就是配置文件中硬编码了生产环境的数据库密码。
Nginx的核心配置需要注意两个点:一是静态文件路径的配置,把dist目录设置为站点根目录;二是接口请求的反向代理配置,将/api路径的请求转发到后端应用地址。
server { listen 80; server_name localhost; location / { root /opt/hrms/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files配置是在做单页应用时容易遗漏的关键点。Vue Router使用history模式时,用户访问某个子路由并进行页面刷新,Nginx会直接查找对应路径的静态文件,然而这个文件其实是不存在的。加上try_files配置后,所有不存在的路径请求都会回退到index.html,由前端路由接管,完美解决刷新404问题。
5.4 代码质量与项目规范
项目代码的命名规范遵循了阿里Java开发手册的标准。类名使用大驼峰,方法名使用小驼峰,常量使用全大写加下划线分隔,Service层接口和实现类分离。Controller层只负责参数接收和结果封装,不写业务逻辑;Service层是业务逻辑的主要承载者;Mapper层只做数据访问。分层职责清晰,后期接手的人不需要在代码之间反复横跳,一眼就知道去哪找逻辑。
写单元测试是一个需要坚持的好习惯。我对薪资计算、考勤状态判定这类核心算法都写了单元测试,用JUnit 5实现。单元测试不是测试工作量的堆砌,而是在代码修改后第一时间告诉开发人员“你没改坏东西”。薪资模块改过一次,跑一遍既有测试用例,发现有一条用例挂了,提前就暴露了问题,比上线后被财务部门发现少了几百块钱要强无数倍。
6. 常见问题与排查技巧实录
6.1 登录认证环节的典型问题
第一个高频问题:前端点击登录没有任何反应,浏览器控制台显示跨域请求错误。排查思路是先用Postman直接请求后端登录接口,确认后端接口本身是否正常。后端正常的话,问题就出在代理配置或者CORS配置上。我实际踩过的坑是Vue CLI代理配置中的changeOrigin参数没有设置,导致后端收到的请求头来源不对,最终还是代理转发没有正确处理跨域。
第二个高频问题:登录成功后访问其他接口返回401错误。这个问题的原因通常是请求拦截器没有正确附加Token到请求头,或者后端的JWT过滤器判断Token的密钥与签发Token时的密钥不一致。排查时先看浏览器存储中Token是否存在,再看请求头中Authorization字段是否带上了值,最后看后端日志中过滤器的校验失败原因。
6.2 员工模块的权限与数据问题
新增员工成功但列表页看不到该员工。这种问题要先想到逻辑删除标志位的坑。排查时发现是查询条件中filterDeleted方法用错了位置,写在了某个分支条件里,导致部分查询场景没有过滤已删除数据。修掉这个Bug的过程让我对MyBatis Plus的Wrapper构建逻辑更加小心了——公共过滤条件一定要放在所有查询方法都必经的同一个地方,不能散落在各个条件分支里。
普通用户登录后看到了全部员工列表,而不是本部门员工列表。这个问题涉及数据权限控制。我设计的方案是注解@DataScope配合部门ID过滤,在Mapper查询SQL中自动追加部门权限条件。初次实现时注解没有加到查询方法上,权限条件没有生效。修正后管理员能看到全部数据,普通员工只能看到本部门及其子部门下的员工数据。
6.3 考勤与薪资模块的边界数据问题
考勤记录生成定时任务跑完之后,发现一部分员工没有生成考勤记录。排查原因是这部分员工在排班计划表中没有维护对应的排班计划。定时任务在设计时直接跳过了无排班计划的员工,导致漏数据。修改方案是定时任务对无排班计划的员工生成“提醒记录”,提醒考勤管理员尽快补充排班信息,这样就不会出现静默丢数据的情况。
薪资计算结果与手工计算不一致,差了几块钱。排查定位到是社保缴费基数的上下限配置没有考虑到最新年度的调整数据。这类边界配置的变更往往不会被及时发现,建议薪资结构表中维护一个配置版本号,每次调整都作为一条新记录插入,计算时取最新版本,同时保留历史版本可以做追溯和复盘。
6.4 前端页面的渲染与交互问题
表格数据正常返回但页面显示空白,打开浏览器控制台发现Vue警告数据没有定义。这类问题在使用ElementUI时比较常见,大部分原因是组件绑定的数据变量在data()中没有声明初始化。Vue 2的响应式系统要求必须在data函数中预先声明响应式属性。解决方案就是初始化所有需要响应式更新的数据字段。
弹窗表单编辑回显时,部门下拉框显示的是部门ID而不是部门名称。排坑的经验是:后端返回的实体类中同时包含deptId和deptName两个字段,弹窗打开时先用后端返回详情数据回显表单,部门下拉框的选中逻辑要基于deptId匹配。如果只返回deptId而不返回部门名称的下拉选项数据,下拉框就显示不出名字来。
7. 项目扩展方向与实际心得
7.1 再有时间可以继续做的扩展功能
第一版系统跑通之后,如果有余力,可以往这几个方向扩展。
报表统计方面,可以把员工入离职趋势、部门人数分布、考勤异常统计这些数据做成可视化图表。后端增加几个统计接口输出聚合数据,前端用ECharts做图表展示,这一个扩展就能让系统从“管理工具”升级成“决策辅助工具”,演示效果会好很多。
消息提醒方面,目前系统是纯被动响应式,审批流程中的待办事项全靠用户主动刷新页面查看。引入WebSocket或者轮询机制,实现在线消息通知和待办事项提醒,对使用体验的提升非常明显。
移动端适配方面,核心的考勤打卡、请假审批这类高频功能,可以单独做一个H5页面,不需要完整做一套移动App,用Spring Boot的REST接口直接复用后端能力,前端用移动端组件库重新做几个适配页面就行。
7.2 我从这个项目中沉淀的三条经验
第一条经验是关于业务理解的。开始写代码之前,我花了两天时间研究HR系统的业务流程,弄清楚员工从入职到离职经历了哪些环节、每个环节涉及哪些部门哪些角色。这些前置研究看起来拖慢了开发进度,但在动手编码之后带来了非常直接的好处——数据库设计一次成型,业务模块之间的数据流打通几乎没走过回头路。做业务系统,最忌讳的是一上来就建表写代码,到后面发现业务理解有偏差再返工,成本翻好几倍。
第二条经验是关于技术选型的。面对一个具体的项目,成熟的、团队熟悉度高的技术方案才是最优解。Spring Boot + Vue + ElementUI在2025年看起来确实不算新潮,但它的生态成熟度是无可比拟的,遇到问题百分之百能找到现成答案。反过来,如果当时选了个刚出没多久的新框架,可能连文档都还没补全,遇到一个编译错误就要查半天,项目节奏就全打乱了。
第三条经验是关于质量意识的。系统里的每一个金额字段、每一个判定逻辑、每一个边界条件,在上线之前都要经过反复验证。HR系统服务的对象是公司内部的员工,一张错误计算的工资条足以让整个团队对系统的信任度清零。质量是靠测试用例、代码走查、上线前演练堆出来的,不是靠口号喊出来的。
这套系统做完之后,我自己最大的感受是:一个看似普通的CRUD项目,做扎实了同样能收获巨大的成长。从数据库设计到权限控制,从算法逻辑到部署上线,每一个环节都是实际开发中必然面对的能力。如果你也打算动手做一套类似的系统,不妨先把这个项目的核心闭环定义清楚,把每个模块的业务逻辑想透,再开始写第一行代码。