Files
ccdi/docs/plans/2026-03-09-bank-statement-duplicate-check-design.md

297 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 银行流水入库重复校验设计
## 概述
`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. 唯一键规则对后续其他写入入口同样生效