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

8.6 KiB
Raw Blame History

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

概述

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. 去重键定义

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

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 继续由服务层显式设置

建议新增私有辅助方法,例如:

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_NAMECUSTOMER_ACCOUNT_NAME 的历史值做 TRIM
  2. LE_ACCOUNT_NAMECUSTOMER_ACCOUNT_NAMENULL 回填为空串
  3. 清理已存在的重复测试数据,只保留一条
  4. 新增唯一键

建议迁移脚本内容包含:

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_DATEAMOUNT_BALANCE 在现有表设计中已经是业务判重的一部分
  • project_id 是当前业务写入必填字段,迁移前应确认测试库不存在空值

4. Mapper SQL 调整

CcdiBankStatementMapper.xml 中的批量插入改为 no-op upsert

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”

如果实测成立,则可以直接据此计算:

重复跳过数 = 尝试写入数 - 实际新增数

如果实测不稳定,则需要降级为:

  • 日志里保留“接口返回数 / 尝试写入数”
  • 或在实现阶段补充额外计数方案

该点不影响去重正确性,只影响日志精度。

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. 唯一键规则对后续其他写入入口同样生效