# 员工信息异步导入功能设计文档 **创建日期**: 2026-02-06 **设计者**: Claude Code **状态**: 已确认 --- ## 一、需求概述 ### 1.1 背景 当前员工信息导入功能为同步处理,存在以下问题: - 导入大量数据时前端等待时间长,用户体验差 - 导入失败记录无法保留和查询 - 未充分利用批量操作提升性能 ### 1.2 目标 - 实现异步导入,提升用户体验 - 失败记录存储在Redis中,保留7天,支持查询 - 新数据批量插入,已有数据使用`ON DUPLICATE KEY UPDATE`批量更新 - 以前端页面按钮方式提供失败记录查询功能 ### 1.3 核心决策 - **唯一标识**: 柜员号(employeeId) - **Redis TTL**: 7天 - **进度反馈**: 后台处理 + 完成通知 - **失败数据格式**: JSON对象列表存储 --- ## 二、系统架构设计 ### 2.1 整体架构 采用**生产者-消费者模式**: ``` 前端 → Controller(立即返回) → 异步Service → Redis ↑ ↓ └──── 轮询查询状态 ←─────────┘ ``` **核心流程**: 1. 前端提交Excel文件 2. Controller立即返回taskId,不阻塞 3. 异步线程处理导入逻辑 4. 结果实时写入Redis 5. 前端轮询查询导入状态 6. 完成后通知用户,如有失败显示查询按钮 ### 2.2 技术选型 | 技术 | 用途 | 说明 | |------|------|------| | Spring @Async | 异步处理 | 独立线程池处理导入任务 | | Redis | 结果存储 | 存储导入状态和失败记录,7天TTL | | MyBatis Plus | 批量插入 | saveBatch方法批量插入新数据 | | 自定义SQL | 批量更新 | INSERT ... ON DUPLICATE KEY UPDATE | | ThreadPoolExecutor | 线程管理 | 核心线程2,最大线程5 | --- ## 三、数据库设计 ### 3.1 表结构修改 确保`ccdi_employee`表的`employee_id`字段有UNIQUE约束: ```sql ALTER TABLE ccdi_employee ADD UNIQUE KEY uk_employee_id (employee_id); ``` ### 3.2 Redis数据结构 **状态信息存储**: ``` Key: import:employee:{taskId} Type: Hash TTL: 604800秒 (7天) Fields: - status: PROCESSING | SUCCESS | PARTIAL_SUCCESS | FAILED - totalCount: 总记录数 - successCount: 成功数 - failureCount: 失败数 - startTime: 开始时间戳 - endTime: 结束时间戳 - message: 状态描述 ``` **失败记录存储**: ``` Key: import:employee:{taskId}:failures Type: List (JSON序列化) TTL: 604800秒 (7天) Value: [ { "employeeId": "1234567", "name": "张三", "idCard": "110101199001011234", "deptId": 100, "phone": "13800138000", "status": "0", "hireDate": "2020-01-01", "errorMessage": "身份证号格式错误" }, ... ] ``` --- ## 四、后端实现设计 ### 4.1 异步配置 **AsyncConfig.java**: ```java @Configuration @EnableAsync public class AsyncConfig { @Bean("importExecutor") public Executor importExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(5); executor.setQueueCapacity(100); executor.setThreadNamePrefix("import-async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } } ``` ### 4.2 核心VO类 **ImportResultVO** (导入提交结果): ```java @Data public class ImportResultVO { private String taskId; private String status; private String message; } ``` **ImportStatusVO** (导入状态): ```java @Data public class ImportStatusVO { private String taskId; private String status; private Integer totalCount; private Integer successCount; private Integer failureCount; private Integer progress; private Long startTime; private Long endTime; private String message; } ``` **ImportFailureVO** (失败记录): ```java @Data public class ImportFailureVO { private Long employeeId; private String name; private String idCard; private Long deptId; private String phone; private String status; private String hireDate; private String errorMessage; } ``` ### 4.3 Service层接口 **ICcdiEmployeeService**新增: ```java /** * 异步导入员工数据 * @param excelList Excel数据列表 * @param isUpdateSupport 是否更新已存在的数据 * @return CompletableFuture包含导入结果 */ CompletableFuture importEmployeeAsync( List excelList, boolean isUpdateSupport ); /** * 查询导入状态 * @param taskId 任务ID * @return 导入状态信息 */ ImportStatusVO getImportStatus(String taskId); /** * 获取导入失败记录 * @param taskId 任务ID * @return 失败记录列表 */ List getImportFailures(String taskId); ``` ### 4.4 核心业务逻辑 **数据分类**: ```java // 1. 批量查询已存在的柜员号 Set existingIds = employeeMapper.selectBatchIds( excelList.stream() .map(CcdiEmployeeExcel::getEmployeeId) .collect(Collectors.toList()) ).stream() .map(CcdiEmployee::getEmployeeId) .collect(Collectors.toSet()); // 2. 分类为新数据和更新数据 List newRecords = new ArrayList<>(); List updateRecords = new ArrayList<>(); for (CcdiEmployeeExcel excel : excelList) { CcdiEmployee employee = convertToEntity(excel); if (existingIds.contains(excel.getEmployeeId())) { updateRecords.add(employee); } else { newRecords.add(employee); } } ``` **批量插入**: ```java if (!newRecords.isEmpty()) { employeeService.saveBatch(newRecords, 500); } ``` **批量更新**: ```java if (!updateRecords.isEmpty() && isUpdateSupport) { employeeMapper.insertOrUpdateBatch(updateRecords); } ``` **失败记录处理**: ```java List failures = new ArrayList<>(); for (CcdiEmployeeExcel excel : excelList) { try { // 验证和导入逻辑 validateAndImport(excel); } catch (Exception e) { ImportFailureVO failure = new ImportFailureVO(); BeanUtils.copyProperties(excel, failure); failure.setErrorMessage(e.getMessage()); failures.add(failure); } } // 存入Redis if (!failures.isEmpty()) { String key = "import:employee:" + taskId + ":failures"; redisTemplate.opsForValue().set( key, JSON.toJSONString(failures), 7, TimeUnit.DAYS ); } ``` ### 4.5 Mapper SQL **CcdiEmployeeMapper.xml**: ```xml INSERT INTO ccdi_employee (employee_id, name, dept_id, id_card, phone, hire_date, status, create_time, update_by, update_time, remark) VALUES (#{item.employeeId}, #{item.name}, #{item.deptId}, #{item.idCard}, #{item.phone}, #{item.hireDate}, #{item.status}, NOW(), #{item.updateBy}, NOW(), #{item.remark}) ON DUPLICATE KEY UPDATE name = VALUES(name), dept_id = VALUES(dept_id), phone = VALUES(phone), hire_date = VALUES(hire_date), status = VALUES(status), update_by = VALUES(update_by), update_time = NOW(), remark = VALUES(remark) ``` --- ## 五、Controller层API设计 ### 5.1 修改导入接口 **接口**: `POST /ccdi/employee/importData` **改动**: - 改为立即返回taskId - 使用异步处理 **响应示例**: ```json { "code": 200, "msg": "导入任务已提交,正在后台处理", "data": { "taskId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "PROCESSING", "message": "任务已创建" } } ``` ### 5.2 新增状态查询接口 **接口**: `GET /ccdi/employee/importStatus/{taskId}` **Swagger注解**: ```java @Operation(summary = "查询员工导入状态") @GetMapping("/importStatus/{taskId}") public AjaxResult getImportStatus(@PathVariable String taskId) ``` **响应示例**: ```json { "code": 200, "data": { "taskId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "SUCCESS", "totalCount": 100, "successCount": 95, "failureCount": 5, "progress": 100, "startTime": 1707225600000, "endTime": 1707225900000, "message": "导入完成" } } ``` ### 5.3 新增失败记录查询接口 **接口**: `GET /ccdi/employee/importFailures/{taskId}` **参数**: - `taskId`: 任务ID (路径参数) - `pageNum`: 页码 (可选,默认1) - `pageSize`: 每页条数 (可选,默认10) **响应格式**: TableDataInfo **Swagger注解**: ```java @Operation(summary = "查询导入失败记录") @GetMapping("/importFailures/{taskId}") public TableDataInfo getImportFailures( @PathVariable String taskId, @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize ) ``` --- ## 六、前端实现设计 ### 6.1 API定义 **api/ccdiEmployee.js**新增: ```javascript // 查询导入状态 export function getImportStatus(taskId) { return request({ url: '/ccdi/employee/importStatus/' + taskId, method: 'get' }) } // 查询导入失败记录 export function getImportFailures(taskId, pageNum, pageSize) { return request({ url: '/ccdi/employee/importFailures/' + taskId, method: 'get', params: { pageNum, pageSize } }) } ``` ### 6.2 导入流程优化 **修改handleFileSuccess方法**: ```javascript handleFileSuccess(response, file, fileList) { this.upload.isUploading = false; this.upload.open = false; if (response.code === 200) { const taskId = response.data.taskId; // 显示后台处理提示 this.$notify({ title: '导入任务已提交', message: '正在后台处理中,处理完成后将通知您', type: 'info', duration: 3000 }); // 开始轮询检查状态 this.startImportStatusPolling(taskId); } else { this.$modal.msgError(response.msg); } } ``` ### 6.3 轮询状态检查 ```javascript data() { return { // ...其他data pollingTimer: null } }, methods: { startImportStatusPolling(taskId) { this.pollingTimer = setInterval(async () => { const response = await getImportStatus(taskId); if (response.data.status !== 'PROCESSING') { clearInterval(this.pollingTimer); this.handleImportComplete(response.data); } }, 2000); // 每2秒轮询一次 }, handleImportComplete(statusResult) { if (statusResult.status === 'SUCCESS') { this.$notify({ title: '导入完成', message: `全部成功!共导入${statusResult.totalCount}条数据`, type: 'success', duration: 5000 }); this.getList(); } else if (statusResult.failureCount > 0) { this.$notify({ title: '导入完成', message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}条`, type: 'warning', duration: 5000 }); // 显示查看失败记录按钮 this.showFailureButton = true; this.currentTaskId = statusResult.taskId; // 刷新列表 this.getList(); } }, beforeDestroy() { // 组件销毁时清除定时器 if (this.pollingTimer) { clearInterval(this.pollingTimer); } } } ``` ### 6.4 失败记录查询UI **页面按钮**: ```vue 查看导入失败记录 ({{ currentTaskId }}) ``` **失败记录对话框**: ```vue
关闭 导出失败记录
``` **方法实现**: ```javascript data() { return { // 失败记录相关 showFailureButton: false, currentTaskId: null, failureDialogVisible: false, failureList: [], failureLoading: false, failureTotal: 0, failureQueryParams: { pageNum: 1, pageSize: 10 } } }, methods: { viewImportFailures() { this.failureDialogVisible = true; this.getFailureList(); }, getFailureList() { this.failureLoading = true; getImportFailures( this.currentTaskId, this.failureQueryParams.pageNum, this.failureQueryParams.pageSize ).then(response => { this.failureList = response.rows; this.failureTotal = response.total; this.failureLoading = false; }); }, exportFailures() { this.download( 'ccdi/employee/exportFailures/' + this.currentTaskId, {}, `导入失败记录_${new Date().getTime()}.xlsx` ); } } ``` --- ## 七、错误处理与边界情况 ### 7.1 异常场景处理 | 场景 | 处理方式 | |------|----------| | 导入文件格式错误 | 上传阶段校验,不创建任务,返回错误提示 | | 单条数据验证失败 | 记录到Redis失败列表,继续处理其他数据 | | Redis连接失败 | 记录日志报警,降级处理,返回警告 | | 线程池队列满 | CallerRunsPolicy,由提交线程执行 | | 部分成功 | status=PARTIAL_SUCCESS,显示失败记录按钮 | | 全部失败 | status=FAILED,显示失败记录按钮 | | taskId不存在 | 返回404,提示任务不存在或已过期 | ### 7.2 数据一致性 - 使用`@Transactional`保证批量操作原子性 - 新数据插入和已有数据更新在同一事务 - 任意步骤失败,整体回滚 - Redis状态更新在事务提交后执行 ### 7.3 幂等性 - taskId使用UUID,全局唯一 - 同一文件多次导入产生多个taskId - 支持查询历史任务状态和失败记录 - 失败记录独立存储,互不影响 ### 7.4 性能优化 - 批量插入每批500条,平衡性能和内存 - 使用ON DUPLICATE KEY UPDATE替代先查后更新 - Redis操作使用Pipeline批量执行 - 线程池复用,避免频繁创建销毁 --- ## 八、测试策略 ### 8.1 单元测试 - 测试数据分类逻辑(新数据vs已有数据) - 测试批量插入和批量更新 - 测试异常处理和失败记录收集 - 测试Redis读写操作 ### 8.2 集成测试 - 测试完整导入流程(提交→处理→查询) - 测试并发导入多个文件 - 测试Redis异常降级 - 测试线程池满载情况 ### 8.3 性能测试 - 100条数据导入时间 < 2秒 - 1000条数据导入时间 < 10秒 - 10000条数据导入时间 < 60秒 - 导入状态查询响应时间 < 100ms ### 8.4 前端测试 - 测试轮询逻辑正确性 - 测试通知显示和关闭 - 测试失败记录分页查询 - 测试组件销毁时清除定时器 --- ## 九、实施检查清单 ### 9.1 后端任务 - [ ] 创建AsyncConfig配置类 - [ ] 添加数据库UNIQUE约束 - [ ] 创建VO类(ImportResultVO, ImportStatusVO, ImportFailureVO) - [ ] 实现Service层异步方法 - [ ] 实现Redis状态存储逻辑 - [ ] 实现数据分类和批量操作 - [ ] 编写Mapper XML SQL - [ ] 添加Controller接口 - [ ] 更新Swagger文档 ### 9.2 前端任务 - [ ] 添加API方法定义 - [ ] 修改导入成功处理逻辑 - [ ] 实现轮询状态检查 - [ ] 添加查看失败记录按钮 - [ ] 创建失败记录对话框 - [ ] 实现分页查询失败记录 - [ ] 添加导出失败记录功能 ### 9.3 测试任务 - [ ] 编写单元测试用例 - [ ] 生成测试脚本 - [ ] 执行集成测试 - [ ] 进行性能测试 - [ ] 生成测试报告 --- ## 十、API文档更新 更新`doc`目录下的接口文档,包含: - 修改的导入接口说明 - 新增的状态查询接口 - 新增的失败记录查询接口 - 请求/响应示例 --- ## 附录 ### A. Redis Key命名规范 ``` import:employee:{taskId} # 导入状态 import:employee:{taskId}:failures # 失败记录列表 ``` ### B. 状态枚举 | 状态值 | 说明 | 前端行为 | |--------|------|----------| | PROCESSING | 处理中 | 继续轮询 | | SUCCESS | 全部成功 | 显示成功通知,刷新列表 | | PARTIAL_SUCCESS | 部分成功 | 显示警告通知,显示失败按钮 | | FAILED | 全部失败 | 显示错误通知,显示失败按钮 | ### C. 相关文件清单 **后端**: - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/config/AsyncConfig.java` - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/ImportResultVO.java` - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/ImportStatusVO.java` - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/ImportFailureVO.java` - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiEmployeeService.java` - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiEmployeeServiceImpl.java` - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/mapper/CcdiEmployeeMapper.java` - `ruoyi-ccdi/src/main/resources/mapper/ccdi/CcdiEmployeeMapper.xml` - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiEmployeeController.java` **前端**: - `ruoyi-ui/src/api/ccdiEmployee.js` - `ruoyi-ui/src/views/ccdiEmployee/index.vue` --- **文档版本**: 1.0 **最后更新**: 2026-02-06