diff --git a/heritage-plugins/heritage-oss/pom.xml b/heritage-plugins/heritage-oss/pom.xml new file mode 100644 index 0000000..0b8e858 --- /dev/null +++ b/heritage-plugins/heritage-oss/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + org.leocoder.heritage + heritage-plugins + ${revision} + + + heritage-oss + heritage-oss + 阿里云OSS对象存储模块 + + + + + org.leocoder.heritage + heritage-common + ${revision} + + + + + com.aliyun.oss + aliyun-sdk-oss + + + + + io.minio + minio + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter-web + + + + \ No newline at end of file diff --git a/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/annotation/EnableCoderOss.java b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/annotation/EnableCoderOss.java new file mode 100644 index 0000000..795585f --- /dev/null +++ b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/annotation/EnableCoderOss.java @@ -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 { +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/config/MinioConfig.java b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/config/MinioConfig.java new file mode 100644 index 0000000..af56dc8 --- /dev/null +++ b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/config/MinioConfig.java @@ -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; +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/config/OssAutoConfiguration.java b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/config/OssAutoConfiguration.java new file mode 100644 index 0000000..9729b49 --- /dev/null +++ b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/config/OssAutoConfiguration.java @@ -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); + } +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/config/OssConfig.java b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/config/OssConfig.java new file mode 100644 index 0000000..78e5cc2 --- /dev/null +++ b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/config/OssConfig.java @@ -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; +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/LocalStorageService.java b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/LocalStorageService.java new file mode 100644 index 0000000..476e863 --- /dev/null +++ b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/LocalStorageService.java @@ -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 uploadFile(MultipartFile file, String fileName, String folderPath) { + try { + log.info("本地存储上传文件: fileName={}, folderPath={}", fileName, folderPath); + + // 构建完整的本地存储路径 + String fullPath = getBasePath() + "/" + folderPath; + + // 使用现有的上传工具类 + Map 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; + } +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/MinioStorageService.java b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/MinioStorageService.java new file mode 100644 index 0000000..a268031 --- /dev/null +++ b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/MinioStorageService.java @@ -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 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 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() + ); + } + } +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/OssStorageService.java b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/OssStorageService.java new file mode 100644 index 0000000..31db250 --- /dev/null +++ b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/OssStorageService.java @@ -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 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 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(); + } +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/StorageService.java b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/StorageService.java new file mode 100644 index 0000000..50ad28a --- /dev/null +++ b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/StorageService.java @@ -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 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(); +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/StorageServiceFactory.java b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/StorageServiceFactory.java new file mode 100644 index 0000000..48ea992 --- /dev/null +++ b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/service/StorageServiceFactory.java @@ -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 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; + } + } +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/utils/OssUtil.java b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/utils/OssUtil.java new file mode 100644 index 0000000..b6a7028 --- /dev/null +++ b/heritage-plugins/heritage-oss/src/main/java/org/leocoder/heritage/oss/utils/OssUtil.java @@ -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)); + } +} \ No newline at end of file