# 中介导入功能优化设计文档 ## 概述 本设计文档描述了如何使用 MySQL 的 `INSERT ... ON DUPLICATE KEY UPDATE` 语句优化中介信息导入功能,替代现有的"先删除再插入" 更新模式,提升性能并简化代码逻辑。 **设计日期**: 2026-02-08 **目标**: 优化个人中介和实体中介的批量导入性能 **核心改进**: 使用 `ON DUPLICATE KEY UPDATE` 实现 Upsert 操作 --- ## 一、整体架构设计 ### 1.1 核心变更 **保持现有架构:** - Controller 层:`CcdiIntermediaryController` - 无需修改 - Service 层:`CcdiIntermediaryServiceImpl` - 简化逻辑 - Mapper 层:新增批量导入方法 **架构优化点:** | 层级 | 现有方案 | 优化方案 | 改进点 | |---------|--------------------------|-----------------------------------------|--------------| | Mapper | `insertBatch` + `delete` | `importBatch` (ON DUPLICATE KEY UPDATE) | 单次SQL完成插入或更新 | | Service | 查询→分类→删除→插入 | 验证→直接导入 | 减少50%代码量 | | 数据库 | 2-3次操作 | 1次操作 | 减少30-40%响应时间 | ### 1.2 数据流变化 **优化前流程:** ``` 解析Excel → 验证数据 → 批量查询已存在记录 → 分类数据 → 批量删除已存在记录 → 批量插入新记录和更新记录 ``` **优化后流程:** ``` 解析Excel → 验证数据 → 批量 INSERT ON DUPLICATE KEY UPDATE ``` **简化关键点:** - 移除"批量查询已存在记录"步骤 - 移除"分类新增/更新记录"步骤 - 移除"批量删除已存在记录"步骤 --- ## 二、SQL实现细节 ### 2.1 个人中介批量导入SQL **Mapper方法签名:** ```java void importPersonBatch(@Param("list") List list); ``` **SQL实现 (CcdiBizIntermediaryMapper.xml):** ```xml INSERT INTO cdi_biz_intermediary ( person_id, name, gender, phone, address, intermediary_type, data_source, created_by, updated_by ) VALUES ( #{item.personId}, #{item.name}, #{item.gender}, #{item.phone}, #{item.address}, #{item.intermediaryType}, #{item.dataSource}, #{item.createdBy}, #{item.updatedBy} ) ON DUPLICATE KEY UPDATE name = IF(#{item.name} IS NOT NULL AND #{item.name} != '', #{item.name}, name), gender = IF(#{item.gender} IS NOT NULL AND #{item.gender} != '', #{item.gender}, gender), phone = IF(#{item.phone} IS NOT NULL AND #{item.phone} != '', #{item.phone}, phone), address = IF(#{item.address} IS NOT NULL AND #{item.address} != '', #{item.address}, address), intermediary_type = IF(#{item.intermediaryType} IS NOT NULL AND #{item.intermediaryType} != '', #{item.intermediaryType}, intermediary_type), update_time = NOW(), update_by = #{item.updatedBy} ``` ### 2.2 实体中介批量导入SQL **Mapper方法签名:** ```java void importEntityBatch(@Param("list") List list); ``` **SQL实现 (CcdiEnterpriseBaseInfoMapper.xml):** ```xml INSERT INTO cdi_enterprise_base_info ( social_credit_code, enterprise_name, legal_representative, phone, address, risk_level, ent_source, data_source, created_by, updated_by ) VALUES ( #{item.socialCreditCode}, #{item.enterpriseName}, #{item.legalRepresentative}, #{item.phone}, #{item.address}, #{item.riskLevel}, #{item.entSource}, #{item.dataSource}, #{item.createdBy}, #{item.updatedBy} ) ON DUPLICATE KEY UPDATE enterprise_name = IF(#{item.enterpriseName} IS NOT NULL AND #{item.enterpriseName} != '', #{item.enterpriseName}, enterprise_name), legal_representative = IF(#{item.legalRepresentative} IS NOT NULL AND #{item.legalRepresentative} != '', #{item.legalRepresentative}, legal_representative), phone = IF(#{item.phone} IS NOT NULL AND #{item.phone} != '', #{item.phone}, phone), address = IF(#{item.address} IS NOT NULL AND #{item.address} != '', #{item.address}, address), update_time = NOW(), update_by = #{item.updatedBy} ``` ### 2.3 关键设计要点 **1. 非空字段更新策略:** ```sql field = IF(#{item.field} IS NOT NULL AND #{item.field} != '', #{item.field}, field) ``` - 只更新Excel中非空的字段 - 保留数据库中的原有值 - 避免误清空数据 **2. 审计字段处理:** | 字段 | INSERT时 | UPDATE时 | |------|----------|----------| | created_by | 设置当前用户 | 不更新 | | create_time | 数据库默认NOW() | 不更新 | | updated_by | NULL | 设置当前用户 | | update_time | 数据库默认NOW() | 更新为NOW() | **3. 唯一键约束:** - 个人中介: `person_id` (证件号) - 实体中介: `social_credit_code` (统一社会信用代码) **4. 批量操作优化:** - 每批最多500条记录 - 避免SQL过长导致性能问题 - 超过500条时分批处理 --- ## 三、Service层实现 ### 3.1 isUpdateSupport参数处理 采用**方案C: Service层预处理** ```java @Override @Async @Transactional(rollbackFor = Exception.class) public void importPersonAsync(List excelList, Boolean isUpdateSupport, String taskId, String userName) { List validRecords = new ArrayList<>(); List failures = new ArrayList<>(); // 1. 数据验证阶段 for (CcdiIntermediaryPersonExcel excel : excelList) { try { validatePersonData(excel); CcdiBizIntermediary intermediary = new CcdiBizIntermediary(); BeanUtils.copyProperties(excel, intermediary); intermediary.setDataSource("IMPORT"); intermediary.setCreatedBy(userName); if (isUpdateSupport) { intermediary.setUpdatedBy(userName); } validRecords.add(intermediary); } catch (Exception e) { IntermediaryPersonImportFailureVO failure = new IntermediaryPersonImportFailureVO(); BeanUtils.copyProperties(excel, failure); failure.setErrorMessage(e.getMessage()); failures.add(failure); } } // 2. 根据isUpdateSupport选择处理方式 if (isUpdateSupport) { // 更新模式:直接批量导入,数据库自动处理INSERT或UPDATE importBatchWithUpdateSupport(validRecords, 500); } else { // 仅新增模式:先查询已存在的记录,对冲突的抛出异常 Set existingIds = getExistingPersonIds(validRecords); for (CcdiBizIntermediary record : validRecords) { if (existingIds.contains(record.getPersonId())) { throw new RuntimeException("该证件号已存在"); } } // 确认无冲突后,批量插入 importBatchWithoutUpdateSupport(validRecords, 500); } // 3. 更新导入状态 ImportResult result = new ImportResult(); result.setTotalCount(excelList.size()); result.setSuccessCount(validRecords.size()); result.setFailureCount(failures.size()); String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; updateImportStatus(taskId, finalStatus, result); } ``` ### 3.2 代码简化对比 **优化前 (约120行):** ```java // 1. 批量查询已存在记录 Set existingIds = getExistingPersonIds(excelList); // 2. 分类数据 for (excel : excelList) { if (existingIds.contains(excel.getPersonId())) { if (isUpdateSupport) { updateRecords.add(convert(excel)); } else { throw new RuntimeException("已存在"); } } else { newRecords.add(convert(excel)); } } // 3. 批量插入新数据 if (!newRecords.isEmpty()) { saveBatch(newRecords, 500); } // 4. 批量更新已有数据(先删除再插入) if (!updateRecords.isEmpty() && isUpdateSupport) { List personIds = updateRecords.stream() .map(CcdiBizIntermediary::getPersonId) .collect(Collectors.toList()); LambdaQueryWrapper deleteWrapper = new LambdaQueryWrapper<>(); deleteWrapper.in(CcdiBizIntermediary::getPersonId, personIds); intermediaryMapper.delete(deleteWrapper); intermediaryMapper.insertBatch(updateRecords); } ``` **优化后 (约60行):** ```java // 1. 验证数据并转换 for (excel : excelList) { validatePersonData(excel); validRecords.add(convert(excel)); } // 2. 直接批量导入(数据库自动处理INSERT或UPDATE) if (isUpdateSupport) { intermediaryMapper.importPersonBatch(validRecords); } else { // 仅新增模式:检查唯一性 checkUniqueAndInsert(validRecords); } ``` --- ## 四、错误处理与边界情况 ### 4.1 错误分类处理 | 错误类型 | 处理方式 | 状态标记 | |-------------------------------|--------------------|-----------------| | 数据验证错误 | 添加到失败列表,继续处理后续数据 | PARTIAL_SUCCESS | | 唯一性冲突 (isUpdateSupport=false) | 抛出异常,添加到失败列表 | PARTIAL_SUCCESS | | SQL执行错误 | 事务回滚,记录详细错误信息 | FAILED | | 所有记录失败 | 状态为FAILED | FAILED | | 部分成功 | 状态为PARTIAL_SUCCESS | PARTIAL_SUCCESS | ### 4.2 边界情况处理 | 场景 | 处理方式 | |---------------|------------------------| | Excel为空 | 返回"至少需要一条数据" | | 所有数据格式错误 | 成功数=0,失败数=总数,状态=FAILED | | 超大数据量(>5000条) | 分批处理,每批500条 | | 并发导入相同数据 | 依靠数据库唯一索引保证一致性 | | NULL字段更新 | 使用IF语句跳过,保留原值 | | 空字符串字段更新 | 视为NULL,不更新 | ### 4.3 事务处理 ```java @Async @Transactional(rollbackFor = Exception.class) public void importPersonAsync(...) { try { // 数据验证 // 批量导入 // 更新状态 } catch (Exception e) { // 事务自动回滚 // 记录错误日志 // 更新状态为FAILED throw e; } } ``` --- ## 五、测试策略 ### 5.1 单元测试 **Mapper层测试:** - ✅ 批量插入全新记录 - ✅ 批量更新已存在记录 - ✅ 混合场景(部分新记录+部分已存在) - ✅ NULL值字段不覆盖原值 - ✅ 审计字段正确设置和更新 - ✅ 唯一键冲突处理 **Service层测试:** - ✅ `isUpdateSupport=true` 的完整流程 - ✅ `isUpdateSupport=false` 时重复数据抛异常 - ✅ 数据验证逻辑(必填字段、格式校验) - ✅ 事务回滚机制 - ✅ 失败记录保存到Redis ### 5.2 集成测试场景 | 测试场景 | 测试步骤 | 预期结果 | |---------|--------------------------------|------------------| | 新增模式测试 | 导入100条全新记录 | 全部成功插入,审计字段正确 | | 更新模式测试 | 导入→修改→再导入 | 数据正确更新,NULL字段保留 | | 混合模式测试 | 50新+50已存在记录 | 新记录插入,旧记录更新 | | 仅新增冲突测试 | 导入已存在记录(isUpdateSupport=false) | 抛出异常,记录失败 | | 空文件测试 | 导入空Excel | 返回"至少需要一条数据" | | 全部失败测试 | 所有数据格式错误 | 状态=FAILED,失败数=总数 | | 大数据量测试 | 导入2000+条记录 | 分批处理,全部成功 | | 并发测试 | 同时导入相同数据 | 依靠唯一索引保证一致性 | ### 5.3 性能测试 **测试数据:** - 500条记录 - 1000条记录 - 2000条记录 **性能指标:** - 总响应时间 - 数据库操作次数 - 内存使用情况 **预期性能提升:** - 更新模式下性能提升 30-40% - 数据库操作次数减少 2次(查询+删除) --- ## 六、实施计划 ### 6.1 实施步骤 1. **数据库准备** - 确认 `cdi_biz_intermediary.person_id` 有唯一索引 - 确认 `cdi_enterprise_base_info.social_credit_code` 有唯一索引 2. **Mapper层实现** - 在 `CcdiBizIntermediaryMapper` 接口添加 `importPersonBatch` 方法 - 在 `CcdiEnterpriseBaseInfoMapper` 接口添加 `importEntityBatch` 方法 - 在对应的XML文件实现SQL语句 3. **Service层重构** - 修改 `CcdiIntermediaryPersonImportServiceImpl.importPersonAsync` 方法 - 修改 `CcdiIntermediaryEntityImportServiceImpl.importEntityAsync` 方法 - 简化逻辑,移除删除操作 4. **单元测试** - 编写Mapper层测试 - 编写Service层测试 5. **集成测试** - 使用现有测试数据验证功能 - 对比优化前后的性能 6. **文档更新** - 更新API文档 - 记录性能优化结果 ### 6.2 向后兼容性 - ✅ API接口保持不变,前端无需修改 - ✅ 返回数据格式不变 - ✅ 错误处理机制不变 - ✅ Redis状态管理不变 ### 6.3 风险评估 | 风险 | 影响 | 缓解措施 | |----------|----------|--------------| | 唯一索引缺失 | 功能失败 | 实施前检查索引存在性 | | 数据库版本兼容性 | SQL语法不支持 | 确认MySQL 5.7+ | | 并发冲突 | 数据不一致 | 依赖数据库唯一索引和事务 | | 性能回退 | 响应变慢 | 进行性能测试对比 | --- ## 七、预期收益 ### 7.1 性能提升 | 指标 | 优化前 | 优化后 | 提升 | |---------------|--------------|------------|--------| | 数据库操作次数 | 3次(查询+删除+插入) | 1次(UPSERT) | -66% | | 代码行数 | ~120行 | ~60行 | -50% | | 响应时间(1000条更新) | 基准 | 减少30-40% | 30-40% | ### 7.2 代码质量 - ✅ 逻辑更清晰,易于维护 - ✅ 减少出错可能性 - ✅ 更好的事务一致性 - ✅ 符合数据库最佳实践 ### 7.3 可维护性 - SQL集中在XML文件,易于优化 - 业务逻辑简化,降低认知负担 - 错误处理更精确 - 测试覆盖更全面 --- ## 八、附录 ### 8.1 相关文件 - Controller: `CcdiIntermediaryController.java` - Service接口: `ICcdiIntermediaryService.java` - Service实现: `CcdiIntermediaryServiceImpl.java` - Import Service: `CcdiIntermediaryPersonImportServiceImpl.java` - Mapper接口: `CcdiBizIntermediaryMapper.java` - Mapper XML: `CcdiBizIntermediaryMapper.xml` ### 8.2 数据库表结构 **个人中介表 (cdi_biz_intermediary):** ```sql UNIQUE KEY `uk_person_id` (`person_id`) ``` **实体中介表 (cdi_enterprise_base_info):** ```sql PRIMARY KEY (`social_credit_code`) ``` ### 8.3 测试数据 - 测试文件: `doc/test-data/purchase_transaction/purchase_test_data_2000_final.xlsx` - 测试脚本: 待生成 --- **文档版本**: 1.0 **最后更新**: 2026-02-08 **状态**: 待评审