做人事管理系统这件事,听起来不算难,但真要把一套 SpringBoot + Vue + MyBatis + MySQL 的前后端分离项目从零跑通、跑稳、还能交付出去,中间隔着一整套工程化设计。这次我专门把这套组合里的完整项目拆给你看,从模块划分、数据库设计、后端接口、前端页面到最终部署,一条线讲完。内容偏实战,不聊空理论,适合正在做毕业设计、想转 Java 全栈、或者接过外包但还没完整走完前后端分离全流程的开发者。你自己动手把这条线走通之后,再遇到类似的管理系统项目,其实都是同一套打法换皮而已。
1. 项目概述与技术选型
1.1 人事系统到底在解决什么问题
很多人一听到“人事系统”就觉得是增删改查、没什么技术含量,我实际做过几个类似项目之后看法不一样。人事系统最大的难点从来不是写代码,而是把公司里那一堆零散、重复、口径不一致的数据收拢到一个统一模型里。
传统公司怎么管人事?一个 Excel 花名册,一个考勤机导出的打卡记录,一张手填的请假单,工资核算再拿另一套公式算。数据一旦分散,问题就来了:员工转岗了花名册没更新,考勤数据和请假单对不上,月底算工资要靠人肉核对。人事系统要解决的就是这四件事:员工信息统一管理、组织架构准确可查、考勤请假流程留痕、薪资数据可回溯。再往上走,还有权限控制——不同角色看到的数据范围完全不同,普通员工只能看自己的档案,部门经理能看部门数据,HR 能看全量数据。
我习惯先把业务流程梳理清楚再动手,因为人事系统的业务边界太容易模糊了。举个实际例子,一个员工从入职到离职,中间要经历入职登记、试用期评估、转正、调岗、调薪、离职交接,这些节点每一个都可能产生一条流程记录,而传统 Excel 完全留不下这些过程数据。所以做这个系统前,先要把公司实际的业务流走一遍,再抽象成功能模块,这比直接建表写 CRUD 重要得多。
1.2 为什么选 SpringBoot + Vue + MyBatis + MySQL
这套技术栈在网上被讨论很多,但真实原因不是什么“最流行”、“最先进”,而是它在中小型项目里综合成本最低、最稳。我个人的选型判断标准有三个:好不好招人、好不好维护、好不好部署。这套组合三条全占。
SpringBoot 的价值在于开箱即用的生态和约定优于配置的理念。一个电商系统、一个 OA 系统、一个招聘后台,无论什么业务,SpringBoot 都能快速把 RESTful API 搭起来,内嵌 Tomcat 又省掉了外部容器的配置。Vue 负责前端,渐进式框架的组件化开发方式非常适合管理后台这类页面:表格、表单、弹窗、树形结构,每个页面都是一套可复用的组件组合。
选 MyBatis 而不是 JPA,是我在做过几个报表需求之后才坚定的。人事系统里多表 JOIN 非常频繁,比如查员工列表要关联部门表、岗位表,查薪资要关联员工表和社保表,MyBatis 的 XML 里写 SQL 完全可控,复杂统计查询一眼就能看懂性能瓶颈在哪。JPA 在简单 CRUD 上确实省事,但一遇到复杂查询,生成的 SQL 会让你排查起来非常头疼。MySQL 就不用多说了,开源免费、社区资料全、基本是中小项目的数据库标配。
这套技术栈的另一个好处是前后端分离之后,前后端可以并行开发。前端不用等后端接口写完再动工,只要先约定好接口文档,各自开发,最后联调就行。对于 2-3 人的小团队或者单兵作战的开发者来说,这种并行能力能省出大量时间。
1.3 前后端分离架构的核心思路
前后端分离说白了就是让前端不再直接渲染服务端页面,而是只负责页面展示和用户交互,业务逻辑全部通过 HTTP 请求打到后端 API 上。一次完整请求的流程是这样的:浏览器打开 Vue 页面,Vue 通过 axios 发送请求到后端接口,后端 Controller 接收请求后调用 Service 层处理业务,Service 再通过 MyBatis 操作 MySQL,最后把 JSON 数据返回给前端渲染。
这套架构下,前端工程和后端工程是两个独立项目,甚至部署在两台不同的服务器上。开发环境下,前端跑在 npm 启动的 dev server 上,后端跑在 8080 端口的 SpringBoot 上,中间靠代理解决跨域;生产环境下,前端打包成静态文件交给 Nginx,后端打成 jar 包独立运行,Nginx 把 /api/ 开头的请求转发给后端服务。
为什么不用传统的单体 JSP 方案?我举个最直观的例子:JSP 模式下,前端页面和后端代码混在一起,每次改一行 HTML 都要重新编译整个项目,而且前端没有独立的工程化手段,没办法用组件复用、状态管理这些工具。前后端分离之后,前端可以单独做工程化、单独测试、单独部署,后端只需要专注返回 JSON 数据,接口可以同时服务 Web 端、小程序端、移动端,扩展性完全不在一个层级。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 功能模块梳理与数据库设计
2.1 先拆业务,再定模块
人事系统看着简单,但功能点其实很多。我做项目前习惯先画一张业务脑图,把角色和操作对齐:HR 要做什么、部门经理要做什么、普通员工要做什么。对齐之后再划分模块,就不会漏功能或者做多余功能了。
这套人事系统我拆成了六个核心模块:
| 模块名称 | 核心功能 | 关联数据表 |
|---|---|---|
| 系统管理 | 用户、角色、菜单权限分配 | sys_user, sys_role, sys_menu, sys_user_role |
| 组织管理 | 部门维护、岗位维护 | department, position |
| 员工管理 | 花名册、入职登记、调岗、离职 | employee, employee_post |
| 考勤管理 | 打卡记录、请假申请、加班登记 | attendance, leave_request |
| 薪资管理 | 工资条生成、社保参数维护 | salary, social_security |
| 报表中心 | 入离职统计、部门人数统计 | 基于以上表统计 |
为什么把系统管理放在第一位?因为它控制着整个系统谁能看什么。人事数据属于敏感数据,权限设计不做好,系统上线就是要出问题的。组织管理是人事系统的骨架,员工一定要挂在部门下面,部门要有层级关系,这样才能做“部门维度”的数据隔离。员工管理是最核心的业务模块,考勤和薪资都要以员工档案为基础。
2.2 数据库表结构设计的关键设计点
建表是这套系统里最重要的环节之一,我之前自己走过弯路,上来就直接写字段,结果业务一扩展就要改表。现在我的习惯是先梳理实体关系,再建表。这套人事系统的核心表关系是这样的:
- 部门表用
parent_id自关联实现树形结构,一级部门下面挂二级部门 - 员工表和用户表分开,员工不一定有登录账号,但每个用户一定关联一个员工
- 考勤表和薪资表都通过
employee_id关联员工,不直接关联用户表 - 所有业务表都加
create_time、update_time、deleted三个通用字段
我用 MySQL 建员工表的 SQL 给你们做个参考:
sql复制CREATE TABLE employee (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
emp_no VARCHAR(20) NOT NULL UNIQUE COMMENT '工号',
name VARCHAR(50) NOT NULL COMMENT '姓名',
gender TINYINT DEFAULT 1 COMMENT '1男 2女',
phone VARCHAR(20) COMMENT '手机号',
email VARCHAR(100) COMMENT '邮箱',
dept_id BIGINT COMMENT '部门ID',
position_id BIGINT COMMENT '岗位ID',
hire_date DATE COMMENT '入职日期',
status TINYINT DEFAULT 1 COMMENT '1在职 2试用 3离职',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT DEFAULT 0,
INDEX idx_dept (dept_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工表';
这里有几个细节要特别说。emp_no 工号我设置了唯一索引,很多公司在员工编号上有自己的规则,比如年份加流水号,如果不加唯一索引,很容易在录入重复数据。status 字段代表员工状态,注意它和 deleted 逻辑删除是两个概念,前者是业务状态,后者是防止物理误删。时间字段一律用 DATETIME 而不是 VARCHAR 存时间字符串,否则后面做按月统计报表会非常难受。
另外,所有涉及金额的字段,比如薪资数字,我用 DECIMAL(10,2) 而不是 DOUBLE。人事系统的钱一分都不能差,DOUBLE 的精度问题在累加多次之后会出现小额偏差,这在薪资场景是无法接受的。
2.3 RBAC 权限模型与角色设计
这套系统采用经典的 RBAC 模型,也就是用户-角色-权限三层结构。我在实际项目里发现,最省事的权限粒度是“角色关联菜单”,不直接给用户分配菜单。原因是公司里角色基本是固定的,HR、部门经理、普通员工,人员流动只影响用户和角色的绑定关系,不会频繁改菜单权限。如果我直接给用户分配菜单,一旦调整一个角色的权限,就得把所有相关用户重新配一遍。
具体实现上,数据库设计五张核心表:用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。菜单表里每条记录代表一个可访问的菜单项或按钮,比如“员工管理-新增”、“员工管理-编辑”这种按钮级权限,也作为菜单记录存在,只是页面地址为空、标记为按钮类型。
前后端的权限控制分工是这样的:前端控制“看得到什么”,后端控制“能不能调”。前端根据用户角色返回的菜单列表,动态渲染左侧导航栏;后端在接口层面用拦截器校验 token 身份,再用注解标记接口所需权限,防止有人跳过前端页面直接调接口。单做前端隐藏是绝对不行的,数据安全必须以后端校验为准,这是我反复强调的一个点。
3. 后端接口设计与核心实现
3.1 SpringBoot 项目初始化与分层结构
后端我习惯用 Maven 管理依赖,SpringBoot 版本选 2.7.x 稳定版,这个版本配合 MyBatis 和 JDK 8/11 都兼容得很好。项目结构上用了标准的分层结构:
code复制com.xxx.hr
├── controller # 接口层,只做参数接收和结果返回
├── service # 业务层,处理业务逻辑和事务
├── mapper # MyBatis Mapper接口
├── entity # 数据库实体类
├── dto # 请求和响应对象,不直接暴露实体
├── common # 统一返回结果、异常处理、工具类
└── config # 拦截器、跨域、WebMvc配置
分层这件事我吃过不少亏。以前图省事,把业务逻辑直接写在 Controller 里,结果接口一复杂,参数校验、业务处理、异常分支全都堆在一起,后期改一处就要动整个方法。现在严格按层来:Controller 只负责接收参数和调用 Service,Service 处理核心业务逻辑并加事务,Mapper 只做数据访问,层与层之间靠接口调用,改动不会互相牵连。
统一返回结果也是必须要做的。我定义了一个 Result 对象,所有接口都返回 { code, message, data } 这种结构。前端只需要判断 code 是否为 0,就知道请求是否成功,不需要每个接口设计一套返回格式。异常统一抛给全局异常处理器,业务异常返回 500 和错误信息,参数校验失败返回 400,这样前端处理起来非常统一。
3.2 登录认证与 JWT 实现
人事系统的登录认证我用 JWT + 拦截器实现,没有引入 Spring Security,因为对于这个体量的项目,Spring Security 的配置成本有点高,JWT 方案完全够用,而且逻辑清晰、好排查问题。登录流程是这样的:
- 用户输入账号密码,前端把账号密码以 JSON 形式 POST 到 /api/auth/login
- 后端 Controller 接收后,在 Service 层通过用户名查询用户,用 BCrypt 校验密码
- 密码校验通过后,查询该用户的角色和菜单权限,生成 JWT token
- 返回 token 和用户基本信息,前端把 token 存到 localStorage
- 之后前端每次请求都在 Authorization 请求头带上
Bearer token - 后端拦截器拦截所有 /api/** 请求,解析 token,校验合法性,从 token 里取出用户 ID
JWT 生成代码里最关键的两个参数是过期时间和密钥。过期时间我一般设置为 2 小时,密钥用一个足够长的随机字符串。注意密钥一定不能写死在业务代码里让别人一眼看到,要放到配置文件里,部署时用环境变量注入。
java复制public String generateToken(Long userId, String username) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + EXPIRE_TIME);
return Jwts.builder()
.setSubject(username)
.claim("userId", userId)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
拦截器里我会先校验 token 的签名和过期时间,再把 userId 存入 ThreadLocal,方便后续 Service 层直接获取当前登录用户。这里有一个坑,就是拦截器注册时一定要排除登录接口和静态资源路径,否则前端还没登录就被拦截了。我一开始做的时候就因为这个吃了亏,前端一直报 401,排查了半天才发现是拦截路径配置不对。
3.3 MyBatis 核心配置与 Mapper 实现
MyBatis 在 SpringBoot 里的整合非常简单,引入 mybatis-spring-boot-starter 之后,在 application.yml 里配置 Mapper XML 的位置和实体类别名即可。我习惯把 Mapper 接口和 XML 文件分开,XML 统一放在 resources/mapper 目录下,这样 SQL 语句集中管理,后期调优直接改 XML 不用重新编译。
实际开发中,员工分页查询是最典型的复杂 SQL 场景。查询条件可能有姓名关键字、部门、状态、入职日期范围,每个条件都有可能为空,这时候就用 MyBatis 动态 SQL 的 <if> 标签拼条件:
xml复制<select id="selectEmployeePage" resultType="com.xxx.hr.entity.Employee">
SELECT e.*, d.name AS deptName, p.name AS positionName
FROM employee e
LEFT JOIN department d ON e.dept_id = d.id
LEFT JOIN position p ON e.position_id = p.id
<where>
e.deleted = 0
<if test="name != null and name != ''">
AND e.name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="deptId != null">
AND e.dept_id = #{deptId}
</if>
<if test="status != null">
AND e.status = #{status}
</if>
<if test="startDate != null">
AND e.hire_date >= #{startDate}
</if>
</where>
ORDER BY e.create_time DESC
LIMIT #{offset}, #{pageSize}
</select>
分页参数的计算看起来简单,但容易出错:offset = (pageNum - 1) * pageSize。如果前端传的 pageNum 从 1 开始,后端计算 offset 时一定要减 1,否则第一页数据就会被漏掉。我见过太多人在这上面栽跟头了。
关于 MyBatis 缓存,默认情况下一级缓存是 SqlSession 级别的,同一个会话中相同的查询直接命中缓存;二级缓存默认是关闭的,要手动开启。但我在人事系统里推荐先不开二级缓存,因为员工数据变更频繁,一旦缓存脏数据,整个系统都要背锅。宁可多查几次库,也不要让用户看到错误的数据。
3.4 关键业务接口:员工分页与入职统计
员工分页接口是整套系统调用量最高的接口,前端员工管理页面每次翻页、筛选都要触发。接口返回的 PageResult 结构包含 total 总条数 和 list 当前页数据。实现上先执行一个 COUNT 查询获取总条数,再执行分页查询获取数据。
为什么要单独 COUNT 一次而不是 SQL_CALC_FOUND_ROWS?因为 SQL_CALC_FOUND_ROWS 在高并发下性能表现不稳定,两次查询在业务简单时更可控,而且 MyBatis 对这两次查询的事务一致性要求不高,出现微小偏差可以接受。
入职统计接口我用了 MySQL 的 DATE_FORMAT 函数,按月份分组统计:
sql复制SELECT DATE_FORMAT(hire_date, '%Y-%m') AS month, COUNT(*) AS cnt
FROM employee
WHERE deleted = 0
AND hire_date >= #{startDate}
AND hire_date <= #{endDate}
GROUP BY month
ORDER BY month
这个报表接口看起来简单,但一定要在 hire_date 字段上建索引,否则公司规模大了之后,全表扫描统计会让接口响应时间从几百毫秒飙升到几秒,页面就会明显卡顿。
4. 前端页面与 Vue 实现
4.1 Vue 项目搭建与工程化准备
前端我用 Vue 3 加 Element Plus 这套组合,理由很直接:Element Plus 的表格、表单、弹窗、分页组件非常适合管理后台场景,配合 Vue 3 的 Composition API 写业务逻辑更顺手。如果你公司项目还在用 Vue 2,也别担心,这套代码逻辑基本一致,只是组件库换成 Element UI 而已。
脚手架我推荐 Vite,原因是启动速度和热更新体验确实比 Webpack 好很多。初始化命令一行搞定:
bash复制npm create vite@latest hr-frontend -- --template vue
创建完项目后,需要安装的依赖包括:
- vue-router:前端路由管理
- pinia:状态管理,替代 Vuex,更简单的 API
- axios:HTTP 请求库
- element-plus:组件库
项目目录结构上,我按功能划分:src/api 统一放接口调用函数,src/views 放页面组件,src/router 放路由配置,src/store 放全局状态,src/utils 放 axios 封装和工具函数。这样项目大了以后不会乱。
4.2 路由守卫与 axios 请求封装
路由守卫是整个前端权限控制的第一道关卡。我配置了全局前置守卫,每次路由跳转前检查 localStorage 里有没有 token。没有 token 就强制跳转到登录页,有 token 才允许访问系统页面。这种控制方式简单有效,能挡掉大部分未登录直接输入地址访问页面的情况。
js复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) {
next('/login')
} else {
next()
}
})
axios 封装是另一个关键点。我统一把 baseURL 设置为 /api,开发环境通过 Vite 代理把请求转发到后端 8080 端口,请求拦截器里自动加上 Authorization 头,响应拦截器处理全局的 code 判断和错误消息提示。这样一个页面调用接口时只需要写业务逻辑,不用关心 token 添加和错误处理。
封装响应拦截器时有一个细节要注意:后端返回的 HTTP 状态码不一定是 200,比如未登录时返回 401。我在响应拦截器里先判断 HTTP 状态码,如果是 401 就清掉本地 token 并跳转登录页,其他错误用 Element Plus 的消息组件弹出后端返回的 message。这个处理逻辑几乎是所有管理后台通用的模式。
4.3 核心页面实操:登录页、员工管理页
登录页是最简单但体验要求最高的页面。我用 Element Plus 的表单组件带上校验规则,账号、密码两个字段都必填,密码输入框用 show-password 开启密码可见切换。提交时调用登录接口,成功后把 token 和用户信息写入 localStorage,再跳转到首页。
员工管理页是整套系统最有代表性的页面,包含了管理后台最常见的交互模式:表格展示、条件查询、分页、新增弹窗、编辑回显、删除确认。页面加载时调用分页查询接口获取第一页数据,输入查询条件后点击搜索按钮重新查询。新增和编辑共用一个弹窗表单,区别是编辑时回显该条记录的数据,提交时根据是否有 ID 决定调用新增还是更新接口。
删除操作我做了一个二次确认弹窗,防止误删。实际项目中删除员工不能直接物理删除,而是调用接口把 deleted 字段置为 1,这样数据还在库里,只是查询时被过滤掉,出问题还能恢复。这个策略对人不对物,因为人事数据追溯非常重要,物理删了就真的没了。
前端权限控制我写了一个自定义指令 v-permission,按钮上标记需要的权限编码,没有权限就隐藏按钮。这个指令在菜单管理中给角色分配按钮权限时会用到,如果有“给部门经理开放导出按钮而 HR 才有删除按钮”这种差异化需求,一个指令就能解决。
5. 部署上线与配置细节
5.1 前后端联调与跨域处理
前后端联调是所有人都会遇到的一道坎。开发环境下,前端跑在 5173 端口,后端跑在 8080 端口,浏览器直接发请求必然跨域。我推荐用 Vite 的代理配置解决,而不是在后端开启 CORS。原因是代理配置只影响开发环境,生产环境用 Nginx 反代,后端代码不需要做任何改动。
js复制// vite.config.js
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
上面这段配置的意思是,前端请求 /api/login 时,Vite 开发服务器会把请求转发到 http://localhost:8080/api/login。这样浏览器感知不到跨域存在,后端也不需要配置 cross-origin 相关的过滤器。
后端跨域我坚持不开全局 CORS。为什么?因为一旦后端开启跨域允许所有来源,生产环境下任何人从任意域名都能直接调用接口,配合 JWT 没过期的话,风险比较大。生产环境用 Nginx 把同一个域名的 API 路径反代给后端,从根本上规避了跨域问题,也更安全。
5.2 两种打包部署方式对比
生产环境部署有两种主流方式,我两种都实测过,可以根据你的场景选。
第一种方式最简单:前端执行 npm run build 生成 dist 目录,把 dist 里的文件复制到 SpringBoot 项目的 src/main/resources/static 目录下,再执行 mvn package 打成 jar。这样一个 jar 包同时包含前端页面和后端接口,部署时只需要 java -jar hr-system.jar 一条命令,适合演示和内部小范围试用。但这种方式有个明显的不足:前端代码更新一次,就要重新打一次后端 jar,前后端耦合回去了,而且前端资源交给 Java 服务器处理,静态文件响应速度没有 Nginx 快。
第二种方式是我在实际交付时首选:前端静态文件交给 Nginx,后端 jar 独立运行。前端打包后把 dist 里的文件上传到 Nginx 的 html 目录,后端 mvn package 后运行 jar,Nginx 配置把 /api/ 请求转向 8080 端口:
nginx复制server {
listen 80;
server_name your-domain.com;
root /usr/share/nginx/html;
index 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;
}
location / {
try_files $uri $uri/ /index.html;
}
}
try_files 这行配置一定要加,原因是 Vue Router 使用 history 模式时,刷新一个诸如 /employee/list 的子页面路径,Nginx 找不到这个真实文件会返回 404,try_files 把所有路径都回退到 index.html,由 Vue Router 自己处理路由。这个坑我帮不少人排查过,刷一下就白屏基本都是这个原因。
5.3 MySQL 环境配置与初始化脚本
部署环节里最容易被忽略的就是数据库环境。MySQL 8.0 在 JDBC 连接时默认开启 SSL,如果后端连接串没有明确配置,经常会报 SSL 连接错误。我处理这个问题的方式是在 JDBC 连接串上显式加参数:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/hr_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: your-password
useSSL=false 解决 SSL 握手失败,serverTimezone=Asia/Shanghai 解决时区不一致导致的时间和日期查询偏差,characterEncoding=utf8 必须配,否则中文字符写入和查询会乱码。这三个参数一个都不能少,全部都是实测踩过坑之后总结出来的。
MySQL 初始化脚本我习惯按顺序执行:先建库,再建表,最后插入基础数据。基础数据至少要有 admin 管理员账号、一个 HR 角色、一个普通员工角色,以及基础的管理菜单记录。密码不能用明文,我提前用 SpringBoot 里的 BCryptPasswordEncoder 生成好密文,写死在初始化 SQL 里,这样系统启动后直接用 admin 账号登录即可,不用再单独调接口初始化管理员。
6. 常见问题与排查技巧实录
6.1 问题排查速查表
我在开发和部署这套系统时遇到过不少问题,这里整理一张排查速查表,各位可以直接对照着看:
| 现象 | 可能的根因 | 解决办法 |
|---|---|---|
| 启动报 Access denied for user | 数据库账号密码错误或没有远程访问权限 | 核对连接串账号密码,用 root 登录执行授权 GRANT |
| 启动报 Communications link failure | MySQL 服务没启动或端口被占用 | 检查 MySQL 进程状态,确认 3306 端口正在监听 |
| 中文乱码 | 连接串没有指定 utf8 或数据库默认字符集不对 | 连接串加 characterEncoding=utf8,建库时指定 utf8mb4 |
| 前端请求跨域报错 | 开发环境没配代理,或生产环境 Nginx 没有反代 /api | 开发用 Vite proxy,生产用 Nginx location /api 转发 |
| 刷新子页面 404 | Vue history 模式没有配置 Nginx try_files | 按上文 Nginx 配置加 try_files 回退 |
| 登录请求返回 401 | 拦截器没有放行登录接口 | 排查 WebMvc 配置中排除 /api/auth/login |
| 分页第一页丢数据 | offset 计算错误,忘了 (pageNum-1) | 检查后端 offset = (pageNum - 1) * pageSize |
| 查询报 Unknown column | SQL 里用了 MySQL 保留字做列名 | 字段加反引号或直接改字段名 |
| JWT 过期后接口报错 | 过期时间设置太短 | 调整 JWT 过期时间,或前端在响应拦截器统一跳登录页 |
6.2 我踩过的坑和避坑建议
这套系统写完送给客户使用之后,我陆续发现了一些比较隐蔽的问题,拿出来分享一下。
第一个坑是 MyBatis Mapper XML 路径不匹配。启动时一直报 Invalid bound statement,排查了半天发现是我把 XML 文件放错了目录,SpringBoot 默认不会扫描 resources 目录下的 mapper 文件夹,必须在 application.yml 里配置 mybatis.mapper-locations: classpath:mapper/*.xml。这个配置遗漏了,应用能启动,但所有 Mapper 方法执行都会报错,是最容易忽视的问题之一。
第二个坑是 Excel 导出的数据量处理。人事系统免不了要导花名册,几千条数据一次性查出再输出,内存和时间开销都比较大。我后来在导出接口里加了异步导出,请求过来先返回“导出任务已创建”,后台线程分批从数据库读取数据写入 Excel,完成后把文件路径存到记录里,前端轮询任务状态再下载。这个优化让导出大列表时用户体验好了很多。
第三个坑是时间处理的前后端一致性。Java 后端默认序列化的日期格式是带毫秒的 ISO 格式,前端用 Vue 直接绑定到 Element Plus 的 date-picker 上会出现格式对不上。我的解决方案是在 application.yml 里全局配置 Jackson 的日期格式化,统一为 yyyy-MM-dd HH:mm:ss,前端展示时间不用再做转换,数据库里存 DATETIME 类型也正好匹配。
第四个建议是关于代码备份和版本管理。做这类交付型项目,强烈建议一开始就建 Git 仓库,每个功能模块做完就提交一次,部署上线前打一个 tag。我有一次给客户临时加字段,改完才发现把之前的查询逻辑改坏了,没有版本管理就只能靠记忆回退,非常痛苦。有了 Git 之后,随时可以对比改动、快速回滚,给自己省很多事。
另外再提一句,如果你打算拿这个项目作为面试作品,建议把数据库设计文档、接口文档和部署文档都整理出来。面试官最看重的其实不是你写了几万行代码,而是你对整套系统从设计到落地的完整把控能力。把这些文档准备好,讲项目时思路会清晰很多,也能体现你的工程化意识。
