diff --git a/doc/plans/2026-02-06-intermediary-async-import-design.md b/doc/plans/2026-02-06-intermediary-async-import-design.md new file mode 100644 index 0000000..77d0da4 --- /dev/null +++ b/doc/plans/2026-02-06-intermediary-async-import-design.md @@ -0,0 +1,1087 @@ +# 中介库异步导入功能设计文档 + +**创建日期:** 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 +**审核状态:** 待审核