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

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个文件");
}
// 校验文件大小和格式
// 校验文件数量、空文件和大小,文件名业务规则统一在 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 {

View File

@@ -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("(?<!\\d)\\d{17}[0-9Xx](?!\\d)");
private static final Pattern FILE_NAME_SPACE_PATTERN = Pattern.compile("[\\s\\u3000]+");
@Data
private static class FetchBankStatementResult {
@@ -347,6 +350,8 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
log.info("【文件上传】项目信息验证通过: projectId={}, lsfxProjectId={}", projectId, lsfxProjectId);
List<String> normalizedFileNames = normalizeAndValidateUploadFileNames(files);
// Critical Fix #2 & #4: 保存临时文件和创建记录在同一个循环中,确保一一对应
List<String> tempFilePaths = new ArrayList<>();
List<CcdiFileUploadRecord> 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<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秒重试机制

View File

@@ -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<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUserId).thenReturn(9527L);
mocked.when(SecurityUtils::getUsername).thenReturn("admin");
when(fileUploadService.submitPullBankInfo(PROJECT_ID, dto.getIdCards(), "2026-03-01", "2026-03-10", 9527L, "admin"))
.thenReturn("batch-1");
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<SecurityUtils> 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);
}
}

View File

@@ -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<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
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.<CcdiFileUploadRecord>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.<CcdiFileUploadRecord>argThat(item ->
assertEquals("删除成功已开始项目重新打标", result);
verify(lsfxClient, never()).deleteFiles(any());
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>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);