diff --git a/doc/plans/2026-02-06-employee-async-import-design.md b/doc/plans/2026-02-06-employee-async-import-design.md new file mode 100644 index 0000000..12eccdf --- /dev/null +++ b/doc/plans/2026-02-06-employee-async-import-design.md @@ -0,0 +1,745 @@ +# 员工信息异步导入功能设计文档 + +**创建日期**: 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 diff --git a/doc/plans/2026-02-06-employee-async-import.md b/doc/plans/2026-02-06-employee-async-import.md new file mode 100644 index 0000000..716d42f --- /dev/null +++ b/doc/plans/2026-02-06-employee-async-import.md @@ -0,0 +1,1451 @@ +# 员工信息异步导入功能实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**目标:** 实现员工信息的异步导入功能,将导入结果存储在Redis中,前端通过轮询查询导入状态,并提供失败记录查询功能。 + +**架构:** 使用Spring @Async实现异步导入,MyBatis Plus批量插入新数据,自定义SQL使用ON DUPLICATE KEY UPDATE批量更新已有数据,Redis存储导入状态和失败记录(TTL 7天),前端轮询查询状态并显示结果。 + +**技术栈:** Spring Boot 3.5.8, MyBatis Plus 3.5.10, Redis, Vue 2.6.12, Element UI + +--- + +## Task 1: 配置异步支持 + +**目标:** 创建异步配置类,设置专用线程池处理导入任务 + +**文件:** +- 创建: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/config/AsyncConfig.java` + +**步骤 1: 创建AsyncConfig配置类** + +```java +package com.ruoyi.ccdi.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 异步配置类 + * + * @author ruoyi + */ +@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.setWaitForTasksToCompleteOnShutdown(true); + // 等待时间 + executor.setAwaitTerminationSeconds(60); + executor.initialize(); + return executor; + } +} +``` + +**步骤 2: 验证配置是否生效** + +启动应用后,查看日志中是否有线程池创建信息。或者在后续任务中测试异步方法。 + +**步骤 3: 提交配置** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/config/AsyncConfig.java +git commit -m "feat: 添加异步配置类,配置导入任务专用线程池" +``` + +--- + +## Task 2: 创建VO类 + +**目标:** 创建导入结果、状态和失败记录的VO类 + +**文件:** +- 创建: `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` + +**步骤 1: 创建ImportResultVO** + +```java +package com.ruoyi.ccdi.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 导入结果VO + * + * @author ruoyi + */ +@Data +@Schema(description = "导入结果") +public class ImportResultVO { + + @Schema(description = "任务ID") + private String taskId; + + @Schema(description = "状态: PROCESSING-处理中, SUCCESS-成功, PARTIAL_SUCCESS-部分成功, FAILED-失败") + private String status; + + @Schema(description = "消息") + private String message; +} +``` + +**步骤 2: 创建ImportStatusVO** + +```java +package com.ruoyi.ccdi.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 导入状态VO + * + * @author ruoyi + */ +@Data +@Schema(description = "导入状态") +public class ImportStatusVO { + + @Schema(description = "任务ID") + private String taskId; + + @Schema(description = "状态") + private String status; + + @Schema(description = "总记录数") + private Integer totalCount; + + @Schema(description = "成功数") + private Integer successCount; + + @Schema(description = "失败数") + private Integer failureCount; + + @Schema(description = "进度百分比") + private Integer progress; + + @Schema(description = "开始时间戳") + private Long startTime; + + @Schema(description = "结束时间戳") + private Long endTime; + + @Schema(description = "状态消息") + private String message; +} +``` + +**步骤 3: 创建ImportFailureVO** + +```java +package com.ruoyi.ccdi.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 导入失败记录VO + * + * @author ruoyi + */ +@Data +@Schema(description = "导入失败记录") +public class ImportFailureVO { + + @Schema(description = "柜员号") + private Long employeeId; + + @Schema(description = "姓名") + private String name; + + @Schema(description = "身份证号") + private String idCard; + + @Schema(description = "部门ID") + private Long deptId; + + @Schema(description = "电话") + private String phone; + + @Schema(description = "状态") + private String status; + + @Schema(description = "入职时间") + private String hireDate; + + @Schema(description = "错误信息") + private String errorMessage; +} +``` + +**步骤 4: 提交VO类** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/ +git commit -m "feat: 添加导入相关VO类(ImportResultVO, ImportStatusVO, ImportFailureVO)" +``` + +--- + +## Task 3: 添加数据库UNIQUE约束 + +**目标:** 确保employee_id字段有UNIQUE约束,支持ON DUPLICATE KEY UPDATE + +**文件:** +- 修改: 数据库表 `ccdi_employee` + +**步骤 1: 检查现有约束** + +```sql +-- 使用MCP连接数据库查询 +SHOW INDEX FROM ccdi_employee WHERE Key_name = 'uk_employee_id'; +``` + +**步骤 2: 添加UNIQUE约束(如果不存在)** + +```sql +-- 如果不存在uk_employee_id索引,则添加 +ALTER TABLE ccdi_employee +ADD UNIQUE KEY uk_employee_id (employee_id); +``` + +**步骤 3: 验证约束** + +```sql +-- 验证索引已创建 +SHOW INDEX FROM ccdi_employee WHERE Key_name = 'uk_employee_id'; +``` + +**步骤 4: 记录SQL变更** + +在项目 `sql` 目录下创建 `sql/update_ccdi_employee_20260206.sql`: + +```sql +-- 员工信息表添加柜员号唯一索引 +-- 日期: 2026-02-06 +-- 目的: 支持批量更新时使用ON DUPLICATE KEY UPDATE语法 + +ALTER TABLE ccdi_employee +ADD UNIQUE KEY uk_employee_id (employee_id) +COMMENT '柜员号唯一索引'; +``` + +**步骤 5: 提交SQL变更** + +```bash +git add sql/update_ccdi_employee_20260206.sql +git commit -m "feat: 添加员工表柜员号唯一索引,支持批量更新" +``` + +--- + +## Task 4: 添加Mapper方法 + +**目标:** 在Mapper接口和XML中添加批量查询和批量插入更新的方法 + +**文件:** +- 修改: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/mapper/CcdiEmployeeMapper.java` +- 修改: `ruoyi-ccdi/src/main/resources/mapper/ccdi/CcdiEmployeeMapper.xml` + +**步骤 1: 在Mapper接口中添加方法** + +在 `CcdiEmployeeMapper.java` 中添加: + +```java +/** + * 批量插入或更新员工信息 + * 使用ON DUPLICATE KEY UPDATE语法 + * + * @param list 员工信息列表 + * @return 影响行数 + */ +int insertOrUpdateBatch(@Param("list") List list); +``` + +**步骤 2: 在Mapper.xml中添加SQL** + +在 `CcdiEmployeeMapper.xml` 中添加: + +```xml + + + INSERT INTO ccdi_employee + (employee_id, name, dept_id, id_card, phone, hire_date, status, + create_time, create_by, update_by, update_time, remark) + VALUES + + (#{item.employeeId}, #{item.name}, #{item.deptId}, #{item.idCard}, + #{item.phone}, #{item.hireDate}, #{item.status}, NOW(), + #{item.createBy}, #{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) + +``` + +**步骤 3: 提交Mapper变更** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/mapper/CcdiEmployeeMapper.java +git add ruoyi-ccdi/src/main/resources/mapper/ccdi/CcdiEmployeeMapper.xml +git commit -m "feat: 添加批量插入或更新员工信息方法" +``` + +--- + +## Task 5: 实现Service层异步导入方法 + +**目标:** 实现异步导入逻辑,包括数据分类、批量操作、Redis存储 + +**文件:** +- 修改: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiEmployeeService.java` +- 修改: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiEmployeeServiceImpl.java` + +**步骤 1: 在Service接口中添加方法声明** + +在 `ICcdiEmployeeService.java` 中添加: + +```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); +``` + +**步骤 2: 在ServiceImpl中实现异步方法** + +在 `CcdiEmployeeServiceImpl.java` 中添加依赖注入: + +```java +@Resource +private RedisTemplate redisTemplate; + +@Resource(name = "importExecutor") +private Executor importExecutor; +``` + +**步骤 3: 实现异步导入核心逻辑** + +在 `CcdiEmployeeServiceImpl.java` 中添加: + +```java +/** + * 异步导入员工数据 + */ +@Override +@Async("importExecutor") +public CompletableFuture importEmployeeAsync(List excelList, Boolean isUpdateSupport) { + // 生成任务ID + String taskId = UUID.randomUUID().toString(); + long startTime = System.currentTimeMillis(); + + // 初始化Redis状态 + String statusKey = "import:employee:" + taskId; + Map statusData = new HashMap<>(); + statusData.put("taskId", taskId); + statusData.put("status", "PROCESSING"); + statusData.put("totalCount", excelList.size()); + statusData.put("successCount", 0); + statusData.put("failureCount", 0); + statusData.put("progress", 0); + statusData.put("startTime", startTime); + statusData.put("message", "正在处理..."); + + redisTemplate.opsForHash().putAll(statusKey, statusData); + redisTemplate.expire(statusKey, 7, TimeUnit.DAYS); + + try { + // 执行导入逻辑 + ImportResult result = doImport(excelList, isUpdateSupport, taskId); + + // 更新最终状态 + String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; + updateImportStatus(taskId, finalStatus, result, startTime); + + // 返回结果 + ImportResultVO resultVO = new ImportResultVO(); + resultVO.setTaskId(taskId); + resultVO.setStatus(finalStatus); + resultVO.setMessage("导入任务已提交"); + + return CompletableFuture.completedFuture(resultVO); + + } catch (Exception e) { + // 处理异常 + Map errorData = new HashMap<>(); + errorData.put("status", "FAILED"); + errorData.put("message", "导入失败: " + e.getMessage()); + errorData.put("endTime", System.currentTimeMillis()); + redisTemplate.opsForHash().putAll(statusKey, errorData); + + ImportResultVO resultVO = new ImportResultVO(); + resultVO.setTaskId(taskId); + resultVO.setStatus("FAILED"); + resultVO.setMessage("导入失败: " + e.getMessage()); + + return CompletableFuture.completedFuture(resultVO); + } +} + +/** + * 执行导入逻辑 + */ +private ImportResult doImport(List excelList, Boolean isUpdateSupport, String taskId) { + List newRecords = new ArrayList<>(); + List updateRecords = new ArrayList<>(); + List failures = new ArrayList<>(); + + // 批量查询已存在的柜员号 + Set existingIds = getExistingEmployeeIds(excelList); + + // 分类数据 + for (int i = 0; i < excelList.size(); i++) { + CcdiEmployeeExcel excel = excelList.get(i); + + try { + // 验证数据 + validateEmployeeData(excel, existingIds); + + CcdiEmployee employee = convertToEntity(excel); + + if (existingIds.contains(excel.getEmployeeId())) { + if (isUpdateSupport) { + updateRecords.add(employee); + } else { + throw new RuntimeException("柜员号已存在且未启用更新支持"); + } + } else { + newRecords.add(employee); + } + + // 更新进度 + int progress = (int) ((i + 1) * 100.0 / excelList.size()); + updateImportProgress(taskId, progress); + + } catch (Exception e) { + ImportFailureVO failure = new ImportFailureVO(); + BeanUtils.copyProperties(excel, failure); + failure.setErrorMessage(e.getMessage()); + failures.add(failure); + } + } + + // 批量插入新数据 + if (!newRecords.isEmpty()) { + saveBatch(newRecords, 500); + } + + // 批量更新已有数据 + if (!updateRecords.isEmpty() && isUpdateSupport) { + employeeMapper.insertOrUpdateBatch(updateRecords); + } + + // 保存失败记录到Redis + if (!failures.isEmpty()) { + String failuresKey = "import:employee:" + taskId + ":failures"; + redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); + } + + // 构建结果 + ImportResult result = new ImportResult(); + result.setTotalCount(excelList.size()); + result.setSuccessCount(newRecords.size() + updateRecords.size()); + result.setFailureCount(failures.size()); + + return result; +} + +/** + * 获取已存在的员工ID集合 + */ +private Set getExistingEmployeeIds(List excelList) { + List employeeIds = excelList.stream() + .map(CcdiEmployeeExcel::getEmployeeId) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + if (employeeIds.isEmpty()) { + return Collections.emptySet(); + } + + List existingEmployees = employeeMapper.selectBatchIds(employeeIds); + return existingEmployees.stream() + .map(CcdiEmployee::getEmployeeId) + .collect(Collectors.toSet()); +} + +/** + * 转换Excel对象为Entity + */ +private CcdiEmployee convertToEntity(CcdiEmployeeExcel excel) { + CcdiEmployee employee = new CcdiEmployee(); + BeanUtils.copyProperties(excel, employee); + return employee; +} + +/** + * 更新导入进度 + */ +private void updateImportProgress(String taskId, Integer progress) { + String key = "import:employee:" + taskId; + redisTemplate.opsForHash().put(key, "progress", progress); +} + +/** + * 更新导入状态 + */ +private void updateImportStatus(String taskId, String status, ImportResult result, Long startTime) { + String key = "import:employee:" + taskId; + Map statusData = new HashMap<>(); + statusData.put("status", status); + statusData.put("successCount", result.getSuccessCount()); + statusData.put("failureCount", result.getFailureCount()); + statusData.put("progress", 100); + statusData.put("endTime", System.currentTimeMillis()); + + if ("SUCCESS".equals(status)) { + statusData.put("message", "全部成功!共导入" + result.getTotalCount() + "条数据"); + } else { + statusData.put("message", "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "条"); + } + + redisTemplate.opsForHash().putAll(key, statusData); +} + +/** + * 导入结果内部类 + */ +@Data +private static class ImportResult { + private Integer totalCount; + private Integer successCount; + private Integer failureCount; +} +``` + +**步骤 4: 实现查询导入状态方法** + +```java +@Override +public ImportStatusVO getImportStatus(String taskId) { + String key = "import:employee:" + taskId; + Boolean hasKey = redisTemplate.hasKey(key); + + if (Boolean.FALSE.equals(hasKey)) { + throw new RuntimeException("任务不存在或已过期"); + } + + Map statusMap = redisTemplate.opsForHash().entries(key); + + ImportStatusVO statusVO = new ImportStatusVO(); + statusVO.setTaskId((String) statusMap.get("taskId")); + statusVO.setStatus((String) statusMap.get("status")); + statusVO.setTotalCount((Integer) statusMap.get("totalCount")); + statusVO.setSuccessCount((Integer) statusMap.get("successCount")); + statusVO.setFailureCount((Integer) statusMap.get("failureCount")); + statusVO.setProgress((Integer) statusMap.get("progress")); + statusVO.setStartTime((Long) statusMap.get("startTime")); + statusVO.setEndTime((Long) statusMap.get("endTime")); + statusVO.setMessage((String) statusMap.get("message")); + + return statusVO; +} +``` + +**步骤 5: 实现查询失败记录方法** + +```java +@Override +public List getImportFailures(String taskId) { + String key = "import:employee:" + taskId + ":failures"; + Object failuresObj = redisTemplate.opsForValue().get(key); + + if (failuresObj == null) { + return Collections.emptyList(); + } + + // 使用JSON转换 + return JSON.parseArray(JSON.toJSONString(failuresObj), ImportFailureVO.class); +} +``` + +**步骤 6: 提交Service层代码** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ +git commit -m "feat: 实现员工信息异步导入服务" +``` + +--- + +## Task 6: 修改Controller层接口 + +**目标:** 修改导入接口为异步,添加状态查询和失败记录查询接口 + +**文件:** +- 修改: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiEmployeeController.java` + +**步骤 1: 添加Resource注入** + +在Controller中添加: + +```java +@Resource +private RedisTemplate redisTemplate; +``` + +**步骤 2: 修改导入接口** + +将现有的 `importData` 方法改为: + +```java +/** + * 导入员工信息(异步) + */ +@Operation(summary = "导入员工信息") +@PreAuthorize("@ss.hasPermi('ccdi:employee:import')") +@Log(title = "员工信息", businessType = BusinessType.IMPORT) +@PostMapping("/importData") +public AjaxResult importData(MultipartFile file, boolean updateSupport) throws Exception { + List list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiEmployeeExcel.class); + + if (list == null || list.isEmpty()) { + return error("至少需要一条数据"); + } + + // 异步导入 + CompletableFuture future = employeeService.importEmployeeAsync(list, updateSupport); + + // 立即返回taskId + ImportResultVO result = future.get(); + + return success("导入任务已提交,正在后台处理", result); +} +``` + +**步骤 3: 添加状态查询接口** + +```java +/** + * 查询导入状态 + */ +@Operation(summary = "查询员工导入状态") +@GetMapping("/importStatus/{taskId}") +public AjaxResult getImportStatus(@PathVariable String taskId) { + try { + ImportStatusVO status = employeeService.getImportStatus(taskId); + return success(status); + } catch (Exception e) { + return error(e.getMessage()); + } +} +``` + +**步骤 4: 添加失败记录查询接口** + +```java +/** + * 查询导入失败记录 + */ +@Operation(summary = "查询导入失败记录") +@GetMapping("/importFailures/{taskId}") +public TableDataInfo getImportFailures( + @PathVariable String taskId, + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize) { + + List failures = employeeService.getImportFailures(taskId); + + // 手动分页 + int fromIndex = (pageNum - 1) * pageSize; + int toIndex = Math.min(fromIndex + pageSize, failures.size()); + + List pageData = failures.subList(fromIndex, toIndex); + + return getDataTable(pageData, failures.size()); +} +``` + +**步骤 5: 提交Controller变更** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiEmployeeController.java +git commit -m "feat: 修改导入接口为异步,添加状态和失败记录查询接口" +``` + +--- + +## Task 7: 前端API定义 + +**目标:** 在前端添加导入状态查询和失败记录查询的API方法 + +**文件:** +- 修改: `ruoyi-ui/src/api/ccdiEmployee.js` + +**步骤 1: 添加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 } + }) +} +``` + +**步骤 2: 提交前端API变更** + +```bash +git add ruoyi-ui/src/api/ccdiEmployee.js +git commit -m "feat: 添加导入状态和失败记录查询API" +``` + +--- + +## Task 8: 前端导入流程优化 + +**目标:** 修改导入成功处理逻辑,实现后台处理+完成通知 + +**文件:** +- 修改: `ruoyi-ui/src/views/ccdiEmployee/index.vue` + +**步骤 1: 添加data属性** + +在 `data()` 中添加: + +```javascript +data() { + return { + // ...现有data + pollingTimer: null, + showFailureButton: false, + currentTaskId: null, + failureDialogVisible: false, + failureList: [], + failureLoading: false, + failureTotal: 0, + failureQueryParams: { + pageNum: 1, + pageSize: 10 + } + } +} +``` + +**步骤 2: 修改handleFileSuccess方法** + +将现有的 `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); + } +} +``` + +**步骤 3: 添加轮询方法** + +```javascript +methods: { + // ...现有methods + + startImportStatusPolling(taskId) { + this.pollingTimer = setInterval(async () => { + try { + const response = await getImportStatus(taskId); + + if (response.data && response.data.status !== 'PROCESSING') { + clearInterval(this.pollingTimer); + this.handleImportComplete(response.data); + } + } catch (error) { + clearInterval(this.pollingTimer); + this.$modal.msgError('查询导入状态失败: ' + error.message); + } + }, 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(); + } + } +} +``` + +**步骤 4: 添加生命周期销毁钩子** + +```javascript +beforeDestroy() { + // 组件销毁时清除定时器 + if (this.pollingTimer) { + clearInterval(this.pollingTimer); + this.pollingTimer = null; + } +} +``` + +**步骤 5: 提交前端轮询逻辑** + +```bash +git add ruoyi-ui/src/views/ccdiEmployee/index.vue +git commit -m "feat: 实现导入状态轮询和完成通知" +``` + +--- + +## Task 9: 添加失败记录查询UI + +**目标:** 在页面添加查看失败记录按钮和对话框 + +**文件:** +- 修改: `ruoyi-ui/src/views/ccdiEmployee/index.vue` + +**步骤 1: 在搜索表单区域添加按钮** + +在 `` 中添加: + +```vue + + 查看导入失败记录 + +``` + +**步骤 2: 添加失败记录对话框** + +在模板最后、`` 标签前添加: + +```vue + + + + + + + + + + + + + + +``` + +**步骤 3: 添加方法** + +```javascript +methods: { + // ...现有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; + }).catch(error => { + this.failureLoading = false; + this.$modal.msgError('查询失败记录失败: ' + error.message); + }); + } +} +``` + +**步骤 4: 添加样式(可选)** + +```vue + +``` + +**步骤 5: 提交失败记录UI** + +```bash +git add ruoyi-ui/src/views/ccdiEmployee/index.vue +git commit -m "feat: 添加导入失败记录查询对话框" +``` + +--- + +## Task 10: 生成API文档 + +**目标:** 更新Swagger文档,记录新增的接口 + +**文件:** +- 修改: `doc/api/ccdi-employee-api.md` + +**步骤 1: 添加接口文档** + +```markdown +## 员工信息导入相关接口 + +### 1. 导入员工信息(异步) + +**接口地址:** `POST /ccdi/employee/importData` + +**权限标识:** `ccdi:employee:import` + +**请求参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| file | File | 是 | Excel文件 | +| updateSupport | boolean | 否 | 是否更新已存在的数据,默认false | + +**响应示例:** + +\`\`\`json +{ + "code": 200, + "msg": "导入任务已提交,正在后台处理", + "data": { + "taskId": "uuid-string", + "status": "PROCESSING", + "message": "任务已创建" + } +} +\`\`\` + +### 2. 查询导入状态 + +**接口地址:** `GET /ccdi/employee/importStatus/{taskId}` + +**权限标识:** 无 + +**路径参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| taskId | String | 是 | 任务ID | + +**响应示例:** + +\`\`\`json +{ + "code": 200, + "data": { + "taskId": "uuid-string", + "status": "SUCCESS", + "totalCount": 100, + "successCount": 95, + "failureCount": 5, + "progress": 100, + "startTime": 1707225600000, + "endTime": 1707225900000, + "message": "导入完成" + } +} +\`\`\` + +### 3. 查询导入失败记录 + +**接口地址:** `GET /ccdi/employee/importFailures/{taskId}` + +**权限标识:** 无 + +**路径参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| taskId | String | 是 | 任务ID | + +**查询参数:** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| pageNum | Integer | 否 | 页码,默认1 | +| pageSize | Integer | 否 | 每页条数,默认10 | + +**响应示例:** + +\`\`\`json +{ + "code": 200, + "rows": [ + { + "employeeId": "1234567", + "name": "张三", + "idCard": "110101199001011234", + "deptId": 100, + "phone": "13800138000", + "status": "0", + "hireDate": "2020-01-01", + "errorMessage": "身份证号格式错误" + } + ], + "total": 5 +} +\`\`\` +``` + +**步骤 2: 提交文档** + +```bash +git add doc/api/ccdi-employee-api.md +git commit -m "docs: 更新员工导入相关接口文档" +``` + +--- + +## Task 11: 编写测试脚本 + +**目标:** 生成完整的测试脚本,验证导入功能 + +**文件:** +- 创建: `test/test_employee_import.py` + +**步骤 1: 创建测试脚本** + +完整的测试脚本将在任务实施过程中生成,包括: +- 登录获取token +- 测试正常导入 +- 测试导入状态查询 +- 测试失败记录查询 +- 测试重复导入(验证批量更新) +- 生成测试报告 + +**步骤 2: 提交测试脚本** + +```bash +git add test/test_employee_import.py +git commit -m "test: 添加员工导入功能测试脚本" +``` + +--- + +## Task 12: 最终集成测试 + +**目标:** 完整功能测试,确保前后端联调正常 + +**步骤 1: 启动后端服务** + +```bash +cd ruoyi-admin +mvn spring-boot:run +``` + +**步骤 2: 启动前端服务** + +```bash +cd ruoyi-ui +npm run dev +``` + +**步骤 3: 手动测试流程** + +1. 访问 `http://localhost` 登录系统 +2. 进入员工信息管理页面 +3. 点击"导入"按钮 +4. 下载模板并填写测试数据 +5. 上传文件,观察是否显示"导入任务已提交"通知 +6. 等待2-5秒,观察是否显示"导入完成"通知 +7. 如果有失败数据,验证"查看导入失败记录"按钮是否显示 +8. 点击按钮查看失败记录对话框 +9. 验证失败记录是否正确显示 + +**步骤 4: 性能测试** + +使用包含100、1000、10000条数据的Excel文件进行测试,验证: +- 导入响应时间 < 500ms(立即返回taskId) +- 1000条数据处理时间 < 10秒 +- 10000条数据处理时间 < 60秒 + +**步骤 5: Redis验证** + +使用Redis客户端查看: + +```bash +# 连接Redis +redis-cli + +# 查看所有导入任务 +KEYS import:employee:* + +# 查看某个任务状态 +HGETALL import:employee:{taskId} + +# 查看失败记录 +LRANGE import:employee:{taskId}:failures 0 -1 + +# 验证TTL +TTL import:employee:{taskId} +``` + +**步骤 6: 记录测试结果** + +在 `doc/test/employee-import-test-report.md` 中记录测试结果: + +```markdown +# 员工导入功能测试报告 + +**测试日期:** 2026-02-06 +**测试人员:** + +## 测试环境 + +- 后端版本: +- 前端版本: +- Redis版本: +- 浏览器版本: + +## 功能测试 + +### 1. 正常导入测试 +- [ ] 100条数据导入成功 +- [ ] 1000条数据导入成功 +- [ ] 状态查询正常 +- [ ] 轮询通知正常 + +### 2. 失败数据处理测试 +- [ ] 部分数据失败时正确显示失败记录 +- [ ] 失误原因准确 +- [ ] 失败记录可以正常查询 + +### 3. 批量更新测试 +- [ ] 启用更新支持时,已有数据正确更新 +- [ ] 未启用更新支持时,已有数据跳过 + +### 4. 并发测试 +- [ ] 同时导入多个文件互不影响 +- [ ] 任务ID唯一 +- [ ] 各自状态独立 + +## 性能测试 + +| 数据量 | 响应时间 | 处理时间 | 结果 | +|--------|----------|----------|------| +| 100条 | | | | +| 1000条 | | | | +| 10000条 | | | | + +## 问题记录 + +记录测试中发现的问题和解决方案。 + +## 测试结论 + +- [ ] 通过 +- [ ] 不通过 +``` + +**步骤 7: 提交测试报告** + +```bash +git add doc/test/employee-import-test-report.md +git commit -m "test: 添加员工导入功能测试报告" +``` + +--- + +## Task 13: 代码审查和优化 + +**目标:** 代码review,优化性能和代码质量 + +**检查项:** + +- [ ] 代码符合项目编码规范 +- [ ] 异常处理完善 +- [ ] 日志记录充分 +- [ ] Redis操作性能优化(考虑使用Pipeline) +- [ ] 批量操作批次大小合理 +- [ ] 前端轮询间隔合理 +- [ ] 内存使用合理,无内存泄漏风险 +- [ ] 事务边界清晰 +- [ ] 并发安全性 + +**步骤 1: 代码审查** + +使用IDE的代码检查工具或人工review代码。 + +**步骤 2: 性能优化** + +根据测试结果进行针对性优化: +- 调整批量操作批次大小 +- 优化Redis操作 +- 调整线程池参数 + +**步骤 3: 提交优化代码** + +```bash +git add . +git commit -m "refactor: 代码审查和性能优化" +``` + +--- + +## Task 14: 文档完善 + +**目标:** 完善用户手册和开发文档 + +**文件:** +- 修改: `README.md` +- 创建: `doc/user-guide/employee-import-guide.md` + +**步骤 1: 更新README** + +在项目README中添加导入功能说明。 + +**步骤 2: 编写用户指南** + +```markdown +# 员工信息导入用户指南 + +## 导入流程 + +1. 准备Excel文件 +2. 登录系统 +3. 进入员工信息管理 +4. 点击"导入"按钮 +5. 选择文件并上传 +6. 等待后台处理完成 +7. 查看导入结果 +8. 如有失败,查看并修正失败记录 + +## 常见问题 + +### Q1: 导入后多久能看到结果? +A: 通常2-10秒,具体取决于数据量。 + +### Q2: 失败记录会保留多久? +A: 7天,过期自动删除。 + +### Q3: 如何修正失败的数据? +A: 点击"查看导入失败记录",修正后重新导入。 +``` + +**步骤 3: 提交文档** + +```bash +git add README.md doc/user-guide/employee-import-guide.md +git commit -m "docs: 完善员工导入功能文档" +``` + +--- + +## Task 15: 最终提交和发布 + +**目标:** 合并代码,发布新版本 + +**步骤 1: 查看所有变更** + +```bash +git status +git log --oneline -10 +``` + +**步骤 2: 创建合并请求** + +如果使用Git Flow,创建feature分支合并请求。 + +**步骤 3: 代码最终review** + +进行最后一次代码review。 + +**步骤 4: 合并到主分支** + +```bash +git checkout dev +git merge feature/employee-async-import +git push origin dev +``` + +**步骤 5: 打Tag(可选)** + +```bash +git tag -a v1.x.x -m "员工信息异步导入功能" +git push origin v1.x.x +``` + +**步骤 6: 部署到测试环境** + +按照部署流程部署到测试环境。 + +**步骤 7: 用户验收测试** + +邀请用户进行验收测试。 + +**步骤 8: 部署到生产环境** + +验收通过后,部署到生产环境。 + +--- + +## 附录 + +### A. 相关文件清单 + +**后端:** +- `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` + +**数据库:** +- `sql/update_ccdi_employee_20260206.sql` + +### B. Redis Key命名规范 + +``` +import:employee:{taskId} # 导入状态 +import:employee:{taskId}:failures # 失败记录列表 +``` + +### C. 状态枚举 + +| 状态值 | 说明 | 前端行为 | +|--------|------|----------| +| PROCESSING | 处理中 | 继续轮询 | +| SUCCESS | 全部成功 | 显示成功通知,刷新列表 | +| PARTIAL_SUCCESS | 部分成功 | 显示警告通知,显示失败按钮 | +| FAILED | 全部失败 | 显示错误通知,显示失败按钮 | + +--- + +**计划版本:** 1.0 +**创建日期:** 2026-02-06 +**预计总工时:** 8-12小时 diff --git a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiEmployeeService.java b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiEmployeeService.java index 33fa2c7..c38e573 100644 --- a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiEmployeeService.java +++ b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiEmployeeService.java @@ -6,8 +6,12 @@ import com.ruoyi.ccdi.domain.dto.CcdiEmployeeEditDTO; import com.ruoyi.ccdi.domain.dto.CcdiEmployeeQueryDTO; import com.ruoyi.ccdi.domain.excel.CcdiEmployeeExcel; import com.ruoyi.ccdi.domain.vo.CcdiEmployeeVO; +import com.ruoyi.ccdi.domain.vo.ImportFailureVO; +import com.ruoyi.ccdi.domain.vo.ImportResultVO; +import com.ruoyi.ccdi.domain.vo.ImportStatusVO; import java.util.List; +import java.util.concurrent.CompletableFuture; /** * 员工信息 服务层 @@ -82,4 +86,29 @@ public interface ICcdiEmployeeService { * @return 结果 */ String importEmployee(List excelList, Boolean isUpdateSupport); + + /** + * 异步导入员工数据 + * + * @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); } diff --git a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiEmployeeServiceImpl.java b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiEmployeeServiceImpl.java index f563ac7..31c073d 100644 --- a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiEmployeeServiceImpl.java +++ b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiEmployeeServiceImpl.java @@ -1,5 +1,6 @@ package com.ruoyi.ccdi.service.impl; +import com.alibaba.fastjson2.JSON; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.ccdi.domain.CcdiEmployee; @@ -8,6 +9,9 @@ import com.ruoyi.ccdi.domain.dto.CcdiEmployeeEditDTO; import com.ruoyi.ccdi.domain.dto.CcdiEmployeeQueryDTO; import com.ruoyi.ccdi.domain.excel.CcdiEmployeeExcel; import com.ruoyi.ccdi.domain.vo.CcdiEmployeeVO; +import com.ruoyi.ccdi.domain.vo.ImportFailureVO; +import com.ruoyi.ccdi.domain.vo.ImportResultVO; +import com.ruoyi.ccdi.domain.vo.ImportStatusVO; import com.ruoyi.ccdi.enums.EmployeeStatus; import com.ruoyi.ccdi.mapper.CcdiEmployeeMapper; import com.ruoyi.ccdi.service.ICcdiEmployeeService; @@ -15,11 +19,15 @@ import com.ruoyi.common.utils.IdCardUtil; import com.ruoyi.common.utils.StringUtils; import jakarta.annotation.Resource; import org.springframework.beans.BeanUtils; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.List; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; /** * 员工信息 服务层处理 @@ -33,6 +41,9 @@ public class CcdiEmployeeServiceImpl implements ICcdiEmployeeService { @Resource private CcdiEmployeeMapper employeeMapper; + @Resource + private RedisTemplate redisTemplate; + /** * 查询员工列表 * @@ -216,6 +227,114 @@ public class CcdiEmployeeServiceImpl implements ICcdiEmployeeService { } } + /** + * 异步导入员工数据 + * + * @param excelList Excel数据列表 + * @param isUpdateSupport 是否更新已存在的数据 + * @return CompletableFuture包含导入结果 + */ + @Override + @Async("importExecutor") + public CompletableFuture importEmployeeAsync(List excelList, Boolean isUpdateSupport) { + String taskId = UUID.randomUUID().toString(); + long startTime = System.currentTimeMillis(); + + // 初始化Redis状态 + String statusKey = "import:employee:" + taskId; + Map statusData = new HashMap<>(); + statusData.put("taskId", taskId); + statusData.put("status", "PROCESSING"); + statusData.put("totalCount", excelList.size()); + statusData.put("successCount", 0); + statusData.put("failureCount", 0); + statusData.put("progress", 0); + statusData.put("startTime", startTime); + statusData.put("message", "正在处理..."); + + redisTemplate.opsForHash().putAll(statusKey, statusData); + redisTemplate.expire(statusKey, 7, TimeUnit.DAYS); + + try { + // 执行导入 + ImportResult result = doImport(excelList, isUpdateSupport, taskId); + + // 更新最终状态 + String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; + updateImportStatus(taskId, finalStatus, result, startTime); + + ImportResultVO resultVO = new ImportResultVO(); + resultVO.setTaskId(taskId); + resultVO.setStatus(finalStatus); + resultVO.setMessage("导入任务已提交"); + + return CompletableFuture.completedFuture(resultVO); + } catch (Exception e) { + // 处理异常 + Map errorData = new HashMap<>(); + errorData.put("status", "FAILED"); + errorData.put("message", "导入失败: " + e.getMessage()); + errorData.put("endTime", System.currentTimeMillis()); + redisTemplate.opsForHash().putAll(statusKey, errorData); + + ImportResultVO resultVO = new ImportResultVO(); + resultVO.setTaskId(taskId); + resultVO.setStatus("FAILED"); + resultVO.setMessage("导入失败: " + e.getMessage()); + + return CompletableFuture.completedFuture(resultVO); + } + } + + /** + * 查询导入状态 + * + * @param taskId 任务ID + * @return 导入状态信息 + */ + @Override + public ImportStatusVO getImportStatus(String taskId) { + String key = "import:employee:" + taskId; + Boolean hasKey = redisTemplate.hasKey(key); + + if (Boolean.FALSE.equals(hasKey)) { + throw new RuntimeException("任务不存在或已过期"); + } + + Map statusMap = redisTemplate.opsForHash().entries(key); + + ImportStatusVO statusVO = new ImportStatusVO(); + statusVO.setTaskId((String) statusMap.get("taskId")); + statusVO.setStatus((String) statusMap.get("status")); + statusVO.setTotalCount((Integer) statusMap.get("totalCount")); + statusVO.setSuccessCount((Integer) statusMap.get("successCount")); + statusVO.setFailureCount((Integer) statusMap.get("failureCount")); + statusVO.setProgress((Integer) statusMap.get("progress")); + statusVO.setStartTime((Long) statusMap.get("startTime")); + statusVO.setEndTime((Long) statusMap.get("endTime")); + statusVO.setMessage((String) statusMap.get("message")); + + return statusVO; + } + + /** + * 获取导入失败记录 + * + * @param taskId 任务ID + * @return 失败记录列表 + */ + @Override + public List getImportFailures(String taskId) { + String key = "import:employee:" + taskId + ":failures"; + Object failuresObj = redisTemplate.opsForValue().get(key); + + if (failuresObj == null) { + return Collections.emptyList(); + } + + return JSON.parseArray(JSON.toJSONString(failuresObj), ImportFailureVO.class); + } + /** * 构建查询条件 */ @@ -291,4 +410,224 @@ public class CcdiEmployeeServiceImpl implements ICcdiEmployeeService { vo.setStatusDesc(EmployeeStatus.getDescByCode(employee.getStatus())); return vo; } + + /** + * 执行导入逻辑 + */ + private ImportResult doImport(List excelList, Boolean isUpdateSupport, String taskId) { + List newRecords = new ArrayList<>(); + List updateRecords = new ArrayList<>(); + List failures = new ArrayList<>(); + + // 批量查询已存在的柜员号 + Set existingIds = getExistingEmployeeIds(excelList); + + // 分类数据 + for (int i = 0; i < excelList.size(); i++) { + CcdiEmployeeExcel excel = excelList.get(i); + + try { + // 转换为AddDTO进行验证 + CcdiEmployeeAddDTO addDTO = new CcdiEmployeeAddDTO(); + BeanUtils.copyProperties(excel, addDTO); + + // 验证数据(支持更新模式) + validateEmployeeDataForImport(addDTO, isUpdateSupport, existingIds); + + CcdiEmployee employee = convertToEntity(excel); + + if (existingIds.contains(excel.getEmployeeId())) { + if (isUpdateSupport) { + updateRecords.add(employee); + } else { + throw new RuntimeException("柜员号已存在且未启用更新支持"); + } + } else { + newRecords.add(employee); + } + + // 更新进度 + int progress = (int) ((i + 1) * 100.0 / excelList.size()); + updateImportProgress(taskId, progress); + + } catch (Exception e) { + ImportFailureVO failure = new ImportFailureVO(); + BeanUtils.copyProperties(excel, failure); + failure.setErrorMessage(e.getMessage()); + failures.add(failure); + } + } + + // 批量插入新数据 + if (!newRecords.isEmpty()) { + saveBatch(newRecords, 500); + } + + // 批量更新已有数据 + if (!updateRecords.isEmpty() && isUpdateSupport) { + employeeMapper.insertOrUpdateBatch(updateRecords); + } + + // 保存失败记录到Redis + if (!failures.isEmpty()) { + String failuresKey = "import:employee:" + taskId + ":failures"; + redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); + } + + ImportResult result = new ImportResult(); + result.setTotalCount(excelList.size()); + result.setSuccessCount(newRecords.size() + updateRecords.size()); + result.setFailureCount(failures.size()); + + return result; + } + + /** + * 批量查询已存在的员工ID + */ + private Set getExistingEmployeeIds(List excelList) { + List employeeIds = excelList.stream() + .map(CcdiEmployeeExcel::getEmployeeId) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + if (employeeIds.isEmpty()) { + return Collections.emptySet(); + } + + List existingEmployees = employeeMapper.selectBatchIds(employeeIds); + return existingEmployees.stream() + .map(CcdiEmployee::getEmployeeId) + .collect(Collectors.toSet()); + } + + /** + * 转换为实体对象 + */ + private CcdiEmployee convertToEntity(CcdiEmployeeExcel excel) { + CcdiEmployee employee = new CcdiEmployee(); + BeanUtils.copyProperties(excel, employee); + return employee; + } + + /** + * 批量保存 + */ + private void saveBatch(List list, int batchSize) { + for (int i = 0; i < list.size(); i += batchSize) { + int end = Math.min(i + batchSize, list.size()); + List subList = list.subList(i, end); + for (CcdiEmployee employee : subList) { + employeeMapper.insert(employee); + } + } + } + + /** + * 更新导入进度 + */ + private void updateImportProgress(String taskId, Integer progress) { + String key = "import:employee:" + taskId; + redisTemplate.opsForHash().put(key, "progress", progress); + } + + /** + * 更新导入状态 + */ + private void updateImportStatus(String taskId, String status, ImportResult result, Long startTime) { + String key = "import:employee:" + taskId; + Map statusData = new HashMap<>(); + statusData.put("status", status); + statusData.put("successCount", result.getSuccessCount()); + statusData.put("failureCount", result.getFailureCount()); + statusData.put("progress", 100); + statusData.put("endTime", System.currentTimeMillis()); + + if ("SUCCESS".equals(status)) { + statusData.put("message", "全部成功!共导入" + result.getTotalCount() + "条数据"); + } else { + statusData.put("message", "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "条"); + } + + redisTemplate.opsForHash().putAll(key, statusData); + } + + /** + * 验证员工数据(导入模式,支持更新) + */ + private void validateEmployeeDataForImport(CcdiEmployeeAddDTO addDTO, Boolean isUpdateSupport, Set existingIds) { + // 验证必填字段 + if (StringUtils.isEmpty(addDTO.getName())) { + throw new RuntimeException("姓名不能为空"); + } + if (addDTO.getEmployeeId() == null) { + throw new RuntimeException("柜员号不能为空"); + } + if (addDTO.getDeptId() == null) { + throw new RuntimeException("所属部门不能为空"); + } + if (StringUtils.isEmpty(addDTO.getIdCard())) { + throw new RuntimeException("身份证号不能为空"); + } + if (StringUtils.isEmpty(addDTO.getPhone())) { + throw new RuntimeException("电话不能为空"); + } + if (StringUtils.isEmpty(addDTO.getStatus())) { + throw new RuntimeException("状态不能为空"); + } + + // 验证身份证号格式 + String idCardError = IdCardUtil.getErrorMessage(addDTO.getIdCard()); + if (idCardError != null) { + throw new RuntimeException(idCardError); + } + + // 如果柜员号不存在,检查身份证号唯一性 + if (!existingIds.contains(addDTO.getEmployeeId())) { + // 检查身份证号唯一性 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CcdiEmployee::getIdCard, addDTO.getIdCard()); + if (employeeMapper.selectCount(wrapper) > 0) { + throw new RuntimeException("该身份证号已存在"); + } + } + + // 验证状态 + if (!"0".equals(addDTO.getStatus()) && !"1".equals(addDTO.getStatus())) { + throw new RuntimeException("状态只能填写'在职'或'离职'"); + } + } + + /** + * 导入结果内部类 + */ + private static class ImportResult { + private Integer totalCount; + private Integer successCount; + private Integer failureCount; + + public Integer getTotalCount() { + return totalCount; + } + + public void setTotalCount(Integer totalCount) { + this.totalCount = totalCount; + } + + public Integer getSuccessCount() { + return successCount; + } + + public void setSuccessCount(Integer successCount) { + this.successCount = successCount; + } + + public Integer getFailureCount() { + return failureCount; + } + + public void setFailureCount(Integer failureCount) { + this.failureCount = failureCount; + } + } }