# 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<String> 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<String> parseIdCardFile(MultipartFile file);
```

创建 `CcdiIdCardExcelRow`：

```java
@Data
public class CcdiIdCardExcelRow {

    @ExcelProperty(index = 0)
    private String idCard;
}
```

在 `CcdiFileUploadServiceImpl` 中用 EasyExcel 实现最小可用解析逻辑：

```java
List<CcdiIdCardExcelRow> 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<List<CcdiFileUploadRecord>> inserted = new AtomicReference<>();
    doAnswer(invocation -> {
        List<CcdiFileUploadRecord> 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<String> 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<SecurityUtils> 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<String> 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 "完成拉取本行信息后端实现与校验"
```
