diff --git a/docs/plans/2026-03-09-file-upload-parse-success-after-bank-statement-design.md b/docs/plans/2026-03-09-file-upload-parse-success-after-bank-statement-design.md new file mode 100644 index 0000000..1370f0e --- /dev/null +++ b/docs/plans/2026-03-09-file-upload-parse-success-after-bank-statement-design.md @@ -0,0 +1,263 @@ +# 流水文件解析成功状态延后到流水入库完成设计 + +## 概述 + +调整流水文件上传异步处理链路中“解析成功”的业务含义。 + +当前实现里,只要流水分析平台返回“解析成功且可确认账户”,系统就会立即把上传记录状态更新为 `parsed_success`,随后才执行步骤 7 获取流水数据并写入本地数据库。 + +本次设计将 `parsed_success` 的含义收紧为: + +- 流水分析平台解析成功 +- 步骤 7 获取流水数据成功 +- 流水数据成功写入本地数据库 + +在步骤 7 完成前,页面继续显示“解析中”。 + +## 背景 + +### 当前现状 + +当前核心逻辑位于 `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`: + +1. 上传文件到流水分析平台 +2. 轮询解析状态 +3. 调用上传状态接口判断是否解析成功 +4. 立即更新 `ccdi_file_upload_record.file_status = parsed_success` +5. 再调用 `fetchAndSaveBankStatements(...)` 获取流水并入库 + +这会产生两个问题: + +1. 前端会看到“解析成功”,但数据库里的流水可能还没有写完 +2. 步骤 7 失败时,记录可能先显示成功,随后又因为异常被改成失败,状态语义不稳定 + +### 前端约束 + +当前前端只识别以下四种状态: + +- `uploading` +- `parsing` +- `parsed_success` +- `parsed_failed` + +本次需求明确要求: + +- 不新增前端状态 +- 当步骤 6 已经确认平台解析成功,但步骤 7 尚未完成时,页面继续显示“解析中” + +## 方案对比 + +### 方案一:仅后移 `parsed_success` 更新时机 + +做法: + +- 步骤 6 解析成功后,不更新状态 +- 执行步骤 7 +- 步骤 7 执行结束后,再更新为 `parsed_success` + +优点: + +- 改动最小 +- 前端和数据库状态枚举都不需要调整 + +缺点: + +- 当前 `fetchAndSaveBankStatements(...)` 没有显式返回成功或失败结果 +- 方法内部存在“记录异常后继续处理”的行为,容易把部分失败误判为成功 + +### 方案二:后移成功状态,并让步骤 7 返回明确执行结果 + +做法: + +- 步骤 6 只确认“平台解析成功且可以获取流水” +- 记录状态继续保持 `parsing` +- 步骤 7 返回结构化结果,例如 `success`、`savedCount`、`errorMessage` +- 只有步骤 7 明确成功后,才更新 `parsed_success` +- 步骤 7 任一关键失败,则更新为 `parsed_failed` + +优点: + +- 状态语义完整且稳定 +- 能避免“伪成功” +- 与当前前端状态模型兼容 + +缺点: + +- 需要对步骤 7 做一定重构 + +### 方案三:拆分为解析状态和入库状态两个维度 + +做法: + +- 新增“解析状态”和“入库状态”两个字段 +- 前端组合展示 + +优点: + +- 状态表达最完整 + +缺点: + +- 涉及数据库、后端查询统计、前端状态映射等多处改动 +- 超出本次需求范围 + +## 最终方案 + +采用方案二。 + +### 核心决策 + +1. `parsed_success` 只表示“流水数据已经成功入库” +2. 步骤 6 解析成功后,记录状态继续保持 `parsing` +3. 步骤 7 必须显式返回成功或失败结果 +4. 步骤 7 失败时,将上传记录更新为 `parsed_failed` +5. 步骤 7 失败时,清理本次 `logId` 对应的已落库流水,避免半成品数据残留 + +## 详细设计 + +### 1. 主流程状态流转 + +调整 `processFileAsync(...)` 的状态流转如下: + +1. 初始创建记录时为 `uploading` +2. 文件上传到流水分析平台成功后,更新为 `parsing` +3. 轮询解析完成 +4. 调用文件上传状态接口判断平台是否解析成功 +5. 若平台解析失败,更新为 `parsed_failed` +6. 若平台解析成功,不更新为 `parsed_success`,继续保持 `parsing` +7. 执行步骤 7 获取流水并入库 +8. 步骤 7 成功后,一次性更新: + - `file_status = parsed_success` + - `enterprise_names` + - `account_nos` + - 清空可能残留的 `error_message` +9. 步骤 7 失败后,更新: + - `file_status = parsed_failed` + - `error_message = 失败原因` + +### 2. 步骤 7 返回结构化结果 + +将 `fetchAndSaveBankStatements(Long projectId, Integer groupId, Integer logId)` 从 `void` 改为返回结构化结果对象。 + +建议新增内部结果对象,例如: + +```java +@Data +private static class FetchBankStatementResult { + private boolean success; + private int totalCount; + private int savedCount; + private String errorMessage; +} +``` + +返回语义建议如下: + +- `success = true` + - 已成功完成全部分页拉取和数据库落库 + - `savedCount` 为实际保存条数 +- `success = false` + - 任一关键步骤失败 + - `errorMessage` 写明失败原因 + +### 3. 步骤 7 的成功判定 + +步骤 7 需同时满足以下条件才算成功: + +1. 首次 `getBankStatement` 请求成功返回 +2. 分页总数计算正常 +3. 所有分页请求成功完成 +4. 所有批量插入操作成功完成 +5. 最终保存条数与已拉取条数一致 + +其中 `totalCount = 0` 的场景按成功处理,原因如下: + +- 平台已经解析成功 +- 业务上允许“解析成功但无流水” +- 否则记录会长期停留在 `parsing` 或被错误标记为失败 + +### 4. 步骤 7 的失败处理 + +当前实现中,分页循环内部发生异常后会记录日志并继续下一页。该行为不适用于本次状态语义。 + +调整后规则: + +1. 首次查询总数失败,直接返回失败 +2. 任一分页请求失败,直接返回失败 +3. 任一批量插入失败,直接返回失败 +4. 返回失败前,清理当前 `logId` 已写入的流水数据 + +### 5. 半成品流水清理 + +`ccdi_bank_statement` 已存在 `batch_id` 字段,且当前实体 `CcdiBankStatement.batchId` 已映射该字段。 + +因此步骤 7 中应确保每条流水都带上本次上传的 `logId`: + +```java +statement.setProjectId(projectId); +statement.setBatchId(logId); +``` + +同时在 `CcdiBankStatementMapper` 中新增清理接口,例如: + +```java +int deleteByProjectIdAndBatchId(@Param("projectId") Long projectId, + @Param("batchId") Integer batchId); +``` + +用于在步骤 7 失败时删除本次已插入的流水,避免出现“部分落库但上传记录失败”的脏数据。 + +### 6. 为什么不使用长事务 + +不建议把步骤 7 做成覆盖远程接口调用和全部分页落库的单个数据库事务,原因如下: + +1. 远程接口调用时间不可控 +2. 全量分页获取可能持续较久 +3. 长事务会占用数据库连接并增加锁持有时间 + +因此本次采用“显式成功判定 + 失败补偿清理”的方式,而不是“长事务回滚”。 + +## 影响范围 + +### 后端 + +- `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` + +### 前端 + +无须改动。 + +当前前端的 `parsing` 状态即可承载“平台解析成功但流水尚未入库完成”的阶段。 + +## 测试设计 + +至少覆盖以下场景: + +1. 平台解析成功,步骤 7 全量拉取并入库成功,最终状态应为 `parsed_success` +2. 平台解析成功,但首次获取流水总数失败,最终状态应为 `parsed_failed` +3. 分页处理中途失败,最终状态应为 `parsed_failed`,且已写入流水被清理 +4. 批量插入失败,最终状态应为 `parsed_failed`,且已写入流水被清理 +5. `totalCount = 0`,最终状态应为 `parsed_success` +6. 平台解析失败,保持现有失败路径 + +## 风险与缓解 + +| 风险 | 影响 | 缓解方案 | +|------|------|----------| +| 步骤 7 重构后改变现有异常处理行为 | 中 | 使用单元测试锁定成功、失败、零数据三类分支 | +| 清理逻辑误删其他流水 | 高 | 删除条件必须同时绑定 `projectId` 和 `batchId(logId)` | +| 失败原因不清晰 | 中 | 统一由步骤 7 返回明确 `errorMessage`,最终写入 `ccdi_file_upload_record.error_message` | + +## 验收标准 + +1. 当流水平台解析成功但本地仍在入库时,上传记录保持 `parsing` +2. 只有本地流水入库完成后,上传记录才变为 `parsed_success` +3. 任一步骤 7 失败,上传记录为 `parsed_failed` +4. 步骤 7 失败后,不残留本次 `logId` 的半成品流水 +5. 前端无需新增状态,现有页面展示符合预期 diff --git a/docs/plans/2026-03-09-file-upload-parse-success-after-bank-statement.md b/docs/plans/2026-03-09-file-upload-parse-success-after-bank-statement.md new file mode 100644 index 0000000..e3e8b73 --- /dev/null +++ b/docs/plans/2026-03-09-file-upload-parse-success-after-bank-statement.md @@ -0,0 +1,362 @@ +# 流水文件解析成功状态延后到流水入库完成 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 让流水文件上传记录只有在步骤 7 获取并保存流水数据成功后才更新为 `parsed_success`,在此之前继续显示 `parsing`。 + +**Architecture:** 重构 `CcdiFileUploadServiceImpl` 的步骤 7,使其返回结构化执行结果而不是吞异常;主流程基于该结果决定最终状态。使用 `ccdi_bank_statement.batch_id` 绑定本次上传 `logId`,在步骤 7 失败时通过 Mapper 补偿删除本次已写入流水,避免半成品数据残留。 + +**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, JUnit 5, Mockito(来自 `spring-boot-starter-test`) + +--- + +### Task 1: 为状态延后规则编写服务层失败测试 + +**Files:** +- Create: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` +- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java:340-619` + +**Step 1: Write the failing test** + +在新测试类中先写“平台解析成功但步骤 7 失败时,记录最终为 `parsed_failed`”的测试。使用 Mockito mock 以下依赖: + +- `LsfxAnalysisClient` +- `CcdiFileUploadRecordMapper` +- `CcdiBankStatementMapper` + +示例骨架: + +```java +@ExtendWith(MockitoExtension.class) +class CcdiFileUploadServiceImplTest { + + @InjectMocks + private CcdiFileUploadServiceImpl service; + + @Mock + private CcdiFileUploadRecordMapper recordMapper; + + @Mock + private CcdiProjectMapper projectMapper; + + @Mock + private LsfxAnalysisClient lsfxClient; + + @Mock + private CcdiBankStatementMapper bankStatementMapper; + + @Test + void processFileAsync_shouldKeepParsingUntilBankStatementsSaved() { + // arrange + // mock 上传成功、轮询完成、状态接口解析成功 + // mock getBankStatement 首次调用抛异常 + // act + // assert + verify(recordMapper, never()).updateById(argThat(record -> + "parsed_success".equals(record.getFileStatus()))); + verify(recordMapper).updateById(argThat(record -> + "parsed_failed".equals(record.getFileStatus()))); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest#processFileAsync_shouldKeepParsingUntilBankStatementsSaved +``` + +Expected: + +- FAIL +- 失败原因应体现当前实现会先更新 `parsed_success`,或测试类尚未编译通过 + +**Step 3: Write a second failing test for the success path** + +补一条成功路径测试,验证步骤 7 成功后才更新为 `parsed_success`: + +```java +@Test +void processFileAsync_shouldMarkSuccessAfterBankStatementsSaved() { + // mock 上传成功、解析成功、getBankStatement 返回 totalCount=0 + // 执行后应只在步骤7完成后出现 parsed_success + verify(recordMapper).updateById(argThat(record -> + "parsed_success".equals(record.getFileStatus()))); +} +``` + +**Step 4: Run both tests to verify they fail** + +Run: + +```bash +mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest +``` + +Expected: + +- FAIL +- 至少一条断言失败,证明当前实现不符合新设计 + +**Step 5: Commit** + +```bash +git add ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java +git commit -m "test(ccdi-project): add file upload status transition tests" +``` + +### Task 2: 重构步骤 7 返回结果对象并延后成功状态更新 + +**Files:** +- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java:340-619` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` + +**Step 1: Add a result object inside the service** + +在 `CcdiFileUploadServiceImpl` 内新增私有静态结果类: + +```java +@Data +private static class FetchBankStatementResult { + private boolean success; + private int totalCount; + private int savedCount; + private String errorMessage; +} +``` + +**Step 2: Change the fetch method signature** + +把: + +```java +private void fetchAndSaveBankStatements(Long projectId, Integer groupId, Integer logId) +``` + +改为: + +```java +private FetchBankStatementResult fetchAndSaveBankStatements(Long projectId, Integer groupId, Integer logId) +``` + +**Step 3: Return explicit failure instead of swallowing exceptions** + +把当前“记录错误后继续下一页”的逻辑改成显式失败返回。例如: + +```java +catch (Exception e) { + result.setSuccess(false); + result.setErrorMessage("获取或保存流水数据失败: " + e.getMessage()); + return result; +} +``` + +**Step 4: Delay `parsed_success` update until the fetch result succeeds** + +把 `processFileAsync(...)` 中当前这段提前成功逻辑: + +```java +record.setFileStatus("parsed_success"); +record.setEnterpriseNames(enterpriseNamesStr); +record.setAccountNos(accountNosStr); +recordMapper.updateById(record); + +fetchAndSaveBankStatements(projectId, lsfxProjectId, logId); +``` + +改成: + +```java +FetchBankStatementResult fetchResult = fetchAndSaveBankStatements(projectId, lsfxProjectId, logId); +if (!fetchResult.isSuccess()) { + record.setFileStatus("parsed_failed"); + record.setErrorMessage(fetchResult.getErrorMessage()); + recordMapper.updateById(record); + return; +} + +record.setFileStatus("parsed_success"); +record.setEnterpriseNames(enterpriseNamesStr); +record.setAccountNos(accountNosStr); +record.setErrorMessage(null); +recordMapper.updateById(record); +``` + +**Step 5: Handle the zero-data path explicitly** + +在首次总数查询后,若 `totalCount == null || totalCount <= 0`,返回成功结果: + +```java +result.setSuccess(true); +result.setTotalCount(0); +result.setSavedCount(0); +return result; +``` + +**Step 6: Run targeted tests** + +Run: + +```bash +mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest +``` + +Expected: + +- PASS +- 新增的状态延后测试全部通过 + +**Step 7: Commit** + +```bash +git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java +git commit -m "refactor(ccdi-project): delay parsed success until bank statements saved" +``` + +### Task 3: 为本次上传绑定 batchId 并补偿清理半成品流水 + +**Files:** +- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java:527-619` +- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankStatementMapper.java:15-23` +- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankStatementMapper.xml:62-87` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` + +**Step 1: Attach the upload logId to each statement** + +在流水转换循环内补齐: + +```java +statement.setProjectId(projectId); +statement.setBatchId(logId); +``` + +**Step 2: Add the cleanup mapper method** + +在 `CcdiBankStatementMapper.java` 中新增: + +```java +int deleteByProjectIdAndBatchId(@Param("projectId") Long projectId, + @Param("batchId") Integer batchId); +``` + +并在 XML 中实现: + +```xml + + delete from ccdi_bank_statement + where project_id = #{projectId} + and batch_id = #{batchId} + +``` + +**Step 3: Call cleanup before returning failure** + +在 `fetchAndSaveBankStatements(...)` 的失败分支中调用: + +```java +bankStatementMapper.deleteByProjectIdAndBatchId(projectId, logId); +``` + +只允许使用 `projectId + logId(batchId)` 双条件,避免误删其他批次数据。 + +**Step 4: Write a failing cleanup test** + +在测试类中新增: + +```java +@Test +void processFileAsync_shouldCleanupInsertedStatementsWhenFetchFails() { + // mock 某页或某批插入失败 + // assert deleteByProjectIdAndBatchId(projectId, logId) 被调用 +} +``` + +**Step 5: Run the new test** + +Run: + +```bash +mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest#processFileAsync_shouldCleanupInsertedStatementsWhenFetchFails +``` + +Expected: + +- 先 FAIL,再在实现后 PASS + +**Step 6: Run the module tests** + +Run: + +```bash +mvn test -pl ccdi-project -Dtest=CcdiBankStatementTest,CcdiFileUploadServiceImplTest +``` + +Expected: + +- PASS +- 旧的 `CcdiBankStatementTest` 不回归 + +**Step 7: Commit** + +```bash +git add 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 +git commit -m "fix(ccdi-project): cleanup partial bank statements on upload failure" +``` + +### Task 4: 回归验证并整理交付 + +**Files:** +- Modify: `docs/plans/2026-03-09-file-upload-parse-success-after-bank-statement-design.md` +- Modify: `docs/plans/2026-03-09-file-upload-parse-success-after-bank-statement.md` + +**Step 1: Run final verification** + +Run: + +```bash +mvn test -pl ccdi-project -Dtest=CcdiBankStatementTest,CcdiFileUploadServiceImplTest +``` + +Expected: + +- PASS +- 无编译错误 + +**Step 2: Inspect git diff** + +Run: + +```bash +git diff -- 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 +``` + +Expected: + +- 只包含状态时机调整、结果对象、清理接口和测试 + +**Step 3: Update docs if implementation deviates** + +若实现中出现与设计或计划不一致的细节,及时回写到这两份文档,避免文档失真。 + +**Step 4: Commit** + +```bash +git add 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 docs/plans/2026-03-09-file-upload-parse-success-after-bank-statement-design.md docs/plans/2026-03-09-file-upload-parse-success-after-bank-statement.md +git commit -m "docs: finalize file upload parse success timing plan" +``` + +--- + +## Implementation Checklist + +- [ ] 服务测试已覆盖“成功延后”和“步骤 7 失败”场景 +- [ ] `fetchAndSaveBankStatements(...)` 改为返回结构化结果 +- [ ] 步骤 7 完成前记录状态保持 `parsing` +- [ ] 步骤 7 成功后才更新 `parsed_success` +- [ ] 步骤 7 失败后更新 `parsed_failed` +- [ ] 本次 `logId` 对应流水写入 `batch_id` +- [ ] 步骤 7 失败时清理本次半成品流水 +- [ ] `totalCount = 0` 场景按成功处理 +- [ ] `ccdi-project` 相关测试通过