# Project Detail Pull Bank Info Backend Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build the backend parse-and-pull workflow for the project detail “拉取本行信息” modal, including ID-card Excel parsing, task submission, thread-pool scheduling, file-upload record updates, and bank-statement ingestion reuse. **Architecture:** Extend the existing `CcdiFileUploadController` and `CcdiFileUploadServiceImpl` instead of creating a second task subsystem. Parse身份证文件 on demand, persist one `ccdi_file_upload_record` per身份证 with `accountNos` initialized to the身份证号, then reuse the existing `fileUploadExecutor` plus a shared “logId ready” pipeline to update file name, polling status, and bank statements consistently for both uploaded files and pulled inner-flow tasks. **Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, MyBatis XML, EasyExcel 3.3.4, JUnit 5, Mockito --- ### Task 1: Add parse/submit contracts and write the first failing parse test **Files:** - Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiPullBankInfoSubmitDTO.java` - Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiIdCardParseVO.java` - Create: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiIdCardExcelRow.java` - Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java` - Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` **Step 1: Write the failing test** 在 `CcdiFileUploadServiceImplTest` 中新增身份证文件解析测试,验证“首个 sheet 第一列、忽略表头、空行、重复值”: ```java @Test void parseIdCardFile_shouldReadFirstSheetFirstColumnAndDeduplicate() throws Exception { MultipartFile file = createIdCardExcel( "身份证号", "110101199001018888", "", "110101199001018888", "110101199001019999" ); List result = service.parseIdCardFile(file); assertEquals(List.of("110101199001018888", "110101199001019999"), result); } ``` 再补一个非法身份证失败测试: ```java @Test void parseIdCardFile_shouldRejectInvalidIdCard() throws Exception { MultipartFile file = createIdCardExcel("身份证号", "123456"); RuntimeException exception = assertThrows(RuntimeException.class, () -> service.parseIdCardFile(file)); assertTrue(exception.getMessage().contains("身份证")); } ``` **Step 2: Run test to verify it fails** Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest` Expected: FAIL because `parseIdCardFile` and the new DTO / VO / Excel row model do not exist yet. **Step 3: Write minimal implementation** 在 `ICcdiFileUploadService` 中新增方法: ```java List parseIdCardFile(MultipartFile file); ``` 创建 `CcdiIdCardExcelRow`: ```java @Data public class CcdiIdCardExcelRow { @ExcelProperty(index = 0) private String idCard; } ``` 在 `CcdiFileUploadServiceImpl` 中用 EasyExcel 实现最小可用解析逻辑: ```java List rows = EasyExcel.read(file.getInputStream(), CcdiIdCardExcelRow.class) .sheet(0) .headRowNumber(1) .doReadSync(); ``` 然后: - `trim()` 去空白 - 过滤空值 - 用 `LinkedHashSet` 去重保序 - 使用 18 位身份证正则校验 - 当无有效身份证时抛出 `RuntimeException("首个sheet第一列未解析到有效身份证号")` **Step 4: Run test to verify it passes** Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest` Expected: PASS for the new parse tests; existing tests remain green. **Step 5: Commit** ```bash git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/dto/CcdiPullBankInfoSubmitDTO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiIdCardParseVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/excel/CcdiIdCardExcelRow.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java git commit -m "新增拉取本行信息参数模型与身份证解析能力" ``` ### Task 2: Add pull-bank-info submission logic and make record initialization fail first **Files:** - Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java` - Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java` - Modify: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml` - Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` **Step 1: Write the failing test** 新增“提交拉取任务时先插入上传记录”的测试,验证 `accountNos` 和 `uploadUser` 初始化正确: ```java @Test void submitPullBankInfo_shouldInsertUploadingRecordsWithIdCardAsAccountNo() { CcdiProject project = new CcdiProject(); project.setProjectId(PROJECT_ID); project.setLsfxProjectId(LSFX_PROJECT_ID); when(projectMapper.selectById(PROJECT_ID)).thenReturn(project); AtomicReference> inserted = new AtomicReference<>(); doAnswer(invocation -> { List records = invocation.getArgument(0); for (int i = 0; i < records.size(); i++) { records.get(i).setId((long) (i + 1)); } inserted.set(new ArrayList<>(records)); return records.size(); }).when(recordMapper).insertBatch(any()); TransactionSynchronizationManager.initSynchronization(); try { String batchId = service.submitPullBankInfo( PROJECT_ID, List.of("110101199001018888", "110101199001019999"), "2026-03-01", "2026-03-10", 9527L, "admin" ); assertNotNull(batchId); assertEquals("110101199001018888", inserted.get().get(0).getAccountNos()); assertEquals("admin", inserted.get().get(0).getUploadUser()); assertEquals("uploading", inserted.get().get(0).getFileStatus()); assertEquals(1, TransactionSynchronizationManager.getSynchronizations().size()); } finally { TransactionSynchronizationManager.clearSynchronization(); } } ``` **Step 2: Run test to verify it fails** Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest` Expected: FAIL because `submitPullBankInfo` does not exist and `insertBatch` SQL does not persist `accountNos`. **Step 3: Write minimal implementation** 在 `ICcdiFileUploadService` 中新增方法: ```java String submitPullBankInfo(Long projectId, List idCards, String startDate, String endDate, Long userId, String username); ``` 在 `CcdiFileUploadServiceImpl` 中实现: - 校验项目存在且带有 `lsfxProjectId` - 校验日期非空、开始日期不大于结束日期 - 校验身份证集合非空 - 为每个身份证创建一条 `CcdiFileUploadRecord` - `record.setAccountNos(idCard);` - `record.setFileName(idCard);` - `record.setUploadUser(username);` - `record.setFileStatus("uploading");` - `record.setUploadTime(new Date());` 在 `CcdiFileUploadRecordMapper.xml` 的 `insertBatch` 中补上 `account_nos`: ```xml insert into ccdi_file_upload_record ( project_id, lsfx_project_id, file_name, file_size, file_status, enterprise_names, account_nos, upload_time, upload_user ) ``` 注册事务提交后的异步调度: ```java TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { CompletableFuture.runAsync(() -> submitPullBankInfoTasks(...)); } }); ``` **Step 4: Run test to verify it passes** Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest` Expected: PASS for the new submission test and no regression in existing upload tests. **Step 5: Commit** ```bash git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java git commit -m "实现拉取本行信息任务提交与记录初始化" ``` ### Task 3: Refactor the shared logId pipeline and add the first failing pull-flow test **Files:** - Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java` - Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` **Step 1: Write the failing test** 新增“拉取行内流水拿到 `logId` 后复用公共流水线”的测试,先验证文件名回写和最终成功: ```java @Test void processPullBankInfoAsync_shouldUpdateFileNameFromStatusResponse() { when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID)); when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse("XX身份证.xlsx")); when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) .thenReturn(buildEmptyBankStatementResponse()); CcdiFileUploadRecord record = buildRecord(); service.processPullBankInfoAsync( PROJECT_ID, LSFX_PROJECT_ID, record, "110101199001018888", "2026-03-01", "2026-03-10", 9527L ); verify(recordMapper, atLeastOnce()).updateById(argThat(item -> "XX身份证.xlsx".equals(item.getFileName()))); verify(recordMapper, atLeastOnce()).updateById(argThat(item -> "parsed_success".equals(item.getFileStatus()))); } ``` 再补一个失败测试,验证 `fetchInnerFlow` 异常只影响当前记录: ```java @Test void processPullBankInfoAsync_shouldMarkParsedFailedWhenFetchInnerFlowThrows() { when(lsfxClient.fetchInnerFlow(any())).thenThrow(new RuntimeException("fetch inner flow failed")); CcdiFileUploadRecord record = buildRecord(); service.processPullBankInfoAsync( PROJECT_ID, LSFX_PROJECT_ID, record, "110101199001018888", "2026-03-01", "2026-03-10", 9527L ); verify(recordMapper, atLeastOnce()).updateById(argThat(item -> "parsed_failed".equals(item.getFileStatus()))); } ``` **Step 2: Run test to verify it fails** Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest` Expected: FAIL because `processPullBankInfoAsync` and the shared “logId ready” pipeline do not exist yet. **Step 3: Write minimal implementation** 在 `CcdiFileUploadServiceImpl` 中拆分两段逻辑: 1. 文件来源阶段 - `processFileAsync(...)` 中只负责上传文件并拿到 `logId` - `processPullBankInfoAsync(...)` 中只负责调用 `fetchInnerFlow` 并拿到 `logId` 2. 公共处理阶段 - 新增 `processRecordAfterLogIdReady(...)` 公共处理方法至少负责: ```java private void processRecordAfterLogIdReady(Long projectId, Integer lsfxProjectId, CcdiFileUploadRecord record, Integer logId) { record.setLogId(logId); record.setFileStatus("parsing"); recordMapper.updateById(record); boolean parsingComplete = waitForParsingComplete(lsfxProjectId, logId.toString()); GetFileUploadStatusResponse statusResponse = lsfxClient.getFileUploadStatus(statusRequest); String fileName = StringUtils.hasText(logItem.getUploadFileName()) ? logItem.getUploadFileName() : logItem.getDownloadFileName(); record.setFileName(fileName); ... } ``` `processPullBankInfoAsync(...)` 最小实现: ```java FetchInnerFlowRequest request = new FetchInnerFlowRequest(); request.setGroupId(lsfxProjectId); request.setCustomerNo(idCard); request.setDataChannelCode("ZJRCU"); request.setRequestDateId(Integer.parseInt(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE))); request.setDataStartDateId(Integer.parseInt(startDate.replace("-", ""))); request.setDataEndDateId(Integer.parseInt(endDate.replace("-", ""))); request.setUploadUserId(userId.intValue()); ``` 从 `FetchInnerFlowResponse.getData()` 里取第一个 `logId` 后进入公共处理方法。 **Step 4: Run test to verify it passes** Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest` Expected: PASS for the new pull-flow tests and the existing file-upload tests. **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 "抽取logId后公共处理流水线并接入本行拉取" ``` ### Task 4: Add controller endpoints and fail on controller tests first **Files:** - Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java` - Create: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java` **Step 1: Write the failing test** 为解析接口和提交接口各写一个控制器测试: ```java @Test void parseIdCardFile_shouldReturnAjaxResultSuccess() { MockMultipartFile file = new MockMultipartFile( "file", "ids.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "test".getBytes(StandardCharsets.UTF_8) ); when(fileUploadService.parseIdCardFile(file)).thenReturn(List.of("110101199001018888")); AjaxResult result = controller.parseIdCardFile(file); assertEquals(200, result.get("code")); } ``` ```java @Test void pullBankInfo_shouldUseCurrentLoginUserInfo() { CcdiPullBankInfoSubmitDTO dto = new CcdiPullBankInfoSubmitDTO(); dto.setProjectId(PROJECT_ID); dto.setIdCards(List.of("110101199001018888")); dto.setStartDate("2026-03-01"); dto.setEndDate("2026-03-10"); try (MockedStatic mocked = mockStatic(SecurityUtils.class)) { mocked.when(SecurityUtils::getUserId).thenReturn(9527L); mocked.when(SecurityUtils::getUserName).thenReturn("admin"); when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "2026-03-01", "2026-03-10", 9527L, "admin")) .thenReturn("batch-1"); AjaxResult result = controller.pullBankInfo(dto); assertEquals(200, result.get("code")); } } ``` **Step 2: Run test to verify it fails** Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadControllerTest` Expected: FAIL because the new controller methods do not exist yet. **Step 3: Write minimal implementation** 在 `CcdiFileUploadController` 中新增接口: ```java @PostMapping("/parse-id-card-file") public AjaxResult parseIdCardFile(@RequestParam MultipartFile file) { List idCards = fileUploadService.parseIdCardFile(file); return AjaxResult.success("解析成功", new CcdiIdCardParseVO(idCards, idCards.size())); } ``` ```java @PostMapping("/pull-bank-info") public AjaxResult pullBankInfo(@RequestBody CcdiPullBankInfoSubmitDTO dto) { Long userId = SecurityUtils.getUserId(); String username = SecurityUtils.getUserName(); String batchId = fileUploadService.submitPullBankInfo( dto.getProjectId(), dto.getIdCards(), dto.getStartDate(), dto.getEndDate(), userId, username ); return AjaxResult.success("拉取任务已提交", batchId); } ``` 补上参数校验和 Swagger 注释。 **Step 4: Run test to verify it passes** Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadControllerTest` Expected: PASS **Step 5: Commit** ```bash git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java git commit -m "新增拉取本行信息后端接口" ``` ### Task 5: Verify the backend end-to-end inside the module **Files:** - Modify if needed after failures: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java` - Modify if needed after failures: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java` - Modify if needed after failures: `ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml` - Modify if needed after failures: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` - Modify if needed after failures: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java` **Step 1: Run focused backend tests** Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest,CcdiFileUploadControllerTest` Expected: PASS **Step 2: Run module compile** Run: `mvn clean compile -pl ccdi-project -am` Expected: BUILD SUCCESS **Step 3: Run the existing upload regression tests** Run: `mvn test -pl ccdi-project -Dtest=CcdiFileUploadServiceImplTest` Expected: PASS with no regression on file-upload flow, no MyBatis binding error, and no missing mapper statements. **Step 4: Fix the smallest failing point if verification breaks** 优先排查: - `insertBatch` 的 XML 列顺序与实体字段不一致 - `uploadFileName` / `downloadFileName` 回写逻辑遗漏空值判断 - `FetchInnerFlowResponse` 取 `logId` 时未处理空列表 - `Long userId` 转 `Integer uploadUserId` 时的空值或溢出保护 **Step 5: Commit** ```bash git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/main/resources/mapper/ccdi/project/CcdiFileUploadRecordMapper.xml ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java git commit -m "完成拉取本行信息后端实现与校验" ```