mirror of
				https://github.com/continew-org/continew-starter.git
				synced 2025-10-30 23:00:11 +08:00 
			
		
		
		
	refactor(storage): 新增存储模块 - 本地和 S3 两种存储模式
This commit is contained in:
		| @@ -70,6 +70,11 @@ | ||||
|         <ttl.version>2.14.5</ttl.version> | ||||
|         <ip2region.version>3.2.12</ip2region.version> | ||||
|         <hutool.version>5.8.34</hutool.version> | ||||
|         <!--对象存储版本--> | ||||
|         <s3.version>2.29.23</s3.version> | ||||
|         <s3-crt.version>0.33.5</s3-crt.version> | ||||
|         <!--缩略图处理版本--> | ||||
|         <thumbnails.version>0.4.20</thumbnails.version> | ||||
|         <!-- Maven Plugin Versions --> | ||||
|         <flatten.version>1.6.0</flatten.version> | ||||
|         <spotless.version>2.43.0</spotless.version> | ||||
| @@ -253,18 +258,32 @@ | ||||
|                 <version>${easy-excel.version}</version> | ||||
|             </dependency> | ||||
|  | ||||
|             <!-- X File Storage(一行代码将文件存储到本地、FTP、SFTP、WebDAV、阿里云 OSS、华为云 OBS...等其它兼容 S3 协议的存储平台) --> | ||||
|             <!--  S3  for Java 2.x  --> | ||||
|             <dependency> | ||||
|                 <groupId>org.dromara.x-file-storage</groupId> | ||||
|                 <artifactId>x-file-storage-spring</artifactId> | ||||
|                 <version>${x-file-storage.version}</version> | ||||
|                 <groupId>software.amazon.awssdk</groupId> | ||||
|                 <artifactId>s3</artifactId> | ||||
|                 <version>${s3.version}</version> | ||||
|             </dependency> | ||||
|  | ||||
|             <!-- Amazon S3(Amazon Simple Storage Service,亚马逊简单存储服务,通用存储协议 S3,兼容主流云厂商对象存储) --> | ||||
|             <!-- 使用AWS基于 CRT 的 S3 客户端 --> | ||||
|             <dependency> | ||||
|                 <groupId>com.amazonaws</groupId> | ||||
|                 <artifactId>aws-java-sdk-s3</artifactId> | ||||
|                 <version>${aws-s3.version}</version> | ||||
|                 <groupId>software.amazon.awssdk.crt</groupId> | ||||
|                 <artifactId>aws-crt</artifactId> | ||||
|                 <version>${s3-crt.version}</version> | ||||
|             </dependency> | ||||
|  | ||||
|             <!-- 基于 AWS CRT 的 S3 客户端的性能增强的 S3 传输管理器 --> | ||||
|             <dependency> | ||||
|                 <groupId>software.amazon.awssdk</groupId> | ||||
|                 <artifactId>s3-transfer-manager</artifactId> | ||||
|                 <version>${s3.version}</version> | ||||
|             </dependency> | ||||
|  | ||||
|             <!--图片处理工具-主要用做图片缩略处理--> | ||||
|             <dependency> | ||||
|                 <groupId>net.coobird</groupId> | ||||
|                 <artifactId>thumbnailator</artifactId> | ||||
|                 <version>${thumbnails.version}</version> | ||||
|             </dependency> | ||||
|  | ||||
|             <!-- Graceful Response(一个Spring Boot技术栈下的优雅响应处理组件,可以帮助开发者完成响应数据封装、异常处理、错误码填充等过程,提高开发效率,提高代码质量) --> | ||||
| @@ -476,6 +495,13 @@ | ||||
|                 <version>${revision}</version> | ||||
|             </dependency> | ||||
|  | ||||
|             <!-- 存储模块 - 核心模块 --> | ||||
|             <dependency> | ||||
|                 <groupId>top.continew</groupId> | ||||
|                 <artifactId>continew-starter-storage-core</artifactId> | ||||
|                 <version>${revision}</version> | ||||
|             </dependency> | ||||
|  | ||||
|             <!-- 存储模块 - 本地存储 --> | ||||
|             <dependency> | ||||
|                 <groupId>top.continew</groupId> | ||||
| @@ -483,6 +509,13 @@ | ||||
|                 <version>${revision}</version> | ||||
|             </dependency> | ||||
|  | ||||
|             <!-- 存储模块 - 对象存储 --> | ||||
|             <dependency> | ||||
|                 <groupId>top.continew</groupId> | ||||
|                 <artifactId>continew-starter-storage-oss</artifactId> | ||||
|                 <version>${revision}</version> | ||||
|             </dependency> | ||||
|  | ||||
|             <!-- 日志模块 - 基于拦截器实现(Spring Boot Actuator HttpTrace 增强版) --> | ||||
|             <dependency> | ||||
|                 <groupId>top.continew</groupId> | ||||
|   | ||||
| @@ -0,0 +1,30 @@ | ||||
| <?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 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||||
|     <modelVersion>4.0.0</modelVersion> | ||||
|     <parent> | ||||
|         <groupId>top.continew</groupId> | ||||
|         <artifactId>continew-starter-storage</artifactId> | ||||
|         <version>${revision}</version> | ||||
|     </parent> | ||||
|  | ||||
|     <artifactId>continew-starter-storage-core</artifactId> | ||||
|     <description>ContiNew Starter 存储模块 - 核心模块</description> | ||||
|  | ||||
|     <dependencies> | ||||
|  | ||||
|         <!--redisson 缓存模块--> | ||||
|         <dependency> | ||||
|             <groupId>top.continew</groupId> | ||||
|             <artifactId>continew-starter-cache-redisson</artifactId> | ||||
|         </dependency> | ||||
|  | ||||
|         <!--图片处理工具-主要用做图片缩略处理--> | ||||
|         <dependency> | ||||
|             <groupId>net.coobird</groupId> | ||||
|             <artifactId>thumbnailator</artifactId> | ||||
|         </dependency> | ||||
|     </dependencies> | ||||
|  | ||||
|  | ||||
| </project> | ||||
| @@ -0,0 +1,42 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.constant; | ||||
|  | ||||
| /** | ||||
|  * 存储 常量 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/16 19:09 | ||||
|  */ | ||||
| public class StorageConstant { | ||||
|  | ||||
|     /** | ||||
|      * 默认存储 Key | ||||
|      */ | ||||
|     public static final String DEFAULT_KEY = "storage:default_config"; | ||||
|  | ||||
|     /** | ||||
|      * 云服务商 域名前缀 | ||||
|      * <p>目前只支持 阿里云-oss 华为云-obs 腾讯云-cos</p> | ||||
|      */ | ||||
|     public static final String[] CLOUD_SERVICE_PREFIX = new String[] {"oss", "cos", "obs"}; | ||||
|  | ||||
|     /** | ||||
|      * 缩略图后缀 | ||||
|      */ | ||||
|     public static final String SMALL_SUFFIX = "small"; | ||||
| } | ||||
| @@ -0,0 +1,35 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.dao; | ||||
|  | ||||
| import top.continew.starter.storage.model.resp.UploadResp; | ||||
|  | ||||
| /** | ||||
|  * 存储记录持久层接口 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/17 16:49 | ||||
|  */ | ||||
| public interface StorageDao { | ||||
|  | ||||
|     /** | ||||
|      * 记录上传信息 | ||||
|      * | ||||
|      * @param uploadResp 上传信息 | ||||
|      */ | ||||
|     void add(UploadResp uploadResp); | ||||
| } | ||||
| @@ -0,0 +1,33 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.dao.impl; | ||||
|  | ||||
| import top.continew.starter.storage.dao.StorageDao; | ||||
| import top.continew.starter.storage.model.resp.UploadResp; | ||||
|  | ||||
| /** | ||||
|  * 默认记录实现,此类并不能真正保存记录,只是用来脱离数据库运行,保证文件上传功能可以正常使用 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/18 08:48 | ||||
|  **/ | ||||
| public class StorageDaoDefaultImpl implements StorageDao { | ||||
|     @Override | ||||
|     public void add(UploadResp uploadResp) { | ||||
|  | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,106 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.decorator; | ||||
|  | ||||
| import top.continew.starter.storage.model.resp.ThumbnailResp; | ||||
| import top.continew.starter.storage.model.resp.UploadResp; | ||||
| import top.continew.starter.storage.strategy.StorageStrategy; | ||||
|  | ||||
| import java.io.InputStream; | ||||
|  | ||||
| /** | ||||
|  * 装饰器基类 - 用于重写 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/30 19:33 | ||||
|  */ | ||||
| public abstract class AbstractStorageDecorator<C> implements StorageStrategy<C> { | ||||
|  | ||||
|     protected StorageStrategy<C> delegate; | ||||
|  | ||||
|     protected AbstractStorageDecorator(StorageStrategy<C> delegate) { | ||||
|         this.delegate = delegate; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public C getClient() { | ||||
|         return delegate.getClient(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public boolean bucketExists(String bucketName) { | ||||
|         return delegate.bucketExists(bucketName); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void createBucket(String bucketName) { | ||||
|         delegate.createBucket(bucketName); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public UploadResp upload(String fileName, InputStream inputStream, String fileType) { | ||||
|         return delegate.upload(fileName, inputStream, fileType); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public UploadResp upload(String fileName, | ||||
|                              String path, | ||||
|                              InputStream inputStream, | ||||
|                              String fileType, | ||||
|                              boolean isThumbnail) { | ||||
|         return delegate.upload(fileName, path, inputStream, fileType, isThumbnail); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public UploadResp upload(String bucketName, | ||||
|                              String fileName, | ||||
|                              String path, | ||||
|                              InputStream inputStream, | ||||
|                              String fileType, | ||||
|                              boolean isThumbnail) { | ||||
|         return delegate.upload(bucketName, fileName, path, inputStream, fileType, isThumbnail); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void upload(String bucketName, String fileName, String path, InputStream inputStream, String fileType) { | ||||
|         delegate.upload(bucketName, fileName, path, inputStream, fileType); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public ThumbnailResp uploadThumbnail(String bucketName, | ||||
|                                          String fileName, | ||||
|                                          String path, | ||||
|                                          InputStream inputStream, | ||||
|                                          String fileType) { | ||||
|         return delegate.uploadThumbnail(bucketName, fileName, path, inputStream, fileType); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public InputStream download(String bucketName, String fileName) { | ||||
|         return delegate.download(bucketName, fileName); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void delete(String bucketName, String fileName) { | ||||
|         delegate.delete(bucketName, fileName); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public String getImageBase64(String bucketName, String fileName) { | ||||
|         return delegate.getImageBase64(bucketName, fileName); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,102 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.enums; | ||||
|  | ||||
| import cn.hutool.core.util.StrUtil; | ||||
| import top.continew.starter.core.enums.BaseEnum; | ||||
|  | ||||
| import java.util.Arrays; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
|  | ||||
| /** | ||||
|  * 文件类型枚举 | ||||
|  * | ||||
|  * @author Charles7c | ||||
|  * @since 2023/12/23 13:38 | ||||
|  */ | ||||
| public enum FileTypeEnum implements BaseEnum<Integer> { | ||||
|  | ||||
|     /** | ||||
|      * 其他 | ||||
|      */ | ||||
|     UNKNOWN(1, "其他", Collections.emptyList()), | ||||
|  | ||||
|     /** | ||||
|      * 图片 | ||||
|      */ | ||||
|     IMAGE(2, "图片", List | ||||
|         .of("jpg", "jpeg", "png", "gif", "bmp", "webp", "ico", "psd", "tiff", "dwg", "jxr", "apng", "xcf")), | ||||
|  | ||||
|     /** | ||||
|      * 文档 | ||||
|      */ | ||||
|     DOC(3, "文档", List.of("txt", "pdf", "doc", "xls", "ppt", "docx", "xlsx", "pptx")), | ||||
|  | ||||
|     /** | ||||
|      * 视频 | ||||
|      */ | ||||
|     VIDEO(4, "视频", List.of("mp4", "avi", "mkv", "flv", "webm", "wmv", "m4v", "mov", "mpg", "rmvb", "3gp")), | ||||
|  | ||||
|     /** | ||||
|      * 音频 | ||||
|      */ | ||||
|     AUDIO(5, "音频", List.of("mp3", "flac", "wav", "ogg", "midi", "m4a", "aac", "amr", "ac3", "aiff")),; | ||||
|  | ||||
|     private final Integer value; | ||||
|     private final String description; | ||||
|     private final List<String> extensions; | ||||
|  | ||||
|     /** | ||||
|      * 根据扩展名查询 | ||||
|      * | ||||
|      * @param extension 扩展名 | ||||
|      * @return 文件类型 | ||||
|      */ | ||||
|     public static FileTypeEnum getByExtension(String extension) { | ||||
|         return Arrays.stream(FileTypeEnum.values()) | ||||
|             .filter(t -> t.getExtensions().contains(StrUtil.emptyIfNull(extension).toLowerCase())) | ||||
|             .findFirst() | ||||
|             .orElse(FileTypeEnum.UNKNOWN); | ||||
|     } | ||||
|  | ||||
|     FileTypeEnum(Integer value, String description, List<String> extensions) { | ||||
|         this.value = value; | ||||
|         this.description = description; | ||||
|         this.extensions = extensions; | ||||
|     } | ||||
|  | ||||
|     public List<String> getExtensions() { | ||||
|         return this.extensions; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public Integer getValue() { | ||||
|         return this.value; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public String getDescription() { | ||||
|         return this.description; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public String getColor() { | ||||
|         return BaseEnum.super.getColor(); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,80 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.manger; | ||||
|  | ||||
| import top.continew.starter.cache.redisson.util.RedisUtils; | ||||
| import top.continew.starter.core.validation.ValidationUtils; | ||||
| import top.continew.starter.storage.constant.StorageConstant; | ||||
| import top.continew.starter.storage.strategy.StorageStrategy; | ||||
|  | ||||
| import java.util.Map; | ||||
| import java.util.concurrent.ConcurrentHashMap; | ||||
|  | ||||
| /** | ||||
|  * 存储策略管理器 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/16 | ||||
|  */ | ||||
| public class StorageManager { | ||||
|  | ||||
|     /** | ||||
|      * 存储策略连接信息 | ||||
|      */ | ||||
|     private static final Map<String, StorageStrategy<?>> STORAGE_STRATEGY = new ConcurrentHashMap<>(); | ||||
|  | ||||
|     /** | ||||
|      * 加载存储策略 | ||||
|      * | ||||
|      * @param code     存储码 | ||||
|      * @param strategy 对应存储策略 | ||||
|      */ | ||||
|     public static void load(String code, StorageStrategy<?> strategy) { | ||||
|         STORAGE_STRATEGY.put(code, strategy); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 卸载存储策略 | ||||
|      * | ||||
|      * @param code 存储码 | ||||
|      */ | ||||
|     public static void unload(String code) { | ||||
|         STORAGE_STRATEGY.remove(code); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 根据 存储 code 获取对应存储策略 | ||||
|      * | ||||
|      * @param code 代码 | ||||
|      * @return {@link StorageStrategy } | ||||
|      */ | ||||
|     public static StorageStrategy<?> instance(String code) { | ||||
|         StorageStrategy<?> strategy = STORAGE_STRATEGY.get(code); | ||||
|         ValidationUtils.throwIfEmpty(strategy, "未找到存储配置:" + code); | ||||
|         return strategy; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 获取默认存储策略 | ||||
|      * | ||||
|      * @return {@link StorageStrategy } | ||||
|      */ | ||||
|     public static StorageStrategy<?> instance() { | ||||
|         return instance(RedisUtils.get(StorageConstant.DEFAULT_KEY)); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,152 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.model.req; | ||||
|  | ||||
| /** | ||||
|  * 存储配置信息 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/11/04 15:13 | ||||
|  **/ | ||||
| public class StorageProperties { | ||||
|  | ||||
|     /** | ||||
|      * 编码 | ||||
|      */ | ||||
|     private String code; | ||||
|  | ||||
|     /** | ||||
|      * 访问密钥 | ||||
|      */ | ||||
|     private String accessKey; | ||||
|  | ||||
|     /** | ||||
|      * 私有密钥 | ||||
|      */ | ||||
|     private String secretKey; | ||||
|  | ||||
|     /** | ||||
|      * 终端节点 | ||||
|      */ | ||||
|     private String endpoint; | ||||
|  | ||||
|     /** | ||||
|      * 桶名称 | ||||
|      */ | ||||
|     private String bucketName; | ||||
|  | ||||
|     /** | ||||
|      * 域名 | ||||
|      */ | ||||
|     private String domain; | ||||
|  | ||||
|     /** | ||||
|      * 作用域 | ||||
|      */ | ||||
|     private String region; | ||||
|  | ||||
|     /** | ||||
|      * 是否是默认存储 | ||||
|      */ | ||||
|     private Boolean isDefault; | ||||
|  | ||||
|     public StorageProperties() { | ||||
|     } | ||||
|  | ||||
|     public String getCode() { | ||||
|         return code; | ||||
|     } | ||||
|  | ||||
|     public void setCode(String code) { | ||||
|         this.code = code; | ||||
|     } | ||||
|  | ||||
|     public String getAccessKey() { | ||||
|         return accessKey; | ||||
|     } | ||||
|  | ||||
|     public void setAccessKey(String accessKey) { | ||||
|         this.accessKey = accessKey; | ||||
|     } | ||||
|  | ||||
|     public String getSecretKey() { | ||||
|         return secretKey; | ||||
|     } | ||||
|  | ||||
|     public void setSecretKey(String secretKey) { | ||||
|         this.secretKey = secretKey; | ||||
|     } | ||||
|  | ||||
|     public String getEndpoint() { | ||||
|         return endpoint; | ||||
|     } | ||||
|  | ||||
|     public void setEndpoint(String endpoint) { | ||||
|         this.endpoint = endpoint; | ||||
|     } | ||||
|  | ||||
|     public String getBucketName() { | ||||
|         return bucketName; | ||||
|     } | ||||
|  | ||||
|     public void setBucketName(String bucketName) { | ||||
|         this.bucketName = bucketName; | ||||
|     } | ||||
|  | ||||
|     public String getDomain() { | ||||
|         return domain; | ||||
|     } | ||||
|  | ||||
|     public void setDomain(String domain) { | ||||
|         this.domain = domain; | ||||
|     } | ||||
|  | ||||
|     public String getRegion() { | ||||
|         return region; | ||||
|     } | ||||
|  | ||||
|     public void setRegion(String region) { | ||||
|         this.region = region; | ||||
|     } | ||||
|  | ||||
|     public Boolean getIsDefault() { | ||||
|         return isDefault; | ||||
|     } | ||||
|  | ||||
|     public void setIsDefault(Boolean isDefault) { | ||||
|         this.isDefault = isDefault; | ||||
|     } | ||||
|  | ||||
|     public StorageProperties(String code, | ||||
|                              String accessKey, | ||||
|                              String secretKey, | ||||
|                              String endpoint, | ||||
|                              String bucketName, | ||||
|                              String domain, | ||||
|                              String region, | ||||
|                              Boolean isDefault) { | ||||
|         this.code = code; | ||||
|         this.accessKey = accessKey; | ||||
|         this.secretKey = secretKey; | ||||
|         this.endpoint = endpoint; | ||||
|         this.bucketName = bucketName; | ||||
|         this.domain = domain; | ||||
|         this.region = region; | ||||
|         this.isDefault = isDefault; | ||||
|  | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,61 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.model.resp; | ||||
|  | ||||
| /** | ||||
|  * 缩略图 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/20 17:00 | ||||
|  */ | ||||
| public class ThumbnailResp { | ||||
|  | ||||
|     /** | ||||
|      * 缩略图大小(字节) | ||||
|      */ | ||||
|     private Long thumbnailSize; | ||||
|  | ||||
|     /** | ||||
|      * 缩略图地址 格式 xxx/xxx/xxx.small.jpg | ||||
|      */ | ||||
|     private String thumbnailPath; | ||||
|  | ||||
|     public ThumbnailResp() { | ||||
|     } | ||||
|  | ||||
|     public ThumbnailResp(Long thumbnailSize, String thumbnailPath) { | ||||
|         this.thumbnailSize = thumbnailSize; | ||||
|         this.thumbnailPath = thumbnailPath; | ||||
|     } | ||||
|  | ||||
|     public Long getThumbnailSize() { | ||||
|         return thumbnailSize; | ||||
|     } | ||||
|  | ||||
|     public void setThumbnailSize(Long thumbnailSize) { | ||||
|         this.thumbnailSize = thumbnailSize; | ||||
|     } | ||||
|  | ||||
|     public String getThumbnailPath() { | ||||
|         return thumbnailPath; | ||||
|     } | ||||
|  | ||||
|     public void setThumbnailPath(String thumbnailPath) { | ||||
|         this.thumbnailPath = thumbnailPath; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,215 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.model.resp; | ||||
|  | ||||
| import java.time.LocalDateTime; | ||||
|  | ||||
| /** | ||||
|  * 上传结果 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/10 | ||||
|  */ | ||||
| public class UploadResp { | ||||
|  | ||||
|     /** | ||||
|      * 存储 code | ||||
|      */ | ||||
|     private String code; | ||||
|  | ||||
|     /** | ||||
|      * 访问地址 | ||||
|      * <p>如果桶为私有,则提供临时链接,时间默认为 12 小时</p> | ||||
|      */ | ||||
|     private String url; | ||||
|  | ||||
|     /** | ||||
|      * 文件基础路径 | ||||
|      */ | ||||
|     private String basePath; | ||||
|  | ||||
|     /** | ||||
|      * 原始 文件名 | ||||
|      */ | ||||
|     private String originalFilename; | ||||
|  | ||||
|     /** | ||||
|      * 扩展名 | ||||
|      */ | ||||
|     private String ext; | ||||
|  | ||||
|     /** | ||||
|      * 文件大小(字节) | ||||
|      */ | ||||
|     private long size; | ||||
|  | ||||
|     /** | ||||
|      * 已上传对象的实体标记(用来校验文件)-S3 | ||||
|      */ | ||||
|     private String eTag; | ||||
|  | ||||
|     /** | ||||
|      * 存储路径 | ||||
|      * <p></p> 格式 桶/文件名 continew/2024/12/24/1234.jpg | ||||
|      */ | ||||
|     private String path; | ||||
|  | ||||
|     /** | ||||
|      * 存储桶 | ||||
|      */ | ||||
|     private String bucketName; | ||||
|  | ||||
|     /** | ||||
|      * 缩略图大小(字节) | ||||
|      */ | ||||
|     private Long thumbnailSize; | ||||
|  | ||||
|     /** | ||||
|      * 缩略图URL | ||||
|      */ | ||||
|     private String thumbnailUrl; | ||||
|  | ||||
|     /** | ||||
|      * 上传时间 | ||||
|      */ | ||||
|     private LocalDateTime createTime; | ||||
|  | ||||
|     public UploadResp() { | ||||
|     } | ||||
|  | ||||
|     public UploadResp(String code, | ||||
|                       String url, | ||||
|                       String basePath, | ||||
|                       String originalFilename, | ||||
|                       String ext, | ||||
|                       long size, | ||||
|                       String eTag, | ||||
|                       String path, | ||||
|                       String bucketName, | ||||
|                       Long thumbnailSize, | ||||
|                       String thumbnailUrl, | ||||
|                       LocalDateTime createTime) { | ||||
|         this.code = code; | ||||
|         this.url = url; | ||||
|         this.basePath = basePath; | ||||
|         this.originalFilename = originalFilename; | ||||
|         this.ext = ext; | ||||
|         this.size = size; | ||||
|         this.eTag = eTag; | ||||
|         this.path = path; | ||||
|         this.bucketName = bucketName; | ||||
|         this.thumbnailSize = thumbnailSize; | ||||
|         this.thumbnailUrl = thumbnailUrl; | ||||
|         this.createTime = createTime; | ||||
|     } | ||||
|  | ||||
|     public String getCode() { | ||||
|         return code; | ||||
|     } | ||||
|  | ||||
|     public void setCode(String code) { | ||||
|         this.code = code; | ||||
|     } | ||||
|  | ||||
|     public String getUrl() { | ||||
|         return url; | ||||
|     } | ||||
|  | ||||
|     public void setUrl(String url) { | ||||
|         this.url = url; | ||||
|     } | ||||
|  | ||||
|     public String getBasePath() { | ||||
|         return basePath; | ||||
|     } | ||||
|  | ||||
|     public void setBasePath(String basePath) { | ||||
|         this.basePath = basePath; | ||||
|     } | ||||
|  | ||||
|     public String getOriginalFilename() { | ||||
|         return originalFilename; | ||||
|     } | ||||
|  | ||||
|     public void setOriginalFilename(String originalFilename) { | ||||
|         this.originalFilename = originalFilename; | ||||
|     } | ||||
|  | ||||
|     public String getExt() { | ||||
|         return ext; | ||||
|     } | ||||
|  | ||||
|     public void setExt(String ext) { | ||||
|         this.ext = ext; | ||||
|     } | ||||
|  | ||||
|     public long getSize() { | ||||
|         return size; | ||||
|     } | ||||
|  | ||||
|     public void setSize(long size) { | ||||
|         this.size = size; | ||||
|     } | ||||
|  | ||||
|     public String geteTag() { | ||||
|         return eTag; | ||||
|     } | ||||
|  | ||||
|     public void seteTag(String eTag) { | ||||
|         this.eTag = eTag; | ||||
|     } | ||||
|  | ||||
|     public String getPath() { | ||||
|         return path; | ||||
|     } | ||||
|  | ||||
|     public void setPath(String path) { | ||||
|         this.path = path; | ||||
|     } | ||||
|  | ||||
|     public String getBucketName() { | ||||
|         return bucketName; | ||||
|     } | ||||
|  | ||||
|     public void setBucketName(String bucketName) { | ||||
|         this.bucketName = bucketName; | ||||
|     } | ||||
|  | ||||
|     public Long getThumbnailSize() { | ||||
|         return thumbnailSize; | ||||
|     } | ||||
|  | ||||
|     public void setThumbnailSize(Long thumbnailSize) { | ||||
|         this.thumbnailSize = thumbnailSize; | ||||
|     } | ||||
|  | ||||
|     public String getThumbnailUrl() { | ||||
|         return thumbnailUrl; | ||||
|     } | ||||
|  | ||||
|     public void setThumbnailUrl(String thumbnailUrl) { | ||||
|         this.thumbnailUrl = thumbnailUrl; | ||||
|     } | ||||
|  | ||||
|     public LocalDateTime getCreateTime() { | ||||
|         return createTime; | ||||
|     } | ||||
|  | ||||
|     public void setCreateTime(LocalDateTime createTime) { | ||||
|         this.createTime = createTime; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,151 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.strategy; | ||||
|  | ||||
| import top.continew.starter.storage.model.resp.ThumbnailResp; | ||||
| import top.continew.starter.storage.model.resp.UploadResp; | ||||
|  | ||||
| import java.io.InputStream; | ||||
|  | ||||
| /** | ||||
|  * 存储策略接口 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/16 11:19 | ||||
|  */ | ||||
| public interface StorageStrategy<C> { | ||||
|  | ||||
|     /** | ||||
|      * 获得客户端 - 用于重写时 获取对应存储 code 客户端 | ||||
|      * | ||||
|      * @return {@link Object } | ||||
|      */ | ||||
|     C getClient(); | ||||
|  | ||||
|     /** | ||||
|      * 检查桶是否存在 | ||||
|      * <p> S3: 检查桶是否存在 </p> | ||||
|      * <p>local: 检查 默认路径 是否存在 </p> | ||||
|      * | ||||
|      * @param bucketName 桶名称 | ||||
|      * @return true 存在 false 不存在 | ||||
|      */ | ||||
|     boolean bucketExists(String bucketName); | ||||
|  | ||||
|     /** | ||||
|      * 创建桶 | ||||
|      * <p> S3: 创建桶 </p> | ||||
|      * <p> local: 创建 默认路径下 指定文件夹 </p> | ||||
|      * | ||||
|      * @param bucketName 桶名称 | ||||
|      */ | ||||
|     void createBucket(String bucketName); | ||||
|  | ||||
|     /** | ||||
|      * 上传文件 - 默认桶 | ||||
|      * | ||||
|      * @param fileName    文件名 | ||||
|      * @param inputStream 输入流 | ||||
|      * @param fileType    文件类型 | ||||
|      * @return 上传响应 | ||||
|      */ | ||||
|     UploadResp upload(String fileName, InputStream inputStream, String fileType); | ||||
|  | ||||
|     /** | ||||
|      * 上传文件 - 默认桶 | ||||
|      * | ||||
|      * @param fileName    文件名 | ||||
|      * @param path        路径 | ||||
|      * @param inputStream 输入流 | ||||
|      * @param fileType    文件类型 | ||||
|      * @param isThumbnail 是缩略图 | ||||
|      * @return {@link UploadResp } | ||||
|      */ | ||||
|     UploadResp upload(String fileName, String path, InputStream inputStream, String fileType, boolean isThumbnail); | ||||
|  | ||||
|     /** | ||||
|      * 上传文件 | ||||
|      * | ||||
|      * @param bucketName  桶名称 | ||||
|      * @param fileName    文件名 | ||||
|      * @param path        路径 | ||||
|      * @param inputStream 输入流 | ||||
|      * @param fileType    文件类型 | ||||
|      * @param isThumbnail 是缩略图 | ||||
|      * @return 上传响应 | ||||
|      */ | ||||
|     UploadResp upload(String bucketName, | ||||
|                       String fileName, | ||||
|                       String path, | ||||
|                       InputStream inputStream, | ||||
|                       String fileType, | ||||
|                       boolean isThumbnail); | ||||
|  | ||||
|     /** | ||||
|      * 文件上传-基础上传 | ||||
|      * | ||||
|      * @param bucketName  桶名称 - 基础上传不做处理 | ||||
|      * @param fileName    文件名 - 基础上传不做处理 | ||||
|      * @param path        路径 - 基础上传不做处理 | ||||
|      * @param inputStream 输入流 | ||||
|      * @param fileType    文件类型 | ||||
|      * @return {@link UploadResp } | ||||
|      */ | ||||
|     void upload(String bucketName, String fileName, String path, InputStream inputStream, String fileType); | ||||
|  | ||||
|     /** | ||||
|      * 上传缩略图 | ||||
|      * | ||||
|      * @param bucketName  桶名称 | ||||
|      * @param fileName    文件名 | ||||
|      * @param inputStream 输入流 | ||||
|      * @param fileType    文件类型 | ||||
|      * @return {@link UploadResp } | ||||
|      */ | ||||
|     ThumbnailResp uploadThumbnail(String bucketName, | ||||
|                                   String fileName, | ||||
|                                   String path, | ||||
|                                   InputStream inputStream, | ||||
|                                   String fileType); | ||||
|  | ||||
|     /** | ||||
|      * 下载文件 | ||||
|      * | ||||
|      * @param bucketName 桶名称 | ||||
|      * @param fileName   文件名 | ||||
|      * @return 文件输入流 | ||||
|      */ | ||||
|     InputStream download(String bucketName, String fileName); | ||||
|  | ||||
|     /** | ||||
|      * 删除文件 | ||||
|      * | ||||
|      * @param bucketName 桶名称 | ||||
|      * @param fileName   文件名 | ||||
|      */ | ||||
|     void delete(String bucketName, String fileName); | ||||
|  | ||||
|     /** | ||||
|      * 获取图像Base64 | ||||
|      * | ||||
|      * @param bucketName 桶名称 | ||||
|      * @param fileName   文件名 | ||||
|      * @return Base64编码的图像 | ||||
|      */ | ||||
|     String getImageBase64(String bucketName, String fileName); | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,81 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.util; | ||||
|  | ||||
| import javax.imageio.ImageIO; | ||||
| import java.awt.*; | ||||
| import java.awt.image.BufferedImage; | ||||
| import java.io.IOException; | ||||
| import java.io.InputStream; | ||||
| import java.io.OutputStream; | ||||
|  | ||||
| /** | ||||
|  * 图像缩略图工具 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/20 16:49 | ||||
|  */ | ||||
| public class ImageThumbnailUtils { | ||||
|  | ||||
|     // 默认缩略图尺寸:100x100 | ||||
|     private static final int DEFAULT_WIDTH = 100; | ||||
|     private static final int DEFAULT_HEIGHT = 100; | ||||
|  | ||||
|     /** | ||||
|      * 根据输入流生成默认大小(100x100)的缩略图并写入输出流 | ||||
|      * | ||||
|      * @param inputStream  原始图片的输入流 | ||||
|      * @param outputStream 缩略图输出流 | ||||
|      * @param suffix       后缀 | ||||
|      * @throws IOException IOException | ||||
|      */ | ||||
|     public static void generateThumbnail(InputStream inputStream, | ||||
|                                          OutputStream outputStream, | ||||
|                                          String suffix) throws IOException { | ||||
|         generateThumbnail(inputStream, outputStream, DEFAULT_WIDTH, DEFAULT_HEIGHT, suffix); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 根据输入流和自定义尺寸生成缩略图并写入输出流 | ||||
|      * | ||||
|      * @param inputStream  原始图片的输入流 | ||||
|      * @param outputStream 缩略图输出流 | ||||
|      * @param width        缩略图宽度 | ||||
|      * @param height       缩略图高度 | ||||
|      * @param suffix       后缀 | ||||
|      * @throws IOException IOException | ||||
|      */ | ||||
|     public static void generateThumbnail(InputStream inputStream, | ||||
|                                          OutputStream outputStream, | ||||
|                                          int width, | ||||
|                                          int height, | ||||
|                                          String suffix) throws IOException { | ||||
|         // 读取原始图片 | ||||
|         BufferedImage originalImage = ImageIO.read(inputStream); | ||||
|  | ||||
|         // 调整图片大小 | ||||
|         Image tmp = originalImage.getScaledInstance(width, height, Image.SCALE_SMOOTH); | ||||
|         BufferedImage thumbnail = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); | ||||
|  | ||||
|         // 画出缩略图 | ||||
|         Graphics2D g2d = thumbnail.createGraphics(); | ||||
|         g2d.drawImage(tmp, 0, 0, null); | ||||
|         g2d.dispose(); | ||||
|         // 写入输出流 | ||||
|         ImageIO.write(thumbnail, suffix, outputStream); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,128 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.util; | ||||
|  | ||||
| import cn.hutool.core.io.FileUtil; | ||||
| import cn.hutool.core.io.IoUtil; | ||||
| import cn.hutool.core.io.file.FileNameUtil; | ||||
| import cn.hutool.core.util.StrUtil; | ||||
| import top.continew.starter.core.constant.StringConstants; | ||||
|  | ||||
| import java.io.ByteArrayInputStream; | ||||
| import java.io.InputStream; | ||||
| import java.net.URI; | ||||
| import java.nio.file.Paths; | ||||
| import java.time.LocalDate; | ||||
| import java.time.LocalDateTime; | ||||
| import java.time.format.DateTimeFormatter; | ||||
|  | ||||
| /** | ||||
|  * 储存工具 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/16 19:55 | ||||
|  */ | ||||
| public class StorageUtils { | ||||
|     public StorageUtils() { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 格式文件名 | ||||
|      * | ||||
|      * @param fileName 文件名 | ||||
|      * @return {@link String } | ||||
|      */ | ||||
|     public static String formatFileName(String fileName) { | ||||
|         // 获取文件后缀名 | ||||
|         String suffix = FileUtil.extName(fileName); | ||||
|         // 获取当前时间的年月日时分秒格式 | ||||
|         DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); | ||||
|         String datetime = LocalDateTime.now().format(formatter); | ||||
|         // 获取当前时间戳 | ||||
|         String timestamp = String.valueOf(System.currentTimeMillis()); | ||||
|         // 生成新的文件名 | ||||
|         return datetime + timestamp + "." + suffix; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 默认文件目录 | ||||
|      * | ||||
|      * @param fileName 文件名 | ||||
|      * @return {@link String } | ||||
|      */ | ||||
|     public static String defaultFileDir(String fileName) { | ||||
|         LocalDate today = LocalDate.now(); | ||||
|         return Paths.get(String.valueOf(today.getYear()), String.valueOf(today.getMonthValue()), String.valueOf(today | ||||
|             .getDayOfMonth()), fileName).toString(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 默认路径地址 格式 2024/03/10/ | ||||
|      * | ||||
|      * @return {@link String } | ||||
|      */ | ||||
|     public static String defaultPath() { | ||||
|         LocalDate today = LocalDate.now(); | ||||
|         return Paths.get(String.valueOf(today.getYear()), String.valueOf(today.getMonthValue()), String.valueOf(today | ||||
|             .getDayOfMonth())) + StringConstants.SLASH; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 根据 endpoint 判断是否带有 http 或 https,如果没有则加上 http 前缀。 | ||||
|      * | ||||
|      * @param endpoint 输入的 endpoint 字符串 | ||||
|      * @return URI 对象 | ||||
|      */ | ||||
|     public static URI createUriWithProtocol(String endpoint) { | ||||
|         // 判断 endpoint 是否包含 http:// 或 https:// 前缀 | ||||
|         if (!endpoint.startsWith("http://") && !endpoint.startsWith("https://")) { | ||||
|             // 如果没有协议前缀,则加上 http:// | ||||
|             endpoint = "http://" + endpoint; | ||||
|         } | ||||
|         // 返回 URI 对象 | ||||
|         return URI.create(endpoint); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 生成缩略图文件名 | ||||
|      * | ||||
|      * @param fileName 文件名 | ||||
|      * @param suffix   后缀 | ||||
|      * @return {@link String } | ||||
|      */ | ||||
|     public static String buildThumbnailFileName(String fileName, String suffix) { | ||||
|         // 获取文件的扩展名 | ||||
|         String extName = FileNameUtil.extName(fileName); | ||||
|         // 去掉扩展名 | ||||
|         String baseName = StrUtil.subBefore(fileName, StringConstants.DOT, true); | ||||
|         // 拼接新的路径:原始路径 + .缩略图后缀 + .扩展名 | ||||
|         return baseName + "." + suffix + "." + extName; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 可重复读流 | ||||
|      * | ||||
|      * @param inputStream 输入流 | ||||
|      * @return {@link InputStream } | ||||
|      */ | ||||
|     public static InputStream ensureByteArrayStream(InputStream inputStream) { | ||||
|         return (inputStream instanceof ByteArrayInputStream) | ||||
|             ? inputStream | ||||
|             : new ByteArrayInputStream(IoUtil.readBytes(inputStream)); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -13,10 +13,10 @@ | ||||
|     <description>ContiNew Starter 存储模块 - 本地存储</description> | ||||
|  | ||||
|     <dependencies> | ||||
|         <!-- Spring Web MVC 模块 --> | ||||
|         <!--存储 - 核心模块--> | ||||
|         <dependency> | ||||
|             <groupId>org.springframework</groupId> | ||||
|             <artifactId>spring-webmvc</artifactId> | ||||
|             <groupId>top.continew</groupId> | ||||
|             <artifactId>continew-starter-storage-core</artifactId> | ||||
|         </dependency> | ||||
|     </dependencies> | ||||
| </project> | ||||
| @@ -0,0 +1,39 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.autoconfigure; | ||||
|  | ||||
| import org.springframework.boot.autoconfigure.AutoConfiguration; | ||||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; | ||||
| import org.springframework.context.annotation.Bean; | ||||
| import top.continew.starter.storage.dao.StorageDao; | ||||
| import top.continew.starter.storage.dao.impl.StorageDaoDefaultImpl; | ||||
|  | ||||
| /** | ||||
|  * 本地存储 - 存储自动配置 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/17 20:23 | ||||
|  */ | ||||
| @AutoConfiguration | ||||
| public class LocalStorageAutoconfigure { | ||||
|  | ||||
|     @Bean | ||||
|     @ConditionalOnMissingBean | ||||
|     public StorageDao storageDao() { | ||||
|         return new StorageDaoDefaultImpl(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,85 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.client; | ||||
|  | ||||
| import org.slf4j.Logger; | ||||
| import org.slf4j.LoggerFactory; | ||||
| import top.continew.starter.storage.model.req.StorageProperties; | ||||
|  | ||||
| import java.io.IOException; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
|  | ||||
| /** | ||||
|  * 本地客户端 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/16 19:37 | ||||
|  */ | ||||
| public class LocalClient { | ||||
|     private static final Logger log = LoggerFactory.getLogger(LocalClient.class); | ||||
|  | ||||
|     /** | ||||
|      * 配置属性 | ||||
|      */ | ||||
|     private final StorageProperties properties; | ||||
|  | ||||
|     /** | ||||
|      * 构造函数 | ||||
|      * | ||||
|      * @param properties 配置属性 | ||||
|      */ | ||||
|     public LocalClient(StorageProperties properties) { | ||||
|         this.properties = properties; | ||||
|         // 判断是否是默认存储,若不存在桶目录,则创建 | ||||
|         if (Boolean.TRUE.equals(properties.getIsDefault())) { | ||||
|             String bucketName = properties.getBucketName(); | ||||
|             if (bucketName != null && !bucketName.isEmpty()) { | ||||
|                 createBucketDirectory(bucketName); | ||||
|             } else { | ||||
|                 log.info("默认存储-存储桶已存在 => {}", bucketName); | ||||
|             } | ||||
|         } | ||||
|         log.info("加载 Local 存储 => {}", properties.getCode()); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 获取属性 | ||||
|      * | ||||
|      * @return {@link StorageProperties } | ||||
|      */ | ||||
|     public StorageProperties getProperties() { | ||||
|         return properties; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 创建桶目录 | ||||
|      * | ||||
|      * @param bucketName 桶名称 | ||||
|      */ | ||||
|     private void createBucketDirectory(String bucketName) { | ||||
|         Path bucketPath = Path.of(bucketName); | ||||
|         try { | ||||
|             if (Files.notExists(bucketPath)) { | ||||
|                 Files.createDirectories(bucketPath); | ||||
|                 log.info("默认存储-存储桶创建成功 : {}", bucketPath.toAbsolutePath()); | ||||
|             } | ||||
|         } catch (IOException e) { | ||||
|             log.error("创建默认存储-存储桶失败 => 路径: {}", bucketPath.toAbsolutePath(), e); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,79 +0,0 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.local.autoconfigure; | ||||
|  | ||||
| import cn.hutool.core.text.CharSequenceUtil; | ||||
| import jakarta.annotation.PostConstruct; | ||||
| import org.slf4j.Logger; | ||||
| import org.slf4j.LoggerFactory; | ||||
| import org.springframework.boot.autoconfigure.AutoConfiguration; | ||||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; | ||||
| import org.springframework.boot.context.properties.EnableConfigurationProperties; | ||||
| import org.springframework.web.servlet.config.annotation.EnableWebMvc; | ||||
| import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; | ||||
| import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; | ||||
| import top.continew.starter.core.constant.PropertiesConstants; | ||||
| import top.continew.starter.core.constant.StringConstants; | ||||
|  | ||||
| import java.util.Map; | ||||
|  | ||||
| /** | ||||
|  * 本地文件自动配置 | ||||
|  * | ||||
|  * @author Charles7c | ||||
|  * @since 1.1.0 | ||||
|  */ | ||||
| @EnableWebMvc | ||||
| @AutoConfiguration | ||||
| @EnableConfigurationProperties(LocalStorageProperties.class) | ||||
| @ConditionalOnProperty(prefix = PropertiesConstants.STORAGE_LOCAL, name = PropertiesConstants.ENABLED, havingValue = "true", matchIfMissing = true) | ||||
| public class LocalStorageAutoConfiguration implements WebMvcConfigurer { | ||||
|  | ||||
|     private static final Logger log = LoggerFactory.getLogger(LocalStorageAutoConfiguration.class); | ||||
|     private final LocalStorageProperties properties; | ||||
|  | ||||
|     public LocalStorageAutoConfiguration(LocalStorageProperties properties) { | ||||
|         this.properties = properties; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void addResourceHandlers(ResourceHandlerRegistry registry) { | ||||
|         Map<String, LocalStorageProperties.LocalStorageMapping> mappingMap = properties.getMapping(); | ||||
|         for (Map.Entry<String, LocalStorageProperties.LocalStorageMapping> mappingEntry : mappingMap.entrySet()) { | ||||
|             LocalStorageProperties.LocalStorageMapping mapping = mappingEntry.getValue(); | ||||
|             String pathPattern = mapping.getPathPattern(); | ||||
|             String location = mapping.getLocation(); | ||||
|             if (CharSequenceUtil.isBlank(location)) { | ||||
|                 throw new IllegalArgumentException("Path pattern [%s] location is null.".formatted(pathPattern)); | ||||
|             } | ||||
|             registry.addResourceHandler(CharSequenceUtil.appendIfMissing(pathPattern, StringConstants.PATH_PATTERN)) | ||||
|                 .addResourceLocations(!location.startsWith("file:") | ||||
|                     ? "file:%s".formatted(this.format(location)) | ||||
|                     : this.format(location)) | ||||
|                 .setCachePeriod(0); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private String format(String location) { | ||||
|         return location.replace(StringConstants.BACKSLASH, StringConstants.SLASH); | ||||
|     } | ||||
|  | ||||
|     @PostConstruct | ||||
|     public void postConstruct() { | ||||
|         log.debug("[ContiNew Starter] - Auto Configuration 'Storage-Local' completed initialization."); | ||||
|     } | ||||
| } | ||||
| @@ -1,105 +0,0 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.local.autoconfigure; | ||||
|  | ||||
| import org.springframework.boot.context.properties.ConfigurationProperties; | ||||
| import org.springframework.util.unit.DataSize; | ||||
| import top.continew.starter.core.constant.PropertiesConstants; | ||||
|  | ||||
| import java.util.HashMap; | ||||
| import java.util.Map; | ||||
|  | ||||
| /** | ||||
|  * 本地存储配置属性 | ||||
|  * | ||||
|  * @author Charles7c | ||||
|  * @since 1.1.0 | ||||
|  */ | ||||
| @ConfigurationProperties(PropertiesConstants.STORAGE_LOCAL) | ||||
| public class LocalStorageProperties { | ||||
|  | ||||
|     /** | ||||
|      * 是否启用本地存储 | ||||
|      */ | ||||
|     private boolean enabled = true; | ||||
|  | ||||
|     /** | ||||
|      * 存储映射 | ||||
|      */ | ||||
|     private Map<String, LocalStorageMapping> mapping = new HashMap<>(); | ||||
|  | ||||
|     /** | ||||
|      * 本地存储映射 | ||||
|      */ | ||||
|     public static class LocalStorageMapping { | ||||
|  | ||||
|         /** | ||||
|          * 路径模式 | ||||
|          */ | ||||
|         private String pathPattern; | ||||
|  | ||||
|         /** | ||||
|          * 资源路径 | ||||
|          */ | ||||
|         private String location; | ||||
|  | ||||
|         /** | ||||
|          * 单文件上传大小限制 | ||||
|          */ | ||||
|         private DataSize maxFileSize = DataSize.ofMegabytes(1); | ||||
|  | ||||
|         public String getPathPattern() { | ||||
|             return pathPattern; | ||||
|         } | ||||
|  | ||||
|         public void setPathPattern(String pathPattern) { | ||||
|             this.pathPattern = pathPattern; | ||||
|         } | ||||
|  | ||||
|         public String getLocation() { | ||||
|             return location; | ||||
|         } | ||||
|  | ||||
|         public void setLocation(String location) { | ||||
|             this.location = location; | ||||
|         } | ||||
|  | ||||
|         public DataSize getMaxFileSize() { | ||||
|             return maxFileSize; | ||||
|         } | ||||
|  | ||||
|         public void setMaxFileSize(DataSize maxFileSize) { | ||||
|             this.maxFileSize = maxFileSize; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public boolean isEnabled() { | ||||
|         return enabled; | ||||
|     } | ||||
|  | ||||
|     public void setEnabled(boolean enabled) { | ||||
|         this.enabled = enabled; | ||||
|     } | ||||
|  | ||||
|     public Map<String, LocalStorageMapping> getMapping() { | ||||
|         return mapping; | ||||
|     } | ||||
|  | ||||
|     public void setMapping(Map<String, LocalStorageMapping> mapping) { | ||||
|         this.mapping = mapping; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,275 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.strategy; | ||||
|  | ||||
| import cn.hutool.core.io.FileUtil; | ||||
| import cn.hutool.core.io.IoUtil; | ||||
| import cn.hutool.core.io.file.FileNameUtil; | ||||
| import cn.hutool.core.util.ObjectUtil; | ||||
| import cn.hutool.core.util.StrUtil; | ||||
| import top.continew.starter.core.constant.StringConstants; | ||||
| import top.continew.starter.core.exception.BusinessException; | ||||
| import top.continew.starter.core.validation.CheckUtils; | ||||
| import top.continew.starter.core.validation.ValidationUtils; | ||||
| import top.continew.starter.storage.client.LocalClient; | ||||
| import top.continew.starter.storage.constant.StorageConstant; | ||||
| import top.continew.starter.storage.dao.StorageDao; | ||||
| import top.continew.starter.storage.enums.FileTypeEnum; | ||||
| import top.continew.starter.storage.model.req.StorageProperties; | ||||
| import top.continew.starter.storage.model.resp.ThumbnailResp; | ||||
| import top.continew.starter.storage.model.resp.UploadResp; | ||||
| import top.continew.starter.storage.util.ImageThumbnailUtils; | ||||
| import top.continew.starter.storage.util.LocalUtils; | ||||
| import top.continew.starter.storage.util.StorageUtils; | ||||
|  | ||||
| import java.io.*; | ||||
| import java.nio.file.Files; | ||||
| import java.nio.file.Path; | ||||
| import java.nio.file.Paths; | ||||
| import java.security.NoSuchAlgorithmException; | ||||
| import java.time.LocalDateTime; | ||||
| import java.util.Base64; | ||||
|  | ||||
| /** | ||||
|  * 本地存储策略 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/16 19:48 | ||||
|  */ | ||||
| public class LocalStorageStrategy implements StorageStrategy<LocalClient> { | ||||
|  | ||||
|     private final LocalClient client; | ||||
|     private final StorageDao storageDao; | ||||
|  | ||||
|     public LocalStorageStrategy(LocalClient client, StorageDao storageDao) { | ||||
|         this.client = client; | ||||
|         this.storageDao = storageDao; | ||||
|     } | ||||
|  | ||||
|     private StorageProperties getStorageProperties() { | ||||
|         return client.getProperties(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public LocalClient getClient() { | ||||
|         return client; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public boolean bucketExists(String bucketName) { | ||||
|         try { | ||||
|             return Files.exists(Path.of(bucketName)); | ||||
|         } catch (RuntimeException e) { | ||||
|             throw new BusinessException("local存储 查询桶 失败", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void createBucket(String bucketName) { | ||||
|         if (!bucketExists(bucketName)) { | ||||
|             try { | ||||
|                 Files.createDirectories(Path.of(bucketName)); | ||||
|             } catch (IOException e) { | ||||
|                 throw new BusinessException("local存储 创建桶 失败", e); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public UploadResp upload(String fileName, InputStream inputStream, String fileType) { | ||||
|         String bucketName = getStorageProperties().getBucketName(); | ||||
|         return this.upload(bucketName, fileName, null, inputStream, fileType, false); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public UploadResp upload(String fileName, | ||||
|                              String path, | ||||
|                              InputStream inputStream, | ||||
|                              String fileType, | ||||
|                              boolean isThumbnail) { | ||||
|         String bucketName = getStorageProperties().getBucketName(); | ||||
|         return this.upload(bucketName, fileName, path, inputStream, fileType, isThumbnail); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public UploadResp upload(String bucketName, | ||||
|                              String fileName, | ||||
|                              String path, | ||||
|                              InputStream inputStream, | ||||
|                              String fileType, | ||||
|                              boolean isThumbnail) { | ||||
|         try { | ||||
|             // 可重复读流 | ||||
|             inputStream = StorageUtils.ensureByteArrayStream(inputStream); | ||||
|             // 获取流大小 | ||||
|             byte[] originalBytes = IoUtil.readBytes(inputStream); | ||||
|             ValidationUtils.throwIf(originalBytes.length == 0, "输入流内容长度不可用或无效"); | ||||
|  | ||||
|             // 获取文件扩展名 | ||||
|             String fileExtension = FileNameUtil.extName(fileName); | ||||
|             // 格式化文件名 防止上传后重复 | ||||
|             String formatFileName = StorageUtils.formatFileName(fileName); | ||||
|             // 判断文件路径是否为空  为空给默认路径 格式 2024/12/30/ | ||||
|             if (StrUtil.isEmpty(path)) { | ||||
|                 path = StorageUtils.defaultPath(); | ||||
|             } | ||||
|             // 判断文件夹是否存在 不存在则创建 | ||||
|             Path folderPath = Paths.get(bucketName, path); | ||||
|             if (!Files.exists(folderPath)) { | ||||
|                 Files.createDirectories(folderPath); | ||||
|             } | ||||
|             ThumbnailResp thumbnailResp = null; | ||||
|             //判断是否需要上传缩略图 前置条件 文件必须为图片 | ||||
|             boolean contains = FileTypeEnum.IMAGE.getExtensions().contains(fileExtension); | ||||
|             if (contains && isThumbnail) { | ||||
|                 try (InputStream thumbnailStream = new ByteArrayInputStream(originalBytes)) { | ||||
|                     thumbnailResp = this.uploadThumbnail(bucketName, formatFileName, path, thumbnailStream, fileType); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // 上传文件 | ||||
|             try (InputStream uploadStream = new ByteArrayInputStream(originalBytes)) { | ||||
|                 this.upload(bucketName, formatFileName, path, uploadStream, fileType); | ||||
|             } | ||||
|  | ||||
|             // 构建文件 md5 | ||||
|             String eTag = LocalUtils.calculateMD5(inputStream); | ||||
|             // 构建 上传后的文件路径地址 格式 xxx/xxx/xxx.jpg | ||||
|             String filePath = Paths.get(path, formatFileName).toString(); | ||||
|             // 构建 文件上传记录 并返回 | ||||
|             return buildStorageRecord(bucketName, fileName, filePath, eTag, originalBytes.length, thumbnailResp); | ||||
|         } catch (NoSuchAlgorithmException | IOException e) { | ||||
|             throw new BusinessException("文件上传异常", e); | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void upload(String bucketName, String fileName, String path, InputStream inputStream, String fileType) { | ||||
|         byte[] fileBytes = IoUtil.readBytes(inputStream); | ||||
|         // 拼接完整地址 | ||||
|         String filePath = Paths.get(bucketName, path, fileName).toString(); | ||||
|         try { | ||||
|             //上传文件 | ||||
|             File targetFile = new File(filePath); | ||||
|             try (FileOutputStream fos = new FileOutputStream(targetFile)) { | ||||
|                 fos.write(fileBytes); | ||||
|             } | ||||
|         } catch (IOException e) { | ||||
|             throw new BusinessException("文件上传异常", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public ThumbnailResp uploadThumbnail(String bucketName, | ||||
|                                          String fileName, | ||||
|                                          String path, | ||||
|                                          InputStream inputStream, | ||||
|                                          String fileType) { | ||||
|         // 获取文件扩展名 | ||||
|         String fileExtension = FileNameUtil.extName(fileName); | ||||
|         // 生成缩略图文件名 | ||||
|         String thumbnailFileName = StorageUtils.buildThumbnailFileName(fileName, StorageConstant.SMALL_SUFFIX); | ||||
|         // 处理文件为缩略图 | ||||
|         try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { | ||||
|             ImageThumbnailUtils.generateThumbnail(inputStream, outputStream, fileExtension); | ||||
|             inputStream = new ByteArrayInputStream(outputStream.toByteArray()); | ||||
|             // 上传文件 | ||||
|             this.upload(bucketName, thumbnailFileName, path, inputStream, null); | ||||
|  | ||||
|             return new ThumbnailResp((long)outputStream.size(), Paths.get(path, thumbnailFileName).toString()); | ||||
|         } catch (IOException e) { | ||||
|             throw new BusinessException("缩略图处理异常", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public InputStream download(String bucketName, String fileName) { | ||||
|         String fullPath = Paths.get(bucketName, fileName).toString(); | ||||
|         File file = new File(fullPath); | ||||
|         try { | ||||
|             return new FileInputStream(file); | ||||
|         } catch (IOException e) { | ||||
|             throw new BusinessException("下载文件异常", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void delete(String bucketName, String fileName) { | ||||
|         try { | ||||
|             String fullPath = Paths.get(bucketName, fileName).toString(); | ||||
|             Files.delete(Paths.get(fullPath)); | ||||
|         } catch (Exception e) { | ||||
|             throw new BusinessException("删除文件异常", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public String getImageBase64(String bucketName, String fileName) { | ||||
|         try (InputStream inputStream = download(bucketName, fileName)) { | ||||
|             if (ObjectUtil.isEmpty(inputStream)) { | ||||
|                 return null; | ||||
|             } | ||||
|             String extName = FileUtil.extName(fileName); | ||||
|             CheckUtils.throwIf(!FileTypeEnum.IMAGE.getExtensions().contains(extName), "{} 不是图像格式", extName); | ||||
|             return Base64.getEncoder().encodeToString(inputStream.readAllBytes()); | ||||
|         } catch (Exception e) { | ||||
|             throw new BusinessException("无法查看图片", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 构建存储记录 | ||||
|      * | ||||
|      * @param bucketName    桶名称 | ||||
|      * @param fileName      原始文件名 | ||||
|      * @param filePath      文件路径 xx/xx/xxx.jpg | ||||
|      * @param eTag          标签 - md5 | ||||
|      * @param size          文件大小 | ||||
|      * @param thumbnailResp 缩略图信息 | ||||
|      * @return {@link UploadResp } | ||||
|      */ | ||||
|     private UploadResp buildStorageRecord(String bucketName, | ||||
|                                           String fileName, | ||||
|                                           String filePath, | ||||
|                                           String eTag, | ||||
|                                           long size, | ||||
|                                           ThumbnailResp thumbnailResp) { | ||||
|         // 获取当前存储 code | ||||
|         String code = client.getProperties().getCode(); | ||||
|         // 构建访问地址前缀 | ||||
|         String baseUrl = "http://" + getStorageProperties().getEndpoint() + StringConstants.SLASH; | ||||
|  | ||||
|         UploadResp resp = new UploadResp(); | ||||
|         resp.setCode(code); | ||||
|         resp.setUrl(baseUrl + filePath); | ||||
|         resp.setBasePath(filePath); | ||||
|         resp.setOriginalFilename(fileName); | ||||
|         resp.setExt(FileNameUtil.extName(fileName)); | ||||
|         resp.setSize(size); | ||||
|         resp.seteTag(eTag); | ||||
|         resp.setPath(filePath); | ||||
|         resp.setBucketName(bucketName); | ||||
|         resp.setCreateTime(LocalDateTime.now()); | ||||
|         if (ObjectUtil.isNotEmpty(thumbnailResp)) { | ||||
|             resp.setThumbnailUrl(baseUrl + thumbnailResp.getThumbnailPath()); | ||||
|             resp.setThumbnailSize(thumbnailResp.getThumbnailSize()); | ||||
|         } | ||||
|         storageDao.add(resp); | ||||
|         return resp; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,46 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.util; | ||||
|  | ||||
| import cn.hutool.core.io.IoUtil; | ||||
| import net.dreamlu.mica.core.utils.DigestUtil; | ||||
|  | ||||
| import java.io.InputStream; | ||||
| import java.security.NoSuchAlgorithmException; | ||||
|  | ||||
| /** | ||||
|  * 本地存储工具 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/27 11:58 | ||||
|  */ | ||||
| public class LocalUtils { | ||||
|     public LocalUtils() { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 计算MD5 | ||||
|      * | ||||
|      * @param inputStream 输入流 | ||||
|      * @return {@link String } | ||||
|      * @throws NoSuchAlgorithmException 没有这样算法例外 | ||||
|      */ | ||||
|     public static String calculateMD5(InputStream inputStream) throws NoSuchAlgorithmException { | ||||
|         byte[] fileBytes = IoUtil.readBytes(inputStream); | ||||
|         return DigestUtil.md5Hex(fileBytes); | ||||
|     } | ||||
| } | ||||
| @@ -1 +1 @@ | ||||
| top.continew.starter.storage.local.autoconfigure.LocalStorageAutoConfiguration | ||||
| top.continew.starter.storage.autoconfigure.LocalStorageAutoconfigure | ||||
| @@ -0,0 +1,63 @@ | ||||
| <?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 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||||
|     <modelVersion>4.0.0</modelVersion> | ||||
|     <parent> | ||||
|         <groupId>top.continew</groupId> | ||||
|         <artifactId>continew-starter-storage</artifactId> | ||||
|         <version>${revision}</version> | ||||
|     </parent> | ||||
|  | ||||
|     <artifactId>continew-starter-storage-oss</artifactId> | ||||
|     <description>ContiNew Starter 存储模块 - 对象存储</description> | ||||
|  | ||||
|     <dependencies> | ||||
|         <!--  S3 SDK for Java 2.x  --> | ||||
|         <dependency> | ||||
|             <groupId>software.amazon.awssdk</groupId> | ||||
|             <artifactId>s3</artifactId> | ||||
|             <exclusions> | ||||
|                 <!-- 基于 Netty 的 HTTP 客户端移除 --> | ||||
|                 <exclusion> | ||||
|                     <groupId>software.amazon.awssdk</groupId> | ||||
|                     <artifactId>netty-nio-client</artifactId> | ||||
|                 </exclusion> | ||||
|                 <!-- 基于 CRT 的 HTTP 客户端移除 --> | ||||
|                 <exclusion> | ||||
|                     <groupId>software.amazon.awssdk</groupId> | ||||
|                     <artifactId>aws-crt-client</artifactId> | ||||
|                 </exclusion> | ||||
|                 <!-- 基于 Apache 的 HTTP 客户端移除 --> | ||||
|                 <exclusion> | ||||
|                     <groupId>software.amazon.awssdk</groupId> | ||||
|                     <artifactId>apache-client</artifactId> | ||||
|                 </exclusion> | ||||
|                 <!-- 配置基于 URL 连接的 HTTP 客户端移除 --> | ||||
|                 <exclusion> | ||||
|                     <groupId>software.amazon.awssdk</groupId> | ||||
|                     <artifactId>url-connection-client</artifactId> | ||||
|                 </exclusion> | ||||
|             </exclusions> | ||||
|         </dependency> | ||||
|  | ||||
|         <!-- 使用AWS基于 CRT 的 S3 客户端 --> | ||||
|         <dependency> | ||||
|             <groupId>software.amazon.awssdk.crt</groupId> | ||||
|             <artifactId>aws-crt</artifactId> | ||||
|         </dependency> | ||||
|  | ||||
|         <!-- 基于 AWS CRT 的 S3 客户端的性能增强的 S3 传输管理器 --> | ||||
|         <dependency> | ||||
|             <groupId>software.amazon.awssdk</groupId> | ||||
|             <artifactId>s3-transfer-manager</artifactId> | ||||
|         </dependency> | ||||
|  | ||||
|         <!--存储 - 核心模块--> | ||||
|         <dependency> | ||||
|             <groupId>top.continew</groupId> | ||||
|             <artifactId>continew-starter-storage-core</artifactId> | ||||
|         </dependency> | ||||
|  | ||||
|     </dependencies> | ||||
|  | ||||
| </project> | ||||
| @@ -0,0 +1,40 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.autoconfigure; | ||||
|  | ||||
| import org.springframework.boot.autoconfigure.AutoConfiguration; | ||||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; | ||||
| import org.springframework.context.annotation.Bean; | ||||
| import top.continew.starter.storage.dao.StorageDao; | ||||
| import top.continew.starter.storage.dao.impl.StorageDaoDefaultImpl; | ||||
|  | ||||
| /** | ||||
|  * 对象存储 - 存储自动配置 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/17 20:23 | ||||
|  */ | ||||
| @AutoConfiguration | ||||
| public class OssStorageAutoconfigure { | ||||
|  | ||||
|     @Bean | ||||
|     @ConditionalOnMissingBean | ||||
|     public StorageDao storageDao() { | ||||
|         return new StorageDaoDefaultImpl(); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,163 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.client; | ||||
|  | ||||
| import org.slf4j.Logger; | ||||
| import org.slf4j.LoggerFactory; | ||||
| import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; | ||||
| import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; | ||||
| import software.amazon.awssdk.services.s3.S3AsyncClient; | ||||
| import software.amazon.awssdk.services.s3.crt.S3CrtHttpConfiguration; | ||||
| import software.amazon.awssdk.services.s3.model.CreateBucketRequest; | ||||
| import software.amazon.awssdk.services.s3.model.HeadBucketRequest; | ||||
| import software.amazon.awssdk.services.s3.model.NoSuchBucketException; | ||||
| import software.amazon.awssdk.services.s3.presigner.S3Presigner; | ||||
| import software.amazon.awssdk.transfer.s3.S3TransferManager; | ||||
| import top.continew.starter.core.exception.BusinessException; | ||||
| import top.continew.starter.storage.model.req.StorageProperties; | ||||
| import top.continew.starter.storage.util.OssUtils; | ||||
| import top.continew.starter.storage.util.StorageUtils; | ||||
|  | ||||
| import java.net.URI; | ||||
| import java.time.Duration; | ||||
|  | ||||
| /** | ||||
|  * S3客户端 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/16 | ||||
|  */ | ||||
| public class OssClient { | ||||
|  | ||||
|     private static final Logger log = LoggerFactory.getLogger(OssClient.class); | ||||
|  | ||||
|     /** | ||||
|      * 配置属性 | ||||
|      */ | ||||
|     private final StorageProperties properties; | ||||
|  | ||||
|     /** | ||||
|      * s3 异步客户端 | ||||
|      */ | ||||
|     private final S3AsyncClient client; | ||||
|  | ||||
|     /** | ||||
|      * S3 数据传输的高级工具 | ||||
|      */ | ||||
|     private final S3TransferManager transferManager; | ||||
|  | ||||
|     /** | ||||
|      * S3 预签名 | ||||
|      */ | ||||
|     private final S3Presigner presigner; | ||||
|  | ||||
|     /** | ||||
|      * 获取属性 | ||||
|      * | ||||
|      * @return {@link StorageProperties } | ||||
|      */ | ||||
|     public StorageProperties getProperties() { | ||||
|         return properties; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 构造方法 | ||||
|      * | ||||
|      * @param s3PropertiesReq 微型性能要求 | ||||
|      */ | ||||
|     public OssClient(StorageProperties s3PropertiesReq) { | ||||
|         this.properties = s3PropertiesReq; | ||||
|  | ||||
|         // 创建认证信息 | ||||
|         StaticCredentialsProvider auth = StaticCredentialsProvider.create(AwsBasicCredentials.create(properties | ||||
|             .getAccessKey(), properties.getSecretKey())); | ||||
|  | ||||
|         URI uriWithProtocol = StorageUtils.createUriWithProtocol(properties.getEndpoint()); | ||||
|  | ||||
|         // 创建 客户端连接 | ||||
|         client = S3AsyncClient.crtBuilder() | ||||
|             .credentialsProvider(auth) // 认证信息 | ||||
|             .endpointOverride(uriWithProtocol) // 连接端点 | ||||
|             .region(OssUtils.getRegion(properties.getRegion())) | ||||
|             .targetThroughputInGbps(20.0) //吞吐量 | ||||
|             .minimumPartSizeInBytes(10 * 1025 * 1024L) | ||||
|             .checksumValidationEnabled(false) | ||||
|             .httpConfiguration(S3CrtHttpConfiguration.builder() | ||||
|                 .connectionTimeout(Duration.ofSeconds(60)) // 设置连接超时 | ||||
|                 .build()) | ||||
|             .build(); | ||||
|  | ||||
|         // 基于 CRT 创建 S3 Transfer Manager 的实例 | ||||
|         this.transferManager = S3TransferManager.builder().s3Client(this.client).build(); | ||||
|  | ||||
|         this.presigner = S3Presigner.builder() | ||||
|             .region(OssUtils.getRegion(properties.getRegion())) | ||||
|             .credentialsProvider(auth) | ||||
|             .endpointOverride(uriWithProtocol) | ||||
|             .build(); | ||||
|  | ||||
|         // 只创建 默认存储的的桶 | ||||
|         if (s3PropertiesReq.getIsDefault()) { | ||||
|             try { | ||||
|                 // 检查存储桶是否存在 | ||||
|                 client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucketName()).build()); | ||||
|                 log.info("默认存储-存储桶 {} 已存在", properties.getBucketName()); | ||||
|             } catch (NoSuchBucketException e) { | ||||
|                 log.info("默认存储桶 {} 不存在,尝试创建...", properties.getBucketName()); | ||||
|                 try { | ||||
|                     // 创建存储桶 | ||||
|                     client.createBucket(CreateBucketRequest.builder().bucket(properties.getBucketName()).build()); | ||||
|                     log.info("默认存储-存储桶 {} 创建成功", properties.getBucketName()); | ||||
|                 } catch (Exception createException) { | ||||
|                     log.error("创建默认存储-存储桶 {} 失败", properties.getBucketName(), createException); | ||||
|                     throw new BusinessException("创建默认存储-桶出错", createException); | ||||
|                 } | ||||
|             } catch (Exception e) { | ||||
|                 log.error("检查默认存储-存储桶 {} 时出错", properties.getBucketName(), e); | ||||
|                 throw new BusinessException("检查默认存储-桶时出错", e); | ||||
|             } | ||||
|         } | ||||
|         log.info("加载 S3 存储 => {}", properties.getCode()); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 获得客户端 | ||||
|      * | ||||
|      * @return {@link S3TransferManager } | ||||
|      */ | ||||
|     public S3AsyncClient getClient() { | ||||
|         return client; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 获得 高效连接客户端 主要用于 上传下载 复制 删除 | ||||
|      * | ||||
|      * @return {@link S3TransferManager } | ||||
|      */ | ||||
|     public S3TransferManager getTransferManager() { | ||||
|         return transferManager; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 获得 S3 预签名 | ||||
|      * | ||||
|      * @return {@link S3Presigner } | ||||
|      */ | ||||
|     public S3Presigner getPresigner() { | ||||
|         return presigner; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,401 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.strategy; | ||||
|  | ||||
| import cn.hutool.core.io.FileUtil; | ||||
| import cn.hutool.core.io.IoUtil; | ||||
| import cn.hutool.core.io.file.FileNameUtil; | ||||
| import cn.hutool.core.util.ObjectUtil; | ||||
| import cn.hutool.core.util.StrUtil; | ||||
| import cn.hutool.json.JSONObject; | ||||
| import org.slf4j.Logger; | ||||
| import org.slf4j.LoggerFactory; | ||||
| import software.amazon.awssdk.core.ResponseInputStream; | ||||
| import software.amazon.awssdk.core.async.AsyncResponseTransformer; | ||||
| import software.amazon.awssdk.core.async.BlockingInputStreamAsyncRequestBody; | ||||
| import software.amazon.awssdk.services.s3.model.*; | ||||
| import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; | ||||
| import software.amazon.awssdk.transfer.s3.model.CompletedUpload; | ||||
| import software.amazon.awssdk.transfer.s3.model.Download; | ||||
| import software.amazon.awssdk.transfer.s3.model.DownloadRequest; | ||||
| import software.amazon.awssdk.transfer.s3.model.Upload; | ||||
| import software.amazon.awssdk.transfer.s3.progress.LoggingTransferListener; | ||||
| import top.continew.starter.core.constant.StringConstants; | ||||
| import top.continew.starter.core.exception.BusinessException; | ||||
| import top.continew.starter.core.validation.CheckUtils; | ||||
| import top.continew.starter.core.validation.ValidationUtils; | ||||
| import top.continew.starter.storage.client.OssClient; | ||||
| import top.continew.starter.storage.constant.StorageConstant; | ||||
| import top.continew.starter.storage.dao.StorageDao; | ||||
| import top.continew.starter.storage.enums.FileTypeEnum; | ||||
| import top.continew.starter.storage.model.req.StorageProperties; | ||||
| import top.continew.starter.storage.model.resp.ThumbnailResp; | ||||
| import top.continew.starter.storage.model.resp.UploadResp; | ||||
| import top.continew.starter.storage.util.ImageThumbnailUtils; | ||||
| import top.continew.starter.storage.util.OssUtils; | ||||
| import top.continew.starter.storage.util.StorageUtils; | ||||
|  | ||||
| import java.io.ByteArrayInputStream; | ||||
| import java.io.ByteArrayOutputStream; | ||||
| import java.io.IOException; | ||||
| import java.io.InputStream; | ||||
| import java.net.HttpURLConnection; | ||||
| import java.nio.file.Paths; | ||||
| import java.time.Duration; | ||||
| import java.time.LocalDateTime; | ||||
| import java.util.Base64; | ||||
| import java.util.List; | ||||
| import java.util.concurrent.CompletionException; | ||||
|  | ||||
| /** | ||||
|  * OSS存储策略 | ||||
|  * <p><a href="https://docs.aws.amazon.com/zh_cn/sdk-for-java/latest/developer-guide/home.html">...</a></p> | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/16 20:29 | ||||
|  */ | ||||
| public class OssStorageStrategy implements StorageStrategy<OssClient> { | ||||
|     private final static Logger log = LoggerFactory.getLogger(OssStorageStrategy.class); | ||||
|  | ||||
|     private final OssClient client; | ||||
|     private final StorageDao storageDao; | ||||
|     private String etag; | ||||
|  | ||||
|     public OssStorageStrategy(OssClient ossClient, StorageDao storageDao) { | ||||
|         this.client = ossClient; | ||||
|         this.storageDao = storageDao; | ||||
|     } | ||||
|  | ||||
|     private StorageProperties getStorageProperties() { | ||||
|         return client.getProperties(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public OssClient getClient() { | ||||
|         return client; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public boolean bucketExists(String bucketName) { | ||||
|         try { | ||||
|             // 调用 headBucket 请求,检查桶是否存在 | ||||
|             client.getClient().headBucket(HeadBucketRequest.builder().bucket(bucketName).build()).join(); | ||||
|             return true;  // 桶存在 | ||||
|         } catch (Exception e) { | ||||
|             // 捕获异常,详细判断具体原因 | ||||
|             if (e.getCause() instanceof NoSuchBucketException) { | ||||
|                 // 桶不存在 | ||||
|                 return false; | ||||
|             } else if (e.getCause() instanceof S3Exception s3Exception) { | ||||
|                 // 检查是否是其他人创建的桶(403 Forbidden 错误) | ||||
|                 if (s3Exception.statusCode() == HttpURLConnection.HTTP_FORBIDDEN) { | ||||
|                     throw new BusinessException("全局重复:存储桶名称已被他人创建:" + bucketName); | ||||
|                 } | ||||
|             } | ||||
|             // 捕获其他所有异常,并抛出 | ||||
|             throw new BusinessException("S3 存储桶查询失败,存储桶名称:" + bucketName, e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void createBucket(String bucketName) { | ||||
|         try { | ||||
|             if (!this.bucketExists(bucketName)) { | ||||
|                 client.getClient().createBucket(CreateBucketRequest.builder().bucket(bucketName).build()).join(); | ||||
|             } | ||||
|         } catch (S3Exception e) { | ||||
|             throw new BusinessException("S3 存储桶,创建失败", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public UploadResp upload(String fileName, InputStream inputStream, String fileType) { | ||||
|         String bucketName = getStorageProperties().getBucketName(); | ||||
|         return this.upload(bucketName, fileName, null, inputStream, fileType, false); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public UploadResp upload(String fileName, | ||||
|                              String path, | ||||
|                              InputStream inputStream, | ||||
|                              String fileType, | ||||
|                              boolean isThumbnail) { | ||||
|         String bucketName = getStorageProperties().getBucketName(); | ||||
|         return this.upload(bucketName, fileName, path, inputStream, fileType, isThumbnail); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public UploadResp upload(String bucketName, | ||||
|                              String fileName, | ||||
|                              String path, | ||||
|                              InputStream inputStream, | ||||
|                              String fileType, | ||||
|                              boolean isThumbnail) { | ||||
|         try { | ||||
|  | ||||
|             // 可重复读流 | ||||
|             inputStream = StorageUtils.ensureByteArrayStream(inputStream); | ||||
|             byte[] fileBytes = IoUtil.readBytes(inputStream); | ||||
|             ValidationUtils.throwIf(fileBytes.length == 0, "输入流内容长度不可用或无效"); | ||||
|             // 获取文件扩展名 | ||||
|             String fileExtension = FileNameUtil.extName(fileName); | ||||
|             // 格式化文件名 防止上传后重复 | ||||
|             String formatFileName = StorageUtils.formatFileName(fileName); | ||||
|             // 判断文件路径是否为空  为空给默认路径 格式 2024/12/30/ | ||||
|             if (StrUtil.isEmpty(path)) { | ||||
|                 path = StorageUtils.defaultPath(); | ||||
|             } | ||||
|             ThumbnailResp thumbnailResp = null; | ||||
|             //判断是否需要上传缩略图 前置条件 文件必须为图片 | ||||
|             boolean contains = FileTypeEnum.IMAGE.getExtensions().contains(fileExtension); | ||||
|             if (contains && isThumbnail) { | ||||
|                 try (InputStream thumbnailStream = new ByteArrayInputStream(fileBytes)) { | ||||
|                     thumbnailResp = this.uploadThumbnail(bucketName, formatFileName, path, thumbnailStream, fileType); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // 上传文件 | ||||
|             try (InputStream uploadStream = new ByteArrayInputStream(fileBytes)) { | ||||
|                 this.upload(bucketName, formatFileName, path, uploadStream, fileType); | ||||
|             } | ||||
|             String eTag = etag; | ||||
|             // 构建 上传后的文件路径地址 格式 xxx/xxx/xxx.jpg | ||||
|             String filePath = Paths.get(path, formatFileName).toString(); | ||||
|             // 构建 文件上传记录 并返回 | ||||
|             return buildStorageRecord(bucketName, fileName, filePath, eTag, fileBytes.length, thumbnailResp); | ||||
|         } catch (IOException e) { | ||||
|             throw new BusinessException("文件上传异常", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void upload(String bucketName, String fileName, String path, InputStream inputStream, String fileType) { | ||||
|         // 构建 S3 存储 文件路径 | ||||
|         String filePath = Paths.get(path, fileName).toString(); | ||||
|         try { | ||||
|             long available = inputStream.available(); | ||||
|             // 构建异步请求体,指定内容长度 | ||||
|             BlockingInputStreamAsyncRequestBody requestBody = BlockingInputStreamAsyncRequestBody.builder() | ||||
|                 .contentLength(available) | ||||
|                 .subscribeTimeout(Duration.ofSeconds(30)) | ||||
|                 .build(); | ||||
|  | ||||
|             // 初始化上传任务 | ||||
|             Upload upload = client.getTransferManager() | ||||
|                 .upload(u -> u.requestBody(requestBody) | ||||
|                     .putObjectRequest(b -> b.bucket(bucketName).key(filePath).contentType(fileType).build()) | ||||
|                     .build()); | ||||
|  | ||||
|             // 写入输入流内容到请求体 | ||||
|             requestBody.writeInputStream(inputStream); | ||||
|             CompletedUpload uploadResult = upload.completionFuture().join(); | ||||
|             etag = uploadResult.response().eTag(); | ||||
|         } catch (IOException e) { | ||||
|             throw new BusinessException("文件上传异常", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public ThumbnailResp uploadThumbnail(String bucketName, | ||||
|                                          String fileName, | ||||
|                                          String path, | ||||
|                                          InputStream inputStream, | ||||
|                                          String fileType) { | ||||
|         // 获取文件扩展名 | ||||
|         String fileExtension = FileNameUtil.extName(fileName); | ||||
|         // 生成缩略图文件名 | ||||
|         String thumbnailFileName = StorageUtils.buildThumbnailFileName(fileName, StorageConstant.SMALL_SUFFIX); | ||||
|         // 处理文件为缩略图 | ||||
|         try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { | ||||
|             ImageThumbnailUtils.generateThumbnail(inputStream, outputStream, fileExtension); | ||||
|             inputStream = new ByteArrayInputStream(outputStream.toByteArray()); | ||||
|             // 上传文件 | ||||
|             this.upload(bucketName, thumbnailFileName, path, inputStream, fileType); | ||||
|             return new ThumbnailResp((long)outputStream.size(), Paths.get(path, thumbnailFileName).toString()); | ||||
|         } catch (IOException e) { | ||||
|             throw new BusinessException("缩略图处理异常", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public InputStream download(String bucketName, String fileName) { | ||||
|         try { | ||||
|             // 构建下载请求 | ||||
|             DownloadRequest<ResponseInputStream<GetObjectResponse>> downloadRequest = DownloadRequest.builder() | ||||
|                 .getObjectRequest(req -> req.bucket(bucketName).key(fileName).build()) // 设置桶名和对象名 | ||||
|                 .addTransferListener(LoggingTransferListener.create()) // 添加传输监听器 | ||||
|                 .responseTransformer(AsyncResponseTransformer.toBlockingInputStream()) // 转换为阻塞输入流 | ||||
|                 .build(); | ||||
|             // 执行下载操作 | ||||
|             Download<ResponseInputStream<GetObjectResponse>> download = client.getTransferManager() | ||||
|                 .download(downloadRequest); | ||||
|             // 直接等待下载完成并返回 InputStream | ||||
|             // 返回输入流 | ||||
|             return download.completionFuture().join().result(); | ||||
|         } catch (CompletionException e) { | ||||
|             // 处理异步执行中的异常 | ||||
|             throw new BusinessException("文件下载失败,错误信息: " + e.getCause().getMessage(), e.getCause()); | ||||
|         } catch (Exception e) { | ||||
|             // 捕获其他异常 | ||||
|             throw new BusinessException("文件下载失败,发生未知错误", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void delete(String bucketName, String fileName) { | ||||
|         try { | ||||
|             client.getClient().deleteObject(DeleteObjectRequest.builder().bucket(bucketName).key(fileName).build()); | ||||
|         } catch (Exception e) { | ||||
|             throw new BusinessException("S3 文件删除失败", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public String getImageBase64(String bucketName, String fileName) { | ||||
|         try (InputStream inputStream = download(bucketName, fileName)) { | ||||
|             if (ObjectUtil.isEmpty(inputStream)) { | ||||
|                 return null; | ||||
|             } | ||||
|             String extName = FileUtil.extName(fileName); | ||||
|             boolean contains = FileTypeEnum.IMAGE.getExtensions().contains(extName); | ||||
|             CheckUtils.throwIf(!contains, "{}非图片格式,无法获取", extName); | ||||
|             return Base64.getEncoder().encodeToString(inputStream.readAllBytes()); | ||||
|         } catch (Exception e) { | ||||
|             throw new BusinessException("图片查看失败", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 构建储存记录 | ||||
|      * | ||||
|      * @param bucketName    桶名称 | ||||
|      * @param fileName      文件名 | ||||
|      * @param filePath      文件路径 | ||||
|      * @param eTag          e 标记 | ||||
|      * @param contentLength 内容长度 | ||||
|      * @param thumbnailResp 相应缩略图 | ||||
|      * @return {@link UploadResp } | ||||
|      */ | ||||
|     private UploadResp buildStorageRecord(String bucketName, | ||||
|                                           String fileName, | ||||
|                                           String filePath, | ||||
|                                           String eTag, | ||||
|                                           long contentLength, | ||||
|                                           ThumbnailResp thumbnailResp) { | ||||
|         // 获取终端地址 | ||||
|         String endpoint = client.getProperties().getEndpoint(); | ||||
|         // 判断桶策略 | ||||
|         boolean isPrivateBucket = this.isPrivate(bucketName); | ||||
|         // 如果是私有桶 则生成私有URL链接 默认 访问时间为 12 小时 | ||||
|         String url = isPrivateBucket | ||||
|             ? this.getPrivateUrl(bucketName, filePath, 12) | ||||
|             : OssUtils.getUrl(endpoint, bucketName) + StringConstants.SLASH + filePath; | ||||
|  | ||||
|         String thumbnailUrl = ""; | ||||
|         long thumbnailSize = 0; | ||||
|         // 判断缩略图响应是否为空 | ||||
|         if (ObjectUtil.isNotEmpty(thumbnailResp)) { | ||||
|             // 同理按照 访问桶策略构建 缩略图访问地址 | ||||
|             thumbnailUrl = isPrivateBucket | ||||
|                 ? this.getPrivateUrl(bucketName, thumbnailResp.getThumbnailPath(), 12) | ||||
|                 : OssUtils.getUrl(endpoint, bucketName) + StringConstants.SLASH + thumbnailResp.getThumbnailPath(); | ||||
|             thumbnailSize = thumbnailResp.getThumbnailSize(); | ||||
|         } | ||||
|  | ||||
|         UploadResp uploadResp = new UploadResp(); | ||||
|         uploadResp.setCode(client.getProperties().getCode()); | ||||
|         uploadResp.setUrl(url); | ||||
|         uploadResp.setBasePath(filePath); | ||||
|         uploadResp.setOriginalFilename(fileName); | ||||
|         uploadResp.setExt(FileNameUtil.extName(fileName)); | ||||
|         uploadResp.setSize(contentLength); | ||||
|         uploadResp.setThumbnailUrl(thumbnailUrl); | ||||
|         uploadResp.setThumbnailSize(thumbnailSize); | ||||
|         uploadResp.seteTag(eTag); | ||||
|         uploadResp.setPath(Paths.get(bucketName, filePath).toString()); | ||||
|         uploadResp.setBucketName(bucketName); | ||||
|         uploadResp.setCreateTime(LocalDateTime.now()); | ||||
|         storageDao.add(uploadResp); | ||||
|         return uploadResp; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 是否为私有桶 | ||||
|      * | ||||
|      * @param bucketName 桶名称 | ||||
|      * @return boolean T 是 F 不是 | ||||
|      */ | ||||
|     private boolean isPrivate(String bucketName) { | ||||
|         try { | ||||
|             // 尝试获取桶的策略 | ||||
|             GetBucketPolicyResponse policyResponse = client.getClient() | ||||
|                 .getBucketPolicy(GetBucketPolicyRequest.builder().bucket(bucketName).build()) | ||||
|                 .join(); | ||||
|             //转成 json | ||||
|             String policy = policyResponse.policy(); | ||||
|             JSONObject json = new JSONObject(policy); | ||||
|             // 为空则是私有 | ||||
|             return ObjectUtil.isEmpty(json.get("Statement")); | ||||
|         } catch (Exception e) { | ||||
|             // 如果 getBucketPolicy 抛出异常,说明不是 MinIO 或不支持策略 | ||||
|             log.warn("获取桶策略失败,可能是 MinIO,异常信息: {}", e.getMessage()); | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             // 获取桶的 ACL 信息 | ||||
|             GetBucketAclResponse aclResponse = client.getClient() | ||||
|                 .getBucketAcl(GetBucketAclRequest.builder().bucket(bucketName).build()) | ||||
|                 .join(); | ||||
|             List<Grant> grants = aclResponse.grants(); | ||||
|             // 只存在 FULL_CONTROL 权限并且只有一个 Grant,则认为是私有桶 | ||||
|             if (grants.size() == 1 && grants.stream() | ||||
|                 .anyMatch(grant -> grant.permission().equals(Permission.FULL_CONTROL))) { | ||||
|                 return true; | ||||
|             } | ||||
|             // 如果存在其他权限 (READ 或 WRITE),认为是公开桶 | ||||
|             return grants.stream() | ||||
|                 .noneMatch(grant -> grant.permission().equals(Permission.READ) || grant.permission() | ||||
|                     .equals(Permission.WRITE)); | ||||
|         } catch (Exception e) { | ||||
|             // 如果 getBucketAcl 失败,可能是权限或连接问题 | ||||
|             log.error("获取桶 ACL 失败: {}", e.getMessage()); | ||||
|             return true; // 出现错误时,默认认为桶是私有的 | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 获取私有URL链接 | ||||
|      * | ||||
|      * @param bucketName 桶名称 | ||||
|      * @param fileName   文件名 | ||||
|      * @param second     授权时间 | ||||
|      * @return {@link String } | ||||
|      */ | ||||
|     private String getPrivateUrl(String bucketName, String fileName, Integer second) { | ||||
|         try { | ||||
|             return client.getPresigner() | ||||
|                 .presignGetObject(GetObjectPresignRequest.builder() | ||||
|                     .signatureDuration(Duration.ofHours(second)) | ||||
|                     .getObjectRequest(GetObjectRequest.builder().bucket(bucketName).key(fileName).build()) | ||||
|                     .build()) | ||||
|                 .url() | ||||
|                 .toString(); | ||||
|         } catch (RuntimeException e) { | ||||
|             throw new BusinessException("获取私有链接异常", e); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,61 @@ | ||||
| /* | ||||
|  * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. | ||||
|  * <p> | ||||
|  * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0; | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * <p> | ||||
|  * http://www.gnu.org/licenses/lgpl.html | ||||
|  * <p> | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
|  | ||||
| package top.continew.starter.storage.util; | ||||
|  | ||||
| import cn.hutool.core.util.StrUtil; | ||||
| import software.amazon.awssdk.regions.Region; | ||||
| import top.continew.starter.core.constant.StringConstants; | ||||
| import top.continew.starter.storage.constant.StorageConstant; | ||||
|  | ||||
| /** | ||||
|  * OSS 工具 | ||||
|  * | ||||
|  * @author echo | ||||
|  * @date 2024/12/17 13:48 | ||||
|  */ | ||||
| public class OssUtils { | ||||
|     public OssUtils() { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 获取作用域 | ||||
|      * <p>如果 region 参数非空,使用 Region.of 方法创建对应的 S3 区域对象,否则返回默认区域</p> | ||||
|      * | ||||
|      * @param region 区域 | ||||
|      * @return {@link Region } | ||||
|      */ | ||||
|     public static Region getRegion(String region) { | ||||
|         return StrUtil.isEmpty(region) ? Region.US_EAST_1 : Region.of(region); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 获取url | ||||
|      * | ||||
|      * @param endpoint   端点 | ||||
|      * @param bucketName 桶名称 | ||||
|      * @return {@link String } | ||||
|      */ | ||||
|     public static String getUrl(String endpoint, String bucketName) { | ||||
|         // 如果是云服务商,直接返回域名或终端点 | ||||
|         if (StrUtil.containsAny(endpoint, StorageConstant.CLOUD_SERVICE_PREFIX)) { | ||||
|             return "http://" + bucketName + StringConstants.DOT + endpoint; | ||||
|         } else { | ||||
|             return "http://" + endpoint + StringConstants.SLASH + bucketName; | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1 @@ | ||||
| top.continew.starter.storage.autoconfigure.OssStorageAutoconfigure | ||||
| @@ -14,7 +14,9 @@ | ||||
|     <description>ContiNew Starter 存储模块</description> | ||||
|  | ||||
|     <modules> | ||||
|         <module>continew-starter-storage-core</module> | ||||
|         <module>continew-starter-storage-local</module> | ||||
|         <module>continew-starter-storage-oss</module> | ||||
|     </modules> | ||||
|  | ||||
|     <dependencies> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 吴泽威
					吴泽威