# 中介库管理导入功能异步化改造设计文档 ## 文档信息 | 项目 | 内容 | |------|------| | **文档标题** | 中介库管理导入功能异步化改造 | | **创建日期** | 2026-02-08 | | **参考实现** | 员工信息导入功能 (CcdiEmployeeController) | | **涉及模块** | 中介库管理 (ccdiIntermediary) | | **改造范围** | 个人中介导入、实体中介导入 | --- ## 1. 背景与目标 ### 1.1 当前问题 **现状**: 中介库管理的导入功能采用**同步处理**方式,用户上传文件后需要等待所有数据处理完成才能收到响应。 **存在问题**: - ⏱️ 大数据量导入时,用户需要长时间等待(可能数十秒甚至数分钟) - 🚫 请求可能因超时而中断 - 😰 用户体验不佳,无法查看导入进度 - ❌ 导入失败后无法查看详细的失败记录 ### 1.2 改造目标 将中介库管理的导入功能改造为**异步处理模式**,参考员工导入的成功实现: **核心目标**: - ⚡ **即时响应**: 用户上传文件后立即获得taskId,无需等待 - 📊 **进度追踪**: 前端轮询查询导入进度和状态 - 💾 **失败重试**: 失败记录保存在Redis,支持7天内查询和重试 - 🔄 **并发处理**: 支持多个用户同时导入,互不阻塞 --- ## 2. 架构设计 ### 2.1 三层架构模式 ``` ┌─────────────────────────────────────────────────────────┐ │ Layer 1: Controller (CcdiIntermediaryController) │ │ - 解析Excel文件 │ │ - 调用主Service的importIntermediaryPerson/Entity() │ │ - 接收taskId │ │ - 封装ImportResultVO返回 │ └──────────────────┬──────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Layer 2: 主Service (CcdiIntermediaryServiceImpl) │ │ - 生成UUID作为taskId │ │ - 初始化Redis状态(PROCESSING) │ │ - 获取当前用户名(SecurityUtils.getUsername()) │ │ - 调用异步Service的importPersonAsync/EntityAsync() │ │ - 立即返回taskId │ └──────────────────┬──────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Layer 3: 异步Service (CcdiIntermediaryPersonImport │ │ /EntityImportServiceImpl) │ │ - @Async异步执行 │ │ - 批量验证、插入、更新数据 │ │ - 保存失败记录到Redis │ │ - 更新最终状态(SUCCESS/PARTIAL_SUCCESS) │ └─────────────────────────────────────────────────────────┘ ``` ### 2.2 数据流转 ``` 用户上传文件 │ ▼ Controller解析Excel │ ▼ 主Service生成taskId + 初始化Redis │ ├──► 立即返回taskId给Controller │ │ │ ▼ │ Controller封装ImportResultVO返回 │ │ │ ▼ │ 前端收到响应,开始轮询查询状态 │ └──► 异步Service后台执行导入 │ ├──► 批量验证数据 ├──► 批量插入/更新数据 ├──► 保存失败记录到Redis └──► 更新Redis状态为SUCCESS/PARTIAL_SUCCESS ``` ### 2.3 Redis状态管理 **状态Key设计**: | 类型 | 个人中介 | 实体中介 | |------|---------|---------| | **导入状态** | `import:intermediary:{taskId}` | `import:intermediary-entity:{taskId}` | | **失败记录** | `import:intermediary:{taskId}:failures` | `import:intermediary-entity:{taskId}:failures` | | **过期时间** | 7天 | 7天 | **状态字段结构** (Hash): ```javascript { taskId: "uuid-string", status: "PROCESSING" | "SUCCESS" | "PARTIAL_SUCCESS", totalCount: 100, successCount: 95, failureCount: 5, progress: 100, startTime: 1234567890, endTime: 1234567900, message: "成功95条,失败5条" } ``` --- ## 3. 详细实现方案 ### 3.1 后端改造 #### 文件1: CcdiIntermediaryServiceImpl.java **路径**: `ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryServiceImpl.java` **需要添加的依赖注入**: ```java @Resource private ICcdiIntermediaryPersonImportService personImportService; @Resource private ICcdiIntermediaryEntityImportService entityImportService; @Resource private RedisTemplate redisTemplate; ``` **改造1: importIntermediaryPerson方法** **原实现** (同步,第251行开始): ```java @Override @Transactional public String importIntermediaryPerson(List<...> list, boolean updateSupport) { // 同步执行所有导入逻辑 // 返回消息字符串 } ``` **新实现** (异步): ```java @Override @Transactional public String importIntermediaryPerson(List list, boolean updateSupport) { String taskId = UUID.randomUUID().toString(); long startTime = System.currentTimeMillis(); // 初始化Redis状态 String statusKey = "import:intermediary:" + taskId; Map statusData = new HashMap<>(); statusData.put("taskId", taskId); statusData.put("status", "PROCESSING"); statusData.put("totalCount", list.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); // 获取当前用户名 String userName = SecurityUtils.getUsername(); // 调用异步方法 personImportService.importPersonAsync(list, updateSupport, taskId, userName); return taskId; } ``` **改造2: importIntermediaryEntity方法** 与个人中介类似,只需修改: - Redis Key前缀为 `import:intermediary-entity:` - 调用 `entityImportService.importEntityAsync()` --- #### 文件2: CcdiIntermediaryController.java **路径**: `ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/controller/CcdiIntermediaryController.java` **需要添加的依赖注入**: ```java @Resource private ICcdiIntermediaryPersonImportService personImportService; @Resource private ICcdiIntermediaryEntityImportService entityImportService; ``` **需要添加的import**: ```java import com.ruoyi.ccdi.domain.vo.ImportResultVO; import com.ruoyi.ccdi.domain.vo.ImportStatusVO; import com.ruoyi.ccdi.domain.vo.IntermediaryPersonImportFailureVO; import com.ruoyi.ccdi.domain.vo.IntermediaryEntityImportFailureVO; import com.ruoyi.ccdi.service.ICcdiIntermediaryPersonImportService; import com.ruoyi.ccdi.service.ICcdiIntermediaryEntityImportService; ``` **改造1: importPersonData方法** (第183-188行) **原实现**: ```java @PostMapping("/importPersonData") public AjaxResult importPersonData(MultipartFile file, boolean updateSupport) throws Exception { List list = EasyExcelUtil.importExcel(...); String message = intermediaryService.importIntermediaryPerson(list, updateSupport); return success(message); } ``` **新实现**: ```java @PostMapping("/importPersonData") public AjaxResult importPersonData(MultipartFile file, @RequestParam(defaultValue = "false") boolean updateSupport) throws Exception { List list = EasyExcelUtil.importExcel( file.getInputStream(), CcdiIntermediaryPersonExcel.class); if (list == null || list.isEmpty()) { return error("至少需要一条数据"); } // 提交异步任务 String taskId = intermediaryService.importIntermediaryPerson(list, updateSupport); // 立即返回,不等待后台任务完成 ImportResultVO result = new ImportResultVO(); result.setTaskId(taskId); result.setStatus("PROCESSING"); result.setMessage("导入任务已提交,正在后台处理"); return AjaxResult.success("导入任务已提交,正在后台处理", result); } ``` **改造2: importEntityData方法** (第196-201行) 与个人中介类似,只需修改: - Excel类为 `CcdiIntermediaryEntityExcel` - 调用 `importIntermediaryEntity()` **新增3: 查询个人中介导入状态** ```java @GetMapping("/importPersonStatus/{taskId}") public AjaxResult getPersonImportStatus(@PathVariable String taskId) { try { ImportStatusVO status = personImportService.getImportStatus(taskId); return success(status); } catch (Exception e) { return error(e.getMessage()); } } ``` **新增4: 查询个人中介导入失败记录** ```java @GetMapping("/importPersonFailures/{taskId}") public TableDataInfo getPersonImportFailures( @PathVariable String taskId, @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize) { List failures = personImportService.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-6: 实体中介的状态和失败记录查询接口** 与个人中介完全对称,只需: - URL中的`Person`改为`Entity` - Service改为`entityImportService` - VO改为`IntermediaryEntityImportFailureVO` **接口路径对照表**: | 功能 | 个人中介 | 实体中介 | |------|---------|---------| | 导入数据 | `POST /importPersonData` | `POST /importEntityData` | | 查询状态 | `GET /importPersonStatus/{taskId}` | `GET /importEntityStatus/{taskId}` | | 查询失败 | `GET /importPersonFailures/{taskId}` | `GET /importEntityFailures/{taskId}` | --- ### 3.2 前端改造 #### 文件1: API接口定义 **路径**: `ruoyi-ui/src/api/ccdiIntermediary.js` **需要添加的方法**: ```javascript import request from '@/utils/request' // 查询个人中介导入状态 export function getPersonImportStatus(taskId) { return request({ url: `/ccdi/intermediary/importPersonStatus/${taskId}`, method: 'get' }) } // 查询个人中介导入失败记录 export function getPersonImportFailures(taskId, pageNum, pageSize) { return request({ url: `/ccdi/intermediary/importPersonFailures/${taskId}`, method: 'get', params: { pageNum, pageSize } }) } // 查询实体中介导入状态 export function getEntityImportStatus(taskId) { return request({ url: `/ccdi/intermediary/importEntityStatus/${taskId}`, method: 'get' }) } // 查询实体中介导入失败记录 export function getEntityImportFailures(taskId, pageNum, pageSize) { return request({ url: `/ccdi/intermediary/importEntityFailures/${taskId}`, method: 'get', params: { pageNum, pageSize } }) } ``` #### 文件2: ImportDialog.vue改造 **路径**: `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue` **需要添加的import**: ```javascript import { getPersonImportStatus, getEntityImportStatus } from "@/api/ccdiIntermediary"; ``` **data中添加的状态管理**: ```javascript data() { return { // ...原有data pollingTimer: null, currentTaskId: null } } ``` **修改handleFileSuccess方法**: ```javascript handleFileSuccess(response) { this.isUploading = false; if (response.code === 200 && response.data && response.data.taskId) { const taskId = response.data.taskId; this.currentTaskId = taskId; // 显示通知 this.$notify({ title: '导入任务已提交', message: '正在后台处理中,处理完成后将通知您', type: 'info', duration: 3000 }); // 关闭对话框 this.visible = false; this.$refs.upload.clearFiles(); // 通知父组件刷新列表 this.$emit("success", taskId); // 开始轮询 this.startImportStatusPolling(taskId); } else { this.$modal.msgError(response.msg || '导入失败'); } } ``` **添加轮询方法**: ```javascript methods: { /** 开始轮询导入状态 */ startImportStatusPolling(taskId) { let pollCount = 0; const maxPolls = 150; // 最多5分钟 this.pollingTimer = setInterval(async () => { try { pollCount++; if (pollCount > maxPolls) { clearInterval(this.pollingTimer); this.$modal.msgWarning('导入任务处理超时,请联系管理员'); return; } // 根据导入类型调用不同的API const apiMethod = this.formData.importType === 'person' ? getPersonImportStatus : getEntityImportStatus; const response = await apiMethod(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 }); } else if (statusResult.failureCount > 0) { this.$notify({ title: '导入完成', message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}条`, type: 'warning', duration: 5000 }); } // 通知父组件更新失败记录状态 this.$emit("import-complete", { taskId: statusResult.taskId, hasFailures: statusResult.failureCount > 0 }); } } /** 组件销毁时清除定时器 */ beforeDestroy() { if (this.pollingTimer) { clearInterval(this.pollingTimer); this.pollingTimer = null; } } ``` --- ## 4. 测试方案 ### 4.1 功能测试用例 #### 测试用例1: 正常导入流程 **前置条件**: - 准备包含10条个人中介数据的Excel文件 - 数据格式正确,所有必填字段都已填写 **测试步骤**: 1. 登录系统,进入中介管理页面 2. 点击"导入"按钮 3. 选择"个人中介"类型 4. 上传Excel文件,不勾选"更新已存在的数据" 5. 点击"开始导入" **预期结果**: - ✅ 立即收到通知:"导入任务已提交,正在后台处理中" - ✅ 导入对话框关闭 - ✅ 2-5秒后收到完成通知(根据数据量) - ✅ 列表自动刷新,显示新导入的数据 - ✅ 如果全部成功,显示绿色通知:"全部成功!共导入10条数据" #### 测试用例2: 数据验证失败 **前置条件**: - 准备包含错误数据的Excel(如身份证号格式错误、姓名为空等) **测试步骤**: 1. 重复测试用例1的步骤 **预期结果**: - ✅ 导入任务正常提交 - ✅ 完成后显示黄色通知:"成功X条,失败Y条" - ✅ 页面出现"查看导入失败记录"按钮 - ✅ 点击按钮可以查看失败原因 - ✅ 失败记录包含:原数据行号、错误信息 #### 测试用例3: 更新模式 **前置条件**: - 数据库中已存在某个证件号的中介记录 - Excel文件中包含相同证件号的数据,但其他字段不同 **测试步骤**: 1. 勾选"更新已存在的数据" 2. 上传Excel文件 **预期结果**: - ✅ 已存在的数据被更新 - ✅ 审计字段`updatedBy`正确记录当前用户 - ✅ `updateTime`更新为当前时间 #### 测试用例4: 实体中介导入 **前置条件**: - 准备包含机构中介数据的Excel文件 **测试步骤**: 1. 选择"机构中介"类型 2. 上传Excel文件 **预期结果**: - ✅ 导入流程与个人中介一致 - ✅ Redis Key前缀为`import:intermediary-entity:` - ✅ 数据正确插入`ccdi_enterprise_base_info`表 #### 测试用例5: 并发导入 **测试步骤**: 1. 打开两个浏览器标签页 2. 同时在不同标签页导入个人中介和实体中介 **预期结果**: - ✅ 两个导入任务互不影响 - ✅ 各自独立显示进度通知 - ✅ 都能正确完成 #### 测试用例6: 大数据量导入 **前置条件**: - 准备包含1000条数据的Excel文件 **测试步骤**: 1. 上传大文件 2. 观察导入过程 **预期结果**: - ✅ 立即返回taskId,不阻塞 - ✅ 轮询查询能正确获取进度 - ✅ 最终完成并显示正确统计信息 ### 4.2 性能测试 #### 性能指标 | 指标 | 目标值 | |------|--------| | 接口响应时间 | < 500ms (立即返回) | | 轮询间隔 | 2秒 | | 轮询超时 | 5分钟 (150次) | | 单批导入大小 | 500条 | | 支持最大文件 | 10MB | | 并发导入任务 | 10个 | #### 测试方法 ```bash # 使用Apache Bench进行压力测试 ab -n 100 -c 10 -T "multipart/form-data; boundary=----WebKitFormBoundary" \ -p test_data.xlsx http://localhost:8080/ccdi/intermediary/importPersonData ``` --- ## 5. 部署与验证 ### 5.1 部署步骤 1. **代码修改** - 按照上述方案修改3个后端文件 - 修改2个前端文件 2. **编译打包** ```bash # 后端 cd ruoyi-info-collection mvn clean package # 前端 cd ruoyi-ui npm run build:prod ``` 3. **重启服务** ```bash # 停止现有服务 # 部署新的jar包 # 启动服务 ``` 4. **验证部署** - 访问Swagger文档: `http://localhost:8080/swagger-ui/index.html` - 确认新的接口已正确注册 ### 5.2 验证清单 - [ ] 个人中介导入接口返回taskId - [ ] 实体中介导入接口返回taskId - [ ] 轮询查询状态接口正常工作 - [ ] 失败记录查询接口返回正确数据 - [ ] 前端轮询机制正常 - [ ] 导入完成通知正确显示 - [ ] Redis状态正确设置和过期 - [ ] 审计字段正确记录操作人 --- ## 6. 风险与注意事项 ### 6.1 潜在风险 | 风险项 | 影响 | 缓解措施 | |--------|------|----------| | Redis服务故障 | 导入状态无法记录 | 确保Redis高可用,增加监控 | | 异步任务执行失败 | 任务状态卡在PROCESSING | 增加超时机制和失败重试 | | 并发量过大 | 系统资源耗尽 | 限制并发导入任务数 | | 轮询频繁 | 服务器压力增大 | 合理设置轮询间隔(2秒) | ### 6.2 注意事项 1. **异步方法无法使用@Transactional** - 异步Service中使用`@Transactional`会失效 - 需要在方法内部手动管理事务 2. **Redis数据过期** - 7天后导入状态和失败记录会自动删除 - 用户需要及时查看失败记录 3. **userName参数** - 中介实体需要记录`createdBy/updatedBy` - 必须传递当前用户名给异步方法 4. **轮询超时处理** - 最多轮询150次(5分钟) - 超时后需要提示用户联系管理员 --- ## 7. 实施计划 ### 7.1 任务分解 | 任务 | 负责人 | 预计时间 | |------|--------|----------| | 1. 后端Service层改造 | 后端开发 | 2小时 | | 2. 后端Controller层改造 | 后端开发 | 1小时 | | 3. 前端API接口定义 | 前端开发 | 0.5小时 | | 4. 前端ImportDialog组件改造 | 前端开发 | 2小时 | | 5. 单元测试 | 测试开发 | 2小时 | | 6. 集成测试 | 测试开发 | 2小时 | | 7. 文档更新 | 技术文档 | 1小时 | **总计**: 约10.5小时 ### 7.2 里程碑 - **T+0**: 完成设计文档 - **T+1天**: 完成后端代码改造和单元测试 - **T+2天**: 完成前端代码改造 - **T+3天**: 完成集成测试和部署 --- ## 8. 附录 ### 8.1 相关文档 - [员工导入功能设计](../员工导入功能/) - [MyBatis Plus批量操作文档](https://baomidou.com/pages/2976a3/) - [Spring异步任务文档](https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#scheduling) ### 8.2 参考代码 - **员工导入Controller**: `CcdiEmployeeController.java:136-191` - **员工导入Service**: `CcdiEmployeeServiceImpl.java:186-208` - **员工异步导入Service**: `CcdiEmployeeImportServiceImpl.java:43-109` - **员工导入前端**: `ruoyi-ui/src/views/ccdiEmployee/index.vue` ### 8.3 数据字典 **导入状态枚举**: | 状态值 | 说明 | |--------|------| | PROCESSING | 处理中 | | SUCCESS | 全部成功 | | PARTIAL_SUCCESS | 部分成功(有失败记录) | **Redis Key设计**: | 类型 | Key模式 | 过期时间 | |------|---------|----------| | 个人中介状态 | `import:intermediary:{taskId}` | 7天 | | 个人中介失败 | `import:intermediary:{taskId}:failures` | 7天 | | 实体中介状态 | `import:intermediary-entity:{taskId}` | 7天 | | 实体中介失败 | `import:intermediary-entity:{taskId}:failures` | 7天 | --- **文档版本**: v1.0 **最后更新**: 2026-02-08 **文档状态**: 待审核