feat(ai-report): 新增MinIO文件存储服务
- 添加AiReportFileConst和AiReportFileExtensionConst常量类 - 实现AiReportMinioStorageService存储服务 - 添加AiReportMinioProperties配置类 - 集成MinIO依赖到ai-report-common模块 - 配置应用yml中的ai-report桶设置 - 更新报告模型控制器使用MinIO存储服务 - 修改报告模型文件存储组件集成MinIO - 添加相关单元测试验证功能
This commit is contained in:
@@ -14,6 +14,12 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.njcn.gather</groupId>
|
||||
<artifactId>ai-report-common</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.njcn.gather</groupId>
|
||||
<artifactId>comservice</artifactId>
|
||||
|
||||
@@ -1,115 +1,84 @@
|
||||
package com.njcn.gather.aireport.reportmodel.component;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
|
||||
import com.njcn.common.pojo.exception.BusinessException;
|
||||
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.InputStream;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Local storage for uploaded report model template files.
|
||||
* 报告模板文件 MinIO 存储服务。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ReportModelFileStorageService {
|
||||
|
||||
private static final String DEFAULT_STORAGE_DIR = "data/report-model";
|
||||
private static final String REPORT_MODEL_STORAGE_DIR = "report-model";
|
||||
private static final DateTimeFormatter DATE_DIR_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||
|
||||
@Value("${files.path:}")
|
||||
private String filesPath;
|
||||
private final AiReportMinioStorageService minioStorageService;
|
||||
|
||||
public StoredFile save(MultipartFile templateFile) {
|
||||
if (templateFile == null || templateFile.isEmpty()) {
|
||||
throw new IllegalArgumentException("模板文件不能为空");
|
||||
}
|
||||
try {
|
||||
Path storageRoot = resolveStorageRoot();
|
||||
String dateDir = LocalDate.now().format(DATE_DIR_FORMATTER);
|
||||
Path storageDir = storageRoot.resolve(dateDir).normalize();
|
||||
if (!storageDir.startsWith(storageRoot)) {
|
||||
throw new IllegalArgumentException("模板文件存储路径不合法");
|
||||
}
|
||||
Files.createDirectories(storageDir);
|
||||
String sanitizedFileName = sanitizeFileName(templateFile.getOriginalFilename());
|
||||
String storedFileName = UUID.randomUUID().toString().replace("-", "") + "-" + sanitizedFileName;
|
||||
Path target = storageDir.resolve(storedFileName).normalize();
|
||||
ensurePathInRoot(storageRoot, target);
|
||||
try (InputStream inputStream = templateFile.getInputStream()) {
|
||||
Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
String storedPath = storageRoot.relativize(target).toString().replace('\\', '/');
|
||||
return new StoredFile(storedPath, sanitizedFileName);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalArgumentException("保存模板文件失败: " + exception.getMessage(), exception);
|
||||
}
|
||||
validateTemplateFile(templateFile);
|
||||
AiReportMinioStorageService.StoredFile storedFile = minioStorageService.saveMultipartFile(
|
||||
AiReportFileConst.BIZ_DIR_REPORT_MODEL, null, templateFile, false);
|
||||
return new StoredFile(storedFile.getObjectName(), storedFile.getOriginalFileName());
|
||||
}
|
||||
|
||||
public Path resolveDownloadPath(String storedPath) {
|
||||
if (trimToNull(storedPath) == null) {
|
||||
throw new IllegalArgumentException("模板文件路径不能为空");
|
||||
}
|
||||
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 byte[] readBytes(String storedPath) {
|
||||
return minioStorageService.readBytes(storedPath);
|
||||
}
|
||||
|
||||
public Path downloadToTempFile(String storedPath, String fileName) {
|
||||
return minioStorageService.downloadToTempFile(storedPath, fileName);
|
||||
}
|
||||
|
||||
public void deleteQuietly(String storedPath) {
|
||||
try {
|
||||
if (trimToNull(storedPath) != null) {
|
||||
Files.deleteIfExists(resolveDownloadPath(storedPath));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Cleanup failure must not hide the original database error.
|
||||
}
|
||||
minioStorageService.deleteQuietly(storedPath);
|
||||
}
|
||||
|
||||
public void deleteTempFileQuietly(Path tempFile) {
|
||||
minioStorageService.deleteTempFileQuietly(tempFile);
|
||||
}
|
||||
|
||||
public String resolveDownloadFileName(String storedPath) {
|
||||
Path fileName = Paths.get(storedPath).getFileName();
|
||||
if (fileName == null) {
|
||||
return "report-model";
|
||||
if (StrUtil.isBlank(storedPath)) {
|
||||
return "report-model.docx";
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
private Path resolveStorageRoot() {
|
||||
String configuredPath = trimToNull(filesPath);
|
||||
Path path = configuredPath == null ? Paths.get(DEFAULT_STORAGE_DIR) : Paths.get(configuredPath, REPORT_MODEL_STORAGE_DIR);
|
||||
return path.toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private void ensurePathInRoot(Path storageRoot, Path target) {
|
||||
if (!target.startsWith(storageRoot)) {
|
||||
throw new IllegalArgumentException("模板文件路径不合法");
|
||||
private void validateTemplateFile(MultipartFile templateFile) {
|
||||
if (templateFile == null || templateFile.isEmpty()) {
|
||||
throw new IllegalArgumentException("模板文件不能为空");
|
||||
}
|
||||
String fileName = templateFile.getOriginalFilename();
|
||||
if (StrUtil.isBlank(fileName)) {
|
||||
throw new IllegalArgumentException("模板文件名不能为空");
|
||||
}
|
||||
String extension = resolveExtension(fileName);
|
||||
if (!AiReportFileExtensionConst.WORD_EXTENSIONS.contains(extension)) {
|
||||
throw new IllegalArgumentException("模板文件仅支持.docx格式");
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFileName(String originalFilename) {
|
||||
String fileName = trimToNull(originalFilename);
|
||||
if (fileName == null) {
|
||||
return "unnamed";
|
||||
private String resolveExtension(String fileName) {
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
if (dotIndex < 0 || dotIndex == fileName.length() - 1) {
|
||||
return "";
|
||||
}
|
||||
String normalizedName = Paths.get(fileName).getFileName().toString();
|
||||
String sanitized = normalizedName.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
return trimToNull(sanitized) == null ? "unnamed" : sanitized;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
return fileName.substring(dotIndex + 1).toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
|
||||
public static class StoredFile {
|
||||
|
||||
@@ -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.reportmodel.component.ReportModelFileStorageService;
|
||||
import com.njcn.gather.aireport.reportmodel.pojo.param.ReportModelParam;
|
||||
import com.njcn.gather.aireport.reportmodel.pojo.po.ReportModelPO;
|
||||
@@ -33,10 +34,8 @@ 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.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
@@ -110,21 +109,14 @@ public class ReportModelController extends BaseController {
|
||||
@GetMapping("/{id}/download")
|
||||
public ResponseEntity<InputStreamResource> download(@PathVariable("id") String id) {
|
||||
ReportModelPO model = reportModelService.requireNormal(id);
|
||||
Path filePath = reportModelFileStorageService.resolveDownloadPath(model.getPath());
|
||||
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "模板文件不存在");
|
||||
}
|
||||
try {
|
||||
String fileName = model.getFileName();
|
||||
if (fileName == null || fileName.trim().isEmpty()) {
|
||||
fileName = reportModelFileStorageService.resolveDownloadFileName(model.getPath());
|
||||
}
|
||||
String fileName = resolveFileName(model);
|
||||
String encodedFileName = encodeFileName(fileName);
|
||||
InputStream inputStream = Files.newInputStream(filePath);
|
||||
AiReportMinioStorageService.StoredObject storedObject = reportModelFileStorageService.getObject(model.getPath());
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFileName)
|
||||
.body(new InputStreamResource(inputStream));
|
||||
.body(new InputStreamResource(storedObject.getInputStream()));
|
||||
} catch (Exception exception) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "读取模板文件失败");
|
||||
}
|
||||
@@ -136,21 +128,19 @@ public class ReportModelController extends BaseController {
|
||||
@GetMapping("/{id}/preview")
|
||||
public ResponseEntity<byte[]> preview(@PathVariable("id") String id) {
|
||||
ReportModelPO model = reportModelService.requireNormal(id);
|
||||
Path filePath = reportModelFileStorageService.resolveDownloadPath(model.getPath());
|
||||
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "模板文件不存在");
|
||||
String fileName = resolveFileName(model);
|
||||
Path tempFile = reportModelFileStorageService.downloadToTempFile(model.getPath(), fileName);
|
||||
try {
|
||||
FilePreviewService.PreviewContent previewContent = filePreviewService.preview(tempFile, fileName);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"inline; filename*=UTF-8''" + encodeFileName(fileName))
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
||||
.contentType(MediaType.parseMediaType(previewContent.getContentType()))
|
||||
.body(previewContent.getContent());
|
||||
} finally {
|
||||
reportModelFileStorageService.deleteTempFileQuietly(tempFile);
|
||||
}
|
||||
String fileName = model.getFileName();
|
||||
if (fileName == null || fileName.trim().isEmpty()) {
|
||||
fileName = reportModelFileStorageService.resolveDownloadFileName(model.getPath());
|
||||
}
|
||||
FilePreviewService.PreviewContent previewContent = filePreviewService.preview(filePath, fileName);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"inline; filename*=UTF-8''" + encodeFileName(fileName))
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate")
|
||||
.contentType(MediaType.parseMediaType(previewContent.getContentType()))
|
||||
.body(previewContent.getContent());
|
||||
}
|
||||
|
||||
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
|
||||
@@ -170,4 +160,12 @@ public class ReportModelController extends BaseController {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "编码模板文件名失败");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveFileName(ReportModelPO model) {
|
||||
String fileName = model.getFileName();
|
||||
if (fileName == null || fileName.trim().isEmpty()) {
|
||||
return reportModelFileStorageService.resolveDownloadFileName(model.getPath());
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ public class ReportModelServiceImpl extends ServiceImpl<ReportModelMapper, Repor
|
||||
}
|
||||
String name = resolveName(param.getName());
|
||||
checkNameUnique(name, model.getId());
|
||||
String oldStoredPath = model.getPath();
|
||||
String newStoredPath = null;
|
||||
if (templateFile != null && !templateFile.isEmpty()) {
|
||||
ReportModelFileStorageService.StoredFile storedFile = reportModelFileStorageService.save(templateFile);
|
||||
@@ -118,7 +119,12 @@ public class ReportModelServiceImpl extends ServiceImpl<ReportModelMapper, Repor
|
||||
model.setName(name);
|
||||
model.setUpdateBy(resolveCurrentUserId());
|
||||
model.setUpdateTime(LocalDateTime.now());
|
||||
return this.updateById(model);
|
||||
boolean updated = this.updateById(model);
|
||||
if (updated && StrUtil.isNotBlank(newStoredPath) && StrUtil.isNotBlank(oldStoredPath)
|
||||
&& !StrUtil.equals(oldStoredPath, newStoredPath)) {
|
||||
reportModelFileStorageService.deleteQuietly(oldStoredPath);
|
||||
}
|
||||
return updated;
|
||||
} catch (RuntimeException exception) {
|
||||
reportModelFileStorageService.deleteQuietly(newStoredPath);
|
||||
throw exception;
|
||||
@@ -135,7 +141,11 @@ public class ReportModelServiceImpl extends ServiceImpl<ReportModelMapper, Repor
|
||||
if (validIds.isEmpty()) {
|
||||
throw new BusinessException(CommonResponseEnum.FAIL, "模板ID不能为空");
|
||||
}
|
||||
return this.lambdaUpdate()
|
||||
List<ReportModelPO> models = this.lambdaQuery()
|
||||
.in(ReportModelPO::getId, validIds)
|
||||
.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
|
||||
.list();
|
||||
boolean deleted = this.lambdaUpdate()
|
||||
.set(ReportModelPO::getStatus, ReportModelConst.STATUS_DELETED)
|
||||
.set(ReportModelPO::getState, ReportModelConst.STATE_DELETED)
|
||||
.set(ReportModelPO::getUpdateBy, resolveCurrentUserId())
|
||||
@@ -143,6 +153,12 @@ public class ReportModelServiceImpl extends ServiceImpl<ReportModelMapper, Repor
|
||||
.in(ReportModelPO::getId, validIds)
|
||||
.eq(ReportModelPO::getStatus, ReportModelConst.STATUS_NORMAL)
|
||||
.update();
|
||||
if (deleted) {
|
||||
for (ReportModelPO model : models) {
|
||||
reportModelFileStorageService.deleteQuietly(model.getPath());
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
package com.njcn.gather.aireport.reportmodel.component;
|
||||
|
||||
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 ReportModelFileStorageServiceTest {
|
||||
|
||||
@Test
|
||||
public void saveSanitizesFileNameAndReturnsRelativePath() throws Exception {
|
||||
Path filesRoot = Files.createTempDirectory("files-root");
|
||||
ReportModelFileStorageService service = new ReportModelFileStorageService();
|
||||
ReflectionTestUtils.setField(service, "filesPath", filesRoot.toString());
|
||||
public void saveShouldDelegateToMinioStorage() {
|
||||
AiReportMinioStorageService minioStorageService = Mockito.mock(AiReportMinioStorageService.class);
|
||||
ReportModelFileStorageService service = new ReportModelFileStorageService(minioStorageService);
|
||||
Mockito.when(minioStorageService.saveMultipartFile(Mockito.anyString(), Mockito.isNull(),
|
||||
Mockito.any(MockMultipartFile.class), Mockito.eq(false)))
|
||||
.thenReturn(new AiReportMinioStorageService.StoredFile("cn-tool-ai-report",
|
||||
"report-model/template-id-a_b.docx", "template-id-a_b.docx", "a_b.docx",
|
||||
"application/octet-stream", 7L));
|
||||
|
||||
ReportModelFileStorageService.StoredFile storedFile = service.save(new MockMultipartFile("templateFile", "../a:b.docx",
|
||||
"application/octet-stream", "content".getBytes("UTF-8")));
|
||||
String storedPath = storedFile.getPath();
|
||||
ReportModelFileStorageService.StoredFile storedFile = service.save(new MockMultipartFile(
|
||||
"templateFile", "../a:b.docx", "application/octet-stream", "content".getBytes()));
|
||||
|
||||
Assert.assertFalse(storedPath.contains(".."));
|
||||
Assert.assertTrue(storedPath.endsWith("-a_b.docx"));
|
||||
Assert.assertEquals("report-model/template-id-a_b.docx", storedFile.getPath());
|
||||
Assert.assertEquals("a_b.docx", storedFile.getFileName());
|
||||
Assert.assertTrue(service.resolveDownloadPath(storedPath)
|
||||
.startsWith(filesRoot.resolve("report-model").toAbsolutePath().normalize()));
|
||||
Assert.assertTrue(Files.exists(service.resolveDownloadPath(storedPath)));
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException.class)
|
||||
public void resolveDownloadPathRejectsPathOutsideStorageRoot() throws Exception {
|
||||
Path filesRoot = Files.createTempDirectory("files-root");
|
||||
ReportModelFileStorageService service = new ReportModelFileStorageService();
|
||||
ReflectionTestUtils.setField(service, "filesPath", filesRoot.toString());
|
||||
|
||||
service.resolveDownloadPath("../outside.docx");
|
||||
public void saveShouldRejectNonDocxFile() {
|
||||
ReportModelFileStorageService service = new ReportModelFileStorageService(Mockito.mock(AiReportMinioStorageService.class));
|
||||
service.save(new MockMultipartFile("templateFile", "template.pdf", "application/pdf", new byte[]{1}));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user