☰
Spring Boot + Vue + MySQL 人力资源管理系统实战开发解析
2026/10/14 4:10:44 网站建设 项目流程

1. 项目概述与核心痛点

先说个常见场景:某公司人事部门每天用Excel维护员工档案、考勤记录和薪资数据,月底做工资条的时候,几张表来回比对,稍不留神就把某个员工的加班时长漏算了,或者把部门归属填错,最后财务那边对不上账,整个人事部都要加班重新核对。这种问题我见得太多了,最初做这个人力资源管理系统,就是冲着解决这些实际麻烦去的。

这个项目的技术栈很明确:后端用Spring Boot,前端用Vue加ElementUI组件库,数据库选MySQL,整个系统按前后端分离的方式来做。它覆盖的模块包括员工档案管理、部门岗位管理、考勤管理、请假管理、薪资管理以及最基础的登录权限控制。适合谁来参考?主要是两类人:一类是想找一套完整实战项目练手的后端或全栈开发者,另一类是公司内部确实需要一个中等体量的管理系统、想自己搭一套而不是买现成SaaS的团队。

做这套系统最大的价值,不在于技术有多新,而在于它把一套真实业务流转的复杂度完整呈现了出来。比如同一张员工表,既要给薪资模块用,又要给考勤模块用,还要给部门统计用,表结构怎么设计、接口怎么划分、数据权限怎么控制,这些都是课本上不会细讲、但实际开发里绕不开的问题。下面我把整个实现过程从头到尾拆开讲,包括我当时的选型理由、数据库设计思路、后端核心代码的逻辑、前端页面怎么组织和联调,以及那些只有自己踩过坑才能总结出来的经验。

2. 技术选型与整体架构拆解

2.1 为什么是Spring Boot + Vue + ElementUI的组合

这套组合放在今天依然是中小型管理系统的主流方案,原因其实很朴实。

后端选Spring Boot,是因为它把Spring生态里的配置地狱彻底解决了。以前做一个SSM项目,要写一大堆XML配置,数据源、事务管理器、MyBatis的SqlSessionFactory,每一项都得手动装配,还没开始写业务代码,光搭环境就要一两天。Spring Boot用自动配置把这些全部接管了,我只需要在application.yml里写几行配置,数据库连接、MyBatis映射、事务管理就都跑起来了。这对做管理系统这种CRUD密集型的项目来说,效率提升非常明显。

前端选Vue加ElementUI,核心原因是组件化开发带来的复用收益。管理系统的大部分页面都是表格加表单的形态,ElementUI把表格、分页、弹窗、表单校验这些高频组件全都封装好了。我只需要关注业务数据的组织,不用自己写一套表格组件再处理各种边界情况。Vue本身的双向绑定机制,也让表单数据的收集和回显变得非常直观,v-model一行代码就能把输入框的值绑定到数据对象上。

还有一个决策是前后端分离。以前用Thymeleaf那种服务端渲染,前端页面也是后端写的,一个团队里后端工程师既要写接口又要改HTML,协作效率很低。前后端分离之后,后端只输出JSON数据,前端只管页面渲染和交互,两边通过HTTP接口通信,职责边界非常清晰,也方便以后做移动端接口复用。

2.2 整体架构分层和包结构

整个系统我按经典的三层架构来组织:控制器层(Controller)、业务逻辑层(Service)、数据访问层(Mapper),另外加了一个实体层(Entity)和通用工具层(Utils)。这是老生常谈的架构,但它确实好用,尤其是在权限控制、事务管理等横切关注点上,分层能让你把公共逻辑抽到统一位置,不至于散落在各个业务代码里。

具体的包结构大概是这样的:

com.example.hrms ├── controller // 接收前端请求,返回统一响应结果 │ ├── EmployeeController.java │ ├── AttendanceController.java │ ├── SalaryController.java │ └── AuthController.java ├── service // 业务逻辑层,处理核心业务流程 │ ├── EmployeeService.java │ ├── AttendanceService.java │ ├── SalaryService.java │ └── impl/ // Service接口的实现类 ├── mapper // MyBatis数据访问接口,对应XML映射文件 ├── entity // 数据库实体类 ├── common // 通用类:统一响应结果、分页对象、常量 ├── config // 配置类:跨域配置、WebMvc配置、Shiro/JWT配置 ├── util // 工具类:JWT工具、日期工具、Excel工具 └── HRMApplication.java // Spring Boot启动类

这套结构最关键的一点是严格遵循单向依赖:Controller依赖Service,Service依赖Mapper,不允许反向依赖。实践中有个好处,就是改动数据库表结构的时候,大部分情况下只需要动Mapper层和Entity层,Service接口保持稳定,Controller和前端就不用跟着改。模块之间做到这个粒度,在多人在一个仓库里协作时才不会频繁冲突。

2.3 系统模块划分与功能边界

人力资源管理系统虽然整体看起来就是一个后台管理平台,但内部模块划分其实很有讲究。我在设计的时候就明确了六大模块:员工管理、部门管理、考勤管理、请假管理、薪资管理、系统管理,每个模块都有清晰的功能边界。

员工管理是核心基础模块,主要负责员工档案的增删改查、Excel批量导入导出、按部门/姓名/工号等条件筛选员工。部门管理相对简单,是树形结构,要支持多级部门。考勤管理处理上下班打卡记录、迟到早退统计、加班登记。请假管理则是独立的流程,包含请假申请、审批状态流转、请假天数自动计算。薪资管理是业务最复杂的模块,涉及基本工资、津贴、扣款、社保、个税、实发工资的计算,还要生成工资条。系统管理负责用户、角色、菜单权限的配置。

模块划分时我特别注意避免职责交叉。比如请假审批通过后,考勤统计里就要能自动扣除请假天数,这个联动逻辑我放在Service层做,而不是让两个模块直接互相调用对方的数据表。这样设计的好处是单一数据源,请假的天数以请假记录表为准,考勤统计查询时JOIN请假数据,而不是考勤表里另存一个请假天数的冗余字段。

3. 数据库设计与表结构解析

3.1 数据库设计的取舍原则

做管理系统,数据库设计直接决定后续开发的效率。我的原则是:核心业务表严格遵循三范式,但允许在统计查询频繁的地方做适度冗余,用空间换时间。

比如部门名称,在部门表里是主字段,但员工列表页面需要显示部门名称,如果每次查员工都要JOIN部门表,数据量大了以后查询效率会下降。我选择在员工表里同时存dept_id和dept_name两个字段,部门改名时写一个同步更新逻辑去刷新员工表的冗余字段。这是一种很现实的取舍——纯粹追求范式,在真实开发里反而会增加查询复杂度。

3.2 员工表和部门表的设计细节

员工表是系统的核心,字段设计我按基础信息、岗位信息、联系信息、状态信息四类来划分。

CREATE TABLE `employee` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `emp_no` varchar(32) NOT NULL COMMENT '工号', `name` varchar(32) NOT NULL COMMENT '姓名', `gender` tinyint(1) DEFAULT NULL COMMENT '性别 1男 0女', `birthday` date DEFAULT NULL COMMENT '出生日期', `dept_id` bigint(20) DEFAULT NULL COMMENT '部门ID', `dept_name` varchar(64) DEFAULT NULL COMMENT '部门名称(冗余)', `position` varchar(64) DEFAULT NULL COMMENT '岗位', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `email` varchar(64) DEFAULT NULL COMMENT '邮箱', `id_card` varchar(18) DEFAULT NULL COMMENT '身份证号', `entry_date` date DEFAULT NULL COMMENT '入职日期', `status` tinyint(1) DEFAULT 1 COMMENT '在职状态 1在职 0离职', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_emp_no` (`emp_no`), KEY `idx_dept_id` (`dept_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工表';

这里有两个非常容易被忽视的设计细节。其一,emp_no工号字段必须加唯一索引,因为工号在业务上天然就是唯一的,用它做员工关联查询非常频繁。其二,状态字段用tinyint(1)而不是varchar,一方面节省存储空间,另一方面在Java代码里可以直接映射为Integer,判断逻辑更清晰。

部门表要注意的是父子关系的表达。我用parent_id字段指向父部门的ID,0表示顶级部门,再配合一个ancestors字段存储完整层级路径,比如0,1,5,这样查询某个部门下所有子部门时就不用做递归了,用LIKE '0,1,%'就能直接查出来。

3.3 考勤表结构设计的特别之处

考勤表是所有表里最容易设计失误的一张。很多初学者会直接设计成"每个员工每天一条记录",然后搞一个status字段表示正常、迟到、早退。但仔细想一下,一个员工一天可能有很多条打卡记录(多次进出),而且请假、出差、外勤这些场景根本不该出现在考勤表里。

我最终的方案是设计两张表:attendance_record存原始打卡记录,attendance_summary存每日汇总数据。

CREATE TABLE `attendance_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `emp_id` bigint(20) NOT NULL, `record_date` date NOT NULL, `clock_in` datetime DEFAULT NULL COMMENT '上班打卡时间', `clock_out` datetime DEFAULT NULL COMMENT '下班打卡时间', `source` tinyint(1) DEFAULT 1 COMMENT '打卡来源 1人脸机 2手动补录', PRIMARY KEY (`id`), KEY `idx_emp_date` (`emp_id`, `record_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='打卡记录表'; CREATE TABLE `attendance_summary` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `emp_id` bigint(20) NOT NULL, `summary_date` date NOT NULL, `status` tinyint(1) DEFAULT NULL COMMENT '1正常 2迟到 3早退 4缺卡 5请假 6出差', `work_hours` decimal(5,2) DEFAULT NULL COMMENT '工作时长(小时)', `late_minutes` int(11) DEFAULT 0 COMMENT '迟到分钟数', PRIMARY KEY (`id`), UNIQUE KEY `uk_emp_summary_date` (`emp_id`, `summary_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤日汇总表'

汇总表加上emp_id + summary_date的唯一索引,这是个关键设计。每日结算的定时任务先删除当日汇总数据再重新生成,天然支持重算,不会因为重复执行产生脏数据。这种"原始记录 + 每日快照"的思路,数据回溯和修正都非常方便。

3.4 薪资表设计:金额字段的精度问题

薪资表的金额字段一律用DECIMAL(10,2),不能用FLOAT或DOUBLE。原因很简单,浮点数在计算机内部用二进制存储,0.1加0.2的结果不是精确的0.3,而是0.30000000000000004。涉及钱的计算如果丢了精度,哪怕只是一分钱,月底对账出了问题都很难排查。DECIMAL是数据库层面的定点数存储,计算过程不会产生精度损失。

薪资表还涉及一个"固浮分离"的设计思路。基本工资、岗位津贴这种固定项存一张表,绩效奖金、加班费、扣款这种每月变化的浮动项存另一张表,两张表通过salary_id关联。实际操作的时候,固定薪资的变动频率很低,如果每次都和浮动项一起重新录入,纯属浪费人力,而且容易录错。拆开之后,每个月生成工资条时只需要处理浮动项,固定项自动带出即可。

4. 后端核心功能实现

4.1 统一响应结果与全局异常处理

前后端分离的项目,统一响应格式是必须的。如果每个接口返回的结构都不一样,前端处理起来会非常痛苦。我的统一响应类长这样:

public class Result<T> { private Integer code; // 状态码 200成功 400参数错误 401未登录 500服务器错误 private String message; // 提示信息 private T data; // 返回数据 public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }

配合@RestControllerAdvice做全局异常处理,业务代码里只需要在最深层抛出异常,Controller层完全不需要写try-catch。比如查询员工详情,如果传入的ID查不到记录,Service层直接抛一个BusinessException("员工不存在"),全局异常处理器捕获后返回对应的错误状态码。这样做的核心价值是让Controller保持干净,业务异常和系统异常统一收敛,不会出现同一个错误在不同接口里有不同返回格式的情况。

4.2 登录鉴权:从Session到JWT的演进

管理系统的登录鉴权,我最终选择了JWT方案。对比传统的Session方案,JWT的核心优势是无状态。Session存储在服务器内存里,用户量增加以后要么扩展服务器内存,要么引入Redis做Session共享,而JWT把用户信息经过签名加密放在Token本身,服务器不需要存储会话状态,天然支持水平扩展。

登录流程是这样的:用户提交用户名和密码,后端验证通过后生成一个Token,Token里包含用户ID和过期时间,用密钥进行HMAC签名,返回给前端。前端拿到Token存在本地存储里,每次请求都在请求头加上Authorization: Bearer <token>,后端通过拦截器解析Token获取当前用户身份。

public String createToken(User user) { long expiration = 24 * 60 * 60 * 1000; // 24小时 return Jwts.builder() .setSubject(user.getId().toString()) .claim("username", user.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }

这里我当时踩了一个坑,就是Token过期时间的处理。如果直接把过期时间设得很长,用户密码泄露后的风险窗口会很大;如果设得太短,用户频繁被踢下线,体验又很差。后来我做了改进:主Token有效期为2小时,刷新Token有效期为7天。前端在请求拦截器里检测到Token快过期时,自动携带刷新Token向后端换取新Token,用户完全无感知。这个机制一开始实现的时候觉得复杂,但做完了对比一下,真的很值得。

权限控制方面,我采用基于角色的访问控制(RBAC)模型。用户关联角色,角色关联权限。后端定义了一个自定义注解@RequiresPermission("employee:add"),加在需要权限的Controller方法上,拦截器解析当前用户的角色和权限列表,判断是否有权访问该接口。菜单级别和按钮级别的权限都用同一套数据模型来控制,前端根据登录后返回的权限码列表,用v-if决定按钮是否渲染。

4.3 员工管理的业务逻辑实现

员工管理看起来就是最简单的CRUD,但真正写业务逻辑时要注意几个点。查询接口我做了分页和条件筛选的组合,用MyBatis-Plus的Page对象配合LambdaQueryWrapper来实现。

public Page<Employee> pageQuery(EmployeeQueryDTO query) { Page<Employee> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Employee> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(query.getName()), Employee::getName, query.getName()) .eq(query.getDeptId() != null, Employee::getDeptId, query.getDeptId()) .eq(query.getStatus() != null, Employee::getStatus, query.getStatus()) .orderByAsc(Employee::getEmpNo); return employeeMapper.selectPage(page, wrapper); }

这里有个细微但重要的点:like和eq方法第一个参数是boolean条件,条件为false时这个拼接条件会被自动忽略。这样代码里的动态SQL不用写一堆if判断,非常清爽。

新增和修改员工时,工号唯一性校验必须放在Service层做,而不是只靠数据库的唯一索引报错。数据库索引是最后一道防线,但是直接暴露数据库报错给用户,体验非常差。应该在Service层先用count()查一次,同理再给出友好提示。

删除操作我采用逻辑删除而非物理删除。员工表加了deleted字段,删除时执行的是UPDATE employee SET deleted = 1 WHERE id = ?,而不是DELETE语句。这么做是因为员工的历史考勤和薪资记录都关联着员工ID,如果物理删除了,这些历史数据就失去了关联依据。逻辑删除配合查询时自动过滤已经删除的记录,既保住了历史数据,又对业务无感知。

4.4 考勤统计与薪资计算的核心逻辑

考勤统计的核心是一段自动结算逻辑,放在AttendanceService里。具体来说,每天凌晨通过定时任务读取前一天的打卡记录,判断每个员工当天是否迟到早退、工作时长是否达标、有没有缺卡记录,然后生成日汇总数据。

判断逻辑不复杂,但边界情况需要仔细处理。比如调休制度下,法定工作日和调休日的判定就不一样。我当时的做法是建了一张workday_calendar表,把每年的工作日、休息日、法定节假日提前配置进去,考勤结算逻辑只认日历表不认自然周,这样遇到调休就非常可控。

薪资计算流程稍微复杂一些,它依赖员工基础薪资、考勤汇总数据、请假记录、社保公积金基数等多张表的数据。每月底结算时,遍历所有在职员工,按公式:应发工资 = 基本工资 + 岗位津贴 + 绩效奖金 + 加班费 - 事假扣款 - 社保 - 个税逐项计算。这里要特别提醒,每一步计算都必须保留中间结果落到数据库,不要只存最终值。比如加班费是按小时算的,属于应发工资,但计算社保基数的时候是按基本工资来的,中间结果没落库的话,月底财务要出明细报表就会很被动。我当时就是每个月跑完薪资计算后,用一份薪资明细表把每一项都存干净,后面财务要任何维度的数据,都能直接查出来,不用重新算。

4.5 Excel批量导入导出

人力资源系统的Excel导入导出是刚需,因为人事部门手里有大量历史数据是存在Excel里的。这里我用的是EasyExcel库,它在内存占用上比起POI原生API有数量级的优势,处理大数据量时不会产生内存溢出。

导入的流程是:前端上传Excel文件,后端用EasyExcel读取并校验每一行数据,校验通过的数据批量插入数据库,校验失败的记录收集错误信息,生成一个错误报告Excel返回给前端。这样用户一次性就能看到所有数据错误在哪里,不用改一行重新导入一次。

导出的流程则相对简单,把查询结果映射到导出VO对象,用EasyExcel.write()写文件,通过HttpServletResponse输出流返回给前端下载。有一个容易忽略的坑是Excel下载时要设置正确的Content-Type和响应头,否则文件名里的中文会乱码。处理方式是:

response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String fileName = URLEncoder.encode("员工花名册", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName);

5. 前端页面实现与交互细节

5.1 Vue项目搭建与路由设计

前端工程我用Vue CLI初始化,安装ElementUI、Axios、Vue Router、Vuex这些常规依赖。项目目录结构按功能模块组织,components里放公共组件,views里放页面,router里配置路由表。

路由设计上采用了动态路由的思路。用户登录成功后,后端返回该用户具备权限的菜单列表,前端根据菜单列表动态注册路由。这样低权限用户访问他没权限的页面URL时,路由本身就匹配不到,从根本上避免了前端页面层面的越权访问。配合Vue Router的beforeEach路由守卫,未登录用户访问任何页面都会先被拦截跳到登录页:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token) { if (to.path === '/login') { next() } else { next('/login') } } else { if (to.path === '/login') { next('/') } else { next() } } })

5.2 Axios封装与Token注入

前端所有HTTP请求都走一个统一的Axios实例,配置好基本URL和请求超时时间。最关键的是请求拦截器和响应拦截器。请求拦截器里从LocalStorage取Token,塞到请求头:

service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config })

响应拦截器统一处理后端返回的数据结构。当后端返回401状态码时,说明Token过期,自动调用刷新Token的接口,成功后把原请求重新发一遍,这个过程对页面是无感知的。刷新失败则清除本地登录状态,跳转到登录页。这个交互链路人已经习惯了,但真正实现好了体验是完全不一样的。

前端再做一层统一错误提示。业务异常在后端返回的code字段里已经区分好了,Axios响应拦截器发现code不等于200时,用ElementUI的Message组件弹出后端返回的message字段。这样前端每个页面的业务代码里就不需要重复写try-catch和错误弹窗了。

5.3 员工管理页面的组件化实现

员工列表页我拆成四个部分:搜索条件区、工具栏、数据表格、分页器。搜索条件区用几个el-input和el-select绑定查询条件,点击查询按钮后重新加载第一页数据。工具栏放新增、导入、导出等操作按钮,数据表格用el-table展示员工列表,操作列放编辑、离职按钮,分页器用el-pagination配合后端分页参数。

这里有个很实用的交互细节:el-table的操作列宽度要固定,否则按钮会挤压变形。我用width="150"固定后操作列布局稳定。搜索表单用el-form的inline属性让表单项在一行排列,不会因为字段多造成换行后布局错乱。

新增和编辑功能我共享同一个弹窗组件,通过传入的员工ID区分。新增时表单全部为空,编辑时用Object.assign把当前行数据深拷贝回表单对象再回显,避免直接修改表格行数据导致表格内容跟着变。

表单校验是管理系统中使用体验很重要的一环,我在el-form里配置了rules校验规则,工号必填且符合字母加数字格式,手机号校验11位数字等。这些规则在用户填写时即时触发,而且后端也有一层同样的校验逻辑,不能只信前端校验——前端校验可以被绕过,后端校验才是真正的边界。

5.4 考勤和薪资页面的可视化展示

考勤页面除了表格展示每日考勤记录,我还加了一个部门出勤率的统计区域,用ECharts的画柱状图展示最近一周各部门的迟到/早退人数。数据来源是后端的一个统计接口,返回按部门和日期聚合好的JSON数据,前端拿到后组装成ECharts需要的格式。

薪资页面则展示月度工资总额的折线图和部门平均薪资对比图。这两个图表的价值在于让人力资源的同事不用导出Excel就能看出趋势变化,比如某个部门的薪资总额连续上涨,可能意味着加班费过高或者人员结构调整,需要关注。

ECharts集成其实很简单,安装echarts依赖,在组件里import * as echarts from 'echarts',然后在mounted钩子里初始化图表,监听窗口大小变化时调用chart.resize()。要注意的是组件销毁时必须调用chart.dispose()释放实例,否则页面切换多了以后会内存泄漏,页面越来越卡。

5.5 前后端联调的实践心得

前后端联调是整个项目最花时间的阶段。第一次联调时最常见的就是跨域问题。开发环境的前端跑在localhost:8080,后端跑在localhost:9090,端口不同跨域就发生了。解决方案是在Spring Boot的配置类里加一个跨域过滤器,允许指定来源的前端地址访问:

@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("http://localhost:8080"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }

有两点尤其要注意:一是AllowCredentials打开时,AllowedOrigin不能用*,必须写具体域名,否则浏览器会拒绝响应;二是如果以后线上部署用了Nginx反代,Nginx层面的跨域配置要跟后端配置统一,不然又会冒出来一堆定位起来很麻烦的跨域报错。

时间字段的时区问题也是联调里必踩的坑。因为服务器和数据库的时区设置不一致,存进去的时间比实际时间多了8小时。这个问题的根因是MySQL连接串上没加serverTimezone=Asia/Shanghai,加上之后问题就解决了。另外前端拿到的时间字符串默认是UTC格式的,需要在展示时通过dayjs转成北京时间再格式化,否则页面上会显示成早8小时的时间值。

6. 常见问题与避坑实录

6.1 我实测遇到过的典型问题

这里我把开发过程中遇到的第一批高频问题整理成表格,每个问题都标注了根因和解决方案,这些基本都是管理系统开发的通用问题,换个业务场景照样适用。

问题现象根因分析解决方案
后端返回的时间比实际少8小时数据库连接串缺少时区配置在JDBC URL加serverTimezone=Asia/Shanghai
前端传的日期格式后端接收不到前端默认ISO格式和后端解析格式不一致在application.yml配置spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
列表页查询很慢,几万条数据要等几秒全表扫描,索引缺失给查询条件字段加联合索引,用EXPLAIN验证索引是否生效
上传Excel时内存溢出POI一次性加载全部数据到内存改用EasyExcel流式读取
删除员工后,历史考勤数据显示异常外键关联被破坏改逻辑删除,保留历史数据
新增员工重名后,Excel导入报主键冲突只校验了姓名没校验工号导入时以工号为主键校验唯一性,姓名不做唯一要求
薪资金额计算有小数位误差使用了Float类型存储金额统一改成BigDecimal,数据库字段用DECIMAL(10,2)
修改部门名称后员工列表仍显示旧名称员工表冗余了dept_name字段但未同步部门更新接口里增加同步更新员工冗余字段的逻辑

6.2 查询性能优化:索引是如何排上用场的

员工列表页在数据量达到十万级时,模糊查询会明显变慢。问题排查使用EXPLAIN后发现,所有查询都走了全表扫描。当时在name字段上建了一个普通索引,但LIKE '%关键词%'这种模糊匹配本身无法利用索引,只能退而求其次,保证其他筛选条件能用上索引。最终我给dept_id + status建了联合索引,因为这些字段是精确等值匹配的,可以高效地过滤掉大部分无关数据。

另外像考勤记录表这种按月查询的表,我给emp_id + record_date建了联合索引,查询某员工某段时间的考勤时,索引命中率非常高,数据库扫描行数从几万降到了几十条,实测接口响应时间从1.8秒降到了200毫秒以内。这个优化方案投入时间很少,效果却非常明显。

6.3 权限控制的三个易漏点

权限控制听起来就是RBAC加一个拦截器就完事了,实际做的时候有三个容易漏掉的地方。

第一个是接口层面的权限校验不能只放在拦截器里校验是否登录,还要校验该用户是否有访问该接口的权限。我一开始只校验了Token有效性,后来发现普通员工账号可以直接调管理员接口,比如直接调/api/employee/delete/1把员工删了。后来给所有敏感接口都加上@RequiresPermission注解,才算堵住这个洞。

第二个是前端按钮级别的权限控制不能以隐藏按钮为唯一手段。隐藏按钮只是提升用户体验,真正的安全边界在后端。有技术基础的用户完全可以绕过前端页面直接构造HTTP请求调用接口,所以后端接口的权限校验必须做到位,跟前端展示互不冲突。

第三个是超级管理员角色的*权限通配符处理。角色表中超级管理员的权限码集合是*,在拦截器里判断时需要先检查是否包含*,否则就会把所有请求都拦截掉了。这个坑虽然小,但不仔细看日志的话,定位起来真的会费一番功夫。

6.4 项目上线部署的关键配置

开发环境跑通之后,上线部署阶段还有几个容易被忽视的配置。首先是生产环境的application-prod.yml要单独一份,把数据库密码、密钥等敏感信息通过环境变量注入,而不是明文写在配置文件里。我自己的实践是配合配置中心做统一管理,但小项目直接用环境变量也已经够用了。

其次,后端项目的打包方式。Spring Boot默认打包成可执行的JAR包,我建议用Maven插件配置finalName指定好产物名称,同时排除掉测试代码避免打包失败。JAR包传输到服务器后,用java -jar xxx.jar --spring.profiles.active=prod启动。

最后是前端打包。Vue项目执行npm run build后会生成dist目录,里面是纯静态文件。生产环境用Nginx托管前端文件,然后把/api开头的请求反向代理到后端服务端口:

server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:9090; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

try_files这一行非常重要,因为Vue Router使用history模式时,刷新页面会向Nginx请求对应的URL路径,正常配置下会404,try_files会让所有未匹配到静态文件的请求都回退到index.html,由前端路由接管。不配这一行的话,刷新二级页面就会白屏报404。这个坑我印象太深了,第一次上线时定位这个问题花了不少时间。

7. 写在最后的实践体会

整个项目从零到一开发和部署上线,我的一个很深的体会是:管理系统本身的技术难度不高,真正考验人的是对业务细节的理解和对数据一致性的把握。数据库设计阶段多花一倍时间思考,后面写代码和排查问题的效率能提升好几倍。尤其是考勤和薪资这种状态多、联动多的模块,表结构设计错了,后面要改涉及的业务逻辑会非常痛。

还有一个建议:前后端联调一定不要等到所有页面都做完再开始,最好是后端每完成一组接口就立刻联调一组,有问题当场改,小步快跑比最后集中排查要省力得多。我和前端搭伴开发时就是这个节奏,基本每天都能出进度,而且问题不会积压。

如果你打算拿这套系统做二次开发或者学习,我建议先从员工管理和部门管理入手,这两个模块最简单也最核心,跑通之后再做考勤和薪资,你对整个系统的数据流和模块间协作就会有比较完整的认识。虽然这套系统用的是比较稳妥的经典方案,但如果你有兴趣,也可以尝试引入更前沿的技术栈来重构部分模块。比如把定时任务换成分布式任务调度框架,把本地缓存升级为Redis做多实例共享,或者把权限模型从RBAC演进到更灵活的ABAC。对练手项目来说,在稳定基础上做一些技术尝试,往往能学到更多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询