feat: 新增heritage-plugins存储插件(第4部分)

- 新增heritage-oss:对象存储插件
- 支持本地存储、阿里云OSS、MinIO三种存储方式
- 提供统一的存储接口,可灵活切换存储类型
- 支持文件上传、下载、删除等操作
This commit is contained in:
Leo 2025-10-08 02:08:14 +08:00
parent db6f4b1922
commit 9c1937b4fb
11 changed files with 1217 additions and 0 deletions

View File

@ -0,0 +1,56 @@
<?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>org.leocoder.heritage</groupId>
<artifactId>heritage-plugins</artifactId>
<version>${revision}</version>
</parent>
<name>heritage-oss</name>
<artifactId>heritage-oss</artifactId>
<description>阿里云OSS对象存储模块</description>
<dependencies>
<!-- 全局公共模块 -->
<dependency>
<groupId>org.leocoder.heritage</groupId>
<artifactId>heritage-common</artifactId>
<version>${revision}</version>
</dependency>
<!-- 阿里云OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
</dependency>
<!-- MinIO SDK -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
</dependency>
<!-- Spring Boot Configuration Processor -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,18 @@
package org.leocoder.heritage.oss.annotation;
import org.leocoder.heritage.oss.config.OssAutoConfiguration;
import org.springframework.context.annotation.Import;
import java.lang.annotation.*;
/**
* 启用Coder OSS插件注解
*
* @author Leocoder
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(OssAutoConfiguration.class)
public @interface EnableCoderOss {
}

View File

@ -0,0 +1,71 @@
package org.leocoder.heritage.oss.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* MinIO配置类
*
* @author Leocoder
*/
@Data
@Component
@ConfigurationProperties(prefix = "coder.minio")
public class MinioConfig {
/**
* MinIO服务端点
*/
private String endpoint;
/**
* Access Key
*/
private String accessKey;
/**
* Secret Key
*/
private String secretKey;
/**
* 存储桶名称
*/
private String bucketName;
/**
* 自定义域名
*/
private String domain;
/**
* 路径前缀
*/
private String pathPrefix = "coder-files";
/**
* 连接超时时间毫秒
*/
private Long connectTimeout = 10000L;
/**
* 写入超时时间毫秒
*/
private Long writeTimeout = 10000L;
/**
* 读取超时时间毫秒
*/
private Long readTimeout = 10000L;
/**
* 是否启用MinIO存储
*/
private Boolean enabled = false;
/**
* 区域设置可选
*/
private String region;
}

View File

@ -0,0 +1,167 @@
package org.leocoder.heritage.oss.config;
import com.aliyun.oss.ClientBuilderConfiguration;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import io.minio.MinioClient;
import lombok.extern.slf4j.Slf4j;
import org.leocoder.heritage.oss.service.LocalStorageService;
import org.leocoder.heritage.oss.service.MinioStorageService;
import org.leocoder.heritage.oss.service.OssStorageService;
import org.leocoder.heritage.oss.service.StorageServiceFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
/**
* OSS自动配置类
*
* @author Leocoder
*/
@Configuration
@EnableConfigurationProperties({OssConfig.class, MinioConfig.class})
@Slf4j
public class OssAutoConfiguration {
/**
* @description [OSS客户端配置]
* @author Leocoder
*/
@Bean
@ConditionalOnProperty(name = "coder.oss.enabled", havingValue = "true")
@ConditionalOnMissingBean
public OSS ossClient(OssConfig ossConfig) {
log.info("初始化OSS客户端: endpoint={}, bucketName={}",
ossConfig.getEndpoint(), ossConfig.getBucketName());
try {
// 创建ClientBuilderConfiguration
ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
// 设置连接超时时间
clientBuilderConfiguration.setConnectionTimeout(ossConfig.getConnectTimeout().intValue());
// 设置读取超时时间
clientBuilderConfiguration.setSocketTimeout(ossConfig.getReadTimeout().intValue());
// 设置连接池大小
clientBuilderConfiguration.setMaxConnections(200);
// 设置请求超时时间
clientBuilderConfiguration.setRequestTimeout(30000);
// 设置失败重试次数
clientBuilderConfiguration.setMaxErrorRetry(3);
// 创建OSS客户端
OSS ossClient = new OSSClientBuilder().build(
ossConfig.getEndpoint(),
ossConfig.getAccessKeyId(),
ossConfig.getAccessKeySecret(),
clientBuilderConfiguration
);
// 验证Bucket是否存在
if (!ossClient.doesBucketExist(ossConfig.getBucketName())) {
log.warn("OSS Bucket不存在: {}", ossConfig.getBucketName());
} else {
log.info("OSS客户端初始化成功");
}
return ossClient;
} catch (Exception e) {
log.error("OSS客户端初始化失败", e);
throw new RuntimeException("OSS客户端初始化失败: " + e.getMessage());
}
}
/**
* @description [OSS存储服务]
* @author Leocoder
*/
@Bean
@ConditionalOnProperty(name = "coder.oss.enabled", havingValue = "true")
@ConditionalOnMissingBean
public OssStorageService ossStorageService(OSS ossClient, OssConfig ossConfig) {
log.info("初始化OSS存储服务");
return new OssStorageService(ossClient, ossConfig);
}
/**
* @description [本地存储服务始终可用]
* @author Leocoder
*/
@Bean
@ConditionalOnMissingBean
public LocalStorageService localStorageService(Environment environment) {
log.info("初始化本地存储服务");
return new LocalStorageService(environment);
}
/**
* @description [MinIO客户端配置]
* @author Leocoder
*/
@Bean
@ConditionalOnProperty(name = "coder.minio.enabled", havingValue = "true")
@ConditionalOnMissingBean
public MinioClient minioClient(MinioConfig minioConfig) {
log.info("初始化MinIO客户端: endpoint={}, bucketName={}",
minioConfig.getEndpoint(), minioConfig.getBucketName());
try {
MinioClient.Builder builder = MinioClient.builder()
.endpoint(minioConfig.getEndpoint())
.credentials(minioConfig.getAccessKey(), minioConfig.getSecretKey());
// 如果配置了区域则设置区域
if (minioConfig.getRegion() != null && !minioConfig.getRegion().trim().isEmpty()) {
builder.region(minioConfig.getRegion());
}
MinioClient minioClient = builder.build();
// 设置超时时间
minioClient.setTimeout(
minioConfig.getConnectTimeout(),
minioConfig.getWriteTimeout(),
minioConfig.getReadTimeout()
);
log.info("MinIO客户端初始化成功");
return minioClient;
} catch (Exception e) {
log.error("MinIO客户端初始化失败", e);
throw new RuntimeException("MinIO客户端初始化失败: " + e.getMessage());
}
}
/**
* @description [MinIO存储服务]
* @author Leocoder
*/
@Bean
@ConditionalOnProperty(name = "coder.minio.enabled", havingValue = "true")
@ConditionalOnMissingBean
public MinioStorageService minioStorageService(MinioClient minioClient, MinioConfig minioConfig) {
log.info("初始化MinIO存储服务");
return new MinioStorageService(minioClient, minioConfig);
}
/**
* @description [存储服务工厂始终可用]
* @author Leocoder
*/
@Bean
@ConditionalOnMissingBean
public StorageServiceFactory storageServiceFactory(ApplicationContext applicationContext) {
log.info("初始化存储服务工厂");
return new StorageServiceFactory(applicationContext);
}
}

View File

@ -0,0 +1,66 @@
package org.leocoder.heritage.oss.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* OSS配置类
*
* @author Leocoder
*/
@Data
@Component
@ConfigurationProperties(prefix = "coder.oss")
public class OssConfig {
/**
* OSS服务端点
*/
private String endpoint;
/**
* Access Key ID
*/
private String accessKeyId;
/**
* Access Key Secret
*/
private String accessKeySecret;
/**
* 存储桶名称
*/
private String bucketName;
/**
* 自定义域名
*/
private String domain;
/**
* 路径前缀
*/
private String pathPrefix = "coder-files";
/**
* 是否启用HTTPS
*/
private Boolean https = true;
/**
* 连接超时时间毫秒
*/
private Long connectTimeout = 10000L;
/**
* 读取超时时间毫秒
*/
private Long readTimeout = 10000L;
/**
* 是否启用OSS存储
*/
private Boolean enabled = false;
}

View File

@ -0,0 +1,158 @@
package org.leocoder.heritage.oss.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.leocoder.heritage.common.constants.CoderConstants;
import org.leocoder.heritage.common.exception.BusinessException;
import org.leocoder.heritage.common.utils.file.FileUtil;
import org.leocoder.heritage.common.utils.file.UploadUtil;
import org.leocoder.heritage.common.utils.ip.IpUtil;
import org.leocoder.heritage.common.utils.ip.ServletUtil;
import org.springframework.core.env.Environment;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.util.Map;
/**
* 本地存储服务实现
* 基于现有的本地文件存储逻辑进行封装
*
* @author Leocoder
*/
@RequiredArgsConstructor
@Slf4j
public class LocalStorageService implements StorageService {
private final Environment env;
/**
* @description [获取本地存储基本路径]
* @author Leocoder
*/
private String getBasePath() {
return env.getProperty("coder.filePath", "/tmp/coder-files");
}
/**
* @description [上传文件到本地存储]
* @author Leocoder
*/
@Override
public Map<String, Object> uploadFile(MultipartFile file, String fileName, String folderPath) {
try {
log.info("本地存储上传文件: fileName={}, folderPath={}", fileName, folderPath);
// 构建完整的本地存储路径
String fullPath = getBasePath() + "/" + folderPath;
// 使用现有的上传工具类
Map<String, Object> fileMap = UploadUtil.coderSingleFile(file, fullPath, 2);
// UploadUtil已经返回了正确的相对路径不需要再次构建URL
log.info("本地存储上传成功: {}", fileMap.get("filePath"));
return fileMap;
} catch (Exception e) {
log.error("本地存储上传失败", e);
throw new BusinessException(500, "文件上传失败: " + e.getMessage());
}
}
/**
* @description [从本地存储删除文件]
* @author Leocoder
*/
@Override
public boolean deleteFile(String filePath) {
try {
if (!StringUtils.hasText(filePath)) {
log.warn("文件路径为空,跳过删除");
return true;
}
log.info("本地存储删除文件: {}", filePath);
boolean result = FileUtil.deleteFile(filePath);
if (result) {
log.info("本地存储删除成功: {}", filePath);
} else {
log.warn("本地存储删除失败: {}", filePath);
}
return result;
} catch (Exception e) {
log.error("本地存储删除文件异常: {}", filePath, e);
return false;
}
}
/**
* @description [获取文件访问URL]
* @author Leocoder
*/
@Override
public String getFileUrl(String filePath) {
if (!StringUtils.hasText(filePath)) {
return "";
}
try {
// 如果已经是完整URL直接返回
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
return filePath;
}
// 构建完整URL
String protocol = IpUtil.getProtocol(ServletUtil.getRequest());
if (!StringUtils.hasText(protocol)) {
protocol = "http";
}
String hostIp = IpUtil.getHostIp(ServletUtil.getRequest());
String hostPort = StringUtils.hasText(env.getProperty("server.port")) ?
env.getProperty("server.port") : "18099";
// 处理文件路径确保以/开头
String normalizedPath = filePath.startsWith("/") ? filePath : "/" + filePath;
return protocol + "://" + hostIp + ":" + hostPort + normalizedPath;
} catch (Exception e) {
log.error("生成文件访问URL失败: {}", filePath, e);
return filePath;
}
}
/**
* @description [检查文件是否存在]
* @author Leocoder
*/
@Override
public boolean fileExists(String filePath) {
if (!StringUtils.hasText(filePath)) {
return false;
}
try {
File file = new File(filePath);
return file.exists() && file.isFile();
} catch (Exception e) {
log.error("检查文件是否存在失败: {}", filePath, e);
return false;
}
}
/**
* @description [获取存储服务类型]
* @author Leocoder
*/
@Override
public String getStorageType() {
// 本地存储对应数据库中的"1"
return CoderConstants.ONE_STRING;
}
}

View File

@ -0,0 +1,229 @@
package org.leocoder.heritage.oss.service;
import io.minio.*;
import io.minio.errors.ErrorResponseException;
import io.minio.http.Method;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.leocoder.heritage.common.constants.CoderConstants;
import org.leocoder.heritage.common.exception.BusinessException;
import org.leocoder.heritage.common.utils.file.FileTypeUtil;
import org.leocoder.heritage.oss.config.MinioConfig;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.util.HashMap;
import java.util.Map;
/**
* MinIO对象存储服务实现
*
* @author Leocoder
*/
@RequiredArgsConstructor
@Slf4j
@Service
@ConditionalOnProperty(name = "coder.minio.enabled", havingValue = "true")
public class MinioStorageService implements StorageService {
private final MinioClient minioClient;
private final MinioConfig minioConfig;
/**
* @description [上传文件到MinIO]
* @author Leocoder
*/
@Override
public Map<String, Object> uploadFile(MultipartFile file, String fileName, String folderPath) {
try {
// 确保存储桶存在
ensureBucketExists();
// 构建对象名称
String objectName = buildObjectName(folderPath, fileName);
// 上传文件
minioClient.putObject(
PutObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
// 构建返回结果Map保持与现有接口兼容
Map<String, Object> fileMap = new HashMap<>();
fileMap.put("fileName", file.getOriginalFilename());
fileMap.put("newName", fileName);
fileMap.put("fileSize", FileTypeUtil.getFileSize(file));
fileMap.put("suffixName", FileTypeUtil.getFileType(file.getOriginalFilename()));
// MinIO对象名用于删除操作
fileMap.put("filePath", objectName);
// 完整的访问URL
fileMap.put("fileUploadPath", getFileUrl(objectName));
log.info("MinIO文件上传成功: {}", fileName);
return fileMap;
} catch (Exception e) {
log.error("MinIO文件上传失败", e);
throw new BusinessException(500, "文件上传失败: " + e.getMessage());
}
}
/**
* @description [从MinIO删除文件]
* @author Leocoder
*/
@Override
public boolean deleteFile(String objectName) {
try {
if (!StringUtils.hasText(objectName)) {
log.warn("MinIO对象名为空跳过删除");
return true;
}
log.info("MinIO删除文件: {}", objectName);
minioClient.removeObject(
RemoveObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(objectName)
.build()
);
log.info("MinIO删除成功: {}", objectName);
return true;
} catch (Exception e) {
log.error("MinIO文件删除失败: {}", objectName, e);
return false;
}
}
/**
* @description [获取文件访问URL]
* @author Leocoder
*/
@Override
public String getFileUrl(String objectName) {
if (!StringUtils.hasText(objectName)) {
return "";
}
try {
// 如果配置了自定义域名构建直接访问URL
if (StringUtils.hasText(minioConfig.getDomain())) {
String cleanDomain = minioConfig.getDomain().replaceAll("/$", "");
String cleanObjectName = objectName.startsWith("/") ? objectName.substring(1) : objectName;
String directUrl = cleanDomain + "/" + minioConfig.getBucketName() + "/" + cleanObjectName;
return directUrl;
}
// 如果没有自定义域名使用MinIO的预签名URL有效期24小时
String presignedUrl = minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(minioConfig.getBucketName())
.object(objectName)
.expiry(24 * 60 * 60)
.build()
);
return presignedUrl;
} catch (Exception e) {
log.error("生成MinIO文件访问URL失败: {}", objectName, e);
return "";
}
}
/**
* @description [检查文件是否存在]
* @author Leocoder
*/
@Override
public boolean fileExists(String objectName) {
try {
if (!StringUtils.hasText(objectName)) {
return false;
}
minioClient.statObject(
StatObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(objectName)
.build()
);
return true;
} catch (ErrorResponseException e) {
if ("NoSuchKey".equals(e.errorResponse().code())) {
return false;
}
log.error("检查MinIO文件是否存在失败: {}", objectName, e);
return false;
} catch (Exception e) {
log.error("检查MinIO文件是否存在失败: {}", objectName, e);
return false;
}
}
/**
* @description [获取存储服务类型]
* @author Leocoder
*/
@Override
public String getStorageType() {
// MinIO存储对应数据库中的"2"
return CoderConstants.TWO_STRING;
}
/**
* @description [构建MinIO对象名称]
* @author Leocoder
*/
private String buildObjectName(String folderPath, String fileName) {
StringBuilder nameBuilder = new StringBuilder();
// 添加路径前缀
if (StringUtils.hasText(minioConfig.getPathPrefix())) {
nameBuilder.append(minioConfig.getPathPrefix()).append("/");
}
// 添加文件夹路径
if (StringUtils.hasText(folderPath)) {
// 确保路径以/结尾
String normalizedPath = folderPath.endsWith("/") ? folderPath : folderPath + "/";
nameBuilder.append(normalizedPath);
}
// 添加文件名
nameBuilder.append(fileName);
return nameBuilder.toString();
}
/**
* @description [确保存储桶存在]
* @author Leocoder
*/
private void ensureBucketExists() throws Exception {
boolean bucketExists = minioClient.bucketExists(
BucketExistsArgs.builder()
.bucket(minioConfig.getBucketName())
.build()
);
if (!bucketExists) {
log.info("创建MinIO存储桶: {}", minioConfig.getBucketName());
minioClient.makeBucket(
MakeBucketArgs.builder()
.bucket(minioConfig.getBucketName())
.region(minioConfig.getRegion())
.build()
);
}
}
}

View File

@ -0,0 +1,189 @@
package org.leocoder.heritage.oss.service;
import com.aliyun.oss.OSS;
import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.oss.model.PutObjectResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.leocoder.heritage.common.constants.CoderConstants;
import org.leocoder.heritage.common.exception.BusinessException;
import org.leocoder.heritage.common.utils.file.FileTypeUtil;
import org.leocoder.heritage.oss.config.OssConfig;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 阿里云OSS存储服务实现
*
* @author Leocoder
*/
@RequiredArgsConstructor
@Slf4j
@ConditionalOnProperty(name = "coder.oss.enabled", havingValue = "true")
public class OssStorageService implements StorageService {
private final OSS ossClient;
private final OssConfig ossConfig;
/**
* @description [上传文件到阿里云OSS]
* @author Leocoder
*/
@Override
public Map<String, Object> uploadFile(MultipartFile file, String fileName, String folderPath) {
try {
log.info("OSS上传文件: fileName={}, folderPath={}", fileName, folderPath);
// 构建对象键
String objectKey = buildObjectKey(folderPath, fileName);
// 设置元数据
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
metadata.setContentDisposition("inline");
// 创建上传请求
PutObjectRequest putObjectRequest = new PutObjectRequest(
ossConfig.getBucketName(),
objectKey,
file.getInputStream(),
metadata
);
// 执行上传
PutObjectResult result = ossClient.putObject(putObjectRequest);
// 构建返回结果Map保持与现有接口兼容
Map<String, Object> fileMap = new HashMap<>();
fileMap.put("fileName", file.getOriginalFilename());
fileMap.put("newName", fileName);
fileMap.put("fileSize", FileTypeUtil.getFileSize(file));
fileMap.put("suffixName", FileTypeUtil.getFileType(file.getOriginalFilename()));
// OSS对象键用于删除操作
fileMap.put("filePath", objectKey);
// 完整的访问URL
fileMap.put("fileUploadPath", getFileUrl(objectKey));
log.info("OSS上传成功: objectKey={}, url={}", objectKey, fileMap.get("fileUploadPath"));
return fileMap;
} catch (Exception e) {
log.error("OSS文件上传失败", e);
throw new BusinessException(500, "文件上传失败: " + e.getMessage());
}
}
/**
* @description [从阿里云OSS删除文件]
* @author Leocoder
*/
@Override
public boolean deleteFile(String objectKey) {
try {
if (!StringUtils.hasText(objectKey)) {
log.warn("OSS对象键为空跳过删除");
return true;
}
log.info("OSS删除文件: {}", objectKey);
ossClient.deleteObject(ossConfig.getBucketName(), objectKey);
log.info("OSS删除成功: {}", objectKey);
return true;
} catch (Exception e) {
log.error("OSS文件删除失败: {}", objectKey, e);
return false;
}
}
/**
* @description [获取文件访问URL]
* @author Leocoder
*/
@Override
public String getFileUrl(String objectKey) {
if (!StringUtils.hasText(objectKey)) {
return "";
}
try {
// 如果配置了自定义域名使用自定义域名
if (StringUtils.hasText(ossConfig.getDomain())) {
return ossConfig.getDomain() + "/" + objectKey;
}
// 使用默认的OSS域名
String protocol = ossConfig.getHttps() ? "https" : "http";
return protocol + "://" + ossConfig.getBucketName() + "." + ossConfig.getEndpoint() + "/" + objectKey;
} catch (Exception e) {
log.error("生成OSS文件访问URL失败: {}", objectKey, e);
return "";
}
}
/**
* @description [检查文件是否存在]
* @author Leocoder
*/
@Override
public boolean fileExists(String objectKey) {
try {
if (!StringUtils.hasText(objectKey)) {
return false;
}
return ossClient.doesObjectExist(ossConfig.getBucketName(), objectKey);
} catch (Exception e) {
log.error("检查OSS文件是否存在失败: {}", objectKey, e);
return false;
}
}
/**
* @description [获取存储服务类型]
* @author Leocoder
*/
@Override
public String getStorageType() {
// OSS存储对应数据库中的"3"
return "3";
}
/**
* @description [构建OSS对象键]
* @author Leocoder
*/
private String buildObjectKey(String folderPath, String fileName) {
StringBuilder keyBuilder = new StringBuilder();
// 添加路径前缀
if (StringUtils.hasText(ossConfig.getPathPrefix())) {
keyBuilder.append(ossConfig.getPathPrefix()).append("/");
}
// 添加文件夹路径
if (StringUtils.hasText(folderPath)) {
// 确保路径以/结尾
String normalizedPath = folderPath.endsWith("/") ? folderPath : folderPath + "/";
keyBuilder.append(normalizedPath);
}
// 添加文件名
keyBuilder.append(fileName);
return keyBuilder.toString();
}
}

View File

@ -0,0 +1,44 @@
package org.leocoder.heritage.oss.service;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
/**
* 存储服务接口
* 提供统一的文件存储操作接口支持多种存储类型本地OSS等
*
* @author Leocoder
*/
public interface StorageService {
/**
* @description [上传文件]
* @author Leocoder
*/
Map<String, Object> uploadFile(MultipartFile file, String fileName, String folderPath);
/**
* @description [删除文件]
* @author Leocoder
*/
boolean deleteFile(String filePath);
/**
* @description [获取文件访问URL]
* @author Leocoder
*/
String getFileUrl(String filePath);
/**
* @description [检查文件是否存在]
* @author Leocoder
*/
boolean fileExists(String filePath);
/**
* @description [获取存储服务类型]
* @author Leocoder
*/
String getStorageType();
}

View File

@ -0,0 +1,93 @@
package org.leocoder.heritage.oss.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.leocoder.heritage.common.exception.BusinessException;
import org.springframework.context.ApplicationContext;
import java.util.Map;
/**
* 存储服务工厂
* 根据配置类型获取对应的存储服务实现
*
* @author Leocoder
*/
@RequiredArgsConstructor
@Slf4j
public class StorageServiceFactory {
private final ApplicationContext applicationContext;
/**
* @description [根据存储类型获取存储服务]
* @author Leocoder
*/
public StorageService getStorageService(String storageType) {
if (storageType == null || storageType.trim().isEmpty()) {
throw new BusinessException(400, "存储类型不能为空");
}
try {
// 获取所有StorageService实现
Map<String, StorageService> storageServiceMap = applicationContext.getBeansOfType(StorageService.class);
for (StorageService service : storageServiceMap.values()) {
if (isMatchingStorageType(service, storageType.toLowerCase())) {
log.debug("获取存储服务: {} -> {}", storageType, service.getClass().getSimpleName());
return service;
}
}
throw new BusinessException(404, "未找到对应的存储服务实现: " + storageType);
} catch (Exception e) {
log.error("获取存储服务失败: {}", storageType, e);
throw new BusinessException(500, "获取存储服务失败: " + e.getMessage());
}
}
/**
* @description [获取默认存储服务MinIO优先然后OSS最后本地存储]
* @author Leocoder
*/
public StorageService getDefaultStorageService() {
try {
// 优先尝试获取MinIO服务
return getStorageService("minio");
} catch (Exception e) {
log.warn("MinIO服务不可用尝试OSS服务", e);
try {
// 尝试获取OSS服务
return getStorageService("oss");
} catch (Exception ex) {
log.warn("OSS服务不可用使用本地存储作为降级方案", ex);
try {
return getStorageService("local");
} catch (Exception localEx) {
log.error("本地存储服务也不可用", localEx);
throw new BusinessException(500, "没有可用的存储服务");
}
}
}
}
/**
* @description [检查存储服务是否匹配指定类型]
* @author Leocoder
*/
private boolean isMatchingStorageType(StorageService service, String storageType) {
String serviceClassName = service.getClass().getSimpleName().toLowerCase();
switch (storageType) {
case "local":
return serviceClassName.contains("local");
case "oss":
return serviceClassName.contains("oss");
case "minio":
return serviceClassName.contains("minio");
default:
return false;
}
}
}

View File

@ -0,0 +1,126 @@
package org.leocoder.heritage.oss.utils;
import lombok.extern.slf4j.Slf4j;
import org.leocoder.heritage.common.utils.date.DateUtil;
import org.leocoder.heritage.common.utils.file.FileTypeUtil;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* OSS工具类
*
* @author Leocoder
*/
@Slf4j
public class OssUtil {
/**
* @description [生成唯一文件名]
* @author Leocoder
*/
public static String generateUniqueFileName(String originalFilename) {
if (!StringUtils.hasText(originalFilename)) {
throw new IllegalArgumentException("原始文件名不能为空");
}
// 获取文件扩展名
String extension = FileTypeUtil.getFileType(originalFilename);
// 生成时间戳
String timeStamp = DateUtil.getCurrentDate("yyyyMMddHHmmss");
// 生成6位UUID
String uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 6);
// 组合文件名
return timeStamp + "-" + uuid + "." + extension;
}
/**
* @description [构建文件夹路径]
* @author Leocoder
*/
public static String buildFolderPath(String folderName, String username) {
LocalDateTime now = LocalDateTime.now();
StringBuilder pathBuilder = new StringBuilder();
if (StringUtils.hasText(folderName)) {
pathBuilder.append(folderName);
}
if (StringUtils.hasText(username)) {
pathBuilder.append("/").append(username);
}
// 添加日期目录结构
pathBuilder.append("/")
.append(now.getYear())
.append("/")
.append(String.format("%02d", now.getMonthValue()))
.append("/")
.append(String.format("%02d", now.getDayOfMonth()));
return pathBuilder.toString();
}
/**
* @description [验证OSS对象键格式]
* @author Leocoder
*/
public static boolean isValidObjectKey(String objectKey) {
if (!StringUtils.hasText(objectKey)) {
return false;
}
// OSS对象名不能以/开头
if (objectKey.startsWith("/")) {
return false;
}
if (objectKey.contains("//")) {
return false;
}
// OSS对象名长度限制
if (objectKey.length() > 1023) {
return false;
}
return true;
}
/**
* @description [规范化对象键]
* @author Leocoder
*/
public static String normalizeObjectKey(String objectKey) {
if (!StringUtils.hasText(objectKey)) {
return objectKey;
}
String normalized = objectKey;
// 移除开头的/
while (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
// 替换连续的///
while (normalized.contains("//")) {
normalized = normalized.replace("//", "/");
}
return normalized;
}
/**
* @description [获取文件类型编码]
* @author Leocoder
*/
public static String getFileTypeCode(String filename) {
return FileTypeUtil.checkFileExtension(FileTypeUtil.getFileType(filename));
}
}