前后端分离这几年基本成了中小型企业内部系统的事实标准,而人事管理系统又是刚需中的刚需。我接手过不少类似的单体老项目,也帮人把SSH那套旧代码迁移到SpringBoot+Vue上来,前后端分离这个架构在中型企业内部系统里的优势非常明显:前端只管交互,后端专注业务和数据,团队协作边界清晰,部署也能各自独立伸缩。如果你正打算自己写一套人事管理系统,或者想把学校/公司的课设项目升级成能说服面试官的作品,这套基于SpringBoot+Vue+MyBatis+MySQL的前后端分离实现方案,值得你从头到尾走一遍。
这个项目覆盖了中小企业人事管理的核心场景:员工档案管理、部门维护、考勤打卡记录、请假审批流、薪资信息管理,外加基于JWT的登录鉴权和基于Vue Router的前端路由守卫。相比网上那些只做了单表CRUD的“玩具项目”,这套系统的业务完整度、代码结构、部署方式都更贴近真实企业项目,可以直接作为课程设计、毕业设计,或者入职前练手的主力项目。
1. 项目到底做了什么:核心模块与技术选型思路
1.1 人事管理系统需要覆盖哪些业务
很多新手上来就急着写代码,结果做着做着发现业务边界根本收不住。我建议先盘一下中小企业人事管理的真实诉求,再决定做哪些功能。实际调研下来,一套能落地的人事系统至少要包含员工管理、部门管理、考勤管理、请假管理和薪资管理这几块。
员工管理是核心,所有其他模块几乎都围绕员工展开。一张员工主表要能覆盖基本信息(姓名、性别、出生日期、身份证号、手机号、邮箱)、岗位信息(所属部门、职位、职级、入职时间、转正时间)和状态信息(在职、试用、离职)。这些字段在设计表结构时就要想清楚,不然后期加字段非常被动。
部门管理看起来简单,但要有树形结构。中小企业虽然部门不多,但层级是存在的,技术部下面还要分前端组、后端组,所以部门表必须支持无限层级,用parent_id做自关联。
考勤和请假是关联性很强的两块。考勤记录每天产生一次,请假审批通过后要能影响考勤结果。这个逻辑在中小企业的规则可以简化,但流程必须完整——员工提交申请、直属主管审批、人事归档。
薪资管理比较敏感,一般人事实操中薪资数据只有HR和管理层能看到,所以权限控制在这里要比其他模块严格,前端要控制按钮级权限,后端也要做角色校验,不能只靠隐藏按钮。
1.2 为什么选SpringBoot+Vue这套组合
前后端分离方案现在是主流,但具体选型各有说法。我见过用若依框架快速生成的,也见过用微服务硬套的,对中小企业人事系统来说,SpringBoot+Vue+MyBatis+MySQL是投资回报率最高的组合,没有之一。
SpringBoot在Java后端领域的统治力不用多说。它解决的问题是Spring时代繁琐的XML配置,内嵌Tomcat让部署变成一条java -jar命令。对中小企业来说,团队里招Java的人远比招其他语言的人容易,生态成熟度也摆在那里。
Vue在中小型管理系统前端方案里的优势是渐进式、上手快、中文文档完善。虽然React也很好,但在国内中小企业里Vue的普及率是真的高,Element UI组件库又是为后台管理系统量身定做的,表格、表单、弹窗、树形控件全是现成的,能省掉大量重复造轮子的时间。
数据库选MySQL是稳的选择,社区版免费、性能足够、运维资料满天飞。人事管理系统的数据量在中小企业的量级下,MySQL完全轻松应对,再加上事务支持、外键约束等能力,数据一致性能得到保障。
1.3 为什么用原生MyBatis而不是MyBatis-Plus
这个选择值得单独拿出来讲。现在很多人一上来就推MyBatis-Plus,确实它的BaseMapper封装了单表CRUD,代码量少了很多。但在这个项目里我用的是原生MyBatis,而且我建议你在这个项目里也用原生MyBatis。
原因是人事管理系统的查询逻辑复杂,经常是多表关联查询,单表CRUD的便利性派不上多少用场,反而MyBatis-Plus的逻辑删除、乐观锁这些特性,在复杂SQL面前有时候还会碍事。热搜词里就有一条“mybatis plus 查询 禁用逻辑删除”,说明很多人被这个坑过。
原生MyBatis的XML SQL是明明白白写出来的,SQL执行效果一眼就能看穿,排查问题非常直接。另外,如果你是从零开始学习框架底层原理,手写XML能让你更理解MyBatis的参数映射、动态SQL和结果集映射机制。等你把原生MyBatis玩透了,再看MyBatis-Plus的封装就觉得非常简单。
注意:如果你确实想用MyBatis-Plus,记得在关联查询时要搞清楚逻辑删除的影响范围,避免出现“明明删掉了还能查出来”或者“关联表条件被自动注入导致查不到”这类诡异问题。
2. 后端从零搭建:SpringBoot+MyBatis+MySQL的实现细节
2.1 项目结构设计与数据库表结构规划
后端项目我强烈建议用标准的分层架构,不要图省事把代码全塞在Controller里。一个清晰的结构能让你在代码量大起来之后依然保持清醒:
com.example.hrms ├── controller ├── service │ └── impl ├── mapper ├── entity ├── dto ├── vo ├── config ├── common │ ├── result │ ├── exception │ └── utils └── interceptorentity对应数据库表,一个字段名都不能马虎;dto接收前端参数,做数据校验;vo返回给前端,控制接口不能把敏感字段(比如密码哈希)暴露出去。这套分层在面试里也是高频考点,值得认真对待。
数据库表的设计我列一下核心几张表:
员工档案表(employee):id、emp_no(工号)、name、gender、birth_date、id_card、phone、email、department_id、position、hire_date、status、created_time、updated_time。
部门表(department):id、parent_id、name、leader_id、created_time。
考勤表(attendance):id、emp_id、attendance_date、check_in_time、check_out_time、status(正常、迟到、早退、缺卡)。
请假申请表(leave_request):id、emp_id、leave_type(年假、事假、病假)、start_time、end_time、reason、status(待审批、通过、驳回)、approver_id、approve_time。
薪资表(salary):id、emp_id、basic_salary、performance_bonus、allowance、social_security_fund、month、actual_salary。
所有表都加created_time和updated_time是通用好习惯,排序和排查问题都方便。工号和身份证号要建唯一索引,因为这是业务上必须保证唯一的字段。
2.2 MyBatis配置与SQL编写要点
SpringBoot集成MyBatis的配置非常简洁,但有几个点很容易栽跟头。spring.datasource配置里,时区参数要写对,推荐使用serverTimezone=Asia/Shanghai,否则数据库连接池会报时区错误。还有url里的参数useSSL=false要加上,本地开发环境不需要SSL加密。
mapper-locations要注意配置正确,如果XML放到了resources目录下的mapper文件夹,记得在application.yml里写清楚:
mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.hrms.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case必须开启,这样数据库的created_time才能自动映射到实体字段createdTime,不然你就要在ResultMap里逐个字段手动映射,累且容易出错。
日志打印这个问题也值得说两句,热词里有“mybatis配置打印”,这个需求很真实。开发阶段一定要有SQL日志,否则报错时根本不知道SQL执行到哪一步。上面的log-impl配置成StdOutImpl就可以在控制台直接看到完整SQL和参数值。
复杂多表查询建议写在XML里,比如查询员工列表需要关联部门名称,这时候在Mapper接口上写一个方法:
List<EmployeeVO> selectEmployeePage(@Param("offset") int offset, @Param("size") int size, @Param("keyword") String keyword);XML里写动态SQL,注意员工姓名、工号、部门这三个维度的条件组合:
<select id="selectEmployeePage" resultType="com.example.hrms.vo.EmployeeVO"> SELECT e.*, d.name AS department_name FROM employee e LEFT JOIN department d ON e.department_id = d.id <where> <if test="keyword != null and keyword != ''"> AND (e.name LIKE CONCAT('%', #{keyword}, '%') OR e.emp_no LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY e.create_time DESC LIMIT #{offset}, #{size} </select>这里有个细节:动态SQL里的if判断,MyBatis的OGNL表达式判断字符串为空要用!= null and != '',不能只判断null,这是初用MyBatis的人最容易漏的。另外建议用CONCAT拼接模糊查询,而不是直接在#{}里写'%${keyword}%',后者有SQL注入风险。
2.3 统一响应体、全局异常处理与登录鉴权
写接口的时候,如果每个接口的返回格式都自己定,前端就会被你坑惨。统一的响应结构非常关键,我在项目里是这么封装的:
public class Result<T> { private Integer code; // 200成功,其他失败 private String message; private T data; // 静态方法 success(), error() 省略 }所有Controller的返回值都包一层Result,前端Axios拦截器就可以统一拦截处理,登录失效返回401时统一跳转到登录页,业务异常返回对应错误码时统一弹提示。这样代码整洁度提升一个档次。
全局异常处理用SpringBoot的@RestControllerAdvice就能搞定。要处理的异常包括参数校验异常(MethodArgumentNotValidException)、业务异常(自定义BusinessException)、运行时异常(Exception兜底)。这里有个坑,参数校验异常如果不用@Validated注解配合@Valid使用,前端传错参数时后端返回的message会很底层,不友好。
登录鉴权这个项目用JWT实现。用户登录成功后,后端生成一个token返回给前端,前端把token存在localStorage里,每次请求在请求头里带Authorization: Bearer xxx。后端写一个拦截器,对需要登录的接口进行token校验。
拦截器的核心逻辑:从请求头取出token,解析JWT得到用户ID和角色,放入ThreadLocal或者request attribute里,方便后续业务代码取用。判断接口是否需要鉴权可以用注解方式,也可以在配置里放白名单。这个项目里登录接口、静态资源不走拦截器,其余全部拦截。
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("OPTIONS")) { return true; // 预检请求直接放行 } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException("未登录或登录已过期"); } // 解析token、校验、存入上下文 Long userId = JwtUtil.parseToken(token.replace("Bearer ", "")); UserContext.set(userId); return true; }注意:OPTIONS请求必须放行,否则前后端分离部署时浏览器跨域预检请求会全部被拦截,前端会报一堆看似莫名其妙的CORS错误。
2.4 角色权限控制的设计
人事系统的权限控制和普通系统不太一样,员工自己只能看到自己的档案,部门主管能审批本部门的请假申请,HR能管理所有员工信息,管理员还能配置部门。这个在RBAC(基于角色的访问控制)模型里可以落地。
数据量小的话,不用搞太复杂的五表RBAC,三张表就够——用户表和角色表关联,角色和权限点关联。我在这个项目里做的是用户在员工表上加了role字段,分ADMIN、HR、MANAGER、EMPLOYEE四种角色,然后写一个权限注解+拦截器的方法:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface RequireRole { String[] value(); }在需要权限控制的接口上标注,比如只有ADMIN和HR才能操作薪资数据:
@RequireRole({"ADMIN", "HR"}) @PostMapping("/salary") public Result<?> saveSalary(@RequestBody SalaryDTO dto) { ... }拦截器里通过反射拿到方法上的RequireRole注解,判断当前用户角色是否在允许列表里。这套方案比起Spring Security要轻量得多,对中小企业系统完全够用,面试的时候也能讲清楚设计思路。
3. 前端Vue实现:从环境配置到页面落地
3.1 Vue环境准备和工程初始化
前端这块第一步就是环境,这一步卡住了不少新手。首先要装Node.js,版本别太新也别太旧,建议用16.x或者18.x的LTS版本。Node版本太新可能导致node-sass这类依赖装不上,太老又跑不动新版的Vue CLI。
npm源建议切换到国内镜像,不然装依赖能等到怀疑人生:
npm config set registry https://registry.npmmirror.com然后创建Vue项目。如果你用的是Vue CLI 5.x版本,创建命令是:
vue create hrms-web手动选择功能,勾选Router、Vuex、Axios(Axios在插件里选,或者后续单独安装)。项目创建完成后,安装Element UI:
npm install element-ui --saveVue 2项目用Element UI,Vue 3项目要用Element Plus,这个别搞混了。
开发调试强烈建议装Vue Devtools浏览器插件。装了它之后,你能在浏览器里直观看到组件的data、computed、props的值,也能在Vuex面板里看到状态的变化路径,排查问题效率提升一半以上。
3.2 路由设计、Axios封装与权限控制
前端路由的设计要跟后端菜单逻辑对应。人事管理系统的路由结构大概是这样的:
const routes = [ { path: '/login', component: Login }, { path: '/', component: Layout, redirect: '/dashboard', children: [ { path: 'dashboard', name: 'Dashboard', component: Dashboard }, { path: 'employee', name: 'EmployeeList', component: EmployeeList, meta: { roles: ['ADMIN', 'HR'] } }, { path: 'attendance', name: 'Attendance', component: Attendance }, { path: 'leave', name: 'LeaveRequest', component: LeaveRequest }, { path: 'salary', name: 'Salary', component: Salary, meta: { roles: ['ADMIN', 'HR'] } } ] } ]路由守卫实现登录验证和角色拦截。beforeEach钩子里先判断有没有token,没token一律踢回登录页;有token再判断meta里的roles,当前用户角色不在列表中就直接拒绝访问。
axios封装这块,请求和响应拦截器一个都不能少。请求拦截器往headers里塞token,响应拦截器做统一错误处理——code为401时清空登录状态跳转登录页,code不为200时ElMessage直接弹错误提示。这样业务代码里就不用每个请求都写了一堆重复的try-catch了。
Vue Router的传参问题也常被问到。跳转编辑员工页面时要带员工ID过去,this.$router.push({ path: '/employee/edit', query: { id: row.id } })是简单的方案,但刷新页面后参数还在。用params传参刷新会丢,所以要慎用。在人事系统里,建议用query或者把ID放在路由的参数位上,保证刷新后还能拿到。
3.3 核心页面实现:员工列表的完整交互
员工列表是人事系统的门面页面,也是前后端交互最复杂的页面。它包含搜索区域、按钮区域(新增、编辑、删除、导出)、表格区域和分页区域。
表格区域我用的是Element UI的el-table,列配置里要注意formatter格式化日期和状态的显示。比如状态字段在数据库里是0/1/2(试用/在职/离职),直接显示数字肯定不行,要用formatter函数翻译成中文,再配合el-tag的type属性用不同颜色区分。
新增和编辑共用一个弹窗对话框,这是写表单页面时最高频的需求。做法是弹窗里放一个el-form,open弹窗时根据是否有id决定是新增还是编辑。新增时清空表单,编辑时调用后端接口获取详情回填表单。保存按钮的loading状态一定要加,防止用户重复提交。
分页这块要注意,el-pagination组件的current-page和page-size要和后端接口的参数一一对应,搜索条件变化后要把当前页重置为1,不然会出现搜出来第5页数据这种反人类体验。
handleSearch() { this.queryParams.pageNum = 1; // 搜索必须重置页码 this.loadData(); }, handlePageChange(page) { this.queryParams.pageNum = page; this.loadData(); }这个重置页码的细节很容易被忽略,但用户体验上非常重要。
4. 完整部署流程:从本地联调到服务器上线
4.1 MySQL安装与数据库初始化
数据库这块的坑大部分人都在MySQL安装环节踩过。Windows下安装MySQL 8.x要注意,新版是MSI安装包,一路Next的时候注意选对MySQL Server版本和端口,默认端口3306,不要和已有服务冲突。
MySQL 8.x默认的认证插件是caching_sha2_password,如果你的JDBC驱动版本太老,连不上数据库会报Authentication plugin错误。解决办法是换MySQL Connector/J 8.x版本驱动,或者创建用户时指定mysql_native_password。
数据库初始化工作的核心是执行SQL脚本。我习惯把建库建表、初始数据分两个脚本文件,一个schema.sql、一个data.sql。初始数据里必须有一条管理员账号,密码存BCrypt加密后的哈希值,不能存明文。
Docker环境下安装MySQL则更简单,跑一条命令就完事:
docker run -d --name mysql8 -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 -e MYSQL_DATABASE=hrms mysql:8.0能用Docker就尽量用Docker,省去环境安装的时间,而且以后要换服务器,容器迁移起来也方便。
4.2 后端打包与启动
后端打包前一定要确认application.yml里的环境配置。生产环境的数据库地址、账号、密码要改对,不能拿着本地数据库配置去部署服务器。
打包命令很简单:
mvn clean package -DskipTests跳过测试的-DskipTests参数务必加上,不然如果项目里写了单元测试,会因为环境问题导致打包失败。
打包完成后target目录下会生成jar文件。启动命令我推荐用nohup放到后台执行,并且指定JVM参数:
nohup java -jar -Xms512m -Xmx1024m hrms-server.jar --spring.profiles.active=prod > startup.log 2>&1 &进程日志输出到startup.log,之后排查问题就看这个日志文件。生产环境建议再配合--spring.config.location指定外部配置文件,这样改配置不用重新打jar包。
4.3 前端打包与Nginx部署
前端打包前需要改API请求的baseURL,让请求指向后端服务的真实地址。这里要说明一下“反向代理”的概念——前端通过Nginx转发API请求到后端,解决跨域问题,这是生产环境的标准做法。
server { listen 80; server_name your.domain.com; root /opt/hrms-web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files那行是Vue路由history模式部署的命根子。Vue Router用了history模式的话,浏览器直接访问某个子路由路径会404,必须配置try_files把请求指向index.html,由前端路由接管。如果你实在不想配置Nginx,也可以走hash模式,URL上会多一个#号,但好处是部署时不用额外配置。
前端打包命令:
npm run build打包产物在dist目录,把dist里的文件上传到服务器上的/opt/hrms-web/dist目录就可以了。
4.4 跨域问题的本质与解决思路
前后端联调时跨域问题是绕不开的。浏览器同源策略规定,不同端口、不同域名、不同协议的请求都算跨域。开发环境最容易撞到这个,前端跑在8080端口,后端跑在8081端口,两个端口就不算同源。
开发环境解决方案是配置Vue CLI的devServer代理:
devServer: { proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } }这样前端请求/api/xxx会被Vite或者webpack-dev-server转发到后端,浏览器看到的都是同源的。生产环境则用Nginx的proxy_pass解决。
后端方面也要配合配置跨域,我推荐写一个全局的CorsConfig类,实现WebMvcConfigurer接口,addCorsMappings里允许来源、方法和请求头。前后端同时配置好,联调才能顺畅。
5. 实操中踩过的坑:常见问题与排查技巧
5.1 SpringBoot版本太高引发的连环坑
这个点必须重点说。现在新项目一创建,SpringBoot很容易拉到2.7.x甚至3.x版本。如果你跟着老教程做,很多配置都对不上,报错信息又看不懂。
SpringBoot 3.x要求JDK 17以上,如果你机器上装的是JDK 8,直接跑不起来。Spring Boot 3.x的javax.servlet包也换成了jakarta.servlet,导致很多第三方库的兼容性问题。热词里“springboot版本太高”这条搜索热度不是没有原因的,我建议这个项目用SpringBoot 2.7.x加上JDK 8的经典组合,网上资料最全,踩坑最少。
进到2.7.x版本内部,也有一个坑:SpringBoot 2.4版本开始,spring.config位置和profile配置规则变了,src/main/resources下的application.properties和application.yml如果同时存在,会有加载优先级问题。解决办法是只用application.yml,不用properties,避免混乱。
5.2 MyBatis相关的几个高频报错
MyBatis的报错大多数都集中在XML映射文件和注解不匹配上。
错误一:Invalid bound statement (not found)。这个报错的意思是Mapper接口的方法没有找到对应的SQL语句。排查思路:检查XML文件的namespace是否等于Mapper接口的全限定名;检查方法名和XML里的id是否一致;检查target/classes里有没有编译进去XML文件。最后一个原因很隐蔽,pom.xml里如果没配置resources包含XML,Maven打包时会把XML文件排除掉。
错误二:TooManyResultsException。一条查询返回了多行数据,但Mapper方法定义的返回值是单个对象。这种一般是SQL条件写窄了,加了联表后产生了重复数据。解决方法是查数据确认结果集,或者用LIMIT 1限制。
错误三:MyBatis第一级缓存导致的数据不同步问题。Spring环境下每执行一个SQL会清掉一级缓存,基本不会踩到,但如果你在同一个事务里先查了员工列表,然后别的手段改了数据,再查还是旧数据。遇到过几次之后,我建议复杂查询场景直接关闭一级缓存或使用二级缓存时特别小心。
5.3 MySQL连接和时区问题排查
MySQL连接数据库时报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized是经典报错,原因就是JDBC连接串里没设置serverTimezone。解决办法:url里加serverTimezone=Asia/Shanghai,同时确认MySQL时区设置正确。
还有一种是Public Key Retrieval is not allowed错误。MySQL 8.x默认配置下,JDBC连接时如果使用caching_sha2_password认证插件,需要显式允许获取公钥。在JDBC url里加上allowPublicKeyRetrieval=true可以解决。
连接池连接超时问题也很常见。SpringBoot默认的HikariCP有个maximumPoolSize默认是10,如果连接数不够,并发一上来就会报HikariPool-1 - Connection is not available, request timed out after 30000ms。可以调大maximumPoolSize,或者检查数据库的max_connections。不过对人事系统这种轻量系统,默认配置一般也够用了。
5.4 Vue前端开发中的高频问题
Vue项目常见的坑集中在依赖安装、路由和打包三块。
npm install报错,十有八九是node-sass和老版本Node的兼容性炸了。这个项目里我建议直接用sass(dart-sass)替代node-sass,装起来更省心。如果项目已经用了node-sass,尝试删除node_modules和package-lock.json后重新安装,还是不行的就把Node切到和项目匹配的版本。
前端请求接口时网络层报404或者502,先别急着看后端,打开浏览器控制台Network面板看看实际请求的URL是不是带上了正确的前缀。开发环境最常见的问题是/api路径拼错了,导致请求发到了前端服务器而不是后端代理。
Vue项目build后部署到服务器,页面白屏。这个大概率是静态资源路径问题。在vue.config.js里设置publicPath为相对路径:
module.exports = { publicPath: './' }这样打包后的index.html里引用的js/css路径就是相对路径,部署到任意子目录下都能正常访问,不用非得放到域名根目录。
5.5 部署环节的零碎问题
服务器上部署时,端口占用是高频问题。SpringBoot默认8080端口,如果服务器上已经有别的服务占用了8080,启动直接报Web server failed to start. Port 8080 was already in use.。解决办法是换端口,或者杀掉占用进程,也可以用server.port配置项改成别的端口。
防火墙问题容易被忽略。云服务器上安全组规则没放行端口,导致浏览器访问不到服务。这个问题最坑的地方在于,服务器上一切正常,curl localhost也正常,就是你从浏览器远程访问不通——基本就是防火墙和安全组的锅。
还有一个是前后端时间格式不一致。后端返回的LocalDateTime默认序列化格式是数组,前端收到看着像天书。解决方法是配置Jackson的日期格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这个配置不改的话,前端那边要花不少力气处理时间展示问题。
我在实际做这个项目的过程中,最大的感受是:考勤和请假这种带审批流的业务,比员工CRUD难做得多。牵扯到状态流转、角色校验、数据联动,每一步都要考虑边界情况。你有时间的话,可以在这个基础上继续扩展招聘管理和培训管理模块,把系统的业务闭环做得更完整。另外,既然前后端都写完了,我强烈建议你抽时间把测试用例补上,至少给Service层的关键业务逻辑写一些单元测试,再把Dockerfile和docker-compose.yml写好。把部署做到一条命令拉起整个环境,这套系统的完整度才真正够看。