diff --git a/continew-starter-dependencies/pom.xml b/continew-starter-dependencies/pom.xml index b3c97d513b8dd9dde9cf4af160cb39c801d5efcf..c6f67f60e76f268502561215324a08636498a74f 100644 --- a/continew-starter-dependencies/pom.xml +++ b/continew-starter-dependencies/pom.xml @@ -70,6 +70,11 @@ 2.14.5 3.2.12 5.8.34 + + 2.29.23 + 0.33.5 + + 0.4.20 1.6.0 2.43.0 @@ -253,18 +258,32 @@ ${easy-excel.version} - + - org.dromara.x-file-storage - x-file-storage-spring - ${x-file-storage.version} + software.amazon.awssdk + s3 + ${s3.version} - + - com.amazonaws - aws-java-sdk-s3 - ${aws-s3.version} + software.amazon.awssdk.crt + aws-crt + ${s3-crt.version} + + + + + software.amazon.awssdk + s3-transfer-manager + ${s3.version} + + + + + net.coobird + thumbnailator + ${thumbnails.version} @@ -476,6 +495,13 @@ ${revision} + + + top.continew + continew-starter-storage-core + ${revision} + + top.continew @@ -483,6 +509,13 @@ ${revision} + + + top.continew + continew-starter-storage-oss + ${revision} + + top.continew diff --git a/continew-starter-storage/continew-starter-storage-core/pom.xml b/continew-starter-storage/continew-starter-storage-core/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..2c54c40bf5b7ec7aa325e79e107ada963c044bab --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + top.continew + continew-starter-storage + ${revision} + + + continew-starter-storage-core + ContiNew Starter 存储模块 - 核心模块 + + + + + + top.continew + continew-starter-cache-redisson + + + + + net.coobird + thumbnailator + + + + + diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/constant/StorageConstant.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/constant/StorageConstant.java new file mode 100644 index 0000000000000000000000000000000000000000..06c6151b603d4f483408f08847618aa5c0106e5c --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/constant/StorageConstant.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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"; + + /** + * 云服务商 域名前缀 + *

目前只支持 阿里云-oss 华为云-obs 腾讯云-cos

+ */ + public static final String[] CLOUD_SERVICE_PREFIX = new String[] {"oss", "cos", "obs"}; + + /** + * 缩略图后缀 + */ + public static final String SMALL_SUFFIX = "small"; +} diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/dao/StorageDao.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/dao/StorageDao.java new file mode 100644 index 0000000000000000000000000000000000000000..ed55e9f3781924bd895a19d36669f4aea63bf31c --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/dao/StorageDao.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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); +} diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/dao/impl/StorageDaoDefaultImpl.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/dao/impl/StorageDaoDefaultImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..0f29d672b2871295168c615a4ffb5a269d2feee4 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/dao/impl/StorageDaoDefaultImpl.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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) { + + } +} diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/decorator/AbstractStorageDecorator.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/decorator/AbstractStorageDecorator.java new file mode 100644 index 0000000000000000000000000000000000000000..e9fdabcbcc6423637650a14968410f4ca7e782a7 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/decorator/AbstractStorageDecorator.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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 implements StorageStrategy { + + protected StorageStrategy delegate; + + protected AbstractStorageDecorator(StorageStrategy 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); + } +} diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/enums/FileTypeEnum.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/enums/FileTypeEnum.java new file mode 100644 index 0000000000000000000000000000000000000000..8dd112faad6edeb0b20c36506d87d5557d27d279 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/enums/FileTypeEnum.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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 { + + /** + * 其他 + */ + 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 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 extensions) { + this.value = value; + this.description = description; + this.extensions = extensions; + } + + public List 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(); + } + +} \ No newline at end of file diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/manger/StorageManager.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/manger/StorageManager.java new file mode 100644 index 0000000000000000000000000000000000000000..8390bd3cd28862d84508fee8893e84473a7cb30c --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/manger/StorageManager.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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> 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)); + } + +} \ No newline at end of file diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/req/StorageProperties.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/req/StorageProperties.java new file mode 100644 index 0000000000000000000000000000000000000000..2583e293763a45709441a8c59ecab9093c87e993 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/req/StorageProperties.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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; + + } +} diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/resp/ThumbnailResp.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/resp/ThumbnailResp.java new file mode 100644 index 0000000000000000000000000000000000000000..5233b4cbcf6285bfa513bdf48d90d9b047b53368 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/resp/ThumbnailResp.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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; + } + +} diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/resp/UploadResp.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/resp/UploadResp.java new file mode 100644 index 0000000000000000000000000000000000000000..d08f3d6cf41460423a9b34fd3413893d3b02c8c3 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/resp/UploadResp.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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; + + /** + * 访问地址 + *

如果桶为私有,则提供临时链接,时间默认为 12 小时

+ */ + private String url; + + /** + * 文件基础路径 + */ + private String basePath; + + /** + * 原始 文件名 + */ + private String originalFilename; + + /** + * 扩展名 + */ + private String ext; + + /** + * 文件大小(字节) + */ + private long size; + + /** + * 已上传对象的实体标记(用来校验文件)-S3 + */ + private String eTag; + + /** + * 存储路径 + *

格式 桶/文件名 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; + } +} diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/strategy/StorageStrategy.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/strategy/StorageStrategy.java new file mode 100644 index 0000000000000000000000000000000000000000..ad6d85962904b340b4249ccece803210ced4d8c3 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/strategy/StorageStrategy.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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 { + + /** + * 获得客户端 - 用于重写时 获取对应存储 code 客户端 + * + * @return {@link Object } + */ + C getClient(); + + /** + * 检查桶是否存在 + *

S3: 检查桶是否存在

+ *

local: 检查 默认路径 是否存在

+ * + * @param bucketName 桶名称 + * @return true 存在 false 不存在 + */ + boolean bucketExists(String bucketName); + + /** + * 创建桶 + *

S3: 创建桶

+ *

local: 创建 默认路径下 指定文件夹

+ * + * @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); + +} diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/util/ImageThumbnailUtils.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/util/ImageThumbnailUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..67e040d7de527b2f82d727456293bf5653dbf277 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/util/ImageThumbnailUtils.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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); + } +} diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/util/StorageUtils.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/util/StorageUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..4cfca27a1da0a71ca2caad7bb1b7429abf75780b --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/util/StorageUtils.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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)); + } + +} diff --git a/continew-starter-storage/continew-starter-storage-local/pom.xml b/continew-starter-storage/continew-starter-storage-local/pom.xml index 0ee4e8ea7e100f8bb29893a92d70e702341b17b2..8d2638ea41412663f8f7080fa5ff4d9e0e09e444 100644 --- a/continew-starter-storage/continew-starter-storage-local/pom.xml +++ b/continew-starter-storage/continew-starter-storage-local/pom.xml @@ -13,10 +13,10 @@ ContiNew Starter 存储模块 - 本地存储 - + - org.springframework - spring-webmvc + top.continew + continew-starter-storage-core \ No newline at end of file diff --git a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/autoconfigure/LocalStorageAutoconfigure.java b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/autoconfigure/LocalStorageAutoconfigure.java new file mode 100644 index 0000000000000000000000000000000000000000..86c8612d43f48a8fa2273291e3a7230346e1aaab --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/autoconfigure/LocalStorageAutoconfigure.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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(); + } +} diff --git a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/client/LocalClient.java b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/client/LocalClient.java new file mode 100644 index 0000000000000000000000000000000000000000..7bec647b8d511fc887965e5a517953f517654a21 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/client/LocalClient.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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); + } + } +} diff --git a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/local/autoconfigure/LocalStorageAutoConfiguration.java b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/local/autoconfigure/LocalStorageAutoConfiguration.java deleted file mode 100644 index 38f8f66de037630dc3cb71989028801eace15050..0000000000000000000000000000000000000000 --- a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/local/autoconfigure/LocalStorageAutoConfiguration.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. - *

- * 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 - *

- * http://www.gnu.org/licenses/lgpl.html - *

- * 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 mappingMap = properties.getMapping(); - for (Map.Entry 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."); - } -} diff --git a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/local/autoconfigure/LocalStorageProperties.java b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/local/autoconfigure/LocalStorageProperties.java deleted file mode 100644 index 182b5716d5669631aca29b906578dbadddddced4..0000000000000000000000000000000000000000 --- a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/local/autoconfigure/LocalStorageProperties.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. - *

- * 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 - *

- * http://www.gnu.org/licenses/lgpl.html - *

- * 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 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 getMapping() { - return mapping; - } - - public void setMapping(Map mapping) { - this.mapping = mapping; - } -} diff --git a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/strategy/LocalStorageStrategy.java b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/strategy/LocalStorageStrategy.java new file mode 100644 index 0000000000000000000000000000000000000000..39bc36d5661c702ad6da26025601be940bb5d72e --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/strategy/LocalStorageStrategy.java @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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 { + + 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; + } +} diff --git a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/util/LocalUtils.java b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/util/LocalUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..07520b12d8b009bc1ed0719fe385521c49c49b83 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/util/LocalUtils.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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); + } +} diff --git a/continew-starter-storage/continew-starter-storage-local/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/continew-starter-storage/continew-starter-storage-local/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 93613475a272d6ff3670ed503e174b550afb8c6d..169edcb258b2d73c112549ddbc981553952eff65 100644 --- a/continew-starter-storage/continew-starter-storage-local/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/continew-starter-storage/continew-starter-storage-local/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1 @@ -top.continew.starter.storage.local.autoconfigure.LocalStorageAutoConfiguration \ No newline at end of file +top.continew.starter.storage.autoconfigure.LocalStorageAutoconfigure \ No newline at end of file diff --git a/continew-starter-storage/continew-starter-storage-oss/pom.xml b/continew-starter-storage/continew-starter-storage-oss/pom.xml new file mode 100644 index 0000000000000000000000000000000000000000..d4d8734bb65d6b39f739b52faed7c20875fead94 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-oss/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + top.continew + continew-starter-storage + ${revision} + + + continew-starter-storage-oss + ContiNew Starter 存储模块 - 对象存储 + + + + + software.amazon.awssdk + s3 + + + + software.amazon.awssdk + netty-nio-client + + + + software.amazon.awssdk + aws-crt-client + + + + software.amazon.awssdk + apache-client + + + + software.amazon.awssdk + url-connection-client + + + + + + + software.amazon.awssdk.crt + aws-crt + + + + + software.amazon.awssdk + s3-transfer-manager + + + + + top.continew + continew-starter-storage-core + + + + + diff --git a/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/autoconfigure/OssStorageAutoconfigure.java b/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/autoconfigure/OssStorageAutoconfigure.java new file mode 100644 index 0000000000000000000000000000000000000000..4dc512c495babcba9fc46b95bc291552d6429e3e --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/autoconfigure/OssStorageAutoconfigure.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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(); + } + +} diff --git a/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/client/OssClient.java b/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/client/OssClient.java new file mode 100644 index 0000000000000000000000000000000000000000..b1b519bcdcd917335ea6415e7c786c4d576e93c3 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/client/OssClient.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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; + } +} diff --git a/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/strategy/OssStorageStrategy.java b/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/strategy/OssStorageStrategy.java new file mode 100644 index 0000000000000000000000000000000000000000..f6eb6f4084e450c243a0e9f0d7d1b8a040e7c425 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/strategy/OssStorageStrategy.java @@ -0,0 +1,401 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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存储策略 + *

...

+ * + * @author echo + * @date 2024/12/16 20:29 + */ +public class OssStorageStrategy implements StorageStrategy { + 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> downloadRequest = DownloadRequest.builder() + .getObjectRequest(req -> req.bucket(bucketName).key(fileName).build()) // 设置桶名和对象名 + .addTransferListener(LoggingTransferListener.create()) // 添加传输监听器 + .responseTransformer(AsyncResponseTransformer.toBlockingInputStream()) // 转换为阻塞输入流 + .build(); + // 执行下载操作 + Download> 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 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); + } + } +} diff --git a/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/util/OssUtils.java b/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/util/OssUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..9e3c5889c67336a034d6bd889c56218b2eeeb5fa --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/util/OssUtils.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + *

+ * 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 + *

+ * http://www.gnu.org/licenses/lgpl.html + *

+ * 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() { + } + + /** + * 获取作用域 + *

如果 region 参数非空,使用 Region.of 方法创建对应的 S3 区域对象,否则返回默认区域

+ * + * @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; + } + } + +} diff --git a/continew-starter-storage/continew-starter-storage-oss/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/continew-starter-storage/continew-starter-storage-oss/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000000000000000000000000000000000000..f611fce917b39eb64702032a9354ee4a45119694 --- /dev/null +++ b/continew-starter-storage/continew-starter-storage-oss/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +top.continew.starter.storage.autoconfigure.OssStorageAutoconfigure \ No newline at end of file diff --git a/continew-starter-storage/pom.xml b/continew-starter-storage/pom.xml index 3c34cc51b21acf7097d4c0c3177942ccda7f3ce3..7f810b7e57fc0bb1510357654c2f61463ab62488 100644 --- a/continew-starter-storage/pom.xml +++ b/continew-starter-storage/pom.xml @@ -14,7 +14,9 @@ ContiNew Starter 存储模块 + continew-starter-storage-core continew-starter-storage-local + continew-starter-storage-oss