297 lines
8.6 KiB
Markdown
297 lines
8.6 KiB
Markdown
|
|
# 银行流水入库重复校验设计
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
在 `fetchAndSaveBankStatements(Long projectId, Integer groupId, Integer logId)` 中,
|
|||
|
|
接口返回的银行流水写入 `ccdi_bank_statement` 前,需要基于业务键避免重复插入。
|
|||
|
|
|
|||
|
|
本次确认的重复判定键为:
|
|||
|
|
|
|||
|
|
- `project_id`
|
|||
|
|
- `LE_ACCOUNT_NAME`
|
|||
|
|
- `TRX_DATE`
|
|||
|
|
- `CUSTOMER_ACCOUNT_NAME`
|
|||
|
|
- `AMOUNT_BALANCE`
|
|||
|
|
|
|||
|
|
目标是让“重复”这条规则尽量落到数据库约束层,服务层只负责标准化输入和保留现有异步处理链路。
|
|||
|
|
|
|||
|
|
## 背景
|
|||
|
|
|
|||
|
|
当前实现位于 `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`:
|
|||
|
|
|
|||
|
|
1. 分页调用流水分析接口获取流水数据
|
|||
|
|
2. 将返回项转换为 `CcdiBankStatement`
|
|||
|
|
3. 直接调用 `CcdiBankStatementMapper.insertBatch(...)` 批量入库
|
|||
|
|
|
|||
|
|
现状没有任何重复校验,重复导入同一批流水时会再次插入。
|
|||
|
|
|
|||
|
|
已确认的业务边界:
|
|||
|
|
|
|||
|
|
- 接口返回的同一批流水自身不会重复
|
|||
|
|
- 如果数据库里已存在相同业务键的流水,保留原记录,不更新原数据
|
|||
|
|
- 命中重复不应让整次文件处理失败
|
|||
|
|
|
|||
|
|
## 方案对比
|
|||
|
|
|
|||
|
|
### 方案一:服务层先查库再插入
|
|||
|
|
|
|||
|
|
做法:
|
|||
|
|
|
|||
|
|
- 服务层先按业务键查库
|
|||
|
|
- 过滤已存在记录
|
|||
|
|
- 仅插入剩余记录
|
|||
|
|
|
|||
|
|
优点:
|
|||
|
|
|
|||
|
|
- 语义直观
|
|||
|
|
- 不需要调整批量插入 SQL
|
|||
|
|
|
|||
|
|
缺点:
|
|||
|
|
|
|||
|
|
- 规则只在当前入口生效,其他写入入口仍可能写入重复数据
|
|||
|
|
- 并发导入时存在竞态窗口
|
|||
|
|
- 代码和 SQL 都会变复杂
|
|||
|
|
|
|||
|
|
### 方案二:数据库唯一键 + no-op upsert
|
|||
|
|
|
|||
|
|
做法:
|
|||
|
|
|
|||
|
|
- 对业务键加唯一约束
|
|||
|
|
- 批量插入改为 `INSERT ... ON DUPLICATE KEY UPDATE bank_statement_id = bank_statement_id`
|
|||
|
|
- 服务层只做字段标准化
|
|||
|
|
|
|||
|
|
优点:
|
|||
|
|
|
|||
|
|
- 重复规则由数据库统一约束
|
|||
|
|
- 并发下稳定
|
|||
|
|
- 代码改动集中且可控
|
|||
|
|
|
|||
|
|
缺点:
|
|||
|
|
|
|||
|
|
- 需要先处理测试库中的存量重复数据
|
|||
|
|
- `ON DUPLICATE KEY` 的受影响行数语义需要在本地 MySQL 实测确认
|
|||
|
|
|
|||
|
|
### 方案三:`INSERT IGNORE`
|
|||
|
|
|
|||
|
|
做法:
|
|||
|
|
|
|||
|
|
- 数据库加唯一键
|
|||
|
|
- 批量插入改为 `INSERT IGNORE`
|
|||
|
|
|
|||
|
|
优点:
|
|||
|
|
|
|||
|
|
- SQL 最短
|
|||
|
|
- 重复会被自动跳过
|
|||
|
|
|
|||
|
|
缺点:
|
|||
|
|
|
|||
|
|
- 可能连非重复键类的数据问题也一起吞掉
|
|||
|
|
- 不利于保留真实错误
|
|||
|
|
|
|||
|
|
## 最终方案
|
|||
|
|
|
|||
|
|
采用方案二:`数据库唯一键 + 写入前标准化 + no-op upsert`。
|
|||
|
|
|
|||
|
|
核心决策:
|
|||
|
|
|
|||
|
|
1. 不做本次接口结果的内存去重
|
|||
|
|
2. 服务层在写入前标准化去重键字段
|
|||
|
|
3. 数据库侧增加唯一键统一兜底重复规则
|
|||
|
|
4. 命中重复时跳过写入,不更新原有业务数据
|
|||
|
|
5. 非重复键类数据库错误仍然向上抛出,并按现有流程标记 `parsed_failed`
|
|||
|
|
|
|||
|
|
## 详细设计
|
|||
|
|
|
|||
|
|
### 1. 去重键定义
|
|||
|
|
|
|||
|
|
重复判定使用以下五元组:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
project_id + LE_ACCOUNT_NAME + TRX_DATE + CUSTOMER_ACCOUNT_NAME + AMOUNT_BALANCE
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
对应到 Java 字段为:
|
|||
|
|
|
|||
|
|
- `projectId`
|
|||
|
|
- `leAccountName`
|
|||
|
|
- `trxDate`
|
|||
|
|
- `customerAccountName`
|
|||
|
|
- `amountBalance`
|
|||
|
|
|
|||
|
|
### 2. 服务层标准化
|
|||
|
|
|
|||
|
|
在 `CcdiFileUploadServiceImpl.fetchAndSaveBankStatements(...)` 中,
|
|||
|
|
`CcdiBankStatement.fromResponse(...)` 返回实体后,补充统一标准化:
|
|||
|
|
|
|||
|
|
- `leAccountName = trimToEmpty(leAccountName)`
|
|||
|
|
- `customerAccountName = trimToEmpty(customerAccountName)`
|
|||
|
|
- `trxDate` 保持接口返回值,不额外改写格式
|
|||
|
|
- `amountBalance` 保持 `BigDecimal` 语义写入数据库 `decimal(19,2)`
|
|||
|
|
- `projectId` 继续由服务层显式设置
|
|||
|
|
|
|||
|
|
建议新增私有辅助方法,例如:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
private String trimToEmpty(String value) {
|
|||
|
|
return value == null ? "" : value.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void normalizeDedupFields(CcdiBankStatement statement) {
|
|||
|
|
statement.setLeAccountName(trimToEmpty(statement.getLeAccountName()));
|
|||
|
|
statement.setCustomerAccountName(trimToEmpty(statement.getCustomerAccountName()));
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
说明:
|
|||
|
|
|
|||
|
|
- 用户已明确 `LE_ACCOUNT_NAME` 对应 `account_name`
|
|||
|
|
- `amountBalance` 的重复语义按数据库金额列处理,不引入额外字符串比较
|
|||
|
|
|
|||
|
|
### 3. 数据库结构调整
|
|||
|
|
|
|||
|
|
为保证唯一键对所有写入入口都有效,需要先把参与判重的可空字符列收紧到统一语义。
|
|||
|
|
|
|||
|
|
测试库迁移步骤:
|
|||
|
|
|
|||
|
|
1. 将 `LE_ACCOUNT_NAME`、`CUSTOMER_ACCOUNT_NAME` 的历史值做 `TRIM`
|
|||
|
|
2. 将 `LE_ACCOUNT_NAME`、`CUSTOMER_ACCOUNT_NAME` 的 `NULL` 回填为空串
|
|||
|
|
3. 清理已存在的重复测试数据,只保留一条
|
|||
|
|
4. 新增唯一键
|
|||
|
|
|
|||
|
|
建议迁移脚本内容包含:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
UPDATE ccdi_bank_statement
|
|||
|
|
SET LE_ACCOUNT_NAME = TRIM(COALESCE(LE_ACCOUNT_NAME, '')),
|
|||
|
|
CUSTOMER_ACCOUNT_NAME = TRIM(COALESCE(CUSTOMER_ACCOUNT_NAME, ''));
|
|||
|
|
|
|||
|
|
ALTER TABLE ccdi_bank_statement
|
|||
|
|
MODIFY COLUMN LE_ACCOUNT_NAME varchar(240) NOT NULL DEFAULT '' COMMENT '企业账号名称',
|
|||
|
|
MODIFY COLUMN CUSTOMER_ACCOUNT_NAME varchar(240) NOT NULL DEFAULT '' COMMENT '对手方企业名称';
|
|||
|
|
|
|||
|
|
ALTER TABLE ccdi_bank_statement
|
|||
|
|
ADD UNIQUE KEY uk_bank_statement_dedup (
|
|||
|
|
project_id,
|
|||
|
|
LE_ACCOUNT_NAME,
|
|||
|
|
TRX_DATE,
|
|||
|
|
CUSTOMER_ACCOUNT_NAME,
|
|||
|
|
AMOUNT_BALANCE
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
备注:
|
|||
|
|
|
|||
|
|
- `TRX_DATE`、`AMOUNT_BALANCE` 在现有表设计中已经是业务判重的一部分
|
|||
|
|
- `project_id` 是当前业务写入必填字段,迁移前应确认测试库不存在空值
|
|||
|
|
|
|||
|
|
### 4. Mapper SQL 调整
|
|||
|
|
|
|||
|
|
将 `CcdiBankStatementMapper.xml` 中的批量插入改为 no-op upsert:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
INSERT INTO ccdi_bank_statement (...)
|
|||
|
|
VALUES
|
|||
|
|
(...),
|
|||
|
|
(...)
|
|||
|
|
ON DUPLICATE KEY UPDATE
|
|||
|
|
bank_statement_id = bank_statement_id
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
语义说明:
|
|||
|
|
|
|||
|
|
- 新记录:正常插入
|
|||
|
|
- 重复记录:命中唯一键,走 duplicate 分支,但不改任何业务字段
|
|||
|
|
- 非重复键类 SQL 错误:仍然抛出异常
|
|||
|
|
|
|||
|
|
这样满足“保留原数据,不进行更新”的业务要求。
|
|||
|
|
|
|||
|
|
### 5. 日志与统计
|
|||
|
|
|
|||
|
|
服务层日志增加三类计数:
|
|||
|
|
|
|||
|
|
- 接口返回数
|
|||
|
|
- 实际新增数
|
|||
|
|
- 重复跳过数
|
|||
|
|
|
|||
|
|
这里有一个实现细节需要在本地 MySQL 上确认:
|
|||
|
|
|
|||
|
|
- `ON DUPLICATE KEY UPDATE bank_statement_id = bank_statement_id` 的受影响行数在 MySQL/JDBC 下是否稳定返回“新增 1、重复 0”
|
|||
|
|
|
|||
|
|
如果实测成立,则可以直接据此计算:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
重复跳过数 = 尝试写入数 - 实际新增数
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
如果实测不稳定,则需要降级为:
|
|||
|
|
|
|||
|
|
- 日志里保留“接口返回数 / 尝试写入数”
|
|||
|
|
- 或在实现阶段补充额外计数方案
|
|||
|
|
|
|||
|
|
该点不影响去重正确性,只影响日志精度。
|
|||
|
|
|
|||
|
|
### 6. 异常处理
|
|||
|
|
|
|||
|
|
命中重复不应视为失败。
|
|||
|
|
|
|||
|
|
处理规则:
|
|||
|
|
|
|||
|
|
- 命中唯一键重复:不抛业务失败,继续处理后续批次
|
|||
|
|
- 真实数据库错误:保持现有异常传播路径
|
|||
|
|
- 外层 `processFileAsync(...)` 捕获真实异常后,仍更新上传记录为 `parsed_failed`
|
|||
|
|
|
|||
|
|
### 7. 文档同步
|
|||
|
|
|
|||
|
|
当前 `assets/对接流水分析/ccdi_bank_statement.md` 中的建表说明与现有实体/Mapper 已有漂移,
|
|||
|
|
例如当前代码已经使用 `project_id`,而该文档片段未体现。
|
|||
|
|
|
|||
|
|
本次实现后应同步更新以下文档,避免数据库说明继续失真:
|
|||
|
|
|
|||
|
|
- `assets/对接流水分析/ccdi_bank_statement.md`
|
|||
|
|
- 如有必要,同步 `docs/plans/2026-03-04-bank-statement-entity-design.md` 中的表结构补充说明
|
|||
|
|
|
|||
|
|
## 影响范围
|
|||
|
|
|
|||
|
|
后端代码:
|
|||
|
|
|
|||
|
|
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
|
|||
|
|
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapper.java`
|
|||
|
|
- `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml`
|
|||
|
|
|
|||
|
|
测试:
|
|||
|
|
|
|||
|
|
- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
|
|||
|
|
|
|||
|
|
数据库与文档:
|
|||
|
|
|
|||
|
|
- 建议新增测试库迁移脚本到 `assets/database/`
|
|||
|
|
- `assets/对接流水分析/ccdi_bank_statement.md`
|
|||
|
|
|
|||
|
|
## 测试设计
|
|||
|
|
|
|||
|
|
至少覆盖以下场景:
|
|||
|
|
|
|||
|
|
1. 标准化逻辑:`null`、前后空格、空串输入
|
|||
|
|
2. 首次导入:记录正常插入
|
|||
|
|
3. 重复导入:不报错、不更新原记录
|
|||
|
|
4. 混合批次:重复记录跳过,新增记录写入
|
|||
|
|
5. 非唯一键类数据库异常:仍然向上抛出并触发 `parsed_failed`
|
|||
|
|
6. 本地 MySQL 验证:确认 no-op upsert 的受影响行数语义
|
|||
|
|
|
|||
|
|
## 风险与缓解
|
|||
|
|
|
|||
|
|
| 风险 | 影响 | 缓解方案 |
|
|||
|
|
|------|------|----------|
|
|||
|
|
| 测试库已有重复数据,新增唯一键失败 | 高 | 先执行清洗脚本,再加唯一键 |
|
|||
|
|
| 可空列绕过唯一键语义 | 高 | 迁移时统一回填空串并收紧为 `NOT NULL` |
|
|||
|
|
| `ON DUPLICATE KEY` 受影响行数语义与预期不一致 | 中 | 实测后决定日志计数方案,不影响去重正确性 |
|
|||
|
|
| 资产文档与代码继续漂移 | 中 | 实现后同步更新表结构说明 |
|
|||
|
|
|
|||
|
|
## 验收标准
|
|||
|
|
|
|||
|
|
1. 使用相同五元组重复导入时,数据库仅保留原记录
|
|||
|
|
2. 重复导入不会更新原记录的任何业务字段
|
|||
|
|
3. 命中重复不会导致上传记录失败
|
|||
|
|
4. 非重复键类数据库错误仍会让上传记录进入 `parsed_failed`
|
|||
|
|
5. 唯一键规则对后续其他写入入口同样生效
|