简介这份资源是2023年湖南科技大学计算机学院综合创新实训3的完整项目报告面向软件工程专业学生及需要完成人事管理系统课程设计的读者。内容围绕人事管理系统的设计与实现展开涵盖需求分析、体系结构设计、接口与界面设计、数据结构与流程设计、数据建模与范式分析以及工厂模式和外观模式结合的类图设计并附有小组规约与工作日志完整呈现了从需求规约到详细设计的软件工程实践链路。资源包为1个docx文档约947KB结构清晰、章节完整便于按模块查阅与参考。目前已有1188人学习下载适合希望系统理解软件工程各阶段文档规范、借鉴UML图绘制与数据库设计思路的学生也可作为课程设计报告的撰写范本帮助快速把握项目组织方式与文档编写要点。1. 从零手搓人事管理系统综合创新实训里最容易被低估的三个技术决策很多同学拿到“人事管理系统”这个题目第一反应是“太简单了”——不就是员工增删改查吗但真正在综合创新实训里动手做你会发现事情远不止于此。一个能跑通、能演示、能让答辩老师点头的人事管理系统背后至少涉及三个关键决策数据模型怎么设计才能支撑后续的考勤、薪资、部门联动权限体系是做成简单的角色判断还是细粒度的功能级控制前后端交互是用传统的表单提交还是前后端分离。这三个决策一旦选错后期改起来就是伤筋动骨。这篇文章面向的是正在做软件工程方向综合创新实训的本科生尤其是选了人事管理系统这个题目的同学。我会按照一个完整的开发流程从需求分析、数据库设计、后端接口实现、前端页面搭建到最后的联调测试和部署把每个环节的关键代码和踩坑经验都讲清楚。你不需要有很深的框架经验只要会基本的 Java 或 Python、懂一点 SQL 和 HTML就能跟着走完整个流程。读完之后你手里应该有一个能演示、能答辩、代码结构清晰的人事管理系统。2. 需求拆解与数据库设计别急着写代码先把 ER 图想明白2.1 人事管理系统到底要管哪些数据很多人一上来就开始建表结果建到一半发现字段不够用或者表和表之间的关系理不清。我的习惯是先用一张纸把核心实体列出来再画 ER 图。人事管理系统的核心实体其实不多部门、职位、员工、用户账号、考勤记录、薪资记录、请假记录。但关键在于它们之间的关系。部门与员工是一对多一个部门有多个员工一个员工只属于一个部门。职位与员工也是一对多但这里有个坑员工调岗后历史职位记录要不要保留如果只是简单地在员工表里放一个 position_id调岗后历史就丢了。常见做法是加一张员工职位变更记录表记录每次调岗的时间、原职位、新职位。考勤记录和员工是一对多每天每人一条记录。薪资记录通常是按月生成和员工也是一对多。请假记录需要关联员工和审批人。这里有一个容易被忽略的点员工和用户账号要不要分开我的建议是分开。员工是人事管理的对象用户账号是系统登录的凭证。一个员工可以没有账号比如离职后账号被禁用但员工记录保留一个账号也可以对应多个员工比如财务人员兼管多个部门的薪资。分开设计后权限控制会灵活很多。2.2 用 SQL 建表的完整脚本与字段说明下面是一份经过实际项目验证的建表脚本以 MySQL 为例。我刻意把字段类型和约束写得比较细因为实训答辩时老师经常会问“为什么这个字段用 DECIMAL 不用 FLOAT”“为什么这里要加唯一索引”。-- 部门表支持多级部门用 parent_id 实现树形结构 CREATE TABLE department ( dept_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 部门ID, dept_name VARCHAR(50) NOT NULL COMMENT 部门名称, parent_id INT DEFAULT NULL COMMENT 上级部门ID顶级部门为NULL, dept_level TINYINT DEFAULT 1 COMMENT 部门层级1为顶级, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, UNIQUE KEY uk_dept_name (dept_name, parent_id) COMMENT 同级部门名称唯一 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT部门表; -- 职位表每个职位属于一个部门 CREATE TABLE position ( pos_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 职位ID, pos_name VARCHAR(50) NOT NULL COMMENT 职位名称, dept_id INT NOT NULL COMMENT 所属部门ID, pos_level TINYINT DEFAULT 1 COMMENT 职位级别1-10, base_salary DECIMAL(10,2) DEFAULT 0.00 COMMENT 基础薪资, FOREIGN KEY (dept_id) REFERENCES department(dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT职位表; -- 员工表核心表注意字段的默认值和可空性 CREATE TABLE employee ( emp_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 员工ID, emp_no VARCHAR(20) NOT NULL COMMENT 工号唯一, emp_name VARCHAR(30) NOT NULL COMMENT 姓名, gender TINYINT DEFAULT 1 COMMENT 性别1男 2女, birth_date DATE DEFAULT NULL COMMENT 出生日期, id_card VARCHAR(18) DEFAULT NULL COMMENT 身份证号, phone VARCHAR(15) DEFAULT NULL COMMENT 手机号, email VARCHAR(50) DEFAULT NULL COMMENT 邮箱, dept_id INT NOT NULL COMMENT 当前部门ID, pos_id INT NOT NULL COMMENT 当前职位ID, hire_date DATE NOT NULL 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, UNIQUE KEY uk_emp_no (emp_no), KEY idx_dept (dept_id), KEY idx_status (status), FOREIGN KEY (dept_id) REFERENCES department(dept_id), FOREIGN KEY (pos_id) REFERENCES position(pos_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT员工表; -- 用户账号表与员工表分离支持一个员工多个账号或一个账号管多个员工 CREATE TABLE sys_user ( user_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(30) NOT NULL COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 密码存SHA-256加盐值, salt VARCHAR(16) NOT NULL COMMENT 盐值, role TINYINT DEFAULT 1 COMMENT 角色1普通员工 2部门主管 3人事专员 4系统管理员, emp_id INT DEFAULT NULL COMMENT 关联员工ID可为空, status TINYINT DEFAULT 1 COMMENT 1启用 0禁用, last_login DATETIME DEFAULT NULL COMMENT 最后登录时间, UNIQUE KEY uk_username (username), FOREIGN KEY (emp_id) REFERENCES employee(emp_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户账号表; -- 考勤记录表每天每人一条用唯一索引防止重复插入 CREATE TABLE attendance ( att_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 考勤ID, emp_id INT NOT NULL COMMENT 员工ID, att_date DATE NOT NULL COMMENT 考勤日期, check_in TIME DEFAULT NULL COMMENT 签到时间, check_out TIME DEFAULT NULL COMMENT 签退时间, status TINYINT DEFAULT 1 COMMENT 1正常 2迟到 3早退 4缺勤 5请假, remark VARCHAR(200) DEFAULT NULL COMMENT 备注, UNIQUE KEY uk_emp_date (emp_id, att_date), FOREIGN KEY (emp_id) REFERENCES employee(emp_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT考勤记录表; -- 薪资记录表按月生成保留历史 CREATE TABLE salary ( sal_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 薪资ID, emp_id INT NOT NULL COMMENT 员工ID, sal_month VARCHAR(7) NOT NULL COMMENT 薪资月份格式2023-06, base_salary DECIMAL(10,2) NOT NULL COMMENT 基本工资, bonus DECIMAL(10,2) DEFAULT 0.00 COMMENT 奖金, deduction DECIMAL(10,2) DEFAULT 0.00 COMMENT 扣款, actual_salary DECIMAL(10,2) NOT NULL COMMENT 实发工资, pay_date DATE DEFAULT NULL COMMENT 发放日期, UNIQUE KEY uk_emp_month (emp_id, sal_month), FOREIGN KEY (emp_id) REFERENCES employee(emp_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT薪资记录表;这份脚本里有几个设计决策值得展开说。第一部门表用了 parent_id 做树形结构而不是单独建一张层级表。这样做的好处是查询简单坏处是递归查询需要写 CTE 或者用程序递归。对于实训项目部门层级一般不超过三层程序递归完全够用。第二员工表的 status 字段用了 TINYINT 而不是 ENUM因为 ENUM 在后续加状态时改表结构很麻烦。第三考勤表的唯一索引 uk_emp_date 非常关键它能在数据库层面防止同一天重复插入考勤记录比在代码里判断更可靠。第四薪资表的 sal_month 用 VARCHAR(7) 存“2023-06”格式而不是用 DATE 存月份第一天这样查询和展示都更直观。2.3 索引怎么加才不拖慢写入索引不是越多越好。上面脚本里我加了几个关键索引employee 表的 idx_dept 和 idx_status因为按部门筛选和按状态筛选是最常见的查询条件。attendance 表的 uk_emp_date 既是唯一约束也是查询索引按员工和日期查考勤会走这个索引。salary 表的 uk_emp_month 同理。但要注意如果你们的实训项目数据量很小几百条员工记录索引的优化效果几乎看不出来反而会让插入变慢。我的建议是外键字段加索引经常出现在 WHERE 条件里的字段加索引其他字段先不加。等数据量上来了再根据慢查询日志补索引。答辩时如果老师问“为什么这个字段没加索引”你可以回答“当前数据量下全表扫描成本可接受加索引反而增加写入开销”这是一个合理的工程判断。3. 后端接口实现用 Spring Boot 把增删改查写出工程感3.1 项目分层与统一返回格式很多同学写后端喜欢把所有逻辑塞进 Controller一个方法几百行后期改起来想哭。我的习惯是严格分层Controller 只负责接收参数和返回结果Service 写业务逻辑Mapper/Repository 负责数据库操作。以 Spring Boot MyBatis 为例目录结构大概是这样的src/main/java/com/example/hrms/ ├── controller/ │ ├── EmployeeController.java │ ├── DepartmentController.java │ └── AttendanceController.java ├── service/ │ ├── EmployeeService.java │ └── impl/EmployeeServiceImpl.java ├── mapper/ │ └── EmployeeMapper.java ├── entity/ │ └── Employee.java ├── dto/ │ ├── EmployeeQueryDTO.java │ └── EmployeeSaveDTO.java └── common/ └── Result.java统一返回格式是工程感的第一步。不要每个接口返回的 JSON 结构都不一样前端处理起来会疯掉。下面是一个简单的统一返回类// common/Result.java public class ResultT { private int code; // 200成功400参数错误500服务器错误 private String message; // 提示信息 private T data; // 返回数据 public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message 操作成功; r.data data; return r; } public static T ResultT fail(int code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } // getter/setter 省略 }这个类看起来简单但它是前后端约定的基础。前端只需要判断 code 是不是 200是就取 data不是就弹 message。不需要每个接口都写一套解析逻辑。3.2 员工分页查询接口的完整实现分页查询是人事管理系统里最复杂的接口之一因为要支持多条件组合筛选。下面是一个完整的实现包含 Controller、Service 和 Mapper 三层。// controller/EmployeeController.java RestController RequestMapping(/api/employee) public class EmployeeController { Autowired private EmployeeService employeeService; GetMapping(/page) public ResultPageResultEmployeeVO pageQuery(EmployeeQueryDTO query) { // 参数校验页码不能小于1每页条数不能超过100 if (query.getPageNum() null || query.getPageNum() 1) { query.setPageNum(1); } if (query.getPageSize() null || query.getPageSize() 100) { query.setPageSize(10); } return Result.success(employeeService.pageQuery(query)); } }// service/impl/EmployeeServiceImpl.java Service public class EmployeeServiceImpl implements EmployeeService { Autowired private EmployeeMapper employeeMapper; Override public PageResultEmployeeVO pageQuery(EmployeeQueryDTO query) { // 计算偏移量 int offset (query.getPageNum() - 1) * query.getPageSize(); // 查询总数 long total employeeMapper.countByCondition(query); // 查询当前页数据 ListEmployeeVO list employeeMapper.selectPage(query, offset); // 封装分页结果 PageResultEmployeeVO result new PageResult(); result.setTotal(total); result.setList(list); result.setPageNum(query.getPageNum()); result.setPageSize(query.getPageSize()); return result; } }!-- mapper/EmployeeMapper.xml -- select idcountByCondition resultTypelong SELECT COUNT(*) FROM employee e LEFT JOIN department d ON e.dept_id d.dept_id LEFT JOIN position p ON e.pos_id p.pos_id where if testempName ! null and empName ! AND e.emp_name LIKE CONCAT(%, #{empName}, %) /if if testdeptId ! null AND e.dept_id #{deptId} /if if teststatus ! null AND e.status #{status} /if if testhireDateStart ! null AND e.hire_date gt; #{hireDateStart} /if if testhireDateEnd ! null AND e.hire_date lt; #{hireDateEnd} /if /where /select select idselectPage resultTypecom.example.hrms.vo.EmployeeVO SELECT e.emp_id, e.emp_no, e.emp_name, e.gender, e.phone, e.hire_date, e.status, d.dept_name, p.pos_name FROM employee e LEFT JOIN department d ON e.dept_id d.dept_id LEFT JOIN position p ON e.pos_id p.pos_id where if testquery.empName ! null and query.empName ! AND e.emp_name LIKE CONCAT(%, #{query.empName}, %) /if if testquery.deptId ! null AND e.dept_id #{query.deptId} /if if testquery.status ! null AND e.status #{query.status} /if if testquery.hireDateStart ! null AND e.hire_date gt; #{query.hireDateStart} /if if testquery.hireDateEnd ! null AND e.hire_date lt; #{query.hireDateEnd} /if /where ORDER BY e.create_time DESC LIMIT #{offset}, #{query.pageSize} /select这段代码里有几个关键点。第一countByCondition 和 selectPage 的 WHERE 条件必须完全一致否则分页数据会对不上。我见过有同学 count 的时候忘了加某个条件结果总数和实际列表条数不一致前端翻页翻到最后一页全是空的。第二LIKE 查询用了 CONCAT(%, #{empName}, %)这是 MyBatis 里防止 SQL 注入的正确写法不要用 ${} 拼接。第三排序用了 create_time DESC保证最新入职的员工排在前面这是人事系统的常见需求。3.3 新增员工时的参数校验与事务处理新增员工看起来简单但有几个坑工号不能重复、部门必须存在、职位必须属于该部门、入职日期不能晚于今天。这些校验如果只在前端做后端接口被直接调用时就会出问题。我的做法是在 Service 层做完整校验并用事务保证数据一致性。// service/impl/EmployeeServiceImpl.java Override Transactional(rollbackFor Exception.class) public void addEmployee(EmployeeSaveDTO dto) { // 1. 校验工号是否重复 Employee exist employeeMapper.selectByEmpNo(dto.getEmpNo()); if (exist ! null) { throw new BusinessException(400, 工号已存在); } // 2. 校验部门是否存在 Department dept departmentMapper.selectById(dto.getDeptId()); if (dept null) { throw new BusinessException(400, 部门不存在); } // 3. 校验职位是否属于该部门 Position pos positionMapper.selectById(dto.getPosId()); if (pos null || !pos.getDeptId().equals(dto.getDeptId())) { throw new BusinessException(400, 职位与部门不匹配); } // 4. 校验入职日期 if (dto.getHireDate().after(new Date())) { throw new BusinessException(400, 入职日期不能晚于今天); } // 5. 插入员工记录 Employee emp new Employee(); BeanUtils.copyProperties(dto, emp); emp.setStatus(1); // 默认在职 employeeMapper.insert(emp); // 6. 如果同时创建账号在这里处理 if (dto.getCreateAccount() ! null dto.getCreateAccount()) { SysUser user new SysUser(); user.setUsername(dto.getEmpNo()); user.setSalt(generateSalt()); user.setPassword(sha256(dto.getEmpNo() user.getSalt())); user.setRole(1); user.setEmpId(emp.getEmpId()); sysUserMapper.insert(user); } }这里的 Transactional 注解很重要。如果第 5 步插入员工成功但第 6 步创建账号失败事务会回滚不会出现“有员工没账号”的脏数据。BusinessException 是自定义异常配合全局异常处理器返回统一的错误格式。4. 前端页面与联调用 Vue 把表格和表单串起来4.1 员工列表页的表格与分页组件前端我用 Vue 3 Element Plus 来演示这是目前实训项目里最常用的组合。员工列表页的核心是一个带筛选条件的表格和分页器。template div classemployee-page !-- 筛选区域 -- el-form :inlinetrue :modelquery classfilter-form el-form-item label姓名 el-input v-modelquery.empName placeholder输入姓名 clearable / /el-form-item el-form-item label部门 el-select v-modelquery.deptId placeholder选择部门 clearable el-option v-ford in deptList :keyd.deptId :labeld.deptName :valued.deptId / /el-select /el-form-item el-form-item label状态 el-select v-modelquery.status placeholder选择状态 clearable el-option label在职 :value1 / el-option label试用 :value2 / el-option label离职 :value3 / /el-select /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button el-button clickhandleReset重置/el-button /el-form-item /el-form !-- 表格区域 -- el-table :datatableData border stripe v-loadingloading el-table-column propempNo label工号 width100 / el-table-column propempName label姓名 width100 / el-table-column propgender label性别 width70 template #default{ row } {{ row.gender 1 ? 男 : 女 }} /template /el-table-column el-table-column propdeptName label部门 width120 / el-table-column propposName label职位 width120 / el-table-column propphone label手机号 width130 / el-table-column prophireDate label入职日期 width110 / el-table-column propstatus label状态 width80 template #default{ row } el-tag :typestatusTagType(row.status) {{ statusText(row.status) }} /el-tag /template /el-table-column el-table-column label操作 fixedright width150 template #default{ row } el-button link typeprimary clickhandleEdit(row)编辑/el-button el-button link typedanger clickhandleDelete(row)删除/el-button /template /el-table-column /el-table !-- 分页区域 -- el-pagination v-model:current-pagequery.pageNum v-model:page-sizequery.pageSize :totaltotal :page-sizes[10, 20, 50] layouttotal, sizes, prev, pager, next, jumper size-changeloadData current-changeloadData / /div /template script setup import { ref, reactive, onMounted } from vue import { ElMessage, ElMessageBox } from element-plus import { getEmployeePage, deleteEmployee } from /api/employee const loading ref(false) const tableData ref([]) const total ref(0) const deptList ref([]) const query reactive({ empName: , deptId: null, status: null, pageNum: 1, pageSize: 10 }) // 加载列表数据 const loadData async () { loading.value true try { const res await getEmployeePage(query) if (res.code 200) { tableData.value res.data.list total.value res.data.total } else { ElMessage.error(res.message) } } finally { loading.value false } } // 查询按钮重置到第一页 const handleSearch () { query.pageNum 1 loadData() } // 重置按钮清空条件并重新加载 const handleReset () { query.empName query.deptId null query.status null query.pageNum 1 loadData() } // 删除确认 const handleDelete (row) { ElMessageBox.confirm(确认删除员工 ${row.empName}, 提示, { type: warning }).then(async () { const res await deleteEmployee(row.empId) if (res.code 200) { ElMessage.success(删除成功) loadData() } else { ElMessage.error(res.message) } }).catch(() {}) } onMounted(() { loadData() }) /script这段代码里有几个实训中容易翻车的点。第一分页器的 size-change 和 current-change 都要绑定 loadData否则切换每页条数时数据不刷新。第二查询按钮要把 pageNum 重置为 1不然你在第 3 页点查询结果可能因为总数变少而显示空白。第三删除操作一定要加确认框这是基本的产品素养答辩时老师看到这个细节会加分。4.2 前后端联调时的跨域与代理配置前后端分离开发时跨域是第一个拦路虎。前端跑在 localhost:5173后端跑在 localhost:8080浏览器会拦截请求。解决方案有两种后端加 CORS 配置或者前端配代理。我一般两个都做开发环境用代理生产环境用 CORS。// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { // 所有 /api 开头的请求转发到后端 /api: { target: http://localhost:8080, changeOrigin: true, // 如果后端接口没有 /api 前缀需要重写 // rewrite: (path) path.replace(/^\/api/, ) } } } })// 后端 CORS 配置 Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }代理配置的关键是 changeOrigin: true它会把请求头里的 Host 改成目标地址避免后端因为 Host 不匹配而拒绝请求。如果后端接口路径本身不带 /api比如是 /employee/page那前端请求 /api/employee/page 时就需要 rewrite 把 /api 去掉。这个细节很多同学会搞混联调时一直 404 找不到原因。5. 避坑与排查那些答辩前夜让你睡不着的问题5.1 中文乱码从数据库到前端全链路排查现象员工姓名在数据库里看是正常的但前端页面显示成“???”或者“æŽå››”。原因字符集不统一。数据库、表、连接、后端响应、前端页面任何一环的字符集不是 utf8mb4中文就会出问题。解决数据库和表用 utf8mb4JDBC 连接串加 characterEncodingutf8后端返回 JSON 时设置 produces application/json;charsetutf-8前端 HTML 的 meta 标签声明 charsetutf-8。排查顺序是从数据库往上查先用客户端直接查数据库如果数据库里就是乱码那问题在写入环节如果数据库正常但接口返回乱码检查 JDBC 连接串和 Spring 的编码配置。5.2 分页总数对不上count 和 select 条件不一致现象列表显示总共 50 条但翻到第 5 页每页 10 条时数据为空。原因count 查询和 select 查询的 WHERE 条件不一致通常是 count 少了一个筛选条件。解决把 WHERE 条件抽成一个可复用的 SQL 片段count 和 select 都引用同一个片段。MyBatis 里可以用sql idwhereCondition定义片段然后用include refidwhereCondition/引入。这样改条件时只需要改一处。5.3 删除部门时外键约束报错现象删除一个部门时数据库报“Cannot delete or update a parent row: a foreign key constraint fails”。原因该部门下还有员工或职位记录外键约束阻止删除。解决两种方案。方案一是先检查部门下是否有员工有则提示“请先转移或删除该部门下的员工”。方案二是软删除给部门表加 is_deleted 字段删除时只标记不物理删除。实训项目推荐方案一逻辑简单答辩时也好解释。5.4 日期格式前后端不一致现象前端传“2023-06-15”到后端后端用 Date 类型接收时报错“Cannot deserialize value of type java.util.Date”。原因Spring 默认不支持这种格式的字符串转 Date。解决在 DTO 的日期字段上加 JsonFormat(pattern yyyy-MM-dd, timezone GMT8) 注解。如果是 GET 请求的参数还需要在 Controller 方法参数上加 DateTimeFormat(pattern yyyy-MM-dd)。两个注解一个管 JSON 反序列化一个管 URL 参数绑定缺一不可。5.5 事务不生效方法内部调用导致代理失效现象在 Service 的 A 方法里直接调用本类的 B 方法B 方法上加了 Transactional 但事务没生效。原因Spring 的事务是基于 AOP 代理的类内部方法调用不走代理对象所以事务注解失效。解决把 B 方法抽到另一个 Service 里或者通过 ApplicationContext 获取当前类的代理对象再调用。更简单的做法是调整代码结构避免在事务方法内部调用另一个需要事务的方法。这个坑在实训答辩时如果被问到能答上来会很加分。6. 部署与演示把系统跑在答辩电脑上的最后一步6.1 用 Docker Compose 一键启动 MySQL 和后端答辩时最尴尬的事情是代码在你自己电脑上跑得好好的拷到答辩教室的电脑上就起不来。常见原因是 MySQL 没装、版本不对、数据库没初始化。用 Docker Compose 可以避免这些问题。# docker-compose.yml version: 3.8 services: mysql: image: mysql:8.0 container_name: hrms-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: hrms MYSQL_CHARACTER_SET_SERVER: utf8mb4 MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci ports: - 3306:3306 volumes: - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql - mysql-data:/var/lib/mysql command: --default-authentication-pluginmysql_native_password backend: build: ./backend container_name: hrms-backend ports: - 8080:8080 depends_on: - mysql environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/hrms?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: root123 volumes: mysql-data:这个 compose 文件做了三件事启动 MySQL 并自动执行 init.sql 建表和插入初始数据启动后端并连接到 MySQL把数据库数据存在 volume 里下次启动数据还在。答辩前只需要在教室电脑上装好 Docker然后执行docker-compose up -d等半分钟就能访问系统。注意 init.sql 里要包含建表语句和至少一条管理员账号数据否则系统启动后没有账号能登录。6.2 答辩演示的脚本化流程演示最怕现场翻车所以我会提前写好一个演示脚本按步骤操作每一步都有预期结果。比如第一步用管理员账号登录展示系统首页第二步进入员工管理展示列表和分页第三步新增一个员工填写姓名、部门、职位保存后列表刷新出现新记录第四步编辑该员工修改手机号保存后确认修改生效第五步按部门筛选展示筛选功能第六步删除该员工确认删除后列表不再显示。每一步控制在 30 秒内总共 3 分钟。演示前把数据库重置到初始状态避免历史数据干扰。6.3 一个让我少熬两小时的调试习惯最后分享一个我自己的习惯在 Service 层的每个方法入口和出口打日志用log.info(addEmployee 入参: {}, dto)和log.info(addEmployee 出参: empId{}, emp.getEmpId())。这样联调时如果前端说“保存没反应”我直接看后端日志就知道请求有没有到、参数对不对、有没有抛异常。比在代码里到处打断点快得多。这个习惯在实训答辩前夜尤其管用因为那时候你根本没时间慢慢调试只能靠日志快速定位。希望帮到你。本文还有配套的精品资源点击获取