校验上传流水文件名身份证信息
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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秒重试机制
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user