feat(ai-report): 新增MinIO文件存储服务

- 添加AiReportFileConst和AiReportFileExtensionConst常量类
- 实现AiReportMinioStorageService存储服务
- 添加AiReportMinioProperties配置类
- 集成MinIO依赖到ai-report-common模块
- 配置应用yml中的ai-report桶设置
- 更新报告模型控制器使用MinIO存储服务
- 修改报告模型文件存储组件集成MinIO
- 添加相关单元测试验证功能
This commit is contained in:
dk
2026-07-14 16:38:11 +08:00
parent aac21e5f73
commit 55a74815b2
29 changed files with 2111 additions and 911 deletions

View File

@@ -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);
}
}

View File

@@ -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, "读取校准报告失败");
}

View File

@@ -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

View File

@@ -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}));
}
}

View File

@@ -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>)