修复流水上传原始文件名保持

This commit is contained in:
wkc
2026-05-06 15:05:36 +08:00
parent 2071d04c08
commit b2e177dd24
4 changed files with 862 additions and 15 deletions

View File

@@ -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());

View File

@@ -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.<CcdiFileUploadRecord>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.<CcdiFileUploadRecord>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<CcdiFileUploadRecord> 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<CcdiFileUploadRecord> 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());

View File

@@ -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<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
when(restTemplate.postForEntity(eq("http://lsfx/upload"), captor.capture(), eq(String.class)))
.thenReturn(ResponseEntity.ok("ok"));
Map<String, Object> 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<String, Object> body = (MultiValueMap<String, Object>) 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<Map<String, Object>> paramsCaptor = ArgumentCaptor.forClass(Map.class);
ArgumentCaptor<Map<String, String>> 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<String, Object> params = new HashMap<>();
params.put("groupId", groupId);
params.put("files", HttpUtil.namedFileResource(file, multipartFileName));
Map<String, String> 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.<CcdiFileUploadRecord>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.<CcdiFileUploadRecord>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` 或无关文件。

View File

@@ -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 结果,避免与项目要求混淆。
## 影响范围
- 影响用户手工上传流水文件链路。
- 不改历史记录。
- 不改数据库结构。
- 不改前端展示字段。
- 不改变拉取本行信息链路的文件名展示逻辑。