diff --git a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java index fc7ac5cc..7dcee28b 100644 --- a/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java +++ b/ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java @@ -65,7 +65,7 @@ public class CcdiFileUploadController extends BaseController { return AjaxResult.error("单次最多上传100个文件"); } - // 校验文件大小和格式 + // 校验文件数量、空文件和大小,文件名业务规则统一在 Service 层处理 for (MultipartFile file : files) { if (file.isEmpty()) { return AjaxResult.error("文件不能为空"); @@ -73,15 +73,6 @@ public class CcdiFileUploadController extends BaseController { if (file.getSize() > 50 * 1024 * 1024) { 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 { 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 0df93e9f..6746822b 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 @@ -64,6 +64,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { private static final int MAX_ERROR_MESSAGE_LENGTH = 2000; 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]$"); + private static final Pattern UPLOAD_FILE_NAME_ID_CARD_PATTERN = + Pattern.compile("(? normalizedFileNames = normalizeAndValidateUploadFileNames(files); + // Critical Fix #2 & #4: 保存临时文件和创建记录在同一个循环中,确保一一对应 List tempFilePaths = new ArrayList<>(); List records = new ArrayList<>(); @@ -362,10 +367,11 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { // 同一个循环中保存临时文件和创建记录,确保索引一一对应 for (int i = 0; i < files.length; i++) { MultipartFile file = files[i]; + String normalizedFileName = normalizedFileNames.get(i); // 1. 保存临时文件 String originalFilename = file.getOriginalFilename(); - String tempFileName = batchId + "_" + i + "_" + System.currentTimeMillis() + "_" + originalFilename; + String tempFileName = batchId + "_" + i + "_" + System.currentTimeMillis() + "_" + normalizedFileName; Path tempFilePath = tempDir.resolve(tempFileName); Files.copy(file.getInputStream(), tempFilePath, StandardCopyOption.REPLACE_EXISTING); @@ -378,7 +384,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { CcdiFileUploadRecord record = new CcdiFileUploadRecord(); record.setProjectId(projectId); record.setLsfxProjectId(lsfxProjectId); - record.setFileName(originalFilename); + record.setFileName(normalizedFileName); record.setFileSize(file.getSize()); record.setFileStatus("uploading"); record.setUploadTime(now); @@ -423,6 +429,42 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService { return batchId; } + private List normalizeAndValidateUploadFileNames(MultipartFile[] files) { + List 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秒重试机制 diff --git a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java index 26e00f36..7fc282f1 100644 --- a/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java +++ b/ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java @@ -1,23 +1,29 @@ package com.ruoyi.ccdi.project.controller; 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.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; 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.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.MockedStatic; 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.web.multipart.MultipartFile; import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -31,6 +37,14 @@ class CcdiFileUploadControllerTest { @Mock private ICcdiFileUploadService fileUploadService; + @Mock + private CcdiProjectAccessService projectAccessService; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + @Test void parseIdCardFile_shouldReturnAjaxResultSuccess() { MockMultipartFile file = new MockMultipartFile( @@ -46,6 +60,29 @@ class CcdiFileUploadControllerTest { 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 void pullBankInfo_shouldUseCurrentLoginUserInfo() { CcdiPullBankInfoSubmitDTO dto = new CcdiPullBankInfoSubmitDTO(); @@ -54,30 +91,35 @@ class CcdiFileUploadControllerTest { dto.setStartDate("2026-03-01"); dto.setEndDate("2026-03-10"); - try (MockedStatic mocked = mockStatic(SecurityUtils.class)) { - mocked.when(SecurityUtils::getUserId).thenReturn(9527L); - mocked.when(SecurityUtils::getUsername).thenReturn("admin"); - when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "2026-03-01", "2026-03-10", 9527L, "admin")) - .thenReturn("batch-1"); + setLoginUser(9527L, "admin"); + when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "2026-03-01", "2026-03-10", 9527L, "admin")) + .thenReturn("batch-1"); - AjaxResult result = controller.pullBankInfo(dto); + AjaxResult result = controller.pullBankInfo(dto); - assertEquals(200, result.get("code")); - } + assertEquals(200, result.get("code")); } @Test void deleteFile_shouldUseCurrentLoginUserId() { - try (MockedStatic mocked = mockStatic(SecurityUtils.class)) { - mocked.when(SecurityUtils::getUserId).thenReturn(9527L); - when(fileUploadService.deleteFileUploadRecord(123L, 9527L)) - .thenReturn("删除成功"); + setLoginUser(9527L, "admin"); + when(fileUploadService.deleteFileUploadRecord(123L, 9527L)) + .thenReturn("删除成功"); - AjaxResult result = controller.deleteFile(123L); + AjaxResult result = controller.deleteFile(123L); - assertEquals(200, result.get("code")); - assertEquals("删除成功", result.get("msg")); - verify(fileUploadService).deleteFileUploadRecord(123L, 9527L); - } + assertEquals(200, result.get("code")); + assertEquals("删除成功", result.get("msg")); + 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); } } 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 50259689..af63b6d5 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 @@ -193,6 +193,88 @@ class CcdiFileUploadServiceImplTest { () -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester")); } + @Test + void batchUploadFiles_shouldNormalizeFilenameBeforeSavingRecordAndTempFile() throws Exception { + setField("uploadPath", tempDir.toString()); + mockProjectWithLsfxProjectId(); + + AtomicReference> inserted = new AtomicReference<>(); + doAnswer(invocation -> { + List records = invocation.getArgument(0); + for (int i = 0; i < records.size(); i++) { + records.get(i).setId((long) (i + 1)); + } + inserted.set(new ArrayList<>(records)); + return records.size(); + }).when(recordMapper).insertBatch(any()); + + 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.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.any(), any()); + } + @Test void submitTasksAsync_shouldNotCreateLocalBatchLogFiles() throws Exception { setField("uploadPath", tempDir.toString()); @@ -439,7 +521,7 @@ class CcdiFileUploadServiceImplTest { } @Test - void deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted() { + void deleteFileUploadRecord_shouldDeleteLocalBankStatementsAndMarkDeleted() { CcdiFileUploadRecord record = buildRecord(); record.setProjectId(PROJECT_ID); record.setLsfxProjectId(LSFX_PROJECT_ID); @@ -449,7 +531,6 @@ class CcdiFileUploadServiceImplTest { project.setProjectId(PROJECT_ID); when(recordMapper.selectById(RECORD_ID)).thenReturn(record); - when(lsfxClient.deleteFiles(any())).thenReturn(buildDeleteFilesResponse()); when(projectMapper.selectById(PROJECT_ID)).thenReturn(project); when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(2); when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1); @@ -457,12 +538,7 @@ class CcdiFileUploadServiceImplTest { String result = service.deleteFileUploadRecord(RECORD_ID, 9527L); assertEquals("删除成功,已开始项目重新打标", result); - verify(lsfxClient).deleteFiles(argThat(request -> - request.getGroupId().equals(LSFX_PROJECT_ID) - && request.getUserId().equals(9527) - && request.getLogIds().length == 1 - && request.getLogIds()[0].equals(LOG_ID) - )); + verify(lsfxClient, never()).deleteFiles(any()); verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID); verify(recordMapper).updateById(org.mockito.ArgumentMatchers.argThat(item -> RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus()) @@ -500,21 +576,23 @@ class CcdiFileUploadServiceImplTest { } @Test - void deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails() { + void deleteFileUploadRecord_shouldSkipLsfxDeleteApi() { CcdiFileUploadRecord record = buildRecord(); record.setFileStatus("parsed_success"); record.setLogId(LOG_ID); record.setLsfxProjectId(LSFX_PROJECT_ID); 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()); - verify(bankTagService, never()).submitAutoRebuild(any(), any()); - verify(recordMapper, never()).updateById(org.mockito.ArgumentMatchers.argThat(item -> + assertEquals("删除成功,已开始项目重新打标", result); + verify(lsfxClient, never()).deleteFiles(any()); + verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID); + verify(recordMapper).updateById(org.mockito.ArgumentMatchers.argThat(item -> "deleted".equals(item.getFileStatus()) )); + verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_FILE_DELETE); } // @Test @@ -754,6 +832,13 @@ class CcdiFileUploadServiceImplTest { }).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() { CcdiFileUploadRecord record = new CcdiFileUploadRecord(); record.setId(RECORD_ID); diff --git a/docs/plans/backend/2026-07-03-bank-upload-filename-validation-backend-implementation.md b/docs/plans/backend/2026-07-03-bank-upload-filename-validation-backend-implementation.md new file mode 100644 index 00000000..488a4206 --- /dev/null +++ b/docs/plans/backend/2026-07-03-bank-upload-filename-validation-backend-implementation.md @@ -0,0 +1,27 @@ +# 上传流水文件名校验后端实施计划 + +## 目标 + +上传流水文件时,后端统一过滤文件名中的空白字符,并要求过滤后的文件名主干包含 18 位身份证号片段。任一文件不满足规则时整批拦截,不创建上传记录、不保存临时文件、不调用流水分析平台。 + +## 实施内容 + +- `CcdiFileUploadController.batchUpload` 只保留项目 ID、文件数量、空文件和文件大小校验,文件名业务校验统一下沉到服务层。 +- `CcdiFileUploadServiceImpl.batchUploadFiles` 在保存临时文件前完成整批文件名归一化和校验。 +- 文件名归一化规则为:`originalFilename == null` 时按空字符串处理,再移除半角空白与全角空格。 +- 扩展名校验、身份证号片段校验、上传记录 `fileName` 和流水平台 multipart filename 均使用归一化后的文件名。 +- 身份证号片段校验使用 `(?