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