491 lines
15 KiB
Markdown
491 lines
15 KiB
Markdown
# 中介导入功能优化设计文档
|
||
|
||
## 概述
|
||
|
||
本设计文档描述了如何使用 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<CcdiBizIntermediary> list);
|
||
```
|
||
|
||
**SQL实现 (CcdiBizIntermediaryMapper.xml):**
|
||
|
||
```xml
|
||
<insert id="importPersonBatch">
|
||
INSERT INTO cdi_biz_intermediary (
|
||
person_id, name, gender, phone, address,
|
||
intermediary_type, data_source, created_by, updated_by
|
||
) VALUES
|
||
<foreach collection="list" item="item" separator=",">
|
||
(
|
||
#{item.personId}, #{item.name}, #{item.gender},
|
||
#{item.phone}, #{item.address}, #{item.intermediaryType},
|
||
#{item.dataSource}, #{item.createdBy}, #{item.updatedBy}
|
||
)
|
||
</foreach>
|
||
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}
|
||
</insert>
|
||
```
|
||
|
||
### 2.2 实体中介批量导入SQL
|
||
|
||
**Mapper方法签名:**
|
||
|
||
```java
|
||
void importEntityBatch(@Param("list") List<CcdiEnterpriseBaseInfo> list);
|
||
```
|
||
|
||
**SQL实现 (CcdiEnterpriseBaseInfoMapper.xml):**
|
||
|
||
```xml
|
||
<insert id="importEntityBatch">
|
||
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
|
||
<foreach collection="list" item="item" separator=",">
|
||
(
|
||
#{item.socialCreditCode}, #{item.enterpriseName},
|
||
#{item.legalRepresentative}, #{item.phone}, #{item.address},
|
||
#{item.riskLevel}, #{item.entSource}, #{item.dataSource},
|
||
#{item.createdBy}, #{item.updatedBy}
|
||
)
|
||
</foreach>
|
||
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}
|
||
</insert>
|
||
```
|
||
|
||
### 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<CcdiIntermediaryPersonExcel> excelList,
|
||
Boolean isUpdateSupport,
|
||
String taskId,
|
||
String userName) {
|
||
List<CcdiBizIntermediary> validRecords = new ArrayList<>();
|
||
List<IntermediaryPersonImportFailureVO> 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<String> 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<String> 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<String> personIds = updateRecords.stream()
|
||
.map(CcdiBizIntermediary::getPersonId)
|
||
.collect(Collectors.toList());
|
||
|
||
LambdaQueryWrapper<CcdiBizIntermediary> 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
|
||
**状态**: 待评审
|