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

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

View File

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

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.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;
}
}

View File

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

View File

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