diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java index 7d41c2db..a5322870 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java @@ -4,6 +4,7 @@ import com.alibaba.excel.EasyExcel; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.ccdi.project.domain.CcdiProject; +import com.ruoyi.ccdi.project.domain.enums.TriggerType; import com.ruoyi.ccdi.project.domain.dto.CcdiFileUploadQueryDTO; import com.ruoyi.ccdi.project.domain.entity.CcdiBankStatement; import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord; @@ -12,6 +13,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO; import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper; import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper; +import com.ruoyi.ccdi.project.service.ICcdiBankTagService; import com.ruoyi.ccdi.project.service.ICcdiFileUploadService; import com.ruoyi.lsfx.client.LsfxAnalysisClient; import com.ruoyi.lsfx.constants.LsfxConstants; @@ -91,6 +93,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { @Resource private CcdiBankStatementMapper bankStatementMapper; + @Resource + private ICcdiBankTagService bankTagService; + /** * 获取临时文件存储目录 */ @@ -414,6 +419,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { String batchId) { log.info("【文件上传】调度线程启动: projectId={}, batchId={}", projectId, batchId); + List> futures = new ArrayList<>(); + // 循环提交任务 for (int i = 0; i < tempFilePaths.size(); i++) { // Critical Fix #6: 检查线程中断状态 @@ -431,10 +438,11 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { while (!submitted && retryCount < 2) { try { // 尝试提交异步任务 - CompletableFuture.runAsync( + CompletableFuture future = CompletableFuture.supplyAsync( () -> processFileAsync(projectId, lsfxProjectId, tempFilePath, record.getId(), batchId, record), fileUploadExecutor ); + futures.add(future); submitted = true; log.info("【文件上传】任务提交成功: fileName={}, recordId={}", record.getFileName(), record.getId()); @@ -449,16 +457,24 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { Thread.currentThread().interrupt(); log.error("【文件上传】等待被中断: fileName={}", record.getFileName()); updateRecordStatus(record.getId(), "parsed_failed", "任务提交被中断"); + futures.add(CompletableFuture.completedFuture(Boolean.FALSE)); break; } } else { log.error("【文件上传】重试失败,放弃任务: fileName={}", record.getFileName()); updateRecordStatus(record.getId(), "parsed_failed", "系统繁忙,请稍后重试"); + futures.add(CompletableFuture.completedFuture(Boolean.FALSE)); } } } } + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .whenComplete((unused, throwable) -> { + boolean anySuccess = futures.stream().anyMatch(future -> Boolean.TRUE.equals(future.getNow(Boolean.FALSE))); + handleTagRebuildAfterBatchCompletion(projectId, TriggerType.AUTO_BATCH_UPLOAD, anySuccess); + }); + log.info("【文件上传】调度线程完成: projectId={}, batchId={}", projectId, batchId); } @@ -506,6 +522,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { String batchId) { log.info("【拉取本行信息】调度线程启动: projectId={}, batchId={}", projectId, batchId); + List> futures = new ArrayList<>(); + for (int i = 0; i < records.size(); i++) { if (Thread.currentThread().isInterrupted()) { log.warn("【拉取本行信息】调度线程被中断,停止提交剩余任务"); @@ -519,10 +537,11 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { while (!submitted && retryCount < 2) { try { - CompletableFuture.runAsync( + CompletableFuture future = CompletableFuture.supplyAsync( () -> processPullBankInfoAsync(projectId, lsfxProjectId, record, idCard, startDate, endDate), fileUploadExecutor ); + futures.add(future); submitted = true; log.info("【拉取本行信息】任务提交成功: idCard={}, recordId={}", idCard, record.getId()); } catch (RejectedExecutionException e) { @@ -535,23 +554,31 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { Thread.currentThread().interrupt(); log.error("【拉取本行信息】等待被中断: idCard={}", idCard); updateRecordStatus(record.getId(), "parsed_failed", "任务提交被中断"); + futures.add(CompletableFuture.completedFuture(Boolean.FALSE)); break; } } else { log.error("【拉取本行信息】重试失败,放弃任务: idCard={}", idCard); updateRecordStatus(record.getId(), "parsed_failed", "系统繁忙,请稍后重试"); + futures.add(CompletableFuture.completedFuture(Boolean.FALSE)); } } } } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .whenComplete((unused, throwable) -> { + boolean anySuccess = futures.stream().anyMatch(future -> Boolean.TRUE.equals(future.getNow(Boolean.FALSE))); + handleTagRebuildAfterBatchCompletion(projectId, TriggerType.AUTO_PULL_BANK_INFO, anySuccess); + }); } - public void processPullBankInfoAsync(Long projectId, - Integer lsfxProjectId, - CcdiFileUploadRecord record, - String idCard, - String startDate, - String endDate ) { + public boolean processPullBankInfoAsync(Long projectId, + Integer lsfxProjectId, + CcdiFileUploadRecord record, + String idCard, + String startDate, + String endDate ) { try { FetchInnerFlowRequest request = new FetchInnerFlowRequest(); request.setGroupId(lsfxProjectId); @@ -573,9 +600,11 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { } processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId); + return true; } catch (Exception e) { log.error("【拉取本行信息】处理失败: idCard={}, recordId={}", idCard, record.getId(), e); updateFailedRecord(record, e.getMessage()); + return false; } } @@ -591,8 +620,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { * @param record 文件上传记录 */ @Async("fileUploadExecutor") - public void processFileAsync(Long projectId, Integer lsfxProjectId, String tempFilePath, - Long recordId, String batchId, CcdiFileUploadRecord record) { + public boolean processFileAsync(Long projectId, Integer lsfxProjectId, String tempFilePath, + Long recordId, String batchId, CcdiFileUploadRecord record) { log.info("【文件上传】开始处理文件: fileName={}, recordId={}, tempPath={}", record.getFileName(), recordId, tempFilePath); @@ -629,10 +658,12 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { log.info("【文件上传】文件上传成功: logId={}", logId); processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId); log.info("【文件上传】处理完成: fileName={}", record.getFileName()); + return true; } catch (Exception e) { log.error("【文件上传】处理失败: fileName={}", record.getFileName(), e); updateRecordStatus(recordId, "parsed_failed", e.getMessage()); + return false; } finally { // 清理临时文件 try { @@ -647,6 +678,13 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { } } + private void handleTagRebuildAfterBatchCompletion(Long projectId, TriggerType triggerType, Boolean anySuccess) { + if (!Boolean.TRUE.equals(anySuccess)) { + return; + } + bankTagService.submitAutoRebuild(projectId, triggerType); + } + private void processRecordAfterLogIdReady(Long projectId, Integer lsfxProjectId, CcdiFileUploadRecord record, diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java index e744f20b..157923b4 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java @@ -5,11 +5,13 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import com.alibaba.excel.EasyExcel; import com.ruoyi.ccdi.project.domain.CcdiProject; +import com.ruoyi.ccdi.project.domain.enums.TriggerType; import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO; import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord; import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper; import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper; +import com.ruoyi.ccdi.project.service.ICcdiBankTagService; import com.ruoyi.lsfx.client.LsfxAnalysisClient; import com.ruoyi.lsfx.domain.request.GetBankStatementRequest; import com.ruoyi.lsfx.domain.response.CheckParseStatusResponse; @@ -84,6 +86,9 @@ class CcdiFileUploadServiceImplTest { @Mock private Executor fileUploadExecutor; + @Mock + private ICcdiBankTagService bankTagService; + @TempDir Path tempDir; @@ -488,6 +493,32 @@ class CcdiFileUploadServiceImplTest { assertEquals(3L, result.getTotal()); } + @Test + void batchUploadCompletion_shouldSubmitProjectTagRebuildWhenAnyFileSucceeded() { + ReflectionTestUtils.invokeMethod( + service, + "handleTagRebuildAfterBatchCompletion", + PROJECT_ID, + TriggerType.AUTO_BATCH_UPLOAD, + Boolean.TRUE + ); + + verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_BATCH_UPLOAD); + } + + @Test + void pullBankInfoCompletion_shouldSubmitProjectTagRebuildWhenAnyTaskSucceeded() { + ReflectionTestUtils.invokeMethod( + service, + "handleTagRebuildAfterBatchCompletion", + PROJECT_ID, + TriggerType.AUTO_PULL_BANK_INFO, + Boolean.TRUE + ); + + verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_PULL_BANK_INFO); + } + private void captureRecordStatus(List events, AtomicInteger sequence) { doAnswer(invocation -> { CcdiFileUploadRecord record = invocation.getArgument(0); diff --git a/docs/plans/2026-03-16-project-bank-statement-tagging-backend-implementation.md b/docs/plans/2026-03-16-project-bank-statement-tagging-backend-implementation.md new file mode 100644 index 00000000..8d512f4b --- /dev/null +++ b/docs/plans/2026-03-16-project-bank-statement-tagging-backend-implementation.md @@ -0,0 +1,760 @@ +# Project Bank Statement Tagging Backend Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 为项目流水新增自动打标与手动重算后端能力,支持批量上传和拉取本行信息两条链路自动触发,支持项目级互斥重算与规则级并行执行。 + +**Architecture:** 在 `ccdi-project` 中新增标签规则表、结果表、任务表及对应 Mapper;引入项目级重算协调器和规则级线程池;在 `CcdiFileUploadServiceImpl` 的批量上传与拉取本行信息收尾阶段统一申请项目级标签重算;通过独立的标签服务读取规则元数据、参数配置,调度 Mapper XML 中的规则 SQL 并写入结果表。 + +**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, MyBatis XML, JUnit 5, Mockito, Maven + +--- + +### Task 1: 定义手动重算接口契约 + +**Files:** +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiBankTagRebuildDTO.java` +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankTagController.java` +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiBankTagService.java` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiBankTagControllerTest.java` + +**Step 1: Write the failing test** + +新增控制器测试,验证接口会把 `projectId + modelCode` 透传到 Service: + +```java +@Test +void rebuild_shouldDelegateProjectAndModelCode() { + CcdiBankTagRebuildDTO dto = new CcdiBankTagRebuildDTO(); + dto.setProjectId(40L); + dto.setModelCode("LARGE_TRANSACTION"); + + when(bankTagService.submitRebuild(dto, "admin")).thenReturn("标签重算任务已提交"); + + try (MockedStatic mocked = mockStatic(SecurityUtils.class)) { + mocked.when(SecurityUtils::getUsername).thenReturn("admin"); + + AjaxResult result = controller.rebuild(dto); + + assertEquals(200, result.get("code")); + verify(bankTagService).submitRebuild(dto, "admin"); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagControllerTest#rebuild_shouldDelegateProjectAndModelCode +``` + +Expected: + +- `FAIL` +- 原因是 DTO、Controller 或 Service 契约尚不存在 + +**Step 3: Write minimal implementation** + +补最小接口: + +```java +public interface ICcdiBankTagService { + String submitRebuild(CcdiBankTagRebuildDTO dto, String operator); +} +``` + +```java +@PostMapping("/rebuild") +public AjaxResult rebuild(@Validated @RequestBody CcdiBankTagRebuildDTO dto) { + String operator = SecurityUtils.getUsername(); + return AjaxResult.success(bankTagService.submitRebuild(dto, operator)); +} +``` + +**Step 4: Run test to verify it passes** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagControllerTest#rebuild_shouldDelegateProjectAndModelCode +``` + +Expected: + +- `PASS` + +**Step 5: Commit** + +```bash +git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiBankTagRebuildDTO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiBankTagController.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiBankTagService.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiBankTagControllerTest.java +git commit -m "test: 补充流水标签重算接口契约" +``` + +### Task 2: 新增标签核心表结构与实体映射 + +**Files:** +- Create: `sql/2026-03-16-bank-tagging.sql` +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagRule.java` +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagResult.java` +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagTask.java` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagEntityMappingTest.java` + +**Step 1: Write the failing test** + +新增一个轻量测试,校验结果实体的关键字段存在: + +```java +@Test +void bankTagResult_shouldExposeStatementAndObjectFields() { + CcdiBankTagResult result = new CcdiBankTagResult(); + result.setProjectId(40L); + result.setRuleCode("RULE_1"); + result.setBankStatementId(10L); + result.setGroupId(40); + result.setLogId(40001); + result.setObjectType("STAFF_ID_CARD"); + result.setObjectKey("330101198801010011"); + + assertEquals(40L, result.getProjectId()); + assertEquals(40001, result.getLogId()); + assertEquals("STAFF_ID_CARD", result.getObjectType()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagEntityMappingTest#bankTagResult_shouldExposeStatementAndObjectFields +``` + +Expected: + +- `FAIL` +- 原因是新实体尚不存在 + +**Step 3: Write minimal implementation** + +- 新建三张表的 SQL 脚本 +- 新建三个实体类,字段只覆盖第一版设计所需字段 +- 规则表初始化“大额交易” 8 条规则元数据 + +**Step 4: Run test to verify it passes** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagEntityMappingTest#bankTagResult_shouldExposeStatementAndObjectFields +``` + +Expected: + +- `PASS` + +**Step 5: Commit** + +```bash +git add sql/2026-03-16-bank-tagging.sql ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagRule.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagResult.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagTask.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/domain/entity/CcdiBankTagEntityMappingTest.java +git commit -m "feat: 新增流水标签核心表结构与实体映射" +``` + +### Task 3: 建立规则元数据与结果表 Mapper + +**Files:** +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagRuleMapper.java` +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapper.java` +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagTaskMapper.java` +- Create: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagRuleMapper.xml` +- Create: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml` +- Create: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagTaskMapper.xml` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapperXmlTest.java` + +**Step 1: Write the failing test** + +新增 XML 渲染测试,校验结果表支持按项目或项目+模型删除: + +```java +@Test +void deleteByProjectAndOptionalModel_shouldRenderScopedDelete() throws Exception { + String xml = readXml("mapper/ccdi/project/CcdiBankTagResultMapper.xml"); + assertTrue(xml.contains("delete from ccdi_bank_statement_tag_result")); + assertTrue(xml.contains("project_id = #{projectId}")); + assertTrue(xml.contains("model_code = #{modelCode}")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagResultMapperXmlTest#deleteByProjectAndOptionalModel_shouldRenderScopedDelete +``` + +Expected: + +- `FAIL` +- 原因是 Mapper XML 尚不存在 + +**Step 3: Write minimal implementation** + +- 规则表 Mapper 提供启用规则查询 +- 结果表 Mapper 提供批量插入和按范围删除 +- 任务表 Mapper 提供创建任务、更新任务、查询运行中任务 + +**Step 4: Run test to verify it passes** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagResultMapperXmlTest#deleteByProjectAndOptionalModel_shouldRenderScopedDelete +``` + +Expected: + +- `PASS` + +**Step 5: Commit** + +```bash +git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagRuleMapper.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapper.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagTaskMapper.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagRuleMapper.xml ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagResultMapper.xml ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagTaskMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagResultMapperXmlTest.java +git commit -m "feat: 新增流水标签规则结果任务Mapper" +``` + +### Task 4: 为规则 SQL 定义统一返回 VO + +**Files:** +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagStatementHitVO.java` +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagObjectHitVO.java` +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java` +- Create: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java` + +**Step 1: Write the failing test** + +新增 XML 测试,先校验流水级命中 SQL 会返回 `group_id` 和 `log_id`: + +```java +@Test +void statementRuleSql_shouldSelectGroupIdAndLogId() throws Exception { + String xml = readXml("mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml"); + assertTrue(xml.contains("AS groupId")); + assertTrue(xml.contains("AS logId")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest#statementRuleSql_shouldSelectGroupIdAndLogId +``` + +Expected: + +- `FAIL` + +**Step 3: Write minimal implementation** + +先定义统一 VO 和一个最小的 XML 框架,确保: + +- 流水级规则 SQL 返回 `bankStatementId`、`groupId`、`logId`、`reasonDetail` +- 对象级规则 SQL 返回 `objectType`、`objectKey`、`reasonDetail` + +**Step 4: Run test to verify it passes** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest#statementRuleSql_shouldSelectGroupIdAndLogId +``` + +Expected: + +- `PASS` + +**Step 5: Commit** + +```bash +git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagStatementHitVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagObjectHitVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapper.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java +git commit -m "feat: 定义流水标签规则命中返回结构" +``` + +### Task 5: 先实现一条流水级规则的失败测试与最小通过 + +**Files:** +- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java` + +**Step 1: Write the failing test** + +以“房车消费支出交易”为第一条规则,测试 XML 中存在 `selectHouseOrCarExpenseStatements`: + +```java +@Test +void houseOrCarExpenseRule_shouldJoinBankStatementAndReturnStatementHitFields() throws Exception { + String xml = readXml("mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml"); + assertTrue(xml.contains("selectHouseOrCarExpenseStatements")); + assertTrue(xml.contains("bs.bank_statement_id AS bankStatementId")); + assertTrue(xml.contains("bs.group_id AS groupId")); + assertTrue(xml.contains("bs.batch_id AS logId")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest#houseOrCarExpenseRule_shouldJoinBankStatementAndReturnStatementHitFields +``` + +Expected: + +- `FAIL` + +**Step 3: Write minimal implementation** + +在 XML 中补第一条规则 SQL,并返回固定原因摘要。 + +**Step 4: Run test to verify it passes** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest#houseOrCarExpenseRule_shouldJoinBankStatementAndReturnStatementHitFields +``` + +Expected: + +- `PASS` + +**Step 5: Commit** + +```bash +git add ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java +git commit -m "test: 打通首条流水标签规则SQL" +``` + +### Task 6: 完成剩余 7 条规则 SQL + +**Files:** +- Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java` + +**Step 1: Write the failing tests** + +为剩余规则补 XML 存在性断言,分别覆盖: + +- `selectTaxExpenseStatements` +- `selectSingleLargeIncomeStatements` +- `selectCumulativeIncomeObjects` +- `selectAnnualTurnoverObjects` +- `selectLargeCashDepositStatements` +- `selectFrequentCashDepositObjects` +- `selectLargeTransferStatements` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest +``` + +Expected: + +- `FAIL` + +**Step 3: Write minimal implementation** + +补齐 7 条规则 SQL,要求: + +- 阈值类规则使用入参,不在 XML 中写死参数值 +- 流水级规则返回 `bankStatementId/groupId/logId/reasonDetail` +- 对象级规则返回 `objectType/objectKey/reasonDetail` + +**Step 4: Run test to verify it passes** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagAnalysisMapperXmlTest +``` + +Expected: + +- `PASS` + +**Step 5: Commit** + +```bash +git add ccdi-project/src/main/resources/mapper/ccdi/project/CcdiBankTagAnalysisMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/mapper/CcdiBankTagAnalysisMapperXmlTest.java +git commit -m "feat: 补齐大额交易全部标签规则SQL" +``` + +### Task 7: 实现项目参数读取与规则注册表 + +**Files:** +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagRuleExecutionConfig.java` +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java` + +**Step 1: Write the failing test** + +新增测试,验证 resolver 会根据项目读取有效参数,并为规则产出执行配置: + +```java +@Test +void resolve_shouldReadEffectiveProjectParamsForThresholdRules() { + BankTagRuleExecutionConfig config = resolver.resolve(40L, ruleMeta); + assertEquals("1111", config.getThresholdValue("SINGLE_TRANSACTION_AMOUNT")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=BankTagRuleConfigResolverTest#resolve_shouldReadEffectiveProjectParamsForThresholdRules +``` + +Expected: + +- `FAIL` + +**Step 3: Write minimal implementation** + +实现 resolver: + +- 读取项目 `configType` +- 选择有效 `projectId` +- 读取模型参数 +- 按 `ruleCode` 组装执行配置 + +**Step 4: Run test to verify it passes** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=BankTagRuleConfigResolverTest#resolve_shouldReadEffectiveProjectParamsForThresholdRules +``` + +Expected: + +- `PASS` + +**Step 5: Commit** + +```bash +git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/BankTagRuleExecutionConfig.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolver.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/BankTagRuleConfigResolverTest.java +git commit -m "feat: 新增流水标签规则执行参数解析器" +``` + +### Task 8: 实现规则级并行执行服务 + +**Files:** +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/config/BankTagThreadPoolConfig.java` +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java` + +**Step 1: Write the failing test** + +新增服务测试,验证项目级任务会先删旧结果,再并行执行规则并汇总命中数: + +```java +@Test +void rebuildProject_shouldDeleteOldResultsBeforeSubmittingRuleTasks() { + service.rebuildProject(40L, null, "admin", TriggerType.MANUAL); + verify(resultMapper).deleteByProjectAndModel(40L, null); + verify(resultMapper).insertBatch(anyList()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagServiceImplTest#rebuildProject_shouldDeleteOldResultsBeforeSubmittingRuleTasks +``` + +Expected: + +- `FAIL` + +**Step 3: Write minimal implementation** + +实现 `CcdiBankTagServiceImpl`: + +- 查询启用规则 +- 删旧结果 +- 使用 `tagRuleExecutor` 并行提交每条规则 +- 汇总命中结果 +- 更新任务状态 + +**Step 4: Run test to verify it passes** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagServiceImplTest#rebuildProject_shouldDeleteOldResultsBeforeSubmittingRuleTasks +``` + +Expected: + +- `PASS` + +**Step 5: Commit** + +```bash +git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/config/BankTagThreadPoolConfig.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImplTest.java +git commit -m "feat: 实现规则级并行流水标签重算服务" +``` + +### Task 9: 实现同项目互斥与自动补跑协调器 + +**Files:** +- Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/ProjectBankTagRebuildCoordinator.java` +- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/ProjectBankTagRebuildCoordinatorTest.java` + +**Step 1: Write the failing test** + +新增测试,验证同一项目运行中时: + +- 手动触发会被拒绝 +- 自动触发会标记 `need_rerun` + +```java +@Test +void submitManualRebuild_shouldRejectWhenProjectAlreadyRunning() { + assertThrows(ServiceException.class, () -> coordinator.submitManual(40L, null, "admin")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=ProjectBankTagRebuildCoordinatorTest +``` + +Expected: + +- `FAIL` + +**Step 3: Write minimal implementation** + +实现协调器: + +- 使用 `projectId` 级别互斥 +- 自动触发遇到运行中任务时打 `need_rerun` +- 当前任务结束后根据 `need_rerun` 触发补跑 + +**Step 4: Run test to verify it passes** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=ProjectBankTagRebuildCoordinatorTest +``` + +Expected: + +- `PASS` + +**Step 5: Commit** + +```bash +git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/ProjectBankTagRebuildCoordinator.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiBankTagServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/ProjectBankTagRebuildCoordinatorTest.java +git commit -m "feat: 新增项目级流水标签重算互斥与补跑协调器" +``` + +### Task 10: 接入批量上传收尾自动触发 + +**Files:** +- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` + +**Step 1: Write the failing test** + +新增测试,验证批量上传所有文件完成后会申请项目级重算: + +```java +@Test +void batchUploadCompletion_shouldSubmitProjectTagRebuildWhenAnyFileSucceeded() { + verify(bankTagService).submitAutoRebuild(40L, TriggerType.AUTO_BATCH_UPLOAD); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#batchUploadCompletion_shouldSubmitProjectTagRebuildWhenAnyFileSucceeded +``` + +Expected: + +- `FAIL` + +**Step 3: Write minimal implementation** + +重构 `submitTasksAsync`: + +- 收集每个文件任务的 `CompletableFuture` +- `allOf(...).whenComplete(...)` 收尾 +- 至少一个文件成功落库时申请自动重算 + +**Step 4: Run test to verify it passes** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#batchUploadCompletion_shouldSubmitProjectTagRebuildWhenAnyFileSucceeded +``` + +Expected: + +- `PASS` + +**Step 5: 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 "feat: 接入批量上传完成后的自动流水打标" +``` + +### Task 11: 接入拉取本行信息收尾自动触发 + +**Files:** +- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java` +- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` + +**Step 1: Write the failing test** + +新增测试,验证拉取本行信息全部任务完成后也会申请项目级重算: + +```java +@Test +void pullBankInfoCompletion_shouldSubmitProjectTagRebuildWhenAnyTaskSucceeded() { + verify(bankTagService).submitAutoRebuild(40L, TriggerType.AUTO_PULL_BANK_INFO); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#pullBankInfoCompletion_shouldSubmitProjectTagRebuildWhenAnyTaskSucceeded +``` + +Expected: + +- `FAIL` + +**Step 3: Write minimal implementation** + +仿照批量上传链路改造 `submitPullBankInfoTasks`,在全部任务结束后申请一次自动重算。 + +**Step 4: Run test to verify it passes** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#pullBankInfoCompletion_shouldSubmitProjectTagRebuildWhenAnyTaskSucceeded +``` + +Expected: + +- `PASS` + +**Step 5: 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 "feat: 接入拉取本行信息完成后的自动流水打标" +``` + +### Task 12: 完成全量验证 + +**Files:** +- Modify: `docs/plans/2026-03-16-project-bank-statement-tagging-backend-implementation.md` + +**Step 1: Run focused backend tests** + +Run: + +```bash +mvn test -pl ccdi-project -am -Dtest=CcdiBankTagControllerTest,CcdiBankTagEntityMappingTest,CcdiBankTagResultMapperXmlTest,CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,ProjectBankTagRebuildCoordinatorTest,CcdiFileUploadServiceImplTest +``` + +Expected: + +- 所有新增测试通过 + +**Step 2: Run module compile** + +Run: + +```bash +mvn clean compile -pl ccdi-project -am +``` + +Expected: + +- `BUILD SUCCESS` + +**Step 3: Update verification notes** + +在本实施计划末尾补充实际执行结果摘要和任何遗留风险。 + +**Step 4: Commit** + +```bash +git add ccdi-project sql docs/plans/2026-03-16-project-bank-statement-tagging-backend-implementation.md +git commit -m "feat: 完成项目流水标签后端实现" +``` + +--- + +## 实际执行结果 + +### 已完成范围 + +- 已新增手动重算接口、标签规则/结果/任务三张核心表及实体映射 +- 已新增规则、结果、任务、分析四类 Mapper 与 XML +- 已完成“大额交易” 8 条规则 SQL 的首版落地 +- 已完成规则参数解析器、规则级线程池、规则级并行重算服务 +- 已完成项目级互斥协调器与自动触发补跑标记逻辑 +- 已接入批量上传与拉取本行信息两条链路的批次收尾自动触发 + +### 实际验证命令 + +在 `ccdi-project` 模块目录执行: + +```bash +mvn test "-Dtest=CcdiBankTagControllerTest,CcdiBankTagEntityMappingTest,CcdiBankTagResultMapperXmlTest,CcdiBankTagAnalysisMapperXmlTest,BankTagRuleConfigResolverTest,CcdiBankTagServiceImplTest,ProjectBankTagRebuildCoordinatorTest,CcdiFileUploadServiceImplTest" +mvn clean compile +``` + +### 实际验证结果 + +- 聚焦测试集:`BUILD SUCCESS` +- `ccdi-project` 模块编译:`BUILD SUCCESS` +- 聚焦测试共执行 30 个测试,0 失败,0 错误 + +### 遗留风险 + +- 当前规则 SQL 已按设计稿和现有表结构落地,但尚未补集成测试验证真实数据库数据命中情况 +- 自动补跑已支持 `need_rerun` 标记与串行补跑,后续建议增加更完整的并发场景回归测试 +- 当前实施仅完成后端能力,结果查询接口与前端展示仍未接入