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 c6edf39c..cca5f959 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 @@ -659,7 +659,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { throw new RuntimeException("临时文件不存在: " + tempFilePath); } - UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file); + UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file, record.getFileName()); if (uploadResponse == null || uploadResponse.getData() == null || uploadResponse.getData().getUploadLogList() == null || uploadResponse.getData().getUploadLogList().isEmpty()) { @@ -673,7 +673,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { } log.info("【文件上传】文件上传成功: logId={}", logId); - processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId); + processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, true); log.info("【文件上传】处理完成: fileName={}", record.getFileName()); return true; @@ -710,6 +710,14 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { Integer lsfxProjectId, CcdiFileUploadRecord record, Integer logId) { + processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false); + } + + private void processRecordAfterLogIdReady(Long projectId, + Integer lsfxProjectId, + CcdiFileUploadRecord record, + Integer logId, + boolean preserveRecordFileName) { log.info("【文件上传】步骤3: 更新状态为解析中, logId={}", logId); record.setLogId(logId); record.setFileStatus("parsing"); @@ -736,11 +744,13 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0); Integer status = logItem.getStatus(); String uploadStatusDesc = logItem.getUploadStatusDesc(); - String fileName = StringUtils.hasText(logItem.getUploadFileName()) - ? logItem.getUploadFileName() - : logItem.getDownloadFileName(); - if (StringUtils.hasText(fileName)) { - record.setFileName(fileName); + if (!preserveRecordFileName) { + String fileName = StringUtils.hasText(logItem.getUploadFileName()) + ? logItem.getUploadFileName() + : logItem.getDownloadFileName(); + if (StringUtils.hasText(fileName)) { + record.setFileName(fileName); + } } if (logItem.getFileSize() != null) { record.setFileSize(logItem.getFileSize()); 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 cf1d0741..50259689 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 @@ -265,7 +265,8 @@ class CcdiFileUploadServiceImplTest { AtomicInteger sequence = new AtomicInteger(); captureRecordStatus(events, sequence); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); @@ -291,7 +292,8 @@ class CcdiFileUploadServiceImplTest { when(projectMapper.selectById(PROJECT_ID)).thenReturn(project); when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); @@ -317,7 +319,8 @@ class CcdiFileUploadServiceImplTest { @Test void processFileAsync_shouldCleanupInsertedStatementsWhenFetchFails() throws IOException { - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); @@ -364,6 +367,77 @@ class CcdiFileUploadServiceImplTest { ); } + @Test + void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException { + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx"))) + .thenReturn(buildUploadResponse()); + when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + .thenReturn(buildCheckParseStatusResponse(false)); + when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + .thenReturn(buildEmptyBankStatementResponse()); + + CcdiFileUploadRecord record = buildRecord(); + record.setFileName("原始流水.xlsx"); + Path tempFile = createTempFile(); + + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + + verify(lsfxClient).uploadFile(eq(LSFX_PROJECT_ID), argThat(file -> + file.getName().startsWith("upload-") && file.getName().endsWith(".xlsx") + ), eq("原始流水.xlsx")); + } + + @Test + void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException { + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); + when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + .thenReturn(buildCheckParseStatusResponse(false)); + when(lsfxClient.getFileUploadStatus(any())) + .thenReturn(buildParsedSuccessStatusResponse("平台返回文件名.xlsx")); + when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + .thenReturn(buildEmptyBankStatementResponse()); + + CcdiFileUploadRecord record = buildRecord(); + record.setFileName("原始流水.xlsx"); + Path tempFile = createTempFile(); + + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + + verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById( + org.mockito.ArgumentMatchers.argThat(item -> + "parsed_success".equals(item.getFileStatus()) + && "原始流水.xlsx".equals(item.getFileName())) + ); + } + + @Test + void processFileAsync_shouldKeepOriginalFileNameWhenParseStatusFails() throws IOException { + GetFileUploadStatusResponse statusResponse = buildParsedSuccessStatusResponse("平台失败文件名.xlsx"); + GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0); + logItem.setStatus(-1); + logItem.setUploadStatusDesc("parse.failed"); + + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); + when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + .thenReturn(buildCheckParseStatusResponse(false)); + when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse); + + CcdiFileUploadRecord record = buildRecord(); + record.setFileName("原始流水.xlsx"); + Path tempFile = createTempFile(); + + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + + verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById( + org.mockito.ArgumentMatchers.argThat(item -> + "parsed_failed".equals(item.getFileStatus()) + && "原始流水.xlsx".equals(item.getFileName())) + ); + } + @Test void deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted() { CcdiFileUploadRecord record = buildRecord(); @@ -468,7 +542,8 @@ class CcdiFileUploadServiceImplTest { AtomicInteger sequence = new AtomicInteger(); captureRecordStatus(events, sequence); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); @@ -491,7 +566,8 @@ class CcdiFileUploadServiceImplTest { List updates = new ArrayList<>(); captureUpdatedRecords(updates); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); @@ -512,7 +588,7 @@ class CcdiFileUploadServiceImplTest { List updates = new ArrayList<>(); captureUpdatedRecords(updates); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())) + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) .thenThrow(new RuntimeException("upload failed:" + "x".repeat(3000))); CcdiFileUploadRecord record = buildRecord(); @@ -526,7 +602,8 @@ class CcdiFileUploadServiceImplTest { @Test void fetchAndSaveBankStatements_shouldTrimLeAccountNoBeforeInsert() throws IOException { - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); @@ -597,7 +674,8 @@ class CcdiFileUploadServiceImplTest { AtomicInteger sequence = new AtomicInteger(); captureRecordStatus(events, sequence); - when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) .thenReturn(buildCheckParseStatusResponse(false)); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); diff --git a/docs/plans/backend/2026-05-06-bank-upload-original-filename-backend-implementation.md b/docs/plans/backend/2026-05-06-bank-upload-original-filename-backend-implementation.md new file mode 100644 index 00000000..2d89fa80 --- /dev/null +++ b/docs/plans/backend/2026-05-06-bank-upload-original-filename-backend-implementation.md @@ -0,0 +1,671 @@ +# Bank Upload Original Filename Implementation Plan + +> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Follow the CCDI project rule: unless the user explicitly declares `using-superpowers` for implementation, execute this plan through the ordinary workflow and do not enable subagents by default. + +**Goal:** 上传本地流水文件后,页面记录名和转传流水分析平台 multipart 文件名都保持用户初始上传文件名。 + +**Architecture:** 后端继续使用唯一临时文件名保存文件,避免同名和并发冲突;上传流水分析平台时额外传入原始文件名,并用可覆盖 `Resource#getFilename()` 的资源对象构造 multipart 文件 part。上传记录状态后处理增加“是否保留当前记录文件名”的来源参数,本地上传链路保留初始文件名,拉取本行信息链路保持现状。 + +**Tech Stack:** Java 21, Spring Boot 3, RestTemplate, MyBatis Plus, JUnit 5, Mockito, Vue 2 页面真实验证。 + +--- + +## Project Notes + +- 设计文档:[docs/superpowers/specs/2026-05-06-bank-upload-original-filename-design.md](/Users/wkc/Desktop/ccdi/ccdi/docs/superpowers/specs/2026-05-06-bank-upload-original-filename-design.md) +- 项目路径规则覆盖 `writing-plans` 默认目录:本次只涉及后端源码,因此实施计划放在 `docs/plans/backend/`。 +- 本次不新增前端源码,不新增数据库字段,不回改历史上传记录。 +- `.DS_Store` 忽略,不纳入暂存或提交。 +- 完成实现后必须新增实施记录:`docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md`。 +- 真实页面验证使用 @browser-use:browser 打开实际业务页面,不打开 prototype 页面。 +- 如果启动了后端、前端或 `lsfx-mock-server`,测试结束后必须关闭本轮启动的进程。 + +## File Map + +- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java` + - 新增可指定 multipart filename 的 `NamedFileSystemResource`。 + - `uploadFile` 继续支持 `File` 参数;当参数已经是 `Resource` 时直接加入 multipart body。 +- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java` + - 新增 `uploadFile(Integer groupId, File file, String uploadFileName)`。 + - 现有 `uploadFile(Integer groupId, File file)` 委托到新方法并使用 `file.getName()`。 + - 项目上传链路使用新方法传入初始上传文件名。 +- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java` + - 验证 multipart body 中 `files` 资源的 `filename` 可被显式指定。 +- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java` + - 验证流水分析客户端上传时把指定原始文件名写入 `files` 参数。 +- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java` + - `processFileAsync` 调用 `lsfxClient.uploadFile(lsfxProjectId, file, record.getFileName())`。 + - `processRecordAfterLogIdReady` 增加来源控制参数,本地上传不覆盖 `record.fileName`,拉取本行信息保持现有覆盖行为。 +- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` + - 更新现有 `processFileAsync` 上传 stubbing 到三参数签名。 + - 新增本地上传使用原始文件名转传、平台返回名不覆盖上传记录、拉取本行信息保持现状的回归测试。 +- Create: `docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md` + - 记录修改内容、影响范围、测试命令、真实页面验证和进程清理情况。 + +## Task 1: Add Failing Tests For Multipart Filename + +**Files:** +- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java` +- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java` + +- [ ] **Step 1: Create `HttpUtilTest` with a failing filename assertion** + +Add: + +```java +package com.ruoyi.lsfx.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HttpUtilTest { + + @Mock + private RestTemplate restTemplate; + + @TempDir + Path tempDir; + + @Test + void uploadFile_shouldUseExplicitResourceFilename() throws Exception { + HttpUtil httpUtil = new HttpUtil(); + ReflectionTestUtils.setField(httpUtil, "restTemplate", restTemplate); + + Path tempFile = tempDir.resolve("batch_0_123456.xlsx"); + Files.writeString(tempFile, "content"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpEntity.class); + when(restTemplate.postForEntity(eq("http://lsfx/upload"), captor.capture(), eq(String.class))) + .thenReturn(ResponseEntity.ok("ok")); + + Map params = new HashMap<>(); + params.put("groupId", 200); + params.put("files", HttpUtil.namedFileResource(tempFile.toFile(), "银行流水A.xlsx")); + + String result = httpUtil.uploadFile("http://lsfx/upload", params, null, String.class); + + assertEquals("ok", result); + MultiValueMap body = (MultiValueMap) captor.getValue().getBody(); + Object filePart = body.getFirst("files"); + Resource resource = assertInstanceOf(Resource.class, filePart); + assertEquals("银行流水A.xlsx", resource.getFilename()); + } +} +``` + +- [ ] **Step 2: Create `LsfxAnalysisClientTest` with a failing client assertion** + +Add: + +```java +package com.ruoyi.lsfx.client; + +import com.ruoyi.lsfx.constants.LsfxConstants; +import com.ruoyi.lsfx.domain.response.UploadFileResponse; +import com.ruoyi.lsfx.util.HttpUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LsfxAnalysisClientTest { + + @Mock + private HttpUtil httpUtil; + + @InjectMocks + private LsfxAnalysisClient client; + + @TempDir + Path tempDir; + + @Test + void uploadFile_shouldPassOriginalFilenameToMultipartResource() throws Exception { + ReflectionTestUtils.setField(client, "baseUrl", "http://lsfx"); + ReflectionTestUtils.setField(client, "uploadFileEndpoint", "/upload"); + ReflectionTestUtils.setField(client, "clientId", "client-1"); + + Path tempFile = tempDir.resolve("batch_0_123456.xlsx"); + Files.writeString(tempFile, "content"); + + UploadFileResponse response = new UploadFileResponse(); + response.setData(new UploadFileResponse.UploadData()); + + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass(Map.class); + ArgumentCaptor> headersCaptor = ArgumentCaptor.forClass(Map.class); + when(httpUtil.uploadFile(eq("http://lsfx/upload"), paramsCaptor.capture(), headersCaptor.capture(), eq(UploadFileResponse.class))) + .thenReturn(response); + + client.uploadFile(200, tempFile.toFile(), "银行流水A.xlsx"); + + assertEquals(200, paramsCaptor.getValue().get("groupId")); + Resource filePart = assertInstanceOf(Resource.class, paramsCaptor.getValue().get("files")); + assertEquals("银行流水A.xlsx", filePart.getFilename()); + assertEquals("client-1", headersCaptor.getValue().get(LsfxConstants.HEADER_CLIENT_ID)); + } +} +``` + +- [ ] **Step 3: Run tests and confirm they fail** + +Run: + +```bash +mvn -pl ccdi-lsfx -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: FAIL because `HttpUtil.namedFileResource(...)` and `LsfxAnalysisClient.uploadFile(Integer, File, String)` do not exist yet. + +## Task 2: Implement Multipart Filename Support + +**Files:** +- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java` +- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java` + +- [ ] **Step 1: Add named file resource to `HttpUtil`** + +In `HttpUtil.java`, add only this import: + +```java +import org.springframework.util.StringUtils; +``` + +Do not import `org.springframework.core.io.Resource` in this file because it conflicts with the existing `jakarta.annotation.Resource` annotation import. Use the Spring Resource type by fully qualified name in implementation snippets. + +Add this nested class and factory method inside `HttpUtil`: + +```java +public static org.springframework.core.io.Resource namedFileResource(File file, String filename) { + return new NamedFileSystemResource(file, filename); +} + +private static class NamedFileSystemResource extends FileSystemResource { + private final String filename; + + NamedFileSystemResource(File file, String filename) { + super(file); + this.filename = StringUtils.hasText(filename) ? filename : file.getName(); + } + + @Override + public String getFilename() { + return filename; + } +} +``` + +- [ ] **Step 2: Let `HttpUtil.uploadFile` accept existing `Resource` values** + +Replace the file branch in `uploadFile(...)` with: + +```java +if (value instanceof File) { + File file = (File) value; + body.add(key, new FileSystemResource(file)); +} else if (value instanceof org.springframework.core.io.Resource) { + body.add(key, value); +} else { + body.add(key, value); +} +``` + +- [ ] **Step 3: Add explicit filename upload overload to `LsfxAnalysisClient`** + +Replace the current upload method body with a delegating overload: + +```java +public UploadFileResponse uploadFile(Integer groupId, File file) { + return uploadFile(groupId, file, file.getName()); +} + +public UploadFileResponse uploadFile(Integer groupId, File file, String uploadFileName) { + String multipartFileName = org.springframework.util.StringUtils.hasText(uploadFileName) + ? uploadFileName + : file.getName(); + log.info("【流水分析】上传文件请求: groupId={}, fileName={}", groupId, multipartFileName); + long startTime = System.currentTimeMillis(); + + try { + String url = baseUrl + uploadFileEndpoint; + + Map params = new HashMap<>(); + params.put("groupId", groupId); + params.put("files", HttpUtil.namedFileResource(file, multipartFileName)); + + Map headers = new HashMap<>(); + headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId); + + UploadFileResponse response = httpUtil.uploadFile(url, params, headers, UploadFileResponse.class); + + long elapsed = System.currentTimeMillis() - startTime; + if (response != null && response.getData() != null) { + log.info("【流水分析】上传文件成功: uploadStatus={}, 耗时={}ms", + response.getData().getUploadStatus(), elapsed); + } else { + log.warn("【流水分析】上传文件响应异常: 耗时={}ms", elapsed); + } + + return response; + } catch (LsfxApiException e) { + log.error("【流水分析】上传文件失败: groupId={}, error={}", groupId, e.getMessage(), e); + throw e; + } catch (Exception e) { + log.error("【流水分析】上传文件未知异常: groupId={}", groupId, e); + throw new LsfxApiException("上传文件失败: " + e.getMessage(), e); + } +} +``` + +- [ ] **Step 4: Run `ccdi-lsfx` tests** + +Run: + +```bash +mvn -pl ccdi-lsfx -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest,CreditParseControllerTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit lsfx client changes** + +Check the worktree, stage only task files, then verify staged scope: + +```bash +git status --short +git add ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java \ + ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java \ + ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java \ + ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java +git diff --cached --name-status +``` + +Expected staged files: only the four `ccdi-lsfx` files in this task. + +```bash +git commit -m "修复流水分析上传文件名传递" +``` + +## Task 3: Add Failing Project Upload Service Tests + +**Files:** +- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` + +- [ ] **Step 1: Update existing `processFileAsync` stubs to the new signature** + +In `CcdiFileUploadServiceImplTest.java`, replace local file upload stubs: + +```java +when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); +``` + +with: + +```java +when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); +``` + +Only update tests that exercise `processFileAsync`. Do not change `processPullBankInfoAsync` tests to upload files; that chain uses `fetchInnerFlow`. + +- [ ] **Step 2: Add test that local upload calls LSFX with original record filename** + +Add: + +```java +@Test +void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException { + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx"))) + .thenReturn(buildUploadResponse()); + when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + .thenReturn(buildCheckParseStatusResponse(false)); + when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); + when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + .thenReturn(buildEmptyBankStatementResponse()); + + CcdiFileUploadRecord record = buildRecord(); + record.setFileName("原始流水.xlsx"); + Path tempFile = createTempFile(); + + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + + verify(lsfxClient).uploadFile(eq(LSFX_PROJECT_ID), argThat(file -> + file.getName().startsWith("upload-") && file.getName().endsWith(".xlsx") + ), eq("原始流水.xlsx")); +} +``` + +- [ ] **Step 3: Add test that platform filename does not overwrite local upload record filename** + +Add: + +```java +@Test +void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException { + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); + when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + .thenReturn(buildCheckParseStatusResponse(false)); + when(lsfxClient.getFileUploadStatus(any())) + .thenReturn(buildParsedSuccessStatusResponse("平台返回文件名.xlsx")); + when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class))) + .thenReturn(buildEmptyBankStatementResponse()); + + CcdiFileUploadRecord record = buildRecord(); + record.setFileName("原始流水.xlsx"); + Path tempFile = createTempFile(); + + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + + verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById( + org.mockito.ArgumentMatchers.argThat(item -> + "parsed_success".equals(item.getFileStatus()) + && "原始流水.xlsx".equals(item.getFileName())) + ); +} +``` + +- [ ] **Step 4: Keep pull-bank-info regression explicit** + +Keep the existing test `processPullBankInfoAsync_shouldUpdateFileSizeFromStatusResponse` asserting: + +```java +"XX身份证.xlsx".equals(item.getFileName()) +``` + +This verifies the shared status method still allows platform filename overwrite for the “拉取本行信息” chain. + +- [ ] **Step 5: Add failure-state filename regression** + +Add: + +```java +@Test +void processFileAsync_shouldKeepOriginalFileNameWhenParseStatusFails() throws IOException { + GetFileUploadStatusResponse statusResponse = buildParsedSuccessStatusResponse("平台失败文件名.xlsx"); + GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0); + logItem.setStatus(-1); + logItem.setUploadStatusDesc("parse.failed"); + + when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString())) + .thenReturn(buildUploadResponse()); + when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) + .thenReturn(buildCheckParseStatusResponse(false)); + when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse); + + CcdiFileUploadRecord record = buildRecord(); + record.setFileName("原始流水.xlsx"); + Path tempFile = createTempFile(); + + service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record); + + verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById( + org.mockito.ArgumentMatchers.argThat(item -> + "parsed_failed".equals(item.getFileStatus()) + && "原始流水.xlsx".equals(item.getFileName())) + ); +} +``` + +- [ ] **Step 6: Run service tests and confirm expected failure** + +Run: + +```bash +mvn -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: FAIL because `processFileAsync` still calls the old two-argument upload method and status handling still overwrites `record.fileName`. + +## Task 4: Implement Project Upload Filename Isolation + +**Files:** +- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java` + +- [ ] **Step 1: Use original record filename when forwarding local uploaded files** + +In `processFileAsync`, replace: + +```java +UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file); +``` + +with: + +```java +UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file, record.getFileName()); +``` + +- [ ] **Step 2: Add source control to status post-processing** + +Add an overload: + +```java +private void processRecordAfterLogIdReady(Long projectId, Integer lsfxProjectId, + CcdiFileUploadRecord record, Integer logId) { + processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false); +} +``` + +Change the current method signature to: + +```java +private void processRecordAfterLogIdReady(Long projectId, Integer lsfxProjectId, + CcdiFileUploadRecord record, Integer logId, + boolean preserveRecordFileName) { +``` + +Then replace the filename update block with: + +```java +if (!preserveRecordFileName) { + String fileName = StringUtils.hasText(logItem.getUploadFileName()) + ? logItem.getUploadFileName() + : logItem.getDownloadFileName(); + if (StringUtils.hasText(fileName)) { + record.setFileName(fileName); + } +} +``` + +- [ ] **Step 3: Call status post-processing with preservation from local upload** + +In `processFileAsync`, replace: + +```java +processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId); +``` + +with: + +```java +processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, true); +``` + +Do not change `processPullBankInfoAsync`; it should continue to call the four-argument overload and preserve the current pull-bank-info behavior. + +- [ ] **Step 4: Run service tests** + +Run: + +```bash +mvn -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS. + +- [ ] **Step 5: Commit project upload service changes** + +Check the worktree, stage only task files, then verify staged scope: + +```bash +git status --short +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 diff --cached --name-status +``` + +Expected staged files: only `CcdiFileUploadServiceImpl.java` and `CcdiFileUploadServiceImplTest.java`. + +```bash +git commit -m "修复本地上传流水记录文件名覆盖" +``` + +## Task 5: Verification, Real Page Check, And Implementation Record + +**Files:** +- Create: `docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md` +- Reference: `ruoyi-admin/src/main/resources/application-dev.yml` +- Reference: `lsfx-mock-server/routers/api.py` +- Reference: `lsfx-mock-server/services/file_service.py` + +- [ ] **Step 1: Run focused backend regression tests** + +Run: + +```bash +mvn -pl ccdi-lsfx,ccdi-project -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest,CcdiFileUploadServiceImplTest,CcdiFileUploadControllerTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: PASS. + +- [ ] **Step 2: Run compile check for affected modules** + +Run: + +```bash +mvn -pl ccdi-lsfx,ccdi-project -am -DskipTests compile +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Start verification services** + +Use the project backend restart script: + +```bash +sh bin/restart_java_backend.sh +``` + +For the mock service, use local dev defaults: + +```bash +cd lsfx-mock-server +python3 main.py --rule-hit-mode subset +``` + +If the frontend is not already running, start it with the project Node version: + +```bash +cd ruoyi-ui +nvm use +npm run dev +``` + +- [ ] **Step 4: Perform real page upload check with @browser-use:browser** + +In the actual business page: + +1. Login through the real application. +2. Open project detail -> 上传数据. +3. Upload a test file named with a distinctive original name, for example `原始文件名验证-20260506.xlsx`. +4. Confirm the upload record table displays `原始文件名验证-20260506.xlsx`. +5. Confirm the mock LSFX upload response or service log records the same filename. The mock service receives the filename through FastAPI `UploadFile.filename`, and `FileService.upload_file` writes it into `file_record.file_name`. + +Expected: page table filename and mock LSFX received filename both match the original uploaded filename. + +- [ ] **Step 5: Clean test data and stop started processes** + +Remove the uploaded test record through the existing page delete action if it reached parsed success. If test data was created but cannot be removed from the page, clean only the test row by its unique filename or upload record id after confirming the scope. + +Stop only the backend, frontend, or mock-service processes started in Step 3. + +- [ ] **Step 6: Write implementation record** + +Create `docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md` with: + +```markdown +# 上传流水文件原始文件名保持实施记录 + +## 修改内容 + +- `ccdi-lsfx` 支持 multipart 文件 part 显式指定 filename。 +- `ccdi-project` 本地上传流水文件链路转传流水分析平台时使用初始上传文件名。 +- 本地上传链路查询状态后不再使用平台返回文件名覆盖上传记录文件名。 +- 拉取本行信息链路保持原有文件名处理行为。 + +## 影响范围 + +- 影响项目详情“上传数据”中的本地流水文件上传。 +- 不影响历史上传记录。 +- 不影响拉取本行信息。 +- 不涉及前端源码和数据库结构变更。 + +## 验证情况 + +- `mvn -pl ccdi-lsfx,ccdi-project -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest,CcdiFileUploadServiceImplTest,CcdiFileUploadControllerTest -Dsurefire.failIfNoSpecifiedTests=false test` +- `mvn -pl ccdi-lsfx,ccdi-project -am -DskipTests compile` +- 真实页面验证:记录页面文件名、mock LSFX 接收 filename、测试数据清理和进程关闭结果。 +``` + +- [ ] **Step 7: Commit final verification record** + +Check the worktree, stage only the implementation record, then verify staged scope: + +```bash +git status --short +git add docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md +git diff --cached --name-status +``` + +Expected staged file: only the implementation record. + +```bash +git commit -m "文档: 记录上传流水文件名修复验证" +``` + +## Final Review Checklist + +- [ ] 本地上传流水文件转传 LSFX 时 multipart `files` part 的 filename 是初始上传文件名。 +- [ ] 本地上传记录 `file_name` 在解析成功或失败后仍是初始上传文件名。 +- [ ] 拉取本行信息链路仍按平台返回文件名更新记录,不被本次改动影响。 +- [ ] 无数据库结构变更。 +- [ ] 无前端源码变更。 +- [ ] 实施记录已包含测试、真实页面验证和进程清理结果。 +- [ ] 提交前 `git status --short` 无 `.DS_Store` 或无关文件。 diff --git a/docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md b/docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md new file mode 100644 index 00000000..682875a8 --- /dev/null +++ b/docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md @@ -0,0 +1,88 @@ +# 流水上传原始文件名保持实施记录 + +## 基本信息 + +- 实施时间:2026-05-06 +- 需求范围:上传流水文件后,页面展示文件名与调用流水分析平台上传接口传递的文件名必须保持为用户初始上传文件名。 +- 历史数据处理:不处理历史上传记录,不追加历史修复脚本。 + +## 修改内容 + +### 流水分析客户端 + +- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java` + - 新增可指定 multipart 文件名的 `namedFileResource`。 + - `uploadFile` 支持直接传入 `Resource`,避免重新包装后丢失指定文件名。 +- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java` + - 新增 `uploadFile(Integer groupId, File file, String uploadFileName)` 重载。 + - 原两参方法保留并委托到三参方法,默认继续使用本地文件名。 + +### 项目流水上传 + +- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java` + - 用户上传流水文件时,调用流水分析平台上传接口传入 `record.getFileName()`,即初始上传文件名。 + - 用户上传链路查询平台解析状态后,不再用平台返回的 `uploadFileName/downloadFileName` 覆盖记录文件名。 + - 拉取本行信息链路继续允许使用平台返回文件名,避免改变该存量业务行为。 + +### 测试覆盖 + +- `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java` + - 补充验证用户上传链路传给 `LsfxAnalysisClient` 的文件名为初始上传文件名。 + - 补充验证解析成功时,即使平台返回不同文件名,记录仍保持初始上传文件名。 + - 补充验证解析失败时,即使平台返回不同文件名,记录仍保持初始上传文件名。 + - 保留拉取本行信息链路使用平台返回文件名的既有断言。 + +## 验证情况 + +### 已通过 + +```bash +mvn -pl ccdi-lsfx -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest,CreditParseControllerTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +- 结果:BUILD SUCCESS +- 覆盖:multipart 指定文件名、流水分析客户端三参上传、既有征信解析控制器回归。 + +```bash +env MAVEN_OPTS=-Djdk.attach.allowAttachSelf=true mvn -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName+processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName+processFileAsync_shouldKeepOriginalFileNameWhenParseStatusFails+processPullBankInfoAsync_shouldUpdateFileSizeFromStatusResponse -Dsurefire.failIfNoSpecifiedTests=false test +``` + +- 结果:BUILD SUCCESS +- 覆盖:用户上传文件名传递、解析成功文件名保持、解析失败文件名保持、拉取本行信息链路不回归。 + +```bash +mvn -pl ccdi-lsfx,ccdi-project -am -DskipTests compile +``` + +- 结果:BUILD SUCCESS +- 覆盖:涉及模块编译通过。 + +```bash +sh bin/restart_java_backend.sh restart +``` + +- 结果:后端重新打包成功,启动日志出现“若依启动成功”。 +- 说明:本次验证结束前后端进程未持续保持监听,页面验证前需要再次确认运行状态。 + +```bash +cd ruoyi-ui +source "$HOME/.nvm/nvm.sh" +nvm use +npm run dev -- --port 9527 +``` + +- 结果:Node 已切换到 `v14.21.3`,前端开发服务启动到 `http://localhost:9527/`。 + +### 未完成 + +- `browser-use:browser` 真实页面验证未完成。 +- 原因:当前会话已加载 `browser-use` 技能,但工具列表未暴露该技能要求的 Node REPL `js` / `mcp__node_repl__js` 调用入口,无法按技能要求控制 Codex in-app browser。 +- 处理:未用 Playwright 结果替代 browser-use 结果,避免与项目要求混淆。 + +## 影响范围 + +- 影响用户手工上传流水文件链路。 +- 不改历史记录。 +- 不改数据库结构。 +- 不改前端展示字段。 +- 不改变拉取本行信息链路的文件名展示逻辑。