374 lines
8.6 KiB
Markdown
374 lines
8.6 KiB
Markdown
|
|
# 信贷客户家庭关系导入功能对齐方案
|
||
|
|
|
||
|
|
## 概述
|
||
|
|
|
||
|
|
本文档描述了如何将**信贷客户家庭关系**功能的导入实现完全对齐到**员工亲属关系**的成熟模式。
|
||
|
|
|
||
|
|
**参考模板**: `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<String> existingCombinations = getExistingCombinations(excels);
|
||
|
|
// 1 次数据库查询
|
||
|
|
|
||
|
|
for (excel : excels) {
|
||
|
|
String combination = excel.getPersonId() + "|" + ...;
|
||
|
|
if (existingCombinations.contains(combination)) {
|
||
|
|
throw new RuntimeException("该关系已存在");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Excel 内部重复检查
|
||
|
|
|
||
|
|
```java
|
||
|
|
Set<String> 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<CcdiCustFmyRelation> list, int batchSize) {
|
||
|
|
for (int i = 0; i < list.size(); i += batchSize) {
|
||
|
|
int end = Math.min(i + batchSize, list.size());
|
||
|
|
List<CcdiCustFmyRelation> 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<CcdiCustFmyRelationExcel> 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<CustFmyRelationImportFailureVO> 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<CustFmyRelationImportFailureVO> 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<String> batchExistsByCombinations(
|
||
|
|
@Param("combinations") List<String> combinations
|
||
|
|
);
|
||
|
|
|
||
|
|
// XML 实现
|
||
|
|
<select id="batchExistsByCombinations" resultType="string">
|
||
|
|
SELECT CONCAT(person_id, '|', relation_type, '|', relation_cert_no)
|
||
|
|
FROM ccdi_cust_fmy_relation
|
||
|
|
WHERE CONCAT(person_id, '|', relation_type, '|', relation_cert_no) IN
|
||
|
|
<foreach collection="combinations" item="combo" open="(" separator="," close=")">
|
||
|
|
#{combo}
|
||
|
|
</foreach>
|
||
|
|
</select>
|
||
|
|
```
|
||
|
|
|
||
|
|
### 异步导入方法
|
||
|
|
|
||
|
|
```java
|
||
|
|
@Async
|
||
|
|
@Transactional(rollbackFor = Exception.class)
|
||
|
|
public void importRelationsAsync(
|
||
|
|
List<CcdiCustFmyRelationExcel> 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
|