☰
Spring Boot人事管理系统实训:从数据建模到权限控制完整实践
2026/10/20 9:00:35 网站建设 项目流程

简介:这是湖南科技大学计算机学院2023年综合创新实训3的软件工程课程资源,主题为人事管理系统的设计与实现,适合软件工程专业学生、课程设计者及需要撰写实训报告的学习者参考。压缩包内包含1个docx格式的完整实训报告,文件总大小947KB,内容覆盖需求分析、体系结构设计、接口与界面设计、数据结构与流程设计、数据建模、范式分析,以及结合工厂模式与外观模式的详细设计、UML图绘制与工作日志等模块,结构完整可直接用于理解人事管理系统的开发全流程。资源目前已有1188人学习,说明其实践性与参考价值受到认可。通过这份文档,读者可以快速掌握软件工程规范文档的写法,了解从需求规约到详细设计、再到团队分工与进度记录的完整组织方式,也能为自身课程设计或实训项目提供可直接借鉴的模板与思路。

1. 人事管理系统这道实训题,先想清楚它到底考什么

人事管理系统在答辩现场,十个人里有八个能跑通增删改查,但能扛住“为什么这么设计”这句追问的人,一只手数得过来。这道题表面是做个员工信息管理,实际考的是你有没有把组织架构、员工档案、考勤请假、账号权限这条业务链路完整串起来的能力。多数翻车案例不是功能太少,而是做成了“一张大表加四个按钮”的表格工具。

综合创新实训3(软件工程)阶段选人事管理系统,好处在于它覆盖了从建表到部署的完整流程,既有标准的信息管理,又有审批流、权限控制这类有逻辑深度的场景。这篇笔记写给正在做这道实训题、准备答辩,或者想补一遍“数据建模→后端接口→权限控制→避坑→演示验证”全流程的开发者。后面的内容全部按我实际做这类项目时验证过的路数来写,参数和坑都是真实的,不是教科书推导。

2. 选型与数据建模:人事管理系统的地基不能歪

2.1 技术栈选型:三类常见组合到底怎么挑

实训项目选技术栈,第一原则不是“哪个最有面子”,而是“答辩时老师问到的每一层你都能讲清楚”。前后端分离、微服务、消息队列这些词用在人事管理系统上,反而容易被追问到答不上来。常见的可靠组合有三类,我按推荐程度排一下。

方案后端框架前端方案适合谁
ASpring Boot 2.x + MyBatis-PlusVue 3 + Element Plus已经写过前后端联调,想把项目放进简历
BSpring Boot 2.x + MyBatis-PlusThymeleaf 服务端渲染前端不熟,想集中精力做后端逻辑
C现成开源脚手架改脚手架自带页面只剩一周,只求完整跑通

方案A看起来最完整,但前后端联调、跨域、token 存储这些坑会消耗大量时间;方案B虽然页面朴素,但数据流简单,答辩时能把“请求怎么到数据库再返回来”这条链路讲得很清楚;方案C的风险在于,如果连脚手架里的代码都没看过,老师随便指一个注解问你什么意思,场面会很难看。我一般建议选B,或者选A但只保留少量核心页面做前后端分离演示。

确定方向后,后端依赖不需要手写版本号,直接继承 Spring Boot 父工程即可。实训阶段手写版本号反而容易引入冲突,比如 MyBatis-Plus 和某个 Spring Boot 小版本不兼容,排查起来非常耗时。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>

不写版本号是我做实训项目时的习惯:Spring Boot 2.x 的父工程已经把常用依赖版本统一管理好了,自己写<version>反而会覆盖父工程的默认值。如果确实要指定,也得先确认这个版本在 Maven 中央仓库真实存在,并且和当前 Spring Boot 版本做过兼容验证。另外,Lombok 加不加都行,加了能少写 getter/setter,但必须确认 IDE 装了对应插件,否则答辩现场编译报错会非常尴尬。

2.2 数据库表设计:核心表和字段约束一起定下来

人事管理系统最少需要六张表:部门表、员工表、请假申请表,以及第四章要讲的用户、角色、用户角色关联表。前两张表是地基,设计时有一个底线:主键统一用自增BIGINT,每张表必须有create_time和update_time,状态字段用数字不用字符串。这个习惯能让你后面写 MyBatis-Plus 条件构造器时少踩一半的坑。

字段类型有几个容易翻车的点,提前避掉。金额用DECIMAL(10,2)不用DOUBLE,否则累计工资时会有精度误差;手机号用VARCHAR(20)不用BIGINT,否则以 0 开头的号码会丢前导零;工号和部门编码要有唯一约束,这是答辩时“数据库层面防止重复数据”的加分项。日期字段按用途区分:入职日期用DATE,操作记录用DATETIME,不要混用。

员工表的设计直接决定后续接口好不好写。注意区分两个容易混淆的字段:status表示在职或离职,deleted表示逻辑删除。前者是业务状态,员工离职后档案还要保留;后者是数据是否被软删,配合 MyBatis-Plus 的@TableLogic自动过滤。这两个概念在答辩时被问到的概率很高,值得提前想清楚。

CREATE TABLE dept ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '部门ID', dept_name VARCHAR(50) NOT NULL COMMENT '部门名称', dept_code VARCHAR(20) NOT NULL UNIQUE COMMENT '部门编码', manager_id BIGINT DEFAULT NULL COMMENT '负责人ID', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT '部门表'; CREATE TABLE employee ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '员工ID', emp_no VARCHAR(20) NOT NULL UNIQUE COMMENT '工号', name VARCHAR(50) NOT NULL COMMENT '姓名', gender TINYINT DEFAULT NULL COMMENT '1男 2女', dept_id BIGINT NOT NULL COMMENT '所属部门', position VARCHAR(50) COMMENT '岗位', phone VARCHAR(20) COMMENT '手机号', entry_date DATE COMMENT '入职日期', status TINYINT NOT NULL DEFAULT 1 COMMENT '1在职 0离职', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1已删除', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_emp_dept FOREIGN KEY (dept_id) REFERENCES dept(id) ) COMMENT '员工表';

这段 SQL 里有几个值得跟答辩老师讲的细节。dept_code和emp_no上的唯一约束,比在代码里用if判断重复更可靠,因为并发请求同时插入时代码判断会漏掉,数据库约束不会。update_time用ON UPDATE CURRENT_TIMESTAMP,让数据库自动维护更新时间,省掉 Java 代码里手动 set 的步骤。外键fk_emp_dept保证员工必须归属于一个真实存在的部门,但注意后面做员工删除时需要处理关联约束,第五章会专门讲这个坑。

2.3 请假申请表的建表脚本和初始化数据

请假申请表是这套系统里唯一一个带状态流转的表,也是答辩时展示“业务逻辑深度”的关键。设计原则是:一天请假记录对应一个审批状态,不搞复杂的子表。状态字段用TINYINT:0 待审批,1 通过,2 驳回。审批人和审批时间要在通过或驳回时写入,这样人事才能在后台看到完整链路。

CREATE TABLE leave_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '申请ID', employee_id BIGINT NOT NULL COMMENT '申请人ID', leave_type TINYINT NOT NULL COMMENT '1事假 2病假 3年假', start_time DATETIME NOT NULL COMMENT '开始时间', end_time DATETIME NOT NULL COMMENT '结束时间', reason VARCHAR(255) COMMENT '请假原因', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审批 1通过 2驳回', approver_id BIGINT DEFAULT NULL COMMENT '审批人ID', approve_time DATETIME DEFAULT NULL COMMENT '审批时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_leave_emp FOREIGN KEY (employee_id) REFERENCES employee(id) ) COMMENT '请假申请表';

初始化数据不要只插一条 admin 进去,那样演示时列表页面空荡荡的。部门至少 5 个,员工至少 20 个,请假申请至少 3 条且状态各不相同,这样列表筛选、状态展示都有东西可看。但初始化数据用手写 INSERT 太累,我一般用给后面第六章的批量生成脚本留接口,先把两个核心部门插进去当锚点。

INSERT INTO dept (dept_name, dept_code) VALUES ('人事部', 'D001'), ('技术部', 'D002'); INSERT INTO employee (emp_no, name, gender, dept_id, position, entry_date) VALUES ('E0001', '王某', 1, 1, '人事专员', '2022-03-01'), ('E0002', '李某', 2, 2, 'Java开发', '2022-07-15');

这里有个命名约定的问题要提前说明:表名、字段名全部用下划线风格,Java 实体属性用驼峰风格,MyBatis-Plus 默认开启map-underscore-to-camel-case会自动映射。如果不遵守这个约定,查询结果会出现字段全是 null 的玄学问题,而且排查起来很费劲。建议建表时就统一用dept_id而不是deptId,实体类写deptId,让映射交给框架。

3. 后端接口实现:员工、部门调动与审批的核心逻辑

3.1 员工档案 CRUD:参数校验放 Service 层而不是 Controller

员工管理的接口是最普通的增删改查,但普通接口也有值得讲究的地方。我的习惯是 Controller 层只做接收参数和返回结果,业务校验全部放 Service 层。这样写单元测试时直接 new 一个 Service 就能测,不用启动整个 Web 容器,答辩现场调试也快。

@RestController @RequestMapping("/api/employee") public class EmployeeController { private final EmployeeService employeeService; public EmployeeController(EmployeeService employeeService) { this.employeeService = employeeService; } @PostMapping public Result save(@RequestBody @Valid Employee employee) { employeeService.saveEmployee(employee); return Result.ok(); } @DeleteMapping("/{id}") public Result delete(@PathVariable Long id) { employeeService.deleteEmployee(id); return Result.ok(); } }

@Valid注解触发 JSR-303 参数校验,@RequestBody把前端 JSON 自动绑定到实体类。这里有个细节:如果前端传的字段名和实体属性对不上,绑定不会报错,对应属性会是 null,所以接口调试时先打印一遍接收到的对象,别急着查数据库。

Service 层的核心在于重复判断和业务校验。不能只依赖数据库唯一约束,因为数据库报错是异常,用户体验差;代码里先查一次,给出明确提示,异常只留给并发这种极端情况。

public void saveEmployee(Employee employee) { if (StringUtils.isBlank(employee.getEmpNo())) { throw new BizException("工号不能为空"); } Long count = employeeMapper.selectCount( new LambdaQueryWrapper<Employee>() .eq(Employee::getEmpNo, employee.getEmpNo()) ); if (count > 0) { throw new BizException("工号已存在"); } employee.setCreateTime(LocalDateTime.now()); employeeMapper.insert(employee); }

LambdaQueryWrapper的eq方法拼 SQL 的等值条件,用了 Java 方法引用Employee::getEmpNo,好处是字段名写错时编译期就报错,不会等到运行时才暴露。selectCount返回记录数,这里注意 MyBatis-Plus 3.x 的selectCount返回Long,老版本返回Integer,如果你的代码一直报类型不匹配,先查一下版本。

分页查询是另一个必写的接口。大多数实训项目用的是 PageHelper,但既然用了 MyBatis-Plus,直接用它的分页插件更省事。

public Page<Employee> pageQuery(EmployeeQuery query) { LambdaQueryWrapper<Employee> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(query.getName()), Employee::getName, query.getName()) .eq(query.getDeptId() != null, Employee::getDeptId, query.getDeptId()) .eq(Employee::getDeleted, 0) .orderByDesc(Employee::getCreateTime); return employeeMapper.selectPage( new Page<>(query.getPageNum(), query.getPageSize()), wrapper ); }

like和eq的重载方法第一个参数是 boolean 条件,条件为 false 时这个查询条件不会拼进 SQL。这样就不用写一堆if (xxx != null)来判断要不要加条件,代码干净很多。orderByDesc(Employee::getCreateTime)按创建时间倒序,新员工排前面,这是列表页默认应该有的排序。new Page<>(pageNum, pageSize)里pageNum从 1 开始,前端如果传 0,查询结果会是空的,这一点前后端要约定好。

3.2 部门调动:一个事务里完成的双向更新

部门调动是人事管理系统里最容易写错的地方。常见错误是只改了员工的dept_id,忘了更新部门表的员工人数,导致两边数据对不上。正确做法是把“改员工部门 + 旧部门人数减一 + 新部门人数加一”这三个操作放进同一个事务,任何一个失败都整体回滚。

@Transactional(rollbackFor = Exception.class) public void transferDept(Long employeeId, Long newDeptId) { Employee employee = employeeMapper.selectById(employeeId); if (employee == null) { throw new BizException("员工不存在"); } Long oldDeptId = employee.getDeptId(); if (oldDeptId.equals(newDeptId)) { return; } employee.setDeptId(newDeptId); employeeMapper.updateById(employee); deptMapper.reduceStaffCount(oldDeptId); deptMapper.increaseStaffCount(newDeptId); }

@Transactional(rollbackFor = Exception.class)这一行是重点。Spring 事务默认只在抛出运行时异常时才回滚,如果自定义的BizException继承的是Exception,不加rollbackFor就会事务不生效,数据只改了一半,这是经典翻车点。为了保险,所有自定义业务异常我建议都继承RuntimeException,事务层面彻底避免这个问题。

部门人数的更新用数据库原子操作,而不是先查出来再加减:

@Update("UPDATE dept SET staff_count = staff_count - 1 WHERE id = #{deptId} AND staff_count > 0") int reduceStaffCount(Long deptId); @Update("UPDATE dept SET staff_count = staff_count + 1 WHERE id = #{deptId}") int increaseStaffCount(Long deptId);

staff_count - 1放到 SQL 里执行,而不是 Java 里先getStaffCount()再setStaffCount(count-1),原因有两个。第一,避免并发时两个请求同时读到同一个旧值,导致最终只减了一次;第二,AND staff_count > 0防止人数被减成负数,这在数据异常时能兜底。这里的staff_count可以在部门表里加上,它不是冗余,而是为了列表页直接展示,不需要实时聚合查询。

3.3 请假审批:状态机让流程不乱走

请假审批这个功能,表面是 update 一条记录的状态,但如果没有状态判断,就会出现“已经驳回的单子还能再通过”这种业务错误。正确做法是给状态流转定义一个规则:只有待审批状态可以变成通过或驳回,通过和驳回是终态,不能再操作。

转账到代码里就是:更新前先查当前状态,不是待审批就拒绝操作。

public void approveLeave(Long leaveId, Integer action, String operator) { LeaveApply leave = leaveMapper.selectById(leaveId); if (leave == null) { throw new BizException("请假申请不存在"); } if (leave.getStatus() != 0) { throw new BizException("该申请已处理,请勿重复操作"); } if (!action.equals(1) && !action.equals(2)) { throw new BizException("非法的审批操作"); } leave.setStatus(action); leave.setApprover(operator); leave.setApproveTime(LocalDateTime.now()); leaveMapper.updateById(leave); }

这个接口的逻辑可以总结成一张状态流转表,答辩时直接画给老师看:

当前状态操作结果状态
待审批(0)通过已通过(1)
待审批(0)驳回已驳回(2)
已通过(1)任何操作拒绝,抛异常
已驳回(2)任何操作拒绝,抛异常

operator从当前登录用户拿,而不是前端传,否则任何人都能冒充审批人。这里的实现路径是拦截器把登录用户 ID 放进request的 attribute 里,Service 接收参数时从上下文取。前端传过来的action必须是数字,接口里做了白名单判断,不在 1 或 2 范围内直接拒绝。这个判断不是多余的,防止有人传 3、传 0 绕过状态机的约束。

4. 登录与权限:RBAC 权限模型在实训项目里的落地

4.1 用户-角色-用户角色三表设计与数据初始化

人事管理系统里,不需要每个用户单独配权限,那样管理成本太高。用 RBAC 模型,用户关联角色,角色关联权限,员工调岗只需要换角色,不用逐个改权限。实训版本做三张表就够了:用户表、角色表、用户角色关联表。

CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT '密码,BCrypt加密后存储', employee_id BIGINT DEFAULT NULL COMMENT '关联员工ID', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT '用户表'; CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '角色ID', role_code VARCHAR(30) NOT NULL UNIQUE COMMENT '角色编码', role_name VARCHAR(30) NOT NULL COMMENT '角色名称' ) COMMENT '角色表'; CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ) COMMENT '用户角色关联表';

初始化数据直接写死在 SQL 里,答辩时不用再手动建账号。三个角色的划分要和业务对应:管理员可以配置部门和账号,人事专员可以处理员工档案和请假审批,普通员工只能看自己的信息和提交请假。

INSERT INTO sys_role (role_code, role_name) VALUES ('admin', '管理员'), ('hr', '人事专员'), ('employee', '普通员工'); INSERT INTO sys_user (username, password, employee_id, status) VALUES ('admin', '{bcrypt}$2a$10$7QvXlVvXlVvXlVvXlVvXlO', NULL, 1), ('hr01', '{bcrypt}$2a$10$7QvXlVvXlVvXlVvXlVvXlO', 1, 1), ('emp01', '{bcrypt}$2a$10$7QvXlVvXlVvXlVvXlVvXlO', 2, 1); INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1), (2, 2), (3, 3);

初始化数据里我写了{bcrypt}前缀,Spring Security 的DelegatingPasswordEncoder支持这种存储格式。但如果你没有引入 Spring Security,只是为了调接口方便,可以先在 Java 里用BCryptPasswordEncoder生成一段真实密文再放进 SQL,不要直接用明文。这点在第五章会详细讲为什么明文密码是答辩减分项。

4.2 登录接口与密码校验:BCrypt 每次生成密文都不同

登录接口是系统里第一个会被攻击者盯上的接口,密码不能明文存储,登录不能只查一次数据库就放行。我选择 BCrypt 做密码哈希,而不是 MD5 或 SHA-256,因为 BCrypt 内置盐值,同一个密码每次生成的密文都不同,可以直接防彩虹表。MD5 加不加盐都得自己实现盐的存储,实训阶段很容易做错。

public String login(String username, String rawPassword) { SysUser user = userMapper.selectOne( new LambdaQueryWrapper<SysUser>() .eq(SysUser::getUsername, username) ); if (user == null || !passwordEncoder.matches(rawPassword, user.getPassword())) { throw new BizException("用户名或密码错误"); } if (user.getStatus() != 1) { throw new BizException("账号已被禁用"); } List<String> roles = roleMapper.selectRolesByUserId(user.getId()); String token = JwtUtil.createToken(user.getId(), roles); return token; }

密码校验用passwordEncoder.matches(rawPassword, user.getPassword()),这个方法内部会从密文里提取盐,再用盐去哈希明文比较。注意这里不能直接用passwordEncoder.encode(rawPassword).equals(user.getPassword()),因为两次 encode 的结果不一样,永远比不出来。

登录成功返回 token,后续请求在 header 里带Authorization: Bearer <token>。JWT 方案在实训里够用且好讲,但如果答辩老师问“token 被别人偷走怎么办”,你要能答出“JWT 无法主动失效,有效期不能设太长,敏感操作要二次校验”。如果说不清这一点,说明 Session 方案可能更适合你,至少服务端可以主动踢人。

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new BizException(401, "未登录"); } Long userId = JwtUtil.parseUserId(token); request.setAttribute("userId", userId); return true; } }

登录拦截器的职责只有两个:从 header 里取 token,解析出 userId 放进 request 上下文。它不校验角色,角色校验留给后面的路由和按钮权限。这样设计的好处是职责单一:拦截器管“你是谁”,权限判断管“你能干什么”。如果拦截器里既解析 token 又查数据库拿角色列表,每次请求都要多一次数据库查询,性能上不划算,逻辑上也耦合。

4.3 前端路由守卫和按钮级权限控制

后端接口做了拦截还不够,前端也要做两层控制:路由级权限控制用户能访问哪些页面,按钮级权限控制用户能看到哪些操作按钮。只做后端不做前端,普通用户访问/admin路径时虽然接口会拒绝,但页面会白屏,体验很差。

Vue 3 里用路由守卫实现页面级控制,核心逻辑是判断角色是否在目标路由的meta.roles白名单里。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') return } const roles = JSON.parse(localStorage.getItem('roles') || '[]') if (to.meta.roles && !to.meta.roles.some(r => roles.includes(r))) { next('/403') return } next() })

角色列表在登录成功后由后端返回,前端存到 localStorage,刷新页面时从 localStorage 恢复。这里有个安全注意点:localStorage 里的角色可以被用户手动修改,所以前端隐藏按钮只是体验优化,真正的权限校验必须在后端做。答辩时如果老师问“前端改掉角色是不是就能进管理页”,你要回答“前端改了也进不去,因为后端接口会校验真实角色”,然后指向 LoginInterceptor 和后端角色查询代码。

按钮级权限我用一个自定义指令或者工具函数实现,模板里控制删除、审批这类敏感按钮。

function hasPerm(perm) { const perms = JSON.parse(localStorage.getItem('perms') || '[]') return perms.includes(perm) }
<el-button v-if="hasPerm('employee:delete')" type="danger" @click="handleDelete(scope.row)" >删除</el-button>

这里我把权限细化为字符串,如employee:delete、leave:approve,后端登录接口返回的 roles 列表里额外带上 perms。按钮级权限的好处是答辩演示时可以直接展示:管理员能看到删除按钮,普通员工看不到。这个设计在真实项目里是标配,而大多数实训作品没有这层,你做出来就是差异化亮点。

5. 避坑指南:人事管理系统开发和答辩中最常见的五个坑

5.1 中文乱码:数据入库后全是问号

现象:前端页面上显示中文正常,但往数据库插入中文后变成??,或者页面上读出来就是乱码。

原因:最常见的两种,一是 JDBC 连接串没设置字符编码,二是数据库表或字段的字符集不是 utf8mb4。实训项目用 MySQL 5.7 时默认字符集可能是 latin1,插入中文直接丢数据。

解决:JDBC 连接串加上useUnicode=true&characterEncoding=utf8;建表时统一DEFAULT CHARSET=utf8mb4,字符串字段用VARCHAR而不是TEXT的也一并定好。如果已经是乱码,先ALTER TABLE employee CONVERT TO CHARACTER SET utf8mb4;再重新插入测试数据。

5.2 LocalDateTime 前后端格式对不上

现象:前端传2023-08-11 14:30:00,后端报JSON parse error,或者后端返回的时间格式是一长串数字。

原因:Spring Boot 默认使用 Jackson 序列化,LocalDateTime 的默认格式不是yyyy-MM-dd HH:mm:ss。前端和后端各按各的格式解析,互相不认。

解决:在实体时间字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),同时配置全局的 Jackson 格式化器。注意用了 MyBatis-Plus 的自动填充后,LocalDateTime 的序列化格式要单独验证,特别是插入后回显,时间往往会出问题。

5.3 删除员工时外键约束报错

现象:删除一个员工时控制台报Cannot delete or update a parent row: a foreign key constraint fails。

原因:员工在请假申请表里有记录,外键约束阻止了直接删除。这是数据库层面的正确行为,不能绕过,也不应该绕过。

解决:员工删除从物理删除改成逻辑删除。employee 表加deleted字段,MyBatis-Plus 实体上加@TableLogic,删除接口改成update employee set deleted = 1。这样请假记录仍然能关联到员工历史数据,列表查询也会自动过滤掉已删除员工。这个方案比先删子表记录再删员工更合理,因为考勤和请假记录属于审计数据,不应该物理删除。

5.4 密码明文存储:答辩老师的第一个问题

现象:演示时打开数据库表,所有人的密码直接显示成明文,比如123456。老师当场问:“这个系统的密码安全吗?”

原因:图省事,注册或初始化时直接把用户输入的密码存进数据库。这是非常明显的安全隐患,没有任何辩解空间。

解决:引入 Spring Security 的BCryptPasswordEncoder,存密码用encode(),校验密码用matches()。注意 BCrypt 生成的密文长度接近 60 个字符,user 表的 password 字段至少定VARCHAR(100),否则插入报错。初始化数据的密码也要生成密文后再写入 SQL,这一步别偷懒。

5.5 事务和索引被追问时答不上来

现象:功能全部跑通,答辩时老师问“你的系统哪里用了事务?哪里用了索引?”,现场愣住。

原因:代码是照着网上的例子敲的,只关注功能能否跑通,没有主动关注性能和安全设计。这比 bug 更危险,因为功能能跑是应该的,设计意识才是实训考察的重点。

解决:答辩前把三处亮点整理出来:部门调动接口的@Transactional事务、工号和部门编码的唯一索引、BCrypt 密码加密。每处用一两句话说清楚“为什么这样设计”,比罗列十几个功能有用得多。回答事务问题时直接指到 3.2 节那段代码,说明三个更新操作要么全部成功要么全部回滚,这是最直观的事务案例。

6. 让演示更稳的收尾技巧:造数据、验接口、打包部署

实训答辩最怕的不是功能缺,而是用例太少,演示点都串不起来。我习惯在答辩前写一个批量造数脚本,生成几十条部门、员工和请假数据,用真实姓名风格而不是“测试1”“测试2”,演示时观感完全不同。

WITH RECURSIVE seq AS ( SELECT 1 AS n UNION ALL SELECT n + 1 FROM seq WHERE n < 20 ) INSERT INTO dept (dept_name, dept_code) SELECT CONCAT('部门', n), CONCAT('D', LPAD(n, 3, '0')) FROM seq;

这个脚本用 MySQL 8.0 的递归 CTE 快速生成 20 个部门。如果你用的是 MySQL 5.7,递归 CTE 不兼容,可以用UNION ALL拼数字表的方式替代。演示数据的关键不是数量多,而是覆盖不同状态、不同部门,比如请假申请要有待审批和已通过的,这样列表筛选才有东西可展示。

接口验证我不用 Postman 导出 JSON,直接命令行 curl 跑通两个核心接口,答辩现场即使断网也能演示。

TOKEN=$(curl -s -X POST http://localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"admin123"}' | jq -r '.data.token') curl -s "http://localhost:8080/api/employee/page?pageNum=1&pageSize=10" \ -H "Authorization: Bearer $TOKEN"

第一条命令走登录接口拿 token,第二条带 token 查询员工分页列表。jq不是每个环境都装了,如果命令行提示找不到 jq,就先手动发第一条请求把 token 复制出来,再拼到第二条请求里。演示前先把这两条命令跑一遍,确认登录、token 解析、分页查询三个关键链路没有断。

打包部署部分用 Maven 标准命令,注意打包前把测试切到不依赖本地环境的配置,否则演示环境连不上数据库会直接启动失败。

mvn clean package -DskipTests java -jar target/hr-system.jar --server.port=8080

-DskipTests用来跳过测试代码执行,实训阶段没有完善的自动化测试,跳过是合理的。如果数据库地址变了,启动参数加上--spring.datasource.url和--spring.datasource.password,比去改配置文件再重新打包快很多。启动日志里看到Started关键字后再打开浏览器,不要心急。

最后说一个我自己的习惯:不管接到什么新需求,第一件事不是写代码,而是先想清楚数据怎么流动、状态有哪些、谁有权限碰它。这套人事管理系统就是把这三个问题完整锻炼一遍的题。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询