# 信贷客户家庭关系导入功能对齐方案 ## 概述 本文档描述了如何将**信贷客户家庭关系**功能的导入实现完全对齐到**员工亲属关系**的成熟模式。 **参考模板**: `CcdiStaffEnterpriseRelationImportServiceImpl` **修改对象**: `CcdiCustFmyRelationImportServiceImpl` ## 设计目标 1. 提升代码质量和可维护性 2. 优化性能,避免 N+1 查询问题 3. 改善用户体验,提供详细的导入进度和状态反馈 4. 统一日志记录和错误处理机制 ## 架构调整 ### 1. 引入导入工具类 复用 `ImportLogUtils` 进行统一的日志记录: - 导入开始/结束日志 - 批量查询日志 - 进度跟踪日志 - 验证错误日志 - 批量操作日志 ### 2. Redis 状态管理升级 **现状**: 简单 String 值存储状态 ``` "COMPLETED:10:5" ``` **优化**: Hash 结构存储详细状态 ```java { "taskId": "uuid", "status": "SUCCESS" | "PARTIAL_SUCCESS" | "PROCESSING", "totalCount": 100, "successCount": 95, "failureCount": 5, "progress": 100, "startTime": 1234567890, "endTime": 1234567900, "message": "成功95条,失败5条" } ``` - 过期时间: 7 天 - 失败记录: 单独 Key, JSON 序列化, 7 天过期 ### 3. 批量查询优化 **实现 `batchExistsByCombinations` 方法**: - 提取所有 `person_id + relation_type + relation_cert_no` 组合 - 一次性批量查询已存在的组合 - 避免循环查询导致的 N+1 问题 ### 4. 导入结果封装 创建/复用统一的 VO: - `ImportStatusVO`: 导入状态详情 - `ImportResultVO`: 导入提交结果 - `CustFmyRelationImportFailureVO`: 失败记录详情 ## 数据验证逻辑 ### 唯一性检查 **优化前**: 每条记录单独查询 ```java for (excel : excels) { CcdiCustFmyRelation existing = mapper.selectExistingRelations(...); // N 次数据库查询 } ``` **优化后**: 批量查询 ```java Set existingCombinations = getExistingCombinations(excels); // 1 次数据库查询 for (excel : excels) { String combination = excel.getPersonId() + "|" + ...; if (existingCombinations.contains(combination)) { throw new RuntimeException("该关系已存在"); } } ``` ### Excel 内部重复检查 ```java Set processedCombinations = new HashSet<>(); for (excel : excels) { String combination = ...; if (processedCombinations.contains(combination)) { throw new RuntimeException("该关系在导入文件中重复"); } processedCombinations.add(combination); } ``` ### 验证规则 **必填字段**: - 信贷客户身份证号 - 关系类型 - 关系人姓名 - 关系人证件类型 - 关系人证件号码 **格式验证**: - 身份证号: 18位有效格式 - 证件号码: 根据证件类型验证 **长度限制**: - 关系人姓名: ≤ 50 - 关系类型: ≤ 20 - 证件号码: ≤ 50 ## 批量操作优化 ### 分批插入策略 ```java 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); mapper.insertBatch(subList); } } // 调用: 每 500 条为一批 saveBatch(newRecords, 500); ``` ### 批量操作日志 ``` 开始批量插入: 总批次数=5, 每批大小=500 批量插入完成: 成功=2500, 耗时=1234ms ``` ## 失败记录处理 ### 失败记录数据结构 ```java public class CustFmyRelationImportFailureVO { private Integer rowNum; // Excel 行号 private String personId; // 信贷客户身份证号 private String relationType; // 关系类型 private String relationName; // 关系人姓名 private String errorMessage; // 错误消息 } ``` ### Redis 存储优化 **Key**: `import:custFmyRelation:{taskId}:failures` **序列化**: JSON **过期时间**: 7 天 **反序列化**: ```java return JSON.parseArray( JSON.toJSONString(failuresObj), CustFmyRelationImportFailureVO.class ); ``` ## Controller 层调整 ### 导入接口 ```java @PostMapping("/importData") public AjaxResult importData(@RequestParam("file") MultipartFile file) { List excels = EasyExcelUtil.importExcel(file.getInputStream(), ...); if (excels == null || excels.isEmpty()) { return error("至少需要一条数据"); } String taskId = relationService.importRelations(excels); ImportResultVO result = new ImportResultVO(); result.setTaskId(taskId); result.setStatus("PROCESSING"); result.setMessage("导入任务已提交,正在后台处理"); return AjaxResult.success("导入任务已提交,正在后台处理", result); } ``` ### 导入状态查询 ```java @GetMapping("/importStatus/{taskId}") public AjaxResult getImportStatus(@PathVariable String taskId) { ImportStatusVO statusVO = relationImportService.getImportStatus(taskId); return success(statusVO); } ``` ### 失败记录查询 ```java @GetMapping("/importFailures/{taskId}") public TableDataInfo getImportFailures( @PathVariable String taskId, @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize ) { List failures = relationImportService.getImportFailures(taskId); // 手动分页 int fromIndex = (pageNum - 1) * pageSize; int toIndex = Math.min(fromIndex + pageSize, failures.size()); if (fromIndex >= failures.size()) { return getDataTable(new ArrayList<>(), failures.size()); } List pageData = failures.subList(fromIndex, toIndex); return getDataTable(pageData, failures.size()); } ``` ## 导入模板改进 ### 使用字典下拉框 ```java @PostMapping("/importTemplate") public void importTemplate(HttpServletResponse response) { EasyExcelUtil.importTemplateWithDictDropdown( response, CcdiCustFmyRelationExcel.class, "信贷客户家庭关系" ); } ``` ### Excel 实体注解增强 ```java @DictDropdown(type = "ccdi_relation_type") private String relationType; @DictDropdown(type = "ccdi_cert_type") private String relationCertType; ``` ## 修改文件清单 ### 1. Service 层 - `CcdiCustFmyRelationImportServiceImpl.java` - 核心导入逻辑重构 - `CcdiCustFmyRelationServiceImpl.java` - 导入入口方法调整 ### 2. Controller 层 - `CcdiCustFmyRelationController.java` - 接口返回值优化 ### 3. Mapper 层 - `CcdiCustFmyRelationMapper.java` - 添加批量查询方法 - Mapper XML - 实现批量查询 SQL ### 4. VO 类 - 检查/创建 `ImportStatusVO.java` - 检查/创建 `ImportResultVO.java` - 优化 `CustFmyRelationImportFailureVO.java` ### 5. Excel 实体 - `CcdiCustFmyRelationExcel.java` - 添加字典注解 ### 6. 工具类 - 复用 `ImportLogUtils.java` - 复用 `EasyExcelUtil.java` ## 关键代码片段 ### Mapper 批量查询 ```java // Mapper 接口 List batchExistsByCombinations( @Param("combinations") List combinations ); // XML 实现 ``` ### 异步导入方法 ```java @Async @Transactional(rollbackFor = Exception.class) public void importRelationsAsync( List excels, String taskId, String userName // 新增参数,用于审计 ) { // 实现逻辑... } ``` ## 实施步骤 1. **添加 Mapper 批量查询方法** - 在 Mapper 接口添加 `batchExistsByCombinations` - 在 XML 实现 SQL 2. **重构 ImportServiceImpl** - 引入 `ImportLogUtils` - 实现批量查询逻辑 - 添加 Excel 内部重复检查 - 优化 Redis 状态管理 - 改进失败记录存储 3. **创建/优化 VO 类** - 检查并复用已有的 `ImportStatusVO` - 检查并复用已有的 `ImportResultVO` - 优化失败记录 VO 4. **调整 Controller** - 修改导入接口返回值 - 优化状态查询接口 - 优化失败记录查询接口 5. **更新 Excel 实体** - 添加 `@DictDropdown` 注解 6. **测试验证** - 单元测试 - 集成测试 - 性能对比测试 ## 预期效果 ### 性能提升 - 批量查询: 从 N 次减少到 1 次 - 导入 1000 条数据预计提升 50-70% ### 用户体验 - 实时进度反馈 - 详细的错误信息 - 清晰的成功/失败统计 ### 代码质量 - 统一的日志记录 - 完善的错误处理 - 更好的可维护性 ## 创建日期 2026-02-11