校验上传流水文件名身份证信息

This commit is contained in:
wkc
2026-07-03 17:38:46 +08:00
parent bb41fd7e89
commit b7e9a8da03
6 changed files with 277 additions and 46 deletions

View File

@@ -65,7 +65,7 @@ public class CcdiFileUploadController extends BaseController {
return AjaxResult.error("单次最多上传100个文件"); return AjaxResult.error("单次最多上传100个文件");
} }
// 校验文件大小和格式 // 校验文件数量、空文件和大小,文件名业务规则统一在 Service 层处理
for (MultipartFile file : files) { for (MultipartFile file : files) {
if (file.isEmpty()) { if (file.isEmpty()) {
return AjaxResult.error("文件不能为空"); return AjaxResult.error("文件不能为空");
@@ -73,15 +73,6 @@ public class CcdiFileUploadController extends BaseController {
if (file.getSize() > 50 * 1024 * 1024) { if (file.getSize() > 50 * 1024 * 1024) {
return AjaxResult.error("文件 " + file.getOriginalFilename() + " 超过50MB限制"); return AjaxResult.error("文件 " + file.getOriginalFilename() + " 超过50MB限制");
} }
String fileName = file.getOriginalFilename();
if (fileName == null || fileName.trim().isEmpty()) {
return AjaxResult.error("文件名不能为空");
}
String lowerFileName = fileName.toLowerCase();
if (!lowerFileName.endsWith(".xlsx") && !lowerFileName.endsWith(".csv")
&& !lowerFileName.endsWith(".pdf")) {
return AjaxResult.error("文件 " + fileName + " 格式不支持, 仅支持 PDF, CSV, XLSX 文件");
}
} }
try { try {

View File

@@ -64,6 +64,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
private static final int MAX_ERROR_MESSAGE_LENGTH = 2000; private static final int MAX_ERROR_MESSAGE_LENGTH = 2000;
private static final Pattern ID_CARD_PATTERN = private static final Pattern ID_CARD_PATTERN =
Pattern.compile("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])([0-2]\\d|3[01])\\d{3}[0-9Xx]$"); Pattern.compile("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])([0-2]\\d|3[01])\\d{3}[0-9Xx]$");
private static final Pattern UPLOAD_FILE_NAME_ID_CARD_PATTERN =
Pattern.compile("(?<!\\d)\\d{17}[0-9Xx](?!\\d)");
private static final Pattern FILE_NAME_SPACE_PATTERN = Pattern.compile("[\\s\\u3000]+");
@Data @Data
private static class FetchBankStatementResult { private static class FetchBankStatementResult {
@@ -347,6 +350,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
log.info("【文件上传】项目信息验证通过: projectId={}, lsfxProjectId={}", projectId, lsfxProjectId); log.info("【文件上传】项目信息验证通过: projectId={}, lsfxProjectId={}", projectId, lsfxProjectId);
List<String> normalizedFileNames = normalizeAndValidateUploadFileNames(files);
// Critical Fix #2 & #4: 保存临时文件和创建记录在同一个循环中,确保一一对应 // Critical Fix #2 & #4: 保存临时文件和创建记录在同一个循环中,确保一一对应
List<String> tempFilePaths = new ArrayList<>(); List<String> tempFilePaths = new ArrayList<>();
List<CcdiFileUploadRecord> records = new ArrayList<>(); List<CcdiFileUploadRecord> records = new ArrayList<>();
@@ -362,10 +367,11 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
// 同一个循环中保存临时文件和创建记录,确保索引一一对应 // 同一个循环中保存临时文件和创建记录,确保索引一一对应
for (int i = 0; i < files.length; i++) { for (int i = 0; i < files.length; i++) {
MultipartFile file = files[i]; MultipartFile file = files[i];
String normalizedFileName = normalizedFileNames.get(i);
// 1. 保存临时文件 // 1. 保存临时文件
String originalFilename = file.getOriginalFilename(); String originalFilename = file.getOriginalFilename();
String tempFileName = batchId + "_" + i + "_" + System.currentTimeMillis() + "_" + originalFilename; String tempFileName = batchId + "_" + i + "_" + System.currentTimeMillis() + "_" + normalizedFileName;
Path tempFilePath = tempDir.resolve(tempFileName); Path tempFilePath = tempDir.resolve(tempFileName);
Files.copy(file.getInputStream(), tempFilePath, StandardCopyOption.REPLACE_EXISTING); Files.copy(file.getInputStream(), tempFilePath, StandardCopyOption.REPLACE_EXISTING);
@@ -378,7 +384,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
CcdiFileUploadRecord record = new CcdiFileUploadRecord(); CcdiFileUploadRecord record = new CcdiFileUploadRecord();
record.setProjectId(projectId); record.setProjectId(projectId);
record.setLsfxProjectId(lsfxProjectId); record.setLsfxProjectId(lsfxProjectId);
record.setFileName(originalFilename); record.setFileName(normalizedFileName);
record.setFileSize(file.getSize()); record.setFileSize(file.getSize());
record.setFileStatus("uploading"); record.setFileStatus("uploading");
record.setUploadTime(now); record.setUploadTime(now);
@@ -423,6 +429,42 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
return batchId; return batchId;
} }
private List<String> normalizeAndValidateUploadFileNames(MultipartFile[] files) {
List<String> normalizedFileNames = new ArrayList<>();
for (MultipartFile file : files) {
String originalFileName = file.getOriginalFilename() == null ? "" : file.getOriginalFilename();
String normalizedFileName = FILE_NAME_SPACE_PATTERN.matcher(originalFileName).replaceAll("");
validateUploadFileName(normalizedFileName);
normalizedFileNames.add(normalizedFileName);
}
return normalizedFileNames;
}
private void validateUploadFileName(String fileName) {
if (!StringUtils.hasText(fileName)) {
throw new IllegalArgumentException("文件名不能为空");
}
String lowerFileName = fileName.toLowerCase(Locale.ROOT);
if (!lowerFileName.endsWith(".xlsx") && !lowerFileName.endsWith(".csv")
&& !lowerFileName.endsWith(".pdf")) {
throw new IllegalArgumentException("文件 " + fileName + " 格式不支持, 仅支持 PDF, CSV, XLSX 文件");
}
String mainFileName = getMainFileName(fileName);
if (!UPLOAD_FILE_NAME_ID_CARD_PATTERN.matcher(mainFileName).find()) {
throw new IllegalArgumentException("文件 " + fileName + " 文件名未包含身份证信息");
}
}
private String getMainFileName(String fileName) {
int extensionIndex = fileName.lastIndexOf('.');
if (extensionIndex <= 0) {
return fileName;
}
return fileName.substring(0, extensionIndex);
}
/** /**
* 调度线程:循环提交任务到线程池 * 调度线程:循环提交任务到线程池
* 支持等待30秒重试机制 * 支持等待30秒重试机制

View File

@@ -1,23 +1,29 @@
package com.ruoyi.ccdi.project.controller; package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiPullBankInfoSubmitDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiPullBankInfoSubmitDTO;
import com.ruoyi.ccdi.project.service.CcdiProjectAccessService;
import com.ruoyi.ccdi.project.service.ICcdiFileUploadService; import com.ruoyi.ccdi.project.service.ICcdiFileUploadService;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.SecurityUtils; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -31,6 +37,14 @@ class CcdiFileUploadControllerTest {
@Mock @Mock
private ICcdiFileUploadService fileUploadService; private ICcdiFileUploadService fileUploadService;
@Mock
private CcdiProjectAccessService projectAccessService;
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test @Test
void parseIdCardFile_shouldReturnAjaxResultSuccess() { void parseIdCardFile_shouldReturnAjaxResultSuccess() {
MockMultipartFile file = new MockMultipartFile( MockMultipartFile file = new MockMultipartFile(
@@ -46,6 +60,29 @@ class CcdiFileUploadControllerTest {
assertEquals(200, result.get("code")); assertEquals(200, result.get("code"));
} }
@Test
void batchUpload_shouldDelegateFilenameValidationToService() {
MultipartFile[] files = new MultipartFile[]{
new MockMultipartFile(
"files",
"330101199001010011 - 流水.xl sx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content".getBytes(StandardCharsets.UTF_8)
)
};
setLoginUser(9527L, "admin");
when(fileUploadService.batchUploadFiles(PROJECT_ID, files, "admin"))
.thenReturn("batch-1");
AjaxResult result = controller.batchUpload(PROJECT_ID, files);
assertEquals(200, result.get("code"));
assertEquals("batch-1", result.get("data"));
verify(projectAccessService).assertCanOperate(PROJECT_ID);
verify(fileUploadService).batchUploadFiles(PROJECT_ID, files, "admin");
}
@Test @Test
void pullBankInfo_shouldUseCurrentLoginUserInfo() { void pullBankInfo_shouldUseCurrentLoginUserInfo() {
CcdiPullBankInfoSubmitDTO dto = new CcdiPullBankInfoSubmitDTO(); CcdiPullBankInfoSubmitDTO dto = new CcdiPullBankInfoSubmitDTO();
@@ -54,9 +91,7 @@ class CcdiFileUploadControllerTest {
dto.setStartDate("2026-03-01"); dto.setStartDate("2026-03-01");
dto.setEndDate("2026-03-10"); dto.setEndDate("2026-03-10");
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) { setLoginUser(9527L, "admin");
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")) when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "2026-03-01", "2026-03-10", 9527L, "admin"))
.thenReturn("batch-1"); .thenReturn("batch-1");
@@ -64,12 +99,10 @@ class CcdiFileUploadControllerTest {
assertEquals(200, result.get("code")); assertEquals(200, result.get("code"));
} }
}
@Test @Test
void deleteFile_shouldUseCurrentLoginUserId() { void deleteFile_shouldUseCurrentLoginUserId() {
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) { setLoginUser(9527L, "admin");
mocked.when(SecurityUtils::getUserId).thenReturn(9527L);
when(fileUploadService.deleteFileUploadRecord(123L, 9527L)) when(fileUploadService.deleteFileUploadRecord(123L, 9527L))
.thenReturn("删除成功"); .thenReturn("删除成功");
@@ -79,5 +112,14 @@ class CcdiFileUploadControllerTest {
assertEquals("删除成功", result.get("msg")); assertEquals("删除成功", result.get("msg"));
verify(fileUploadService).deleteFileUploadRecord(123L, 9527L); verify(fileUploadService).deleteFileUploadRecord(123L, 9527L);
} }
private void setLoginUser(Long userId, String username) {
SysUser user = new SysUser();
user.setUserName(username);
LoginUser loginUser = new LoginUser(user, Set.of("*:*:*"));
loginUser.setUserId(userId);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
} }
} }

View File

@@ -193,6 +193,88 @@ class CcdiFileUploadServiceImplTest {
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester")); () -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));
} }
@Test
void batchUploadFiles_shouldNormalizeFilenameBeforeSavingRecordAndTempFile() throws Exception {
setField("uploadPath", tempDir.toString());
mockProjectWithLsfxProjectId();
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());
MultipartFile file = new MockMultipartFile(
"files",
"330101199001010011 - 流 水 .xl sx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content".getBytes()
);
TransactionSynchronizationManager.initSynchronization();
try {
String batchId = service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester");
assertNotNull(batchId);
assertNotNull(inserted.get());
assertEquals("330101199001010011-流水.xlsx", inserted.get().get(0).getFileName());
Path tempRoot = tempDir.resolve("temp");
assertTrue(Files.exists(tempRoot));
try (var paths = Files.list(tempRoot)) {
assertTrue(paths.anyMatch(path ->
path.getFileName().toString().endsWith("_330101199001010011-流水.xlsx")));
}
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void batchUploadFiles_shouldRejectFileNameWithoutIdCardBeforeSavingTempFile() throws Exception {
setField("uploadPath", tempDir.toString());
mockProjectWithLsfxProjectId();
MultipartFile file = new MockMultipartFile(
"files",
"普通流水.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content".getBytes()
);
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));
assertTrue(exception.getMessage().contains("身份证"));
assertFalse(Files.exists(tempDir.resolve("temp")));
verify(recordMapper, never()).insertBatch(any());
verify(lsfxClient, never()).uploadFile(any(), org.mockito.ArgumentMatchers.<java.io.File>any(), any());
}
@Test
void batchUploadFiles_shouldRejectBlankNormalizedFilenameBeforeSavingTempFile() throws Exception {
setField("uploadPath", tempDir.toString());
mockProjectWithLsfxProjectId();
MultipartFile file = new MockMultipartFile(
"files",
" \u3000 ",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"content".getBytes()
);
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));
assertTrue(exception.getMessage().contains("文件名不能为空"));
assertFalse(Files.exists(tempDir.resolve("temp")));
verify(recordMapper, never()).insertBatch(any());
verify(lsfxClient, never()).uploadFile(any(), org.mockito.ArgumentMatchers.<java.io.File>any(), any());
}
@Test @Test
void submitTasksAsync_shouldNotCreateLocalBatchLogFiles() throws Exception { void submitTasksAsync_shouldNotCreateLocalBatchLogFiles() throws Exception {
setField("uploadPath", tempDir.toString()); setField("uploadPath", tempDir.toString());
@@ -439,7 +521,7 @@ class CcdiFileUploadServiceImplTest {
} }
@Test @Test
void deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted() { void deleteFileUploadRecord_shouldDeleteLocalBankStatementsAndMarkDeleted() {
CcdiFileUploadRecord record = buildRecord(); CcdiFileUploadRecord record = buildRecord();
record.setProjectId(PROJECT_ID); record.setProjectId(PROJECT_ID);
record.setLsfxProjectId(LSFX_PROJECT_ID); record.setLsfxProjectId(LSFX_PROJECT_ID);
@@ -449,7 +531,6 @@ class CcdiFileUploadServiceImplTest {
project.setProjectId(PROJECT_ID); project.setProjectId(PROJECT_ID);
when(recordMapper.selectById(RECORD_ID)).thenReturn(record); when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(lsfxClient.deleteFiles(any())).thenReturn(buildDeleteFilesResponse());
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project); when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(2); when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(2);
when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1); when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1);
@@ -457,12 +538,7 @@ class CcdiFileUploadServiceImplTest {
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L); String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
assertEquals("删除成功已开始项目重新打标", result); assertEquals("删除成功已开始项目重新打标", result);
verify(lsfxClient).deleteFiles(argThat(request -> verify(lsfxClient, never()).deleteFiles(any());
request.getGroupId().equals(LSFX_PROJECT_ID)
&& request.getUserId().equals(9527)
&& request.getLogIds().length == 1
&& request.getLogIds()[0].equals(LOG_ID)
));
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID); verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item -> verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus()) RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus())
@@ -500,21 +576,23 @@ class CcdiFileUploadServiceImplTest {
} }
@Test @Test
void deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails() { void deleteFileUploadRecord_shouldSkipLsfxDeleteApi() {
CcdiFileUploadRecord record = buildRecord(); CcdiFileUploadRecord record = buildRecord();
record.setFileStatus("parsed_success"); record.setFileStatus("parsed_success");
record.setLogId(LOG_ID); record.setLogId(LOG_ID);
record.setLsfxProjectId(LSFX_PROJECT_ID); record.setLsfxProjectId(LSFX_PROJECT_ID);
when(recordMapper.selectById(RECORD_ID)).thenReturn(record); when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(lsfxClient.deleteFiles(any())).thenThrow(new RuntimeException("lsfx delete failed")); when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1);
assertThrows(RuntimeException.class, () -> service.deleteFileUploadRecord(RECORD_ID, 9527L)); String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
verify(bankStatementMapper, never()).deleteByProjectIdAndBatchId(any(), any()); assertEquals("删除成功已开始项目重新打标", result);
verify(bankTagService, never()).submitAutoRebuild(any(), any()); verify(lsfxClient, never()).deleteFiles(any());
verify(recordMapper, never()).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item -> verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
"deleted".equals(item.getFileStatus()) "deleted".equals(item.getFileStatus())
)); ));
verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_FILE_DELETE);
} }
// @Test // @Test
@@ -754,6 +832,13 @@ class CcdiFileUploadServiceImplTest {
}).when(recordMapper).updateById(any(CcdiFileUploadRecord.class)); }).when(recordMapper).updateById(any(CcdiFileUploadRecord.class));
} }
private void mockProjectWithLsfxProjectId() {
CcdiProject project = new CcdiProject();
project.setProjectId(PROJECT_ID);
project.setLsfxProjectId(LSFX_PROJECT_ID);
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
}
private CcdiFileUploadRecord buildRecord() { private CcdiFileUploadRecord buildRecord() {
CcdiFileUploadRecord record = new CcdiFileUploadRecord(); CcdiFileUploadRecord record = new CcdiFileUploadRecord();
record.setId(RECORD_ID); record.setId(RECORD_ID);

View File

@@ -0,0 +1,27 @@
# 上传流水文件名校验后端实施计划
## 目标
上传流水文件时,后端统一过滤文件名中的空白字符,并要求过滤后的文件名主干包含 18 位身份证号片段。任一文件不满足规则时整批拦截,不创建上传记录、不保存临时文件、不调用流水分析平台。
## 实施内容
- `CcdiFileUploadController.batchUpload` 只保留项目 ID、文件数量、空文件和文件大小校验文件名业务校验统一下沉到服务层。
- `CcdiFileUploadServiceImpl.batchUploadFiles` 在保存临时文件前完成整批文件名归一化和校验。
- 文件名归一化规则为:`originalFilename == null` 时按空字符串处理,再移除半角空白与全角空格。
- 扩展名校验、身份证号片段校验、上传记录 `fileName` 和流水平台 multipart filename 均使用归一化后的文件名。
- 身份证号片段校验使用 `(?<!\d)\d{17}[0-9Xx](?!\d)` 在文件名主干中查找,不做校验位算法。
## 测试计划
- 覆盖含空格文件名归一化后写入上传记录,并用于临时文件名。
- 覆盖无身份证号片段、空白文件名在保存临时文件前拦截。
- 覆盖 Controller 不再按原始文件名提前拦截,能把文件名业务规则交给 Service。
- 执行 `mvn -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest,CcdiFileUploadControllerTest -Dsurefire.failIfNoSpecifiedTests=false test`
- 执行 `mvn -pl ccdi-project -am -DskipTests compile`
## 范围说明
- 本次不修改前端页面。
- 本次不修改流水平台返回 DTO。
- 本次不修改 `CcdiBankStatement.fromResponse``ccdi_bank_statement` 落库逻辑。

View File

@@ -0,0 +1,44 @@
# 上传流水文件名校验实施记录
## 基本信息
- 实施日期2026-07-03
- 实施范围:项目详情上传数据页对应的后端批量上传接口 `/ccdi/file-upload/batch`
- 需求范围:过滤上传流水文件名中的空白字符;文件名主干缺少 18 位身份证号片段时拦截上传
## 修改内容
- `CcdiFileUploadController`
- 批量上传入口只保留项目 ID、文件数量、空文件和文件大小校验。
- 文件名、扩展名和身份证号片段校验统一交给 Service避免 controller 与 service 规则不一致。
- `CcdiFileUploadServiceImpl`
- 新增上传文件名归一化:空文件名按空字符串处理,移除半角空白与全角空格。
- 在保存临时文件和创建上传记录前,先完成整批文件名校验。
- 归一化后的文件名用于上传记录 `fileName`、临时文件名后缀和流水平台 multipart filename。
- 文件名主干必须包含 `(?<!\d)\d{17}[0-9Xx](?!\d)` 命中的身份证号片段。
- `CcdiFileUploadServiceImplTest``CcdiFileUploadControllerTest`
- 新增文件名去空格、无身份证号拦截、空白文件名拦截和 controller 下沉校验测试。
- 将 controller 测试从 static mock 调整为真实 `SecurityContextHolder` 登录用户。
- 对齐当前“上传数据删除不调用流水分析平台删除接口”的既有实现断言。
## 验证情况
```bash
mvn -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest,CcdiFileUploadControllerTest -Dsurefire.failIfNoSpecifiedTests=false test
```
- 结果BUILD SUCCESS
- 覆盖Controller 批量上传入口、Service 文件名归一化与保存前拦截、既有上传/删除相关单元测试。
```bash
mvn -pl ccdi-project -am -DskipTests compile
```
- 结果BUILD SUCCESS
- 覆盖:`ccdi-project` 及依赖模块编译通过。
## 范围说明
- 未修改前端页面。
- 未修改流水平台响应 DTO。
- 未修改 `CcdiBankStatement.fromResponse``ccdi_bank_statement` 落库逻辑。