feat(ai-report): 新增MinIO文件存储服务
- 添加AiReportFileConst和AiReportFileExtensionConst常量类 - 实现AiReportMinioStorageService存储服务 - 添加AiReportMinioProperties配置类 - 集成MinIO依赖到ai-report-common模块 - 配置应用yml中的ai-report桶设置 - 更新报告模型控制器使用MinIO存储服务 - 修改报告模型文件存储组件集成MinIO - 添加相关单元测试验证功能
This commit is contained in:
@@ -3,98 +3,65 @@ package com.njcn.gather.aireport.testdevice.component;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
|
||||
import com.njcn.common.pojo.exception.BusinessException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import com.njcn.gather.aireport.common.constant.AiReportFileConst;
|
||||
import com.njcn.gather.aireport.common.constant.AiReportFileExtensionConst;
|
||||
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
import java.util.Locale;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TestDeviceReportStorageService {
|
||||
|
||||
private static final String DEFAULT_STORAGE_DIR = "data/test-device-report";
|
||||
private static final String TEST_DEVICE_REPORT_STORAGE_DIR = "test-device-report";
|
||||
private static final DateTimeFormatter DATE_DIR_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||
private static final long MAX_REPORT_SIZE = 10L * 1024L * 1024L;
|
||||
private static final int MAX_ORIGINAL_NAME_LENGTH = 80;
|
||||
|
||||
@Value("${files.path:}")
|
||||
private String filesPath;
|
||||
private final AiReportMinioStorageService minioStorageService;
|
||||
|
||||
public String save(MultipartFile reportFile) {
|
||||
if (reportFile == null || reportFile.isEmpty()) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告不能为空");
|
||||
}
|
||||
validatePdf(reportFile.getOriginalFilename(), reportFile.getSize());
|
||||
try (InputStream inputStream = reportFile.getInputStream()) {
|
||||
return savePdf(reportFile.getOriginalFilename(), inputStream, reportFile.getSize());
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "保存校准报告失败");
|
||||
}
|
||||
return minioStorageService.saveMultipartFile(
|
||||
AiReportFileConst.BIZ_DIR_TEST_DEVICE, null, reportFile, false).getObjectName();
|
||||
}
|
||||
|
||||
public String savePdf(String originalFilename, InputStream inputStream, long size) {
|
||||
validatePdf(originalFilename, size);
|
||||
try {
|
||||
Path storageRoot = resolveStorageRoot();
|
||||
String dateDir = LocalDate.now().format(DATE_DIR_FORMATTER);
|
||||
Path storageDir = storageRoot.resolve(dateDir).normalize();
|
||||
ensurePathInRoot(storageRoot, storageDir);
|
||||
Files.createDirectories(storageDir);
|
||||
|
||||
String fileName = UUID.randomUUID().toString().replace("-", "") + "-" + sanitizeFileName(originalFilename);
|
||||
Path target = storageDir.resolve(fileName).normalize();
|
||||
ensurePathInRoot(storageRoot, target);
|
||||
copyWithSizeLimit(inputStream, target);
|
||||
return storageRoot.relativize(target).toString().replace('\\', '/');
|
||||
} catch (BusinessException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "保存校准报告失败");
|
||||
}
|
||||
return minioStorageService.saveInputStream(AiReportFileConst.BIZ_DIR_TEST_DEVICE, null,
|
||||
trimFileName(originalFilename), inputStream, size, "application/pdf", false).getObjectName();
|
||||
}
|
||||
|
||||
public Path resolveDownloadPath(String storedPath) {
|
||||
if (StrUtil.isBlank(storedPath)) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告路径不能为空");
|
||||
}
|
||||
Path storageRoot = resolveStorageRoot();
|
||||
Path target = storageRoot.resolve(storedPath).normalize();
|
||||
ensurePathInRoot(storageRoot, target);
|
||||
return target;
|
||||
public AiReportMinioStorageService.StoredObject getObject(String storedPath) {
|
||||
return minioStorageService.getObject(storedPath);
|
||||
}
|
||||
|
||||
public String resolveDownloadFileName(String storedPath) {
|
||||
Path fileName = Paths.get(storedPath).getFileName();
|
||||
if (fileName == null) {
|
||||
if (StrUtil.isBlank(storedPath)) {
|
||||
return "校准报告.pdf";
|
||||
}
|
||||
String name = fileName.toString();
|
||||
int separatorIndex = name.indexOf('-');
|
||||
return separatorIndex >= 0 && separatorIndex + 1 < name.length() ? name.substring(separatorIndex + 1) : name;
|
||||
int slashIndex = storedPath.lastIndexOf('/');
|
||||
String fileName = slashIndex >= 0 ? storedPath.substring(slashIndex + 1) : storedPath;
|
||||
int separatorIndex = fileName.indexOf('-');
|
||||
return separatorIndex >= 0 && separatorIndex + 1 < fileName.length() ? fileName.substring(separatorIndex + 1) : fileName;
|
||||
}
|
||||
|
||||
public void deleteQuietly(String storedPath) {
|
||||
try {
|
||||
if (StrUtil.isNotBlank(storedPath)) {
|
||||
Files.deleteIfExists(resolveDownloadPath(storedPath));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 文件清理失败不能覆盖原始业务结果。
|
||||
}
|
||||
minioStorageService.deleteQuietly(storedPath);
|
||||
}
|
||||
|
||||
private void validatePdf(String originalFilename, long size) {
|
||||
if (StrUtil.isBlank(originalFilename) || !originalFilename.toLowerCase().endsWith(".pdf")) {
|
||||
String fileName = trimFileName(originalFilename);
|
||||
if (StrUtil.isBlank(fileName)) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告文件名不能为空");
|
||||
}
|
||||
String extension = resolveExtension(fileName);
|
||||
if (!AiReportFileExtensionConst.PDF_EXTENSIONS.contains(extension)) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告仅支持PDF文件");
|
||||
}
|
||||
if (size > MAX_REPORT_SIZE) {
|
||||
@@ -102,48 +69,27 @@ public class TestDeviceReportStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
private void copyWithSizeLimit(InputStream inputStream, Path target) throws IOException {
|
||||
Path tempFile = Files.createTempFile(target.getParent(), "upload-", ".tmp");
|
||||
long bytes = 0L;
|
||||
byte[] buffer = new byte[8192];
|
||||
try {
|
||||
int length;
|
||||
try (InputStream source = inputStream;
|
||||
OutputStream targetStream = Files.newOutputStream(tempFile)) {
|
||||
while ((length = source.read(buffer)) != -1) {
|
||||
bytes += length;
|
||||
if (bytes > MAX_REPORT_SIZE) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告不能超过10MB");
|
||||
}
|
||||
targetStream.write(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
Files.move(tempFile, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
} finally {
|
||||
Files.deleteIfExists(tempFile);
|
||||
private String trimFileName(String originalFilename) {
|
||||
String fileName = originalFilename == null ? null : originalFilename.trim();
|
||||
if (StrUtil.isBlank(fileName)) {
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveStorageRoot() {
|
||||
String configuredPath = StrUtil.isBlank(filesPath) ? null : filesPath.trim();
|
||||
Path path = configuredPath == null ? Paths.get(DEFAULT_STORAGE_DIR) : Paths.get(configuredPath, TEST_DEVICE_REPORT_STORAGE_DIR);
|
||||
return path.toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private void ensurePathInRoot(Path storageRoot, Path target) {
|
||||
if (!target.startsWith(storageRoot)) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告路径不合法");
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFileName(String originalFilename) {
|
||||
String fileName = Paths.get(originalFilename).getFileName().toString();
|
||||
String sanitized = fileName.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
int slashIndex = Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\'));
|
||||
String normalizedName = slashIndex >= 0 ? fileName.substring(slashIndex + 1) : fileName;
|
||||
String sanitized = normalizedName.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
if (sanitized.length() > MAX_ORIGINAL_NAME_LENGTH) {
|
||||
String suffix = sanitized.toLowerCase().endsWith(".pdf") ? ".pdf" : "";
|
||||
String suffix = sanitized.toLowerCase(Locale.ENGLISH).endsWith(".pdf") ? ".pdf" : "";
|
||||
int keepLength = MAX_ORIGINAL_NAME_LENGTH - suffix.length();
|
||||
sanitized = sanitized.substring(0, keepLength) + suffix;
|
||||
}
|
||||
return StrUtil.isBlank(sanitized) ? "report.pdf" : sanitized;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private String resolveExtension(String fileName) {
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
if (dotIndex < 0 || dotIndex == fileName.length() - 1) {
|
||||
return "";
|
||||
}
|
||||
return fileName.substring(dotIndex + 1).toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.njcn.common.pojo.enums.common.LogEnum;
|
||||
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
|
||||
import com.njcn.common.pojo.exception.BusinessException;
|
||||
import com.njcn.common.pojo.response.HttpResult;
|
||||
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
|
||||
import com.njcn.gather.aireport.testdevice.component.TestDeviceReportStorageService;
|
||||
import com.njcn.gather.aireport.testdevice.pojo.param.TestDeviceParam;
|
||||
import com.njcn.gather.aireport.testdevice.pojo.po.TestDevicePO;
|
||||
@@ -34,10 +35,7 @@ import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
@Api(tags = "检测设备管理")
|
||||
@@ -145,19 +143,15 @@ public class TestDeviceController extends BaseController {
|
||||
if (device == null || device.getValidityReport() == null || device.getValidityReport().trim().isEmpty()) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告文件不存在");
|
||||
}
|
||||
Path filePath = reportStorageService.resolveDownloadPath(device.getValidityReport());
|
||||
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "校准报告文件不存在");
|
||||
}
|
||||
try {
|
||||
String fileName = reportStorageService.resolveDownloadFileName(device.getValidityReport());
|
||||
String encodedFileName = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
|
||||
InputStream inputStream = Files.newInputStream(filePath);
|
||||
AiReportMinioStorageService.StoredObject storedObject = reportStorageService.getObject(device.getValidityReport());
|
||||
return ResponseEntity.ok()
|
||||
.contentType(preview ? MediaType.APPLICATION_PDF : MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
(preview ? "inline" : "attachment") + "; filename*=UTF-8''" + encodedFileName)
|
||||
.body(new InputStreamResource(inputStream));
|
||||
.body(new InputStreamResource(storedObject.getInputStream()));
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "读取校准报告失败");
|
||||
}
|
||||
|
||||
@@ -163,13 +163,23 @@ public class TestDeviceServiceImpl extends ServiceImpl<TestDeviceMapper, TestDev
|
||||
if (validIds.isEmpty()) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "检测设备ID不能为空");
|
||||
}
|
||||
return this.lambdaUpdate()
|
||||
List<TestDevicePO> devices = this.lambdaQuery()
|
||||
.in(TestDevicePO::getId, validIds)
|
||||
.eq(TestDevicePO::getStatus, TestDeviceConst.STATUS_NORMAL)
|
||||
.list();
|
||||
boolean deleted = this.lambdaUpdate()
|
||||
.set(TestDevicePO::getStatus, TestDeviceConst.STATUS_DELETED)
|
||||
.set(TestDevicePO::getUpdateBy, resolveCurrentUserId())
|
||||
.set(TestDevicePO::getUpdateTime, LocalDateTime.now())
|
||||
.in(TestDevicePO::getId, validIds)
|
||||
.eq(TestDevicePO::getStatus, TestDeviceConst.STATUS_NORMAL)
|
||||
.update();
|
||||
if (deleted) {
|
||||
for (TestDevicePO device : devices) {
|
||||
reportStorageService.deleteQuietly(device.getValidityReport());
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
package com.njcn.gather.aireport.testdevice.component;
|
||||
|
||||
import com.njcn.common.pojo.exception.BusinessException;
|
||||
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class TestDeviceReportStorageServiceTest {
|
||||
|
||||
@Test
|
||||
public void saveUsesTestDeviceReportDirectoryUnderFilesRoot() throws Exception {
|
||||
Path filesRoot = Files.createTempDirectory("files-root");
|
||||
TestDeviceReportStorageService service = new TestDeviceReportStorageService();
|
||||
ReflectionTestUtils.setField(service, "filesPath", filesRoot.toString());
|
||||
public void saveShouldDelegateToMinioStorage() {
|
||||
AiReportMinioStorageService minioStorageService = Mockito.mock(AiReportMinioStorageService.class);
|
||||
TestDeviceReportStorageService service = new TestDeviceReportStorageService(minioStorageService);
|
||||
Mockito.when(minioStorageService.saveMultipartFile(Mockito.anyString(), Mockito.isNull(),
|
||||
Mockito.any(MockMultipartFile.class), Mockito.eq(false)))
|
||||
.thenReturn(new AiReportMinioStorageService.StoredFile("cn-tool-ai-report",
|
||||
"test-device/report-id-report.pdf", "report-id-report.pdf", "report.pdf",
|
||||
"application/pdf", 8L));
|
||||
|
||||
String storedPath = service.save(new MockMultipartFile("reportFile", "../report.pdf",
|
||||
"application/pdf", "%PDF-1.4".getBytes("UTF-8")));
|
||||
"application/pdf", "%PDF-1.4".getBytes()));
|
||||
|
||||
Path expectedRoot = filesRoot.resolve("test-device-report").toAbsolutePath().normalize();
|
||||
Path actualPath = service.resolveDownloadPath(storedPath);
|
||||
Assert.assertTrue(actualPath.startsWith(expectedRoot));
|
||||
Assert.assertTrue(storedPath.endsWith("-report.pdf"));
|
||||
Assert.assertTrue(Files.exists(actualPath));
|
||||
Assert.assertEquals("test-device/report-id-report.pdf", storedPath);
|
||||
}
|
||||
|
||||
@Test(expected = BusinessException.class)
|
||||
public void saveShouldRejectNonPdfFile() {
|
||||
TestDeviceReportStorageService service = new TestDeviceReportStorageService(Mockito.mock(AiReportMinioStorageService.class));
|
||||
service.save(new MockMultipartFile("reportFile", "report.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.njcn.gather.aireport.testdevice.controller;
|
||||
|
||||
import com.njcn.gather.aireport.common.storage.AiReportMinioStorageService;
|
||||
import com.njcn.gather.aireport.testdevice.component.TestDeviceReportStorageService;
|
||||
import com.njcn.gather.aireport.testdevice.pojo.po.TestDevicePO;
|
||||
import com.njcn.gather.aireport.testdevice.service.TestDeviceService;
|
||||
import io.minio.GetObjectResponse;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
@@ -12,10 +14,6 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
class TestDeviceControllerTest {
|
||||
|
||||
private final TestDeviceService testDeviceService = Mockito.mock(TestDeviceService.class);
|
||||
@@ -27,11 +25,11 @@ class TestDeviceControllerTest {
|
||||
TestDevicePO device = new TestDevicePO();
|
||||
device.setId("device-1");
|
||||
device.setValidityReport("20260626/report.pdf");
|
||||
Path tempFile = Files.createTempFile("test-device-preview-", ".pdf");
|
||||
Files.write(tempFile, "%PDF-1.4".getBytes(StandardCharsets.UTF_8));
|
||||
AiReportMinioStorageService.StoredObject storedObject = Mockito.mock(AiReportMinioStorageService.StoredObject.class);
|
||||
Mockito.when(testDeviceService.requireNormal("device-1")).thenReturn(device);
|
||||
Mockito.when(reportStorageService.resolveDownloadPath("20260626/report.pdf")).thenReturn(tempFile);
|
||||
Mockito.when(reportStorageService.resolveDownloadFileName("20260626/report.pdf")).thenReturn("report.pdf");
|
||||
Mockito.when(reportStorageService.getObject("20260626/report.pdf")).thenReturn(storedObject);
|
||||
Mockito.when(storedObject.getInputStream()).thenReturn(Mockito.mock(GetObjectResponse.class));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ResponseEntity<InputStreamResource> response = (ResponseEntity<InputStreamResource>)
|
||||
|
||||
Reference in New Issue
Block a user