# 中介库异步导入功能设计文档 **创建日期:** 2026-02-06 **设计目标:** 将中介库管理的文件导入功能改造为异步实现,完全复用员工信息/招聘信息异步导入的架构模式 **数据量预期:** 小批量(通常<500条) **架构模式:** 拆分式设计(个人中介和实体中介分别实现) --- ## 一、架构概述 ### 1.1 核心架构 采用**拆分式设计**,为个人中介和实体中介分别创建独立的异步导入服务: - **异步处理层**: 使用Spring `@Async`注解,通过现有的`importExecutor`线程池执行异步任务 - **状态存储层**: 使用Redis Hash存储导入状态,TTL为7天 - **失败记录层**: 使用Redis String存储失败记录,存储JSON数组 - **API层**: 为个人和实体分别提供三个接口(导入、状态查询、失败记录查询) ### 1.2 数据流程 ``` 前端上传Excel(个人/实体) ↓ Controller解析并立即返回taskId ↓ 异步服务在后台处理: 1. 数据验证 2. 分类(新增/更新) 3. 批量操作 4. 保存结果到Redis ↓ 前端每2秒轮询状态 ↓ 状态变为SUCCESS/PARTIAL_SUCCESS/FAILED ↓ 如有失败,显示对应的"查看失败记录"按钮 ``` ### 1.3 Redis Key设计 **个人中介导入:** - **状态Key**: `import:intermediary-person:{taskId}` (Hash结构) - **失败记录Key**: `import:intermediary-person:{taskId}:failures` (String结构) - **TTL**: 7天 **实体中介导入:** - **状态Key**: `import:intermediary-entity:{taskId}` (Hash结构) - **失败记录Key**: `import:intermediary-entity:{taskId}:failures` (String结构) - **TTL**: 7天 ### 1.4 状态枚举 | 状态值 | 说明 | 前端行为 | |--------|------|----------| | PROCESSING | 处理中 | 继续轮询 | | SUCCESS | 全部成功 | 显示成功通知,刷新列表 | | PARTIAL_SUCCESS | 部分成功 | 显示警告通知,显示失败按钮 | | FAILED | 全部失败 | 显示错误通知,显示失败按钮 | --- ## 二、后端组件设计 ### 2.1 VO类设计 #### 2.1.1 IntermediaryPersonImportFailureVO (个人中介失败记录) ```java @Data @Schema(description = "个人中介导入失败记录") public class IntermediaryPersonImportFailureVO { @Schema(description = "姓名") private String name; @Schema(description = "证件号码") private String personId; @Schema(description = "人员类型") private String personType; @Schema(description = "性别") private String gender; @Schema(description = "手机号码") private String mobile; @Schema(description = "所在公司") private String company; @Schema(description = "错误信息") private String errorMessage; } ``` #### 2.1.2 IntermediaryEntityImportFailureVO (实体中介失败记录) ```java @Data @Schema(description = "实体中介导入失败记录") public class IntermediaryEntityImportFailureVO { @Schema(description = "机构名称") private String enterpriseName; @Schema(description = "统一社会信用代码") private String socialCreditCode; @Schema(description = "主体类型") private String enterpriseType; @Schema(description = "企业性质") private String enterpriseNature; @Schema(description = "法定代表人") private String legalRepresentative; @Schema(description = "成立日期") private Date establishDate; @Schema(description = "错误信息") private String errorMessage; } ``` #### 2.1.3 复用VO类 - `ImportResultVO` - 导入结果VO(复用员工导入) - `ImportStatusVO` - 导入状态VO(复用员工导入) ### 2.2 Service层设计 #### 2.2.1 个人中介导入Service接口 ```java public interface ICcdiIntermediaryPersonImportService { /** * 异步导入个人中介数据 * * @param excelList Excel数据列表 * @param isUpdateSupport 是否更新已存在的数据 * @param taskId 任务ID * @param userName 当前用户名(用于审计字段) */ void importPersonAsync(List excelList, Boolean isUpdateSupport, String taskId, String userName); /** * 查询导入状态 * * @param taskId 任务ID * @return 导入状态信息 */ ImportStatusVO getImportStatus(String taskId); /** * 获取导入失败记录 * * @param taskId 任务ID * @return 失败记录列表 */ List getImportFailures(String taskId); } ``` #### 2.2.2 实体中介导入Service接口 ```java public interface ICcdiIntermediaryEntityImportService { /** * 异步导入实体中介数据 * * @param excelList Excel数据列表 * @param isUpdateSupport 是否更新已存在的数据 * @param taskId 任务ID * @param userName 当前用户名(用于审计字段) */ void importEntityAsync(List excelList, Boolean isUpdateSupport, String taskId, String userName); /** * 查询导入状态 * * @param taskId 任务ID * @return 导入状态信息 */ ImportStatusVO getImportStatus(String taskId); /** * 获取导入失败记录 * * @param taskId 任务ID * @return 失败记录列表 */ List getImportFailures(String taskId); } ``` #### 2.2.3 实现类核心逻辑 **个人中介导入实现:** ```java @Service @EnableAsync public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediaryPersonImportService { @Resource private CcdiBizIntermediaryMapper intermediaryMapper; @Resource private RedisTemplate redisTemplate; @Override @Async @Transactional public void importPersonAsync(List excelList, Boolean isUpdateSupport, String taskId, String userName) { List newRecords = new ArrayList<>(); List updateRecords = new ArrayList<>(); List failures = new ArrayList<>(); // 1. 批量查询已存在的证件号 Set existingPersonIds = getExistingPersonIds(excelList); // 2. 分类数据 for (CcdiIntermediaryPersonExcel excel : excelList) { try { // 验证必填字段 if (StringUtils.isEmpty(excel.getName())) { throw new RuntimeException("姓名不能为空"); } if (StringUtils.isEmpty(excel.getPersonId())) { throw new RuntimeException("证件号码不能为空"); } CcdiBizIntermediary person = new CcdiBizIntermediary(); BeanUtils.copyProperties(excel, person); person.setPersonType("中介"); person.setDataSource("IMPORT"); if (existingPersonIds.contains(excel.getPersonId())) { if (isUpdateSupport) { person.setUpdateBy(userName); updateRecords.add(person); } else { throw new RuntimeException("该证件号已存在"); } } else { person.setCreateBy(userName); person.setUpdateBy(userName); newRecords.add(person); } } catch (Exception e) { IntermediaryPersonImportFailureVO failure = new IntermediaryPersonImportFailureVO(); BeanUtils.copyProperties(excel, failure); failure.setErrorMessage(e.getMessage()); failures.add(failure); } } // 3. 批量插入 if (!newRecords.isEmpty()) { intermediaryMapper.insertBatch(newRecords); } // 4. 批量更新 if (!updateRecords.isEmpty()) { intermediaryMapper.updateBatch(updateRecords); } // 5. 保存失败记录到Redis if (!failures.isEmpty()) { String failuresKey = "import:intermediary-person:" + taskId + ":failures"; redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); } // 6. 更新最终状态 ImportResult result = new ImportResult(); result.setTotalCount(excelList.size()); result.setSuccessCount(newRecords.size() + updateRecords.size()); result.setFailureCount(failures.size()); String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; updateImportStatus("intermediary-person", taskId, finalStatus, result); } } ``` **实体中介导入实现:** - 与个人中介类似 - 使用`CcdiEnterpriseBaseInfo`实体 - 唯一键是`socialCreditCode` - Redis Key使用`intermediary-entity` ### 2.3 Controller层设计 #### 2.3.1 修改个人中介导入接口 ```java @Resource private ICcdiIntermediaryPersonImportService personImportService; @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("至少需要一条数据"); } // 生成任务ID String taskId = UUID.randomUUID().toString(); // 获取当前用户名 String userName = getUsername(); // 初始化导入状态到Redis initImportStatus("intermediary-person", taskId, list.size()); // 提交异步任务 personImportService.importPersonAsync(list, updateSupport, taskId, userName); // 立即返回,不等待后台任务完成 ImportResultVO result = new ImportResultVO(); result.setTaskId(taskId); result.setStatus("PROCESSING"); result.setMessage("导入任务已提交,正在后台处理"); return AjaxResult.success("导入任务已提交,正在后台处理", result); } ``` #### 2.3.2 新增个人中介状态查询接口 ```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()); } } ``` #### 2.3.3 新增个人中介失败记录查询接口 ```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()); } ``` #### 2.3.4 实体中介相关接口 类似地,为实体中介添加三个接口: - `/importEntityData` - 导入接口 - `/importEntityStatus/{taskId}` - 状态查询接口 - `/importEntityFailures/{taskId}` - 失败记录查询接口 --- ## 三、前端实现设计 ### 3.1 API定义 在 `ruoyi-ui/src/api/ccdiIntermediary.js` 中添加: ```javascript // 查询个人中介导入状态 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 } }) } ``` ### 3.2 Vue组件修改 #### 3.2.1 新增data属性 ```javascript data() { return { // ...现有data // 个人中介导入相关 personPollingTimer: null, personShowFailureButton: false, personCurrentTaskId: null, personFailureDialogVisible: false, personFailureList: [], personFailureLoading: false, personFailureTotal: 0, personFailureQueryParams: { pageNum: 1, pageSize: 10 }, // 实体中介导入相关 entityPollingTimer: null, entityShowFailureButton: false, entityCurrentTaskId: null, entityFailureDialogVisible: false, entityFailureList: [], entityFailureLoading: false, entityFailureTotal: 0, entityFailureQueryParams: { pageNum: 1, pageSize: 10 } } } ``` #### 3.2.2 修改handleFileSuccess方法 ```javascript handleFileSuccess(response, file, fileList) { this.upload.isUploading = false; this.upload.open = false; if (response.code === 200) { const taskId = response.data.taskId; const importType = this.upload.importType; // 'person' 或 'entity' // 显示后台处理提示 const typeName = importType === 'person' ? '个人中介' : '实体中介'; this.$notify({ title: '导入任务已提交', message: `${typeName}数据正在后台处理中,处理完成后将通知您`, type: 'info', duration: 3000 }); // 根据类型开始轮询 if (importType === 'person') { this.startPersonImportPolling(taskId); } else { this.startEntityImportPolling(taskId); } } else { this.$modal.msgError(response.msg); } } ``` #### 3.2.3 轮询方法 ```javascript methods: { // 个人中介轮询 startPersonImportPolling(taskId) { this.personPollingTimer = setInterval(async () => { try { const response = await getPersonImportStatus(taskId); if (response.data && response.data.status !== 'PROCESSING') { clearInterval(this.personPollingTimer); this.handlePersonImportComplete(response.data); } } catch (error) { clearInterval(this.personPollingTimer); this.$modal.msgError('查询导入状态失败: ' + error.message); } }, 2000); }, // 实体中介轮询 startEntityImportPolling(taskId) { this.entityPollingTimer = setInterval(async () => { try { const response = await getEntityImportStatus(taskId); if (response.data && response.data.status !== 'PROCESSING') { clearInterval(this.entityPollingTimer); this.handleEntityImportComplete(response.data); } } catch (error) { clearInterval(this.entityPollingTimer); this.$modal.msgError('查询导入状态失败: ' + error.message); } }, 2000); }, // 个人中介导入完成处理 handlePersonImportComplete(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.personShowFailureButton = true; this.personCurrentTaskId = statusResult.taskId; this.getList(); } }, // 实体中介导入完成处理 handleEntityImportComplete(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.entityShowFailureButton = true; this.entityCurrentTaskId = statusResult.taskId; this.getList(); } } } ``` #### 3.2.4 生命周期销毁钩子 ```javascript beforeDestroy() { // 清除个人中介轮询定时器 if (this.personPollingTimer) { clearInterval(this.personPollingTimer); this.personPollingTimer = null; } // 清除实体中介轮询定时器 if (this.entityPollingTimer) { clearInterval(this.entityPollingTimer); this.entityPollingTimer = null; } } ``` ### 3.3 UI组件设计 #### 3.3.1 查看失败记录按钮 ```vue 查看个人中介导入失败记录 查看实体中介导入失败记录 ``` #### 3.3.2 个人中介失败记录对话框 ```vue ``` #### 3.3.3 实体中介失败记录对话框 ```vue ``` #### 3.3.4 查询失败记录方法 ```javascript methods: { // 查看个人中介导入失败记录 viewPersonImportFailures() { this.personFailureDialogVisible = true; this.getPersonFailureList(); }, // 获取个人中介失败记录列表 getPersonFailureList() { this.personFailureLoading = true; getPersonImportFailures( this.personCurrentTaskId, this.personFailureQueryParams.pageNum, this.personFailureQueryParams.pageSize ).then(response => { this.personFailureList = response.rows; this.personFailureTotal = response.total; this.personFailureLoading = false; }).catch(error => { this.personFailureLoading = false; this.$modal.msgError('查询失败记录失败: ' + error.message); }); }, // 查看实体中介导入失败记录 viewEntityImportFailures() { this.entityFailureDialogVisible = true; this.getEntityFailureList(); }, // 获取实体中介失败记录列表 getEntityFailureList() { this.entityFailureLoading = true; getEntityImportFailures( this.entityCurrentTaskId, this.entityFailureQueryParams.pageNum, this.entityFailureQueryParams.pageSize ).then(response => { this.entityFailureList = response.rows; this.entityFailureTotal = response.total; this.entityFailureLoading = false; }).catch(error => { this.entityFailureLoading = false; this.$modal.msgError('查询失败记录失败: ' + error.message); }); } } ``` --- ## 四、数据验证与错误处理 ### 4.1 个人中介数据验证规则 #### 4.1.1 必填字段验证 - 姓名 (`name`) - 证件号码 (`personId`) #### 4.1.2 唯一性验证 - 证件号码(`personId`)必须唯一 - 批量查询已存在的证件号: ```java private Set getExistingPersonIds(List excelList) { List personIds = excelList.stream() .map(CcdiIntermediaryPersonExcel::getPersonId) .filter(StringUtils::isNotEmpty) .collect(Collectors.toList()); if (personIds.isEmpty()) { return Collections.emptySet(); } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.in(CcdiBizIntermediary::getPersonId, personIds); List existingList = intermediaryMapper.selectList(wrapper); return existingList.stream() .map(CcdiBizIntermediary::getPersonId) .collect(Collectors.toSet()); } ``` ### 4.2 实体中介数据验证规则 #### 4.2.1 必填字段验证 - 机构名称 (`enterpriseName`) #### 4.2.2 唯一性验证 - 统一社会信用代码(`socialCreditCode`)必须唯一(如果不为空) - 批量查询已存在的统一社会信用代码: ```java private Set getExistingSocialCreditCodes(List excelList) { List codes = excelList.stream() .map(CcdiIntermediaryEntityExcel::getSocialCreditCode) .filter(StringUtils::isNotEmpty) .collect(Collectors.toList()); if (codes.isEmpty()) { return Collections.emptySet(); } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.in(CcdiEnterpriseBaseInfo::getSocialCreditCode, codes); List existingList = enterpriseMapper.selectList(wrapper); return existingList.stream() .map(CcdiEnterpriseBaseInfo::getSocialCreditCode) .collect(Collectors.toSet()); } ``` ### 4.3 错误处理流程 #### 4.3.1 单条数据错误 ```java try { // 验证和处理数据 } catch (Exception e) { IntermediaryPersonImportFailureVO failure = new IntermediaryPersonImportFailureVO(); BeanUtils.copyProperties(excel, failure); failure.setErrorMessage(e.getMessage()); failures.add(failure); // 继续处理下一条数据 } ``` #### 4.3.2 状态更新逻辑 ```java private void updateImportStatus(String taskType, String taskId, String status, ImportResult result) { String key = "import:" + taskType + ":" + 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); } ``` --- ## 五、文件清单 ### 5.1 新增文件 | 文件路径 | 说明 | |---------|------| | `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/IntermediaryPersonImportFailureVO.java` | 个人中介导入失败记录VO | | `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/IntermediaryEntityImportFailureVO.java` | 实体中介导入失败记录VO | | `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiIntermediaryPersonImportService.java` | 个人中介异步导入Service接口 | | `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiIntermediaryEntityImportService.java` | 实体中介异步导入Service接口 | | `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryPersonImportServiceImpl.java` | 个人中介异步导入Service实现 | | `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryEntityImportServiceImpl.java` | 实体中介异步导入Service实现 | | `test/test_intermediary_import.py` | 测试脚本 | ### 5.2 修改文件 | 文件路径 | 修改内容 | |---------|---------| | `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiIntermediaryController.java` | 修改导入接口,添加状态查询和失败记录查询接口(个人+实体共6个接口) | | `ruoyi-ui/src/api/ccdiIntermediary.js` | 添加导入状态和失败记录查询API(4个新方法) | | `ruoyi-ui/src/views/ccdiIntermediary/index.vue` | 添加轮询逻辑、失败记录UI(两套独立组件) | | `doc/api/ccdi_intermediary_api.md` | 更新API文档(新增导入相关接口文档) | ### 5.3 复用组件 | 组件 | 说明 | |------|------| | `ImportResultVO` | 导入结果VO(复用员工导入) | | `ImportStatusVO` | 导入状态VO(复用员工导入) | | `AsyncConfig` | 异步配置(复用员工导入) | | `importExecutor` | 导入任务线程池(复用员工导入) | --- ## 六、实施步骤 ### 6.1 后端实施步骤 #### 步骤1: 创建失败记录VO类 **文件:** - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/IntermediaryPersonImportFailureVO.java` - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/IntermediaryEntityImportFailureVO.java` #### 步骤2: 创建Service接口 **文件:** - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiIntermediaryPersonImportService.java` - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiIntermediaryEntityImportService.java` #### 步骤3: 实现Service **文件:** - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryPersonImportServiceImpl.java` - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryEntityImportServiceImpl.java` **操作:** - 实现`ICcdiIntermediaryPersonImportService`接口 - 实现`ICcdiIntermediaryEntityImportService`接口 - 添加`@EnableAsync`注解 - 注入Mapper和RedisTemplate - 实现异步导入逻辑(包含userName审计字段) - 实现状态查询逻辑 - 实现失败记录查询逻辑 #### 步骤4: 修改Controller **文件:** - `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiIntermediaryController.java` **操作:** - 注入两个导入Service - 修改`importPersonData()`方法(改为异步) - 修改`importEntityData()`方法(改为异步) - 添加`getPersonImportStatus()`方法 - 添加`getPersonImportFailures()`方法 - 添加`getEntityImportStatus()`方法 - 添加`getEntityImportFailures()`方法 - 添加Swagger注解 ### 6.2 前端实施步骤 #### 步骤5: 修改API定义 **文件:** - `ruoyi-ui/src/api/ccdiIntermediary.js` **操作:** - 添加`getPersonImportStatus()`方法 - 添加`getPersonImportFailures()`方法 - 添加`getEntityImportStatus()`方法 - 添加`getEntityImportFailures()`方法 #### 步骤6: 修改Vue组件 **文件:** - `ruoyi-ui/src/views/ccdiIntermediary/index.vue` **操作:** - 添加data属性(个人+实体两套) - 修改`handleFileSuccess()`方法 - 添加轮询方法(个人+实体) - 添加完成处理方法(个人+实体) - 添加失败记录查询方法(个人+实体) - 添加`beforeDestroy()`生命周期钩子 - 添加两个"查看失败记录"按钮 - 添加两个失败记录对话框 ### 6.3 测试与文档 #### 步骤7: 生成测试脚本 **文件:** - `test/test_intermediary_import.py` **操作:** - 编写测试脚本 - 包含:登录、个人/实体导入、状态查询、失败记录查询等测试用例 #### 步骤8: 手动测试 **操作:** - 测试个人中介导入(全部成功、部分失败) - 测试实体中介导入(全部成功、部分失败) - 测试轮询机制 - 测试失败记录UI #### 步骤9: 更新API文档 **文件:** - `doc/api/ccdi_intermediary_api.md` **操作:** - 添加导入相关接口文档 - 包含:请求参数、响应示例、错误码说明 #### 步骤10: 代码提交 **操作:** ```bash git add . git commit -m "feat: 实现中介库异步导入功能" ``` --- ## 七、测试计划 ### 7.1 功能测试 | 测试项 | 测试内容 | 预期结果 | |--------|---------|---------| | 个人中介-正常导入 | 导入100-500条有效个人中介数据 | 全部成功,状态为SUCCESS | | 个人中介-重复导入不更新 | personId已存在,updateSupport=false | 导入失败,提示"该证件号已存在" | | 个人中介-重复导入更新 | personId已存在,updateSupport=true | 更新已有数据,状态为SUCCESS | | 个人中介-部分错误 | 混合有效数据和无效数据 | 部分成功,状态为PARTIAL_SUCCESS | | 实体中介-正常导入 | 导入100-500条有效实体中介数据 | 全部成功,状态为SUCCESS | | 实体中介-重复导入不更新 | socialCreditCode已存在,updateSupport=false | 导入失败,提示"该统一社会信用代码已存在" | | 实体中介-重复导入更新 | socialCreditCode已存在,updateSupport=true | 更新已有数据,状态为SUCCESS | | 实体中介-部分错误 | 混合有效数据和无效数据 | 部分成功,状态为PARTIAL_SUCCESS | | 状态查询 | 调用getImportStatus接口 | 返回正确状态和进度 | | 失败记录查询 | 调用getImportFailures接口 | 返回失败记录列表,支持分页 | | 前端轮询 | 导入后观察轮询行为 | 每2秒查询一次,完成后停止 | | 完成通知 | 导入完成后观察通知 | 显示正确的成功/警告通知 | | 前端UI分离 | 个人和实体导入分别显示不同按钮 | 按钮文案清晰,不会混淆 | | 失败记录UI-个人 | 点击"查看个人中介导入失败记录"按钮 | 显示个人中介对话框,正确展示失败数据 | | 失败记录UI-实体 | 点击"查看实体中介导入失败记录"按钮 | 显示实体中介对话框,正确展示失败数据 | ### 7.2 性能测试 | 测试项 | 测试数据量 | 性能要求 | |--------|-----------|---------| | 导入接口响应时间 | 任意 | < 500ms(立即返回taskId) | | 个人中介数据处理 | 500条 | < 5秒 | | 个人中介数据处理 | 1000条 | < 10秒 | | 实体中介数据处理 | 500条 | < 5秒 | | 实体中介数据处理 | 1000条 | < 10秒 | | Redis存储 | 任意 | 数据正确存储,TTL为7天 | | 前端轮询 | 任意 | 不阻塞UI,不影响用户操作 | ### 7.3 异常测试 | 测试项 | 测试内容 | 预期结果 | |--------|---------|---------| | 空文件 | 上传空Excel文件 | 返回错误提示"至少需要一条数据" | | 格式错误 | 上传非Excel文件 | 解析失败,返回错误提示 | | 不存在的taskId | 查询导入状态时传入随机UUID | 返回错误提示"任务不存在或已过期" | | 并发导入 | 同时上传3个Excel文件(个人+实体) | 生成3个不同的taskId,各自独立处理,互不影响 | | 网络中断 | 导入过程中断开网络 | 异步任务继续执行,恢复后可查询状态 | ### 7.4 数据验证测试 #### 个人中介 | 测试项 | 测试内容 | 预期结果 | |--------|---------|---------| | 姓名缺失 | 缺少name字段 | 记录到失败列表,提示"姓名不能为空" | | 证件号缺失 | 缺少personId字段 | 记录到失败列表,提示"证件号码不能为空" | | 证件号重复 | personId已存在且updateSupport=false | 记录到失败列表,提示"该证件号已存在" | #### 实体中介 | 测试项 | 测试内容 | 预期结果 | |--------|---------|---------| | 机构名称缺失 | 缺少enterpriseName字段 | 记录到失败列表,提示"机构名称不能为空" | | 统一社会信用代码重复 | socialCreditCode已存在且updateSupport=false | 记录到失败列表,提示"该统一社会信用代码已存在" | --- ## 八、参考文档 - 员工信息异步导入实施计划: `doc/plans/2026-02-06-employee-async-import.md` - 员工信息异步导入设计文档: `doc/plans/2026-02-06-employee-async-import-design.md` - 招聘信息异步导入设计文档: `doc/plans/2026-02-06-recruitment-async-import-design.md` - 员工信息导入API文档: `doc/api/ccdi-employee-import-api.md` --- **设计版本:** 1.0 **创建日期:** 2026-02-06 **设计人员:** Claude **审核状态:** 待审核