# 银行流水入库重复校验设计

## 概述

在 `fetchAndSaveBankStatements(Long projectId, Integer groupId, Integer logId)` 中，
接口返回的银行流水写入 `ccdi_bank_statement` 前，需要基于业务键避免重复插入。

本次确认的重复判定键为：

- `project_id`
- `LE_ACCOUNT_NO`
- `ACCOUNTING_DATE_ID`
- `AMOUNT_DR`
- `AMOUNT_CR`

目标是将“什么叫重复”尽量固化到数据库约束层，服务层只负责轻量标准化和保留现有异步处理链路。

## 背景

当前实现位于 `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`：

1. 分页调用流水分析接口获取流水数据
2. 将返回项转换为 `CcdiBankStatement`
3. 直接调用 `CcdiBankStatementMapper.insertBatch(...)` 批量入库

现状没有任何重复校验，重复导入同一批流水时会再次插入。

已确认的业务边界：

- 接口返回的同一批流水自身不会重复
- 接口返回的 `LE_ACCOUNT_NO`、`ACCOUNTING_DATE_ID`、`AMOUNT_DR`、`AMOUNT_CR` 不会是 `null`
- 如果数据库里已存在相同业务键的流水，保留原记录，不更新原数据
- 命中重复不应让整次文件处理失败

## 方案对比

### 方案一：服务层先查库再插入

做法：

- 服务层先按业务键查库
- 过滤已存在记录
- 仅插入剩余记录

优点：

- 语义直观
- 不需要调整批量插入 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. 去重定义整体切换为 `project_id + LE_ACCOUNT_NO + ACCOUNTING_DATE_ID + AMOUNT_DR + AMOUNT_CR`
3. 服务层只保留与新去重键相关的轻量标准化
4. 数据库侧增加唯一键统一兜底重复规则
5. 命中重复时跳过写入，不更新原有业务数据
6. 非重复键类数据库错误仍然向上抛出，并按现有流程标记 `parsed_failed`

## 详细设计

### 1. 去重键定义

重复判定使用以下五元组：

```text
project_id + LE_ACCOUNT_NO + ACCOUNTING_DATE_ID + AMOUNT_DR + AMOUNT_CR
```

对应到 Java 字段为：

- `projectId`
- `leAccountNo`
- `accountingDateId`
- `amountDr`
- `amountCr`

旧设计中的 `LE_ACCOUNT_NAME`、`TRX_DATE`、`CUSTOMER_ACCOUNT_NAME`、`AMOUNT_BALANCE`
不再参与重复判定。

### 2. 服务层标准化

在 `CcdiFileUploadServiceImpl.fetchAndSaveBankStatements(...)` 中，
`CcdiBankStatement.fromResponse(...)` 返回实体后，只保留与新去重键相关的标准化：

- `leAccountNo = leAccountNo.trim()`
- `accountingDateId` 保持接口返回值
- `amountDr`、`amountCr` 保持 `BigDecimal` 语义写入数据库 `decimal(19,2)`
- `projectId` 继续由服务层显式设置

建议新增私有辅助方法，例如：

```java
private String trimAccountNo(String value) {
    return value == null ? null : value.trim();
}

private void normalizeDedupFields(CcdiBankStatement statement) {
    statement.setLeAccountNo(trimAccountNo(statement.getLeAccountNo()));
}
```

说明：

- 因为接口已保证 `LE_ACCOUNT_NO`、`ACCOUNTING_DATE_ID`、`AMOUNT_DR`、`AMOUNT_CR` 不为 `null`，
  服务层不再额外承担空值回填逻辑
- 标准化的目标是避免账号前后空格导致同一条流水被误判为不同记录

### 3. 数据库结构调整

为保证唯一键对所有写入入口都有效，需要先清理测试库中的异常数据，再加唯一键。

测试库迁移步骤：

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_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 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_NO,
        ACCOUNTING_DATE_ID,
        AMOUNT_DR,
        AMOUNT_CR
    );
```

备注：

- `AMOUNT_DR`、`AMOUNT_CR` 在现有表设计中已是 `NOT NULL DEFAULT 0.00`
- `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. 标准化逻辑：`LE_ACCOUNT_NO` 前后空格
2. 首次导入：记录正常插入
3. 重复导入：不报错、不更新原记录
4. 混合批次：重复记录跳过，新增记录写入
5. 非唯一键类数据库异常：仍然向上抛出并触发 `parsed_failed`
6. 本地 MySQL 验证：确认 no-op upsert 的受影响行数语义

## 风险与缓解

| 风险 | 影响 | 缓解方案 |
|------|------|----------|
| 测试库已有异常/重复数据，新增唯一键失败 | 高 | 先清洗异常行和重复行，再加唯一键 |
| `project_id` / `LE_ACCOUNT_NO` / `ACCOUNTING_DATE_ID` 空值绕过唯一键语义 | 高 | 迁移时删除异常测试数据并收紧为 `NOT NULL` |
| `ON DUPLICATE KEY` 受影响行数语义与预期不一致 | 中 | 实测后决定日志计数方案，不影响去重正确性 |
| 资产文档与代码继续漂移 | 中 | 实现后同步更新表结构说明 |

## 验收标准

1. 使用相同五元组重复导入时，数据库仅保留原记录
2. 重复导入不会更新原记录的任何业务字段
3. 命中重复不会导致上传记录失败
4. 非重复键类数据库错误仍会让上传记录进入 `parsed_failed`
5. 唯一键规则对后续其他写入入口同样生效
