docs: update bank statement dedup design key

This commit is contained in:
wkc
2026-03-10 09:38:53 +08:00
parent 4148bea5a9
commit 924605ac3a

View File

@@ -8,12 +8,12 @@
本次确认的重复判定键为:
- `project_id`
- `LE_ACCOUNT_NAME`
- `TRX_DATE`
- `CUSTOMER_ACCOUNT_NAME`
- `AMOUNT_BALANCE`
- `LE_ACCOUNT_NO`
- `ACCOUNTING_DATE_ID`
- `AMOUNT_DR`
- `AMOUNT_CR`
目标是让“重复”这条规则尽量落到数据库约束层,服务层只负责标准化输入和保留现有异步处理链路。
目标是将“什么叫重复”尽量固化到数据库约束层,服务层只负责轻量标准化和保留现有异步处理链路。
## 背景
@@ -28,6 +28,7 @@
已确认的业务边界:
- 接口返回的同一批流水自身不会重复
- 接口返回的 `LE_ACCOUNT_NO``ACCOUNTING_DATE_ID``AMOUNT_DR``AMOUNT_CR` 不会是 `null`
- 如果数据库里已存在相同业务键的流水,保留原记录,不更新原数据
- 命中重复不应让整次文件处理失败
@@ -58,7 +59,7 @@
- 对业务键加唯一约束
- 批量插入改为 `INSERT ... ON DUPLICATE KEY UPDATE bank_statement_id = bank_statement_id`
- 服务层只做字段标准化
- 服务层只做必要的字段标准化
优点:
@@ -68,7 +69,7 @@
缺点:
- 需要先处理测试库中的存量重复数据
- 需要先处理测试库中的存量异常/重复数据
- `ON DUPLICATE KEY` 的受影响行数语义需要在本地 MySQL 实测确认
### 方案三:`INSERT IGNORE`
@@ -95,10 +96,11 @@
核心决策:
1. 不做本次接口结果的内存去重
2. 服务层在写入前标准化去重键字段
3. 数据库侧增加唯一键统一兜底重复规则
4. 命中重复时跳过写入,不更新原有业务数据
5. 非重复键类数据库错误仍然向上抛出,并按现有流程标记 `parsed_failed`
2. 去重定义整体切换为 `project_id + LE_ACCOUNT_NO + ACCOUNTING_DATE_ID + AMOUNT_DR + AMOUNT_CR`
3. 服务层只保留与新去重键相关的轻量标准化
4. 数据库侧增加唯一键统一兜底重复规则
5. 命中重复时跳过写入,不更新原有业务数据
6. 非重复键类数据库错误仍然向上抛出,并按现有流程标记 `parsed_failed`
## 详细设计
@@ -107,82 +109,101 @@
重复判定使用以下五元组:
```text
project_id + LE_ACCOUNT_NAME + TRX_DATE + CUSTOMER_ACCOUNT_NAME + AMOUNT_BALANCE
project_id + LE_ACCOUNT_NO + ACCOUNTING_DATE_ID + AMOUNT_DR + AMOUNT_CR
```
对应到 Java 字段为:
- `projectId`
- `leAccountName`
- `trxDate`
- `customerAccountName`
- `amountBalance`
- `leAccountNo`
- `accountingDateId`
- `amountDr`
- `amountCr`
旧设计中的 `LE_ACCOUNT_NAME``TRX_DATE``CUSTOMER_ACCOUNT_NAME``AMOUNT_BALANCE`
不再参与重复判定。
### 2. 服务层标准化
`CcdiFileUploadServiceImpl.fetchAndSaveBankStatements(...)` 中,
`CcdiBankStatement.fromResponse(...)` 返回实体后,补充统一标准化:
`CcdiBankStatement.fromResponse(...)` 返回实体后,只保留与新去重键相关的标准化:
- `leAccountName = trimToEmpty(leAccountName)`
- `customerAccountName = trimToEmpty(customerAccountName)`
- `trxDate` 保持接口返回值,不额外改写格式
- `amountBalance` 保持 `BigDecimal` 语义写入数据库 `decimal(19,2)`
- `leAccountNo = leAccountNo.trim()`
- `accountingDateId` 保持接口返回值
- `amountDr``amountCr` 保持 `BigDecimal` 语义写入数据库 `decimal(19,2)`
- `projectId` 继续由服务层显式设置
建议新增私有辅助方法,例如:
```java
private String trimToEmpty(String value) {
return value == null ? "" : value.trim();
private String trimAccountNo(String value) {
return value == null ? null : value.trim();
}
private void normalizeDedupFields(CcdiBankStatement statement) {
statement.setLeAccountName(trimToEmpty(statement.getLeAccountName()));
statement.setCustomerAccountName(trimToEmpty(statement.getCustomerAccountName()));
statement.setLeAccountNo(trimAccountNo(statement.getLeAccountNo()));
}
```
说明:
- 用户已明确 `LE_ACCOUNT_NAME` 对应 `account_name`
- `amountBalance` 的重复语义按数据库金额列处理,不引入额外字符串比较
- 因为接口已保证 `LE_ACCOUNT_NO``ACCOUNTING_DATE_ID``AMOUNT_DR``AMOUNT_CR` 不为 `null`
服务层不再额外承担空值回填逻辑
- 标准化的目标是避免账号前后空格导致同一条流水被误判为不同记录
### 3. 数据库结构调整
为保证唯一键对所有写入入口都有效,需要先把参与判重的可空字符列收紧到统一语义
为保证唯一键对所有写入入口都有效,需要先清理测试库中的异常数据,再加唯一键
测试库迁移步骤:
1. `LE_ACCOUNT_NAME``CUSTOMER_ACCOUNT_NAME` 的历史值做 `TRIM`
2. `LE_ACCOUNT_NAME``CUSTOMER_ACCOUNT_NAME``NULL` 回填为空串
3. 清理已存在的重复测试数据,只保留一条
4. 新增唯一键
1. 删除 `project_id``LE_ACCOUNT_NO``ACCOUNTING_DATE_ID` 缺失的测试数据
2. `LE_ACCOUNT_NO` 执行 `TRIM`
3. 按新五元组清理已存在的重复测试数据,只保留一条
4. `project_id``LE_ACCOUNT_NO``ACCOUNTING_DATE_ID` 收紧为 `NOT NULL`
5. 新增唯一键
建议迁移脚本内容包含:
```sql
DELETE FROM ccdi_bank_statement
WHERE project_id IS NULL
OR LE_ACCOUNT_NO IS NULL
OR ACCOUNTING_DATE_ID IS NULL;
UPDATE ccdi_bank_statement
SET LE_ACCOUNT_NAME = TRIM(COALESCE(LE_ACCOUNT_NAME, '')),
CUSTOMER_ACCOUNT_NAME = TRIM(COALESCE(CUSTOMER_ACCOUNT_NAME, ''));
SET LE_ACCOUNT_NO = TRIM(LE_ACCOUNT_NO);
DELETE t1
FROM ccdi_bank_statement t1
JOIN ccdi_bank_statement t2
ON t1.bank_statement_id > t2.bank_statement_id
AND t1.project_id = t2.project_id
AND t1.LE_ACCOUNT_NO = t2.LE_ACCOUNT_NO
AND t1.ACCOUNTING_DATE_ID = t2.ACCOUNTING_DATE_ID
AND t1.AMOUNT_DR = t2.AMOUNT_DR
AND t1.AMOUNT_CR = t2.AMOUNT_CR;
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 '对手方企业名称';
MODIFY COLUMN project_id bigint(20) NOT NULL COMMENT '关联项目ID',
MODIFY COLUMN LE_ACCOUNT_NO varchar(240) NOT NULL DEFAULT '' COMMENT '企业银行账号',
MODIFY COLUMN ACCOUNTING_DATE_ID int(11) NOT NULL COMMENT '账号日期ID';
ALTER TABLE ccdi_bank_statement
ADD UNIQUE KEY uk_bank_statement_dedup (
project_id,
LE_ACCOUNT_NAME,
TRX_DATE,
CUSTOMER_ACCOUNT_NAME,
AMOUNT_BALANCE
LE_ACCOUNT_NO,
ACCOUNTING_DATE_ID,
AMOUNT_DR,
AMOUNT_CR
);
```
备注:
- `TRX_DATE``AMOUNT_BALANCE` 在现有表设计中已经是业务判重的一部分
- `AMOUNT_DR``AMOUNT_CR` 在现有表设计中已`NOT NULL DEFAULT 0.00`
- `project_id` 是当前业务写入必填字段,迁移前应确认测试库不存在空值
- 由于库未上线、测试数据可调整,删除不完整测试数据是可接受方案
### 4. Mapper SQL 调整
@@ -215,20 +236,16 @@ ON DUPLICATE KEY UPDATE
这里有一个实现细节需要在本地 MySQL 上确认:
- `ON DUPLICATE KEY UPDATE bank_statement_id = bank_statement_id` 的受影响行数在 MySQL/JDBC 下是否稳定返回“新增 1、重复 0”
- `ON DUPLICATE KEY UPDATE bank_statement_id = bank_statement_id`
在 MySQL/JDBC 下的受影响行数,是否稳定返回“新增 1、重复 0”
如果实测成立,则可以直接据此计算:
如果实测成立,则可以直接计算:
```text
重复跳过数 = 尝试写入数 - 实际新增数
```
如果实测不稳定,则需要降级为
- 日志里保留“接口返回数 / 尝试写入数”
- 或在实现阶段补充额外计数方案
该点不影响去重正确性,只影响日志精度。
如果实测不稳定,则降级为保守日志,不伪造精确的重复数。
### 6. 异常处理
@@ -271,7 +288,7 @@ ON DUPLICATE KEY UPDATE
至少覆盖以下场景:
1. 标准化逻辑:`null`前后空格、空串输入
1. 标准化逻辑:`LE_ACCOUNT_NO` 前后空格
2. 首次导入:记录正常插入
3. 重复导入:不报错、不更新原记录
4. 混合批次:重复记录跳过,新增记录写入
@@ -282,8 +299,8 @@ ON DUPLICATE KEY UPDATE
| 风险 | 影响 | 缓解方案 |
|------|------|----------|
| 测试库已有重复数据,新增唯一键失败 | 高 | 先执行清洗脚本,再加唯一键 |
| 可空列绕过唯一键语义 | 高 | 迁移时统一回填空串并收紧为 `NOT NULL` |
| 测试库已有异常/重复数据,新增唯一键失败 | 高 | 先清洗异常行和重复行,再加唯一键 |
| `project_id` / `LE_ACCOUNT_NO` / `ACCOUNTING_DATE_ID` 空值绕过唯一键语义 | 高 | 迁移时删除异常测试数据并收紧为 `NOT NULL` |
| `ON DUPLICATE KEY` 受影响行数语义与预期不一致 | 中 | 实测后决定日志计数方案,不影响去重正确性 |
| 资产文档与代码继续漂移 | 中 | 实现后同步更新表结构说明 |