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

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.njcn.gather</groupId>
<artifactId>ai-report</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>ai-report-common</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>njcn-common</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.njcn</groupId>
<artifactId>spingboot2.3.12</artifactId>
<version>2.3.12</version>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.4.3</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,36 @@
package com.njcn.gather.aireport.common.config;
import cn.hutool.core.util.StrUtil;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.exception.BusinessException;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* ai-report MinIO 配置。
*/
@Data
@Component
@ConfigurationProperties(prefix = "storage.minio")
public class AiReportMinioProperties {
private String endpoint;
private String accessKey;
private String secretKey;
private Map<String, String> buckets = new LinkedHashMap<String, String>();
public String requireBucket(String bucketKey) {
String bucketName = buckets.get(bucketKey);
if (StrUtil.isBlank(bucketName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "MinIO桶配置缺失" + bucketKey);
}
return bucketName;
}
}

View File

@@ -0,0 +1,16 @@
package com.njcn.gather.aireport.common.constant;
/**
* ai-report 文件常量。
*/
public final class AiReportFileConst {
private AiReportFileConst() {
}
public static final String BUCKET_KEY_AI_REPORT = "ai-report";
public static final String BIZ_DIR_REPORT_MODEL = "report-model";
public static final String BIZ_DIR_TEST_DEVICE = "test-device";
public static final String BIZ_DIR_TEST_REPORT = "test-report";
}

View File

@@ -0,0 +1,27 @@
package com.njcn.gather.aireport.common.constant;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* ai-report 文件扩展名白名单。
*/
public final class AiReportFileExtensionConst {
private AiReportFileExtensionConst() {
}
public static final Set<String> ALLOWED_EXTENSIONS = unmodifiable("docx", "xlsx", "pdf");
public static final Set<String> WORD_EXTENSIONS = unmodifiable("docx");
public static final Set<String> EXCEL_EXTENSIONS = unmodifiable("xlsx");
public static final Set<String> PDF_EXTENSIONS = unmodifiable("pdf");
private static Set<String> unmodifiable(String... extensions) {
return Collections.unmodifiableSet(new LinkedHashSet<String>(Arrays.asList(extensions)));
}
}

View File

@@ -0,0 +1,384 @@
package com.njcn.gather.aireport.common.storage;
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.config.AiReportMinioProperties;
import com.njcn.gather.aireport.common.constant.AiReportFileConst;
import io.minio.BucketExistsArgs;
import io.minio.GetObjectArgs;
import io.minio.GetObjectResponse;
import io.minio.ListObjectsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import io.minio.Result;
import io.minio.StatObjectArgs;
import io.minio.errors.ErrorResponseException;
import io.minio.messages.Item;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
/**
* ai-report MinIO 存储服务。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AiReportMinioStorageService {
private final AiReportMinioProperties properties;
public StoredFile saveMultipartFile(String bizDir, String ownerId, MultipartFile file, boolean keepOriginalName) {
MultipartFile normalizedFile = requireFile(file);
String originalFileName = normalizeFileName(normalizedFile.getOriginalFilename());
String targetFileName = keepOriginalName ? sanitizeFileName(originalFileName) : buildStoredFileName(originalFileName);
String objectName = buildObjectName(bizDir, ownerId, targetFileName);
String bucketName = requireBucket();
String contentType = resolveContentType(normalizedFile.getContentType(), originalFileName);
try (InputStream inputStream = normalizedFile.getInputStream()) {
putObject(bucketName, objectName, inputStream, normalizedFile.getSize(), contentType);
return new StoredFile(bucketName, objectName, targetFileName, originalFileName, contentType, normalizedFile.getSize());
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
log.error("save multipart file to minio failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "保存文件失败");
}
}
public StoredFile saveInputStream(String bizDir, String ownerId, String originalFileName, InputStream inputStream,
long size, String contentType, boolean keepOriginalName) {
String normalizedFileName = normalizeFileName(originalFileName);
String targetFileName = keepOriginalName ? sanitizeFileName(normalizedFileName) : buildStoredFileName(normalizedFileName);
String objectName = buildObjectName(bizDir, ownerId, targetFileName);
String bucketName = requireBucket();
try {
putObject(bucketName, objectName, inputStream, size, resolveContentType(contentType, normalizedFileName));
return new StoredFile(bucketName, objectName, targetFileName, normalizedFileName,
resolveContentType(contentType, normalizedFileName), size);
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
log.error("save input stream to minio failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "保存文件失败");
}
}
public StoredFile saveBytes(String bizDir, String ownerId, String originalFileName, byte[] content,
String contentType, boolean keepOriginalName) {
byte[] fileContent = content == null ? new byte[0] : content;
return saveInputStream(bizDir, ownerId, originalFileName, new ByteArrayInputStream(fileContent),
fileContent.length, contentType, keepOriginalName);
}
public StoredObject getObject(String objectName) {
if (StrUtil.isBlank(objectName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "文件不存在");
}
String bucketName = requireBucket();
try {
GetObjectResponse response = buildClient().getObject(GetObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
return new StoredObject(bucketName, objectName, response);
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
log.error("read file from minio failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
}
}
public byte[] readBytes(String objectName) {
try (StoredObject storedObject = getObject(objectName);
InputStream inputStream = storedObject.getInputStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
int length;
while ((length = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, length);
}
return outputStream.toByteArray();
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
log.error("read file bytes from minio failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
}
}
public Path downloadToTempFile(String objectName, String fileName) {
String normalizedFileName = normalizeFileName(fileName);
String extension = resolveExtension(normalizedFileName);
String suffix = StrUtil.isBlank(extension) ? ".tmp" : "." + extension;
try (StoredObject storedObject = getObject(objectName);
InputStream inputStream = storedObject.getInputStream()) {
Path tempFile = Files.createTempFile("ai-report-", suffix);
Files.copy(inputStream, tempFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
return tempFile;
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
log.error("download minio object to temp file failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
}
}
public List<String> listFileNames(String prefix) {
List<String> objectNames = listObjectNames(prefix);
List<String> result = new ArrayList<String>();
for (String objectName : objectNames) {
int slashIndex = objectName.lastIndexOf('/');
result.add(slashIndex >= 0 ? objectName.substring(slashIndex + 1) : objectName);
}
return result;
}
public List<String> listObjectNames(String prefix) {
List<String> result = new ArrayList<String>();
if (StrUtil.isBlank(prefix)) {
return result;
}
try {
Iterable<Result<Item>> iterable = buildClient().listObjects(ListObjectsArgs.builder()
.bucket(requireBucket())
.prefix(trimPath(prefix) + "/")
.recursive(true)
.build());
for (Result<Item> itemResult : iterable) {
Item item = itemResult.get();
if (item == null || item.isDir()) {
continue;
}
String objectName = item.objectName();
if (StrUtil.isBlank(objectName)) {
continue;
}
result.add(objectName);
}
return result;
} catch (Exception exception) {
log.error("list minio objects failed, prefix={}", prefix, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
}
}
public void deleteQuietly(String objectName) {
if (StrUtil.isBlank(objectName)) {
return;
}
try {
buildClient().removeObject(RemoveObjectArgs.builder()
.bucket(requireBucket())
.object(objectName)
.build());
} catch (Exception exception) {
log.warn("delete file from minio failed, objectName={}", objectName, exception);
}
}
public boolean exists(String objectName) {
if (StrUtil.isBlank(objectName)) {
return false;
}
try {
buildClient().statObject(StatObjectArgs.builder()
.bucket(requireBucket())
.object(objectName)
.build());
return true;
} catch (ErrorResponseException exception) {
String errorCode = exception.errorResponse() == null ? null : exception.errorResponse().code();
if ("NoSuchKey".equals(errorCode) || "NoSuchObject".equals(errorCode) || "NoSuchBucket".equals(errorCode)) {
return false;
}
log.error("stat minio object failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
} catch (Exception exception) {
log.error("stat minio object failed, objectName={}", objectName, exception);
throw new BusinessException(CommonResponseEnum.FAIL, "读取文件失败");
}
}
public void deleteTempFileQuietly(Path tempFile) {
if (tempFile == null) {
return;
}
try {
Files.deleteIfExists(tempFile);
} catch (IOException ignored) {
// 临时文件清理失败不影响主流程。
}
}
private void putObject(String bucketName, String objectName, InputStream inputStream, long size, String contentType) throws Exception {
ensureBucket(buildClient(), bucketName);
buildClient().putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(inputStream, size, -1)
.contentType(contentType)
.build());
}
private MultipartFile requireFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException(CommonResponseEnum.FAIL, "上传文件不能为空");
}
return file;
}
private String requireBucket() {
validateConfig();
return properties.requireBucket(AiReportFileConst.BUCKET_KEY_AI_REPORT);
}
private void validateConfig() {
if (StrUtil.hasBlank(properties.getEndpoint(), properties.getAccessKey(), properties.getSecretKey())) {
throw new BusinessException(CommonResponseEnum.FAIL, "MinIO配置不完整");
}
}
private MinioClient buildClient() {
return MinioClient.builder()
.endpoint(properties.getEndpoint())
.credentials(properties.getAccessKey(), properties.getSecretKey())
.build();
}
private void ensureBucket(MinioClient client, String bucketName) throws Exception {
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
}
}
private String buildObjectName(String bizDir, String ownerId, String fileName) {
StringBuilder builder = new StringBuilder();
builder.append(trimPath(bizDir));
if (StrUtil.isNotBlank(ownerId)) {
builder.append("/").append(trimPath(ownerId));
}
builder.append("/").append(fileName);
return builder.toString();
}
private String buildStoredFileName(String originalFileName) {
return UUID.randomUUID().toString().replace("-", "") + "-" + sanitizeFileName(originalFileName);
}
private String sanitizeFileName(String originalFileName) {
String fileName = normalizeFileName(originalFileName);
int slashIndex = Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\'));
String normalizedFileName = slashIndex >= 0 ? fileName.substring(slashIndex + 1) : fileName;
return normalizedFileName.replaceAll("[\\\\/:*?\"<>|]", "_");
}
private String normalizeFileName(String originalFileName) {
String fileName = StrUtil.trimToEmpty(originalFileName);
if (StrUtil.isBlank(fileName)) {
throw new BusinessException(CommonResponseEnum.FAIL, "文件名不能为空");
}
return fileName;
}
private String trimPath(String value) {
String normalized = StrUtil.trimToEmpty(value).replace("\\", "/");
while (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
if (StrUtil.isBlank(normalized)) {
throw new BusinessException(CommonResponseEnum.FAIL, "对象目录配置不合法");
}
return normalized;
}
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);
}
private String resolveContentType(String contentType, String fileName) {
if (StrUtil.isNotBlank(contentType)) {
return contentType;
}
return MediaTypeFactory.getMediaType(fileName).map(MediaType::toString)
.orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE);
}
@Getter
public static class StoredFile {
private final String bucketName;
private final String objectName;
private final String storedFileName;
private final String originalFileName;
private final String contentType;
private final long fileSize;
public StoredFile(String bucketName, String objectName, String storedFileName, String originalFileName,
String contentType, long fileSize) {
this.bucketName = bucketName;
this.objectName = objectName;
this.storedFileName = storedFileName;
this.originalFileName = originalFileName;
this.contentType = contentType;
this.fileSize = fileSize;
}
}
public static class StoredObject implements AutoCloseable {
private final String bucketName;
private final String objectName;
private final GetObjectResponse inputStream;
public StoredObject(String bucketName, String objectName, GetObjectResponse inputStream) {
this.bucketName = bucketName;
this.objectName = objectName;
this.inputStream = inputStream;
}
public String getBucketName() {
return bucketName;
}
public String getObjectName() {
return objectName;
}
public GetObjectResponse getInputStream() {
return inputStream;
}
@Override
public void close() throws Exception {
if (inputStream != null) {
inputStream.close();
}
}
}
}