diff --git a/continew-starter-bom/pom.xml b/continew-starter-bom/pom.xml index c6df4851..457ca5ed 100644 --- a/continew-starter-bom/pom.xml +++ b/continew-starter-bom/pom.xml @@ -216,22 +216,10 @@ ${revision} - + top.continew.starter - continew-starter-storage-local - ${revision} - - - - top.continew.starter - continew-starter-storage-oss - ${revision} - - - - top.continew.starter - continew-starter-storage-core + continew-starter-storage ${revision} diff --git a/continew-starter-storage/continew-starter-storage-core/pom.xml b/continew-starter-storage/continew-starter-storage-core/pom.xml deleted file mode 100644 index 3cb90791..00000000 --- a/continew-starter-storage/continew-starter-storage-core/pom.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - 4.0.0 - - top.continew.starter - continew-starter-storage - ${revision} - - - continew-starter-storage-core - jar - - ${project.artifactId} - ContiNew Starter 存储模块 - 核心模块 - - - - - top.continew.starter - continew-starter-cache-redisson - - - - - net.coobird - thumbnailator - - - - - cn.hutool - hutool-crypto - - - 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 deleted file mode 100644 index 16555d3c..00000000 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/decorator/AbstractStorageDecorator.java +++ /dev/null @@ -1,106 +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.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 - * @since 2.9.0 - */ -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/FileType.java b/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/enums/FileType.java deleted file mode 100644 index 888bb414..00000000 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/enums/FileType.java +++ /dev/null @@ -1,101 +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.enums; - -import cn.hutool.core.text.CharSequenceUtil; -import top.continew.starter.core.enums.BaseEnum; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -/** - * 文件类型枚举 - * - * @author Charles7c - * @since 2.9.0 - */ -public enum FileType 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 FileType getByExtension(String extension) { - return Arrays.stream(FileType.values()) - .filter(t -> t.getExtensions().contains(CharSequenceUtil.emptyIfNull(extension).toLowerCase())) - .findFirst() - .orElse(FileType.UNKNOWN); - } - - FileType(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 deleted file mode 100644 index e09096f7..00000000 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/manger/StorageManager.java +++ /dev/null @@ -1,80 +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.manger; - -import top.continew.starter.cache.redisson.util.RedisUtils; -import top.continew.starter.core.util.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 - * @since 2.9.0 - */ -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 deleted file mode 100644 index f3342a0b..00000000 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/req/StorageProperties.java +++ /dev/null @@ -1,152 +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.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 deleted file mode 100644 index 9b149786..00000000 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/resp/ThumbnailResp.java +++ /dev/null @@ -1,61 +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.model.resp; - -/** - * 缩略图 - * - * @author echo - * @since 2.9.0 - */ -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 deleted file mode 100644 index a2990f6b..00000000 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/model/resp/UploadResp.java +++ /dev/null @@ -1,215 +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.model.resp; - -import java.time.LocalDateTime; - -/** - * 上传结果 - * - * @author echo - * @since 2.9.0 - */ -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 deleted file mode 100644 index 58f458f3..00000000 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/strategy/StorageStrategy.java +++ /dev/null @@ -1,151 +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.strategy; - -import top.continew.starter.storage.model.resp.ThumbnailResp; -import top.continew.starter.storage.model.resp.UploadResp; - -import java.io.InputStream; - -/** - * 存储策略接口 - * - * @author echo - * @since 2.9.0 - */ -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 deleted file mode 100644 index 7a00ab8d..00000000 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/util/ImageThumbnailUtils.java +++ /dev/null @@ -1,81 +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.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 - * @since 2.9.0 - */ -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 deleted file mode 100644 index 565c15d3..00000000 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/util/StorageUtils.java +++ /dev/null @@ -1,141 +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.util; - -import cn.hutool.core.io.FileUtil; -import cn.hutool.core.io.IoUtil; -import cn.hutool.core.io.file.FileNameUtil; -import cn.hutool.core.text.CharSequenceUtil; -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 - * @since 2.9.0 - */ -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(); - } - - /** - * 本地存储默认路径地址 格式 - *

mac/linux : 2024/03/10/ - *

windows : 2024\03\10\ - * - * @return {@link String } - */ - public static String localDefaultPath() { - LocalDate today = LocalDate.now(); - return Paths.get(String.valueOf(today.getYear()), String.valueOf(today.getMonthValue()), String.valueOf(today - .getDayOfMonth())) + StringConstants.SLASH; - } - - /** - * 对象存储默认路径 格式 2024/03/10/ - * - * @return {@link String } - */ - public static String ossDefaultPath() { - LocalDate today = LocalDate.now(); - return today.getYear() + StringConstants.SLASH + today.getMonthValue() + StringConstants.SLASH + 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 = CharSequenceUtil.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 deleted file mode 100644 index 5ed3880b..00000000 --- a/continew-starter-storage/continew-starter-storage-local/pom.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - 4.0.0 - - top.continew.starter - continew-starter-storage - ${revision} - - - continew-starter-storage-local - jar - - ${project.artifactId} - ContiNew Starter 存储模块 - 本地存储 - - - - - top.continew.starter - 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/client/LocalClient.java b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/client/LocalClient.java deleted file mode 100644 index ff5904ab..00000000 --- a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/client/LocalClient.java +++ /dev/null @@ -1,86 +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.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 - * @since 2.9.0 - */ -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/strategy/LocalStorageStrategy.java b/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/strategy/LocalStorageStrategy.java deleted file mode 100644 index 42b5d05f..00000000 --- a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/strategy/LocalStorageStrategy.java +++ /dev/null @@ -1,274 +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.strategy; - -import cn.hutool.core.io.FileUtil; -import cn.hutool.core.io.IoUtil; -import cn.hutool.core.io.file.FileNameUtil; -import cn.hutool.core.text.CharSequenceUtil; -import cn.hutool.core.util.ObjectUtil; -import cn.hutool.crypto.digest.DigestUtil; -import top.continew.starter.core.constant.StringConstants; -import top.continew.starter.core.exception.BusinessException; -import top.continew.starter.core.util.validation.CheckUtils; -import top.continew.starter.core.util.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.FileType; -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.StorageUtils; - -import java.io.*; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.LocalDateTime; -import java.util.Base64; - -/** - * 本地存储策略 - * - * @author echo - * @since 2.9.0 - */ -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 (CharSequenceUtil.isEmpty(path)) { - path = StorageUtils.localDefaultPath(); - } - // 判断文件夹是否存在 不存在则创建 - Path folderPath = Paths.get(bucketName, path); - if (!Files.exists(folderPath)) { - Files.createDirectories(folderPath); - } - ThumbnailResp thumbnailResp = null; - //判断是否需要上传缩略图 前置条件 文件必须为图片 - boolean contains = FileType.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 = DigestUtil.md5Hex(IoUtil.readBytes(inputStream)); - // 构建 上传后的文件路径地址 格式 xxx/xxx/xxx.jpg - String filePath = Paths.get(path, formatFileName).toString(); - // 构建 文件上传记录 并返回 - return buildStorageRecord(bucketName, fileName, filePath, eTag, originalBytes.length, thumbnailResp); - } catch (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(!FileType.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/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 deleted file mode 100644 index 54054f60..00000000 --- a/continew-starter-storage/continew-starter-storage-local/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -top.continew.starter.storage.autoconfigure.LocalStorageAutoConfiguration \ 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 deleted file mode 100644 index 057d8ae2..00000000 --- a/continew-starter-storage/continew-starter-storage-oss/pom.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - 4.0.0 - - top.continew.starter - continew-starter-storage - ${revision} - - - continew-starter-storage-oss - jar - - ${project.artifactId} - ContiNew Starter 存储模块 - 对象存储 - - - - - top.continew.starter - continew-starter-storage-core - - - - - 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 - s3-transfer-manager - - - - - software.amazon.awssdk.crt - aws-crt - - - 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 deleted file mode 100644 index f5e19d60..00000000 --- a/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/client/OssClient.java +++ /dev/null @@ -1,163 +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.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 - * @since 2.9.0 - */ -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 deleted file mode 100644 index 39a2edcd..00000000 --- a/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/strategy/OssStorageStrategy.java +++ /dev/null @@ -1,403 +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.strategy; - -import cn.hutool.core.io.FileUtil; -import cn.hutool.core.io.IoUtil; -import cn.hutool.core.io.file.FileNameUtil; -import cn.hutool.core.text.CharSequenceUtil; -import cn.hutool.core.util.ObjectUtil; -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.util.validation.CheckUtils; -import top.continew.starter.core.util.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.FileType; -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.time.Duration; -import java.time.LocalDateTime; -import java.util.Base64; -import java.util.List; -import java.util.concurrent.CompletionException; - -/** - * OSS存储策略 - *

...

- * - * @author echo - * @since 2.9.0 - */ -public class OssStorageStrategy implements StorageStrategy { - - private static final 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 (CharSequenceUtil.isEmpty(path)) { - path = StorageUtils.ossDefaultPath(); - } - ThumbnailResp thumbnailResp = null; - //判断是否需要上传缩略图 前置条件 文件必须为图片 - boolean contains = FileType.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 = path + formatFileName; - // 构建 文件上传记录 并返回 - 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 = path + fileName; - 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().replace("\"", ""); - } 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(), path + thumbnailFileName); - } 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 = FileType.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(bucketName + filePath); - 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 deleted file mode 100644 index ebdcf0f2..00000000 --- a/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/util/OssUtils.java +++ /dev/null @@ -1,61 +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.util; - -import cn.hutool.core.text.CharSequenceUtil; -import software.amazon.awssdk.regions.Region; -import top.continew.starter.core.constant.StringConstants; -import top.continew.starter.storage.constant.StorageConstant; - -/** - * OSS 工具 - * - * @author echo - * @since 2.9.0 - */ -public class OssUtils { - public OssUtils() { - } - - /** - * 获取作用域 - *

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

- * - * @param region 区域 - * @return {@link Region } - */ - public static Region getRegion(String region) { - return CharSequenceUtil.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 (CharSequenceUtil.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 deleted file mode 100644 index d8e593ff..00000000 --- a/continew-starter-storage/continew-starter-storage-oss/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -top.continew.starter.storage.autoconfigure.OssStorageAutoConfiguration \ No newline at end of file diff --git a/continew-starter-storage/pom.xml b/continew-starter-storage/pom.xml index 2a5ccbda..56b1396d 100644 --- a/continew-starter-storage/pom.xml +++ b/continew-starter-storage/pom.xml @@ -10,22 +10,34 @@ continew-starter-storage - pom + jar ${project.artifactId} ContiNew Starter 存储模块 - - continew-starter-storage-core - continew-starter-storage-local - continew-starter-storage-oss - - top.continew.starter continew-starter-core + + + + software.amazon.awssdk + s3 + + + + + net.coobird + thumbnailator + + + + + top.continew.starter + continew-starter-json-jackson + \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/LocalStorageAutoConfiguration.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/LocalStorageAutoConfiguration.java new file mode 100644 index 00000000..54316511 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/LocalStorageAutoConfiguration.java @@ -0,0 +1,59 @@ +/* + * 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.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import top.continew.starter.core.constant.PropertiesConstants; +import top.continew.starter.storage.autoconfigure.properties.LocalStorageConfig; +import top.continew.starter.storage.autoconfigure.properties.StorageProperties; +import top.continew.starter.storage.router.StorageStrategyRegistrar; +import top.continew.starter.storage.strategy.StorageStrategy; +import top.continew.starter.storage.strategy.impl.LocalStorageStrategy; + +import java.util.List; + +/** + * 本地存储自动配置 + * + * @author echo + * @since 2.14.0 + */ +@ConditionalOnProperty(prefix = PropertiesConstants.STORAGE, name = "local") +public class LocalStorageAutoConfiguration implements StorageStrategyRegistrar { + + private final StorageProperties storageProperties; + + public LocalStorageAutoConfiguration(StorageProperties storageProperties) { + this.storageProperties = storageProperties; + } + + /** + * 注册配置策略 + * + * @param strategies 策略列表 + */ + @Bean + @Override + public void register(List strategies) { + for (LocalStorageConfig config : storageProperties.getLocal()) { + if (config.isEnabled()) { + strategies.add(new LocalStorageStrategy(config)); + } + } + } +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/S3StorageAutoConfiguration.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/S3StorageAutoConfiguration.java new file mode 100644 index 00000000..dd48da85 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/S3StorageAutoConfiguration.java @@ -0,0 +1,59 @@ +/* + * 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.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import top.continew.starter.core.constant.PropertiesConstants; +import top.continew.starter.storage.autoconfigure.properties.S3StorageConfig; +import top.continew.starter.storage.autoconfigure.properties.StorageProperties; +import top.continew.starter.storage.router.StorageStrategyRegistrar; +import top.continew.starter.storage.strategy.StorageStrategy; +import top.continew.starter.storage.strategy.impl.OssStorageStrategy; + +import java.util.List; + +/** + * s3存储自动配置 + * + * @author echo + * @since 2.14.0 + */ +@ConditionalOnProperty(prefix = PropertiesConstants.STORAGE, name = "s3") +public class S3StorageAutoConfiguration implements StorageStrategyRegistrar { + + private final StorageProperties properties; + + public S3StorageAutoConfiguration(StorageProperties properties) { + this.properties = properties; + } + + /** + * 注册配置策略 + * + * @param strategies 策略列表 + */ + @Override + @Bean + public void register(List strategies) { + for (S3StorageConfig config : properties.getS3()) { + if (config.isEnabled()) { + strategies.add(new OssStorageStrategy(config)); + } + } + } +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/StorageAutoConfiguration.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/StorageAutoConfiguration.java new file mode 100644 index 00000000..d91c1886 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/StorageAutoConfiguration.java @@ -0,0 +1,250 @@ +/* + * 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 jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.web.servlet.MultipartProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import top.continew.starter.storage.autoconfigure.properties.StorageProperties; +import top.continew.starter.storage.core.FileStorageService; +import top.continew.starter.storage.core.ProcessorRegistry; +import top.continew.starter.storage.core.StrategyProxyFactory; +import top.continew.starter.storage.prehandle.*; +import top.continew.starter.storage.prehandle.impl.*; +import top.continew.starter.storage.router.StorageStrategyRegistrar; +import top.continew.starter.storage.router.StorageStrategyRouter; +import top.continew.starter.storage.service.FileRecorder; +import top.continew.starter.storage.service.impl.DefaultFileRecorder; +import top.continew.starter.storage.strategy.StorageStrategyOverride; + +import java.util.List; + +/** + * 存储自动配置 + * + * @author echo + * @since 2.14.0 + */ +@AutoConfiguration +@EnableConfigurationProperties(StorageProperties.class) +@Import({ProcessorRegistry.class, StrategyProxyFactory.class}) +public class StorageAutoConfiguration { + + private static final Logger log = LoggerFactory.getLogger(StorageAutoConfiguration.class); + + private final StorageProperties properties; + + public StorageAutoConfiguration(StorageProperties properties) { + this.properties = properties; + } + + /** + * 策略路由器 + * + * @param registrars 注册 + * @return {@link StorageStrategyRouter } + */ + @Bean + public StorageStrategyRouter strategyRouter(List registrars) { + return new StorageStrategyRouter(registrars); + } + + /** + * S3存储自动配置 + * + * @return {@link S3StorageAutoConfiguration } + */ + @Bean + public S3StorageAutoConfiguration s3StorageAutoConfiguration() { + return new S3StorageAutoConfiguration(properties); + } + + /** + * 本地存储自动配置 + * + * @return {@link LocalStorageAutoConfiguration } + */ + @Bean + public LocalStorageAutoConfiguration localStorageAutoConfiguration() { + return new LocalStorageAutoConfiguration(properties); + } + + /** + * 文件存储服务 + * + * @param router 路由 + * @param storageProperties 存储属性 + * @param processorRegistry 处理器注册表 + * @param proxyFactory 代理工厂 + * @return {@link FileStorageService } + */ + @Bean + public FileStorageService fileStorageService(StorageStrategyRouter router, + StorageProperties storageProperties, + ProcessorRegistry processorRegistry, + StrategyProxyFactory proxyFactory, + FileRecorder fileRecorder) { + return new FileStorageService(router, storageProperties, processorRegistry, proxyFactory, fileRecorder); + } + + /** + * 文件记录器 + * + * @return {@link FileRecorder } + */ + @Bean + @ConditionalOnMissingBean + public FileRecorder fileRecorder() { + return new DefaultFileRecorder(); + } + + /** + * 默认文件名生成器 + * + * @param registry 登记处 + * @return {@link FileNameGenerator } + */ + @Bean + @ConditionalOnMissingBean(name = "defaultFileNameGenerator") + public FileNameGenerator defaultFileNameGenerator(ProcessorRegistry registry) { + DefaultFileNameGenerator generator = new DefaultFileNameGenerator(); + registry.registerGlobalNameGenerator(generator); + return generator; + } + + /** + * 默认文件路径生成器 + * + * @param registry 注册 + * @return {@link FilePathGenerator } + */ + @Bean + @ConditionalOnMissingBean(name = "defaultFilePathGenerator") + public FilePathGenerator defaultFilePathGenerator(ProcessorRegistry registry) { + DefaultFilePathGenerator generator = new DefaultFilePathGenerator(); + registry.registerGlobalPathGenerator(generator); + return generator; + } + + /** + * 默认缩略图处理器 + * + * @param registry 注册 + * @return {@link ThumbnailProcessor } + */ + @Bean + @ConditionalOnMissingBean(name = "defaultThumbnailProcessor") + @ConditionalOnClass(name = "net.coobird.thumbnailator.Thumbnails") + public ThumbnailProcessor defaultThumbnailProcessor(ProcessorRegistry registry) { + DefaultThumbnailProcessor processor = new DefaultThumbnailProcessor(); + registry.registerGlobalThumbnailProcessor(processor); + return processor; + } + + /** + * 文件大小验证器 + * + * @param registry 注册 + * @return {@link FileValidator } + */ + @Bean + @ConditionalOnMissingBean(name = "fileSizeValidator") + public FileValidator fileSizeValidator(ProcessorRegistry registry, MultipartProperties multipartProperties) { + FileSizeValidator validator = new FileSizeValidator(multipartProperties); + registry.registerGlobalValidator(validator); + return validator; + } + + /** + * 文件类型验证器 + * + * @param registry 注册 + * @return {@link FileValidator } + */ + @Bean + @ConditionalOnMissingBean(name = "fileTypeValidator") + public FileValidator fileTypeValidator(ProcessorRegistry registry) { + FileTypeValidator validator = new FileTypeValidator(); + registry.registerGlobalValidator(validator); + return validator; + } + + /** + * 策略重写自动注册 + */ + @Configuration + @ConditionalOnBean(StorageStrategyOverride.class) + public static class StrategyOverrideConfiguration { + + /** + * 注册覆盖 + */ + @Autowired + public void registerOverrides(List> overrides, StrategyProxyFactory proxyFactory) { + for (StorageStrategyOverride override : overrides) { + proxyFactory.registerOverride(override); + } + } + } + + /** + * 处理器自动注册 + */ + @Configuration + public static class ProcessorAutoConfiguration { + @Autowired(required = false) + public void registerGlobalProcessors(List nameGenerators, + List pathGenerators, + List thumbnailProcessors, + List validators, + List completeProcessors, + ProcessorRegistry registry) { + + // 注册全局处理器 + if (nameGenerators != null) { + nameGenerators.forEach(registry::registerGlobalNameGenerator); + } + if (pathGenerators != null) { + pathGenerators.forEach(registry::registerGlobalPathGenerator); + } + if (thumbnailProcessors != null) { + thumbnailProcessors.forEach(registry::registerGlobalThumbnailProcessor); + } + if (validators != null) { + validators.forEach(registry::registerGlobalValidator); + } + if (completeProcessors != null) { + completeProcessors.forEach(registry::registerGlobalCompleteProcessor); + } + } + } + + @PostConstruct + public void postConstruct() { + log.debug("[ContiNew Starter] - Auto Configuration 'Storage' completed initialization."); + } +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/properties/LocalStorageConfig.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/properties/LocalStorageConfig.java new file mode 100644 index 00000000..6aab1ac9 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/properties/LocalStorageConfig.java @@ -0,0 +1,78 @@ +/* + * 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.properties; + +/** + * 本地存储配置 + * + * @author echo + * @since 2.14.0 + */ +public class LocalStorageConfig { + + /** + * 是否启用 + */ + private boolean enabled; + + /** + * 存储平台 + */ + private String platform; + + /** + * 存储路径 + */ + private String bucketName; + + /** + * 访问路径 + */ + private String endpoint; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getBucketName() { + return bucketName; + } + + public void setBucketName(String bucketName) { + this.bucketName = bucketName; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/properties/S3StorageConfig.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/properties/S3StorageConfig.java new file mode 100644 index 00000000..9088c068 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/properties/S3StorageConfig.java @@ -0,0 +1,208 @@ +/* + * 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.properties; + +/** + * s3存储配置 + * + * @author echo + * @since 2.14.0 + */ +public class S3StorageConfig { + + /** + * 是否启用 + */ + private boolean enabled; + + /** + * 唯一存储码 + */ + private String platform; + + /** + * 连接地址 带 http:// + */ + private String endpoint; + + /** + * 访问密钥 + */ + private String accessKey; + + /** + * 密钥 + */ + private String secretKey; + + /** + * 默认桶 + */ + private String bucketName; + + /** + * 区域 + */ + private String region; + + /** + * 自定义域名 + */ + private String domain; + + /** + * 是否启用传输加速 + */ + private boolean transferAccelerationEnabled = false; + + /** + * 多部分上传阈值(字节) + */ + private long multipartUploadThreshold = 5 * 1024 * 1024; // 5MB + + /** + * 多部分上传的部分大小(字节) + */ + private long multipartUploadPartSize = 5 * 1024 * 1024; // 5MB + + /** + * 请求超时时间(秒) + */ + private int requestTimeout = 30; + + /** + * 默认的对象ACL + */ + private String defaultAcl = "private"; + + /** + * 是否启用路径样式访问 + */ + private boolean pathStyleAccessEnabled = false; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + 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 getBucketName() { + return bucketName; + } + + public void setBucketName(String bucketName) { + this.bucketName = bucketName; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getDomain() { + return domain; + } + + public void setDomain(String domain) { + this.domain = domain; + } + + public boolean isTransferAccelerationEnabled() { + return transferAccelerationEnabled; + } + + public void setTransferAccelerationEnabled(boolean transferAccelerationEnabled) { + this.transferAccelerationEnabled = transferAccelerationEnabled; + } + + public long getMultipartUploadThreshold() { + return multipartUploadThreshold; + } + + public void setMultipartUploadThreshold(long multipartUploadThreshold) { + this.multipartUploadThreshold = multipartUploadThreshold; + } + + public long getMultipartUploadPartSize() { + return multipartUploadPartSize; + } + + public void setMultipartUploadPartSize(long multipartUploadPartSize) { + this.multipartUploadPartSize = multipartUploadPartSize; + } + + public int getRequestTimeout() { + return requestTimeout; + } + + public void setRequestTimeout(int requestTimeout) { + this.requestTimeout = requestTimeout; + } + + public String getDefaultAcl() { + return defaultAcl; + } + + public void setDefaultAcl(String defaultAcl) { + this.defaultAcl = defaultAcl; + } + + public boolean isPathStyleAccessEnabled() { + return pathStyleAccessEnabled; + } + + public void setPathStyleAccessEnabled(boolean pathStyleAccessEnabled) { + this.pathStyleAccessEnabled = pathStyleAccessEnabled; + } +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/properties/StorageProperties.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/properties/StorageProperties.java new file mode 100644 index 00000000..41ed9a3f --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/autoconfigure/properties/StorageProperties.java @@ -0,0 +1,72 @@ +/* + * 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.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import top.continew.starter.core.constant.PropertiesConstants; + +import java.util.ArrayList; +import java.util.List; + +/** + * 存储属性 + * + * @author echo + * @since 2.14.0 + */ +@ConfigurationProperties(prefix = PropertiesConstants.STORAGE) +public class StorageProperties { + + /** + * 默认使用的存储平台 + */ + private String defaultPlatform = "local"; + + /** + * 本地存储配置列表 + */ + private List local = new ArrayList<>(); + + /** + * S3 存储配置列表 + */ + private List s3 = new ArrayList<>(); + + public String getDefaultPlatform() { + return defaultPlatform; + } + + public void setDefaultPlatform(String defaultPlatform) { + this.defaultPlatform = defaultPlatform; + } + + public List getLocal() { + return local; + } + + public void setLocal(List local) { + this.local = local; + } + + public List getS3() { + return s3; + } + + public void setS3(List s3) { + this.s3 = s3; + } +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/core/FileStorageService.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/core/FileStorageService.java new file mode 100644 index 00000000..5a7e0840 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/core/FileStorageService.java @@ -0,0 +1,512 @@ +/* + * 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.core; + +import org.springframework.web.multipart.MultipartFile; +import top.continew.starter.storage.autoconfigure.properties.StorageProperties; +import top.continew.starter.storage.exception.StorageException; +import top.continew.starter.storage.model.context.UploadContext; +import top.continew.starter.storage.model.resp.*; +import top.continew.starter.storage.router.StorageStrategyRouter; +import top.continew.starter.storage.service.FileRecorder; +import top.continew.starter.storage.strategy.StorageStrategy; +import top.continew.starter.storage.strategy.impl.LocalStorageStrategy; + +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 增强版文件存储服务 + * 支持链式调用和更多功能 + * + * @author echo + * @since 2.14.0 + */ +public class FileStorageService { + + private final StorageStrategyRouter router; + private final StorageProperties storageProperties; + private final ProcessorRegistry processorRegistry; + private final StrategyProxyFactory proxyFactory; + private final FileRecorder fileRecorder; + + public FileStorageService(StorageStrategyRouter router, + StorageProperties storageProperties, + ProcessorRegistry processorRegistry, + StrategyProxyFactory proxyFactory, + FileRecorder fileRecorder) { + this.router = router; + this.storageProperties = storageProperties; + this.processorRegistry = processorRegistry; + this.proxyFactory = proxyFactory; + this.fileRecorder = fileRecorder; + } + + /** + * 获取默认存储平台 + */ + public String getDefaultPlatform() { + return storageProperties.getDefaultPlatform(); + } + + /** + * 获取处理器注册表 + */ + public ProcessorRegistry getProcessorRegistry() { + return processorRegistry; + } + + /** + * 创建上传预处理器(链式调用入口) + */ + public UploadPretreatment of(MultipartFile file) { + return new UploadPretreatment(this, file); + } + + /** + * 创建上传预处理器,指定平台 + */ + public UploadPretreatment of(MultipartFile file, String platform) { + return new UploadPretreatment(this, file).setPlatform(platform); + } + + /** + * 创建上传预处理器(支持 byte[]) + */ + public UploadPretreatment of(byte[] bytes, String filename, String contentType) { + FileWrapper wrapper = FileWrapper.of(bytes, filename, contentType); + return new UploadPretreatment(this, wrapper.toMultipartFile()); + } + + /** + * 创建上传预处理器(支持 InputStream) + */ + public UploadPretreatment of(InputStream inputStream, String filename, String contentType) { + FileWrapper wrapper = FileWrapper.of(inputStream, filename, contentType); + return new UploadPretreatment(this, wrapper.toMultipartFile()); + } + + /** + * 创建上传预处理器(支持任意对象) + */ + public UploadPretreatment of(Object obj) { + FileWrapper wrapper = FileWrapper.of(obj); + return new UploadPretreatment(this, wrapper.toMultipartFile()); + } + + /** + * 创建上传预处理器(支持任意对象,指定文件名和类型) + */ + public UploadPretreatment of(Object obj, String filename, String contentType) { + FileWrapper wrapper = FileWrapper.of(obj, filename, contentType); + return new UploadPretreatment(this, wrapper.toMultipartFile()); + } + + /** + * 执行上传(内部方法) + */ + public FileInfo doUpload(UploadContext context) { + StorageStrategy strategy = getStrategy(context.getPlatform()); + + // 执行上传 + strategy.upload(context.getBucket(), context.getFullPath(), context.getFile()); + + // 构建文件信息 + FileInfo fileInfo = strategy.getFileInfo(context.getBucket(), context.getFullPath()); + fileInfo.setOriginalFileName(context.getFile().getOriginalFilename()); + fileInfo.getMetadata().putAll(context.getMetadata()); + + // 保存文件记录 + if (fileRecorder != null) { + fileRecorder.save(fileInfo); + } + + return fileInfo; + } + + /** + * 初始化分片上传 + * + * @param bucket 存储桶 + * @param platform 平台 + * @param path 路径 + * @param contentType 内容类型 + * @param metadata 元数据 + * @return {@link MultipartInitResp } + */ + public MultipartInitResp initMultipartUpload(String bucket, + String platform, + String path, + String contentType, + Map metadata) { + bucket = bucket == null ? getDefaultBucket(platform) : bucket; + StorageStrategy strategy = getStrategy(platform); + MultipartInitResp result = strategy.initMultipartUpload(bucket, path, contentType, metadata); + + // 记录文件信息 + if (fileRecorder != null) { + FileInfo fileInfo = new FileInfo(); + fileInfo.setFileId(result.getFileId()); + fileInfo.setPlatform(platform); + fileInfo.setBucket(bucket); + fileInfo.setPath(path); + fileInfo.setContentType(contentType); + fileInfo.setMetadata(metadata != null ? new HashMap<>(metadata) : new HashMap<>()); + fileInfo.getMetadata().put("uploadId", result.getUploadId()); + fileInfo.getMetadata().put("status", "UPLOADING"); + fileRecorder.save(fileInfo); + } + return result; + } + + /** + * 上传分片 + * + * @param platform 平台 + * @param bucket 存储桶 + * @param path 路径 + * @param uploadId 上传id + * @param partNumber 分片编号 + * @param data 数据 + * @return {@link MultipartUploadResp } + */ + public MultipartUploadResp uploadPart(String platform, + String bucket, + String path, + String uploadId, + int partNumber, + InputStream data) { + StorageStrategy strategy = getStrategy(platform); + MultipartUploadResp result = strategy.uploadPart(bucket, path, uploadId, partNumber, data); + + // 记录分片信息 + if (fileRecorder != null && result.isSuccess()) { + FilePartInfo partInfo = new FilePartInfo(); + partInfo.setUploadId(uploadId); + partInfo.setBucket(bucket); + partInfo.setPath(path); + partInfo.setPartNumber(partNumber); + partInfo.setPartETag(result.getPartETag()); + partInfo.setPartSize(result.getPartSize()); + partInfo.setStatus("SUCCESS"); + partInfo.setUploadTime(LocalDateTime.now()); + fileRecorder.saveFilePart(partInfo); + } + + return result; + } + + /** + * 完成分片上传 + * + * @param platform 平台 + * @param bucket 存储桶 + * @param path 路径 + * @param uploadId 上传id + * @param clientParts 分片信息 + * @return {@link FileInfo } + */ + public FileInfo completeMultipartUpload(String platform, + String bucket, + String path, + String uploadId, + List clientParts) { + // 从 FileRecorder 获取所有分片信息 + List recordedParts = fileRecorder != null + ? fileRecorder.getFileParts(uploadId) + : new ArrayList<>(); + + // 转换为 MultipartUploadResp + List parts = recordedParts.stream().map(partInfo -> { + MultipartUploadResp resp = new MultipartUploadResp(); + resp.setPartNumber(partInfo.getPartNumber()); + resp.setPartETag(partInfo.getPartETag()); + resp.setPartSize(partInfo.getPartSize()); + resp.setSuccess("SUCCESS".equals(partInfo.getStatus())); + return resp; + }).collect(Collectors.toList()); + + // 如果没有记录,使用客户端传入的分片信息 + if (parts.isEmpty() && clientParts != null) { + parts = clientParts; + } + + // 验证分片完整性 + validatePartsCompleteness(parts); + + // 获取策略,判断是否需要验证 + boolean needVerify = true; + StorageStrategy strategy = getStrategy(platform); + if (strategy instanceof LocalStorageStrategy) { + needVerify = false; + } + + // 完成上传 + FileInfo fileInfo = strategy.completeMultipartUpload(bucket, path, uploadId, parts, needVerify); + + // 更新文件记录 + if (fileRecorder != null) { + fileInfo.getMetadata().put("uploadId", uploadId); + fileInfo.getMetadata().put("status", "COMPLETED"); + fileRecorder.update(fileInfo); + + // 删除分片记录 + fileRecorder.deleteFileParts(uploadId); + } + + return fileInfo; + } + + /** + * 取消分片上传 + * + * @param platform 平台 + * @param bucket 存储桶 + * @param path 路径 + * @param uploadId 上传id + */ + public void abortMultipartUpload(String platform, String bucket, String path, String uploadId) { + StorageStrategy strategy = getStrategy(platform); + strategy.abortMultipartUpload(bucket, path, uploadId); + + // 删除相关记录 + if (fileRecorder != null) { + fileRecorder.deleteFileParts(uploadId); + } + } + + /** + * 验证分片完整性 + * + * @param parts 分片信息 + */ + private void validatePartsCompleteness(List parts) { + if (parts.isEmpty()) { + throw new StorageException("没有找到任何分片信息"); + } + + // 检查分片编号连续性 + List partNumbers = parts.stream().map(MultipartUploadResp::getPartNumber).sorted().toList(); + + for (int i = 0; i < partNumbers.size(); i++) { + if (partNumbers.get(i) != i + 1) { + throw new StorageException("分片编号不连续,缺失分片: " + (i + 1)); + } + } + + // 检查是否所有分片都成功 + List failedParts = parts.stream() + .filter(part -> !part.isSuccess()) + .map(MultipartUploadResp::getPartNumber) + .collect(Collectors.toList()); + + if (!failedParts.isEmpty()) { + throw new StorageException("存在失败的分片: " + failedParts); + } + } + + /** + * 列出已上传的分片 + * + * @param platform 平台 + * @param bucket 存储桶 + * @param path 路径 + * @param uploadId 上传id + * @return {@link List }<{@link MultipartUploadResp }> + */ + public List listParts(String platform, String bucket, String path, String uploadId) { + StorageStrategy strategy = router.route(platform); + return strategy.listParts(bucket, path, uploadId); + } + + /** + * 获取存储策略(应用代理) + */ + private StorageStrategy getStrategy(String platform) { + StorageStrategy strategy = router.route(platform); + return proxyFactory.createProxy(strategy); + } + + /** + * 获取默认存储桶 + * + * @param platform 站台 + * @return {@link String } + */ + public String getDefaultBucket(String platform) { + return router.route(platform).defaultBucket(); + } + + /** + * 上传 + * + * @param platform 平台 + * @param bucket 铲斗 + * @param path 路径 + * @param file 文件 + */ + public void upload(String platform, String bucket, String path, MultipartFile file) { + router.route(platform).upload(path, bucket, file); + } + + /** + * 下载文件 + */ + public InputStream download(String platform, String bucket, String path) { + return router.route(platform).download(bucket, path); + } + + /** + * 使用默认存储下载 + */ + public InputStream download(String bucket, String path) { + return download(storageProperties.getDefaultPlatform(), bucket, path); + } + + /** + * 批量下载 + */ + public InputStream batchDownload(String platform, String bucket, List paths) { + return router.route(platform).batchDownload(bucket, paths); + } + + /** + * 删除文件 + */ + public void delete(String platform, String bucket, String path) { + router.route(platform).delete(bucket, path); + } + + /** + * 删除文件 + * + * @param info 信息 + */ + public void delete(FileInfo info) { + router.route(info.getPlatform()).delete(info.getBucket(), info.getFullPath()); + } + + /** + * 批量删除 + */ + public void batchDelete(String platform, String bucket, List paths) { + router.route(platform).batchDelete(bucket, paths); + } + + /** + * 检查文件是否存在 + */ + public boolean exists(String platform, String bucket, String path) { + return router.route(platform).exists(bucket, path); + } + + /** + * 获取文件信息 + */ + public FileInfo getFileInfo(String platform, String bucket, String path) { + return router.route(platform).getFileInfo(bucket, path); + } + + /** + * 列出文件 + */ + public List list(String platform, String bucket, String prefix, int maxKeys) { + return router.route(platform).list(bucket, prefix, maxKeys); + } + + /** + * 复制文件 + */ + public void copy(String platform, String sourceBucket, String targetBucket, String sourcePath, String targetPath) { + router.route(platform).copy(sourceBucket, targetBucket, sourcePath, targetPath); + } + + /** + * 移动文件 + */ + public void move(String platform, String sourceBucket, String targetBucket, String sourcePath, String targetPath) { + router.route(platform).move(sourceBucket, targetBucket, sourcePath, targetPath); + } + + /** + * 生成预签名URL + */ + public String generatePresignedUrl(String platform, String bucket, String path, long expireSeconds) { + return router.route(platform).generatePresignedUrl(bucket, path, expireSeconds); + } + + /** + * 动态注册存储策略 + */ + public void register(T strategy) { + router.registerDynamic(strategy); + } + + /** + * 卸载动态注册的策略 + */ + public boolean unload(String platform) { + if (!router.isDynamic(platform)) { + throw new StorageException("只能卸载动态注册的策略: " + platform); + } + return router.unloadDynamic(platform); + } + + /** + * 获取所有可用策略代码 + */ + public Set getAvailablePlatform() { + return router.getAllPlatform(); + } + + /** + * 检查策略是否存在 + */ + public boolean exists(String platform) { + return router.getAllPlatform().contains(platform); + } + + /** + * 检查是否为动态注册的策略 + */ + public boolean isDynamic(String platform) { + return router.isDynamic(platform); + } + + /** + * 检查是否为配置文件策略 + */ + public boolean isFromConfig(String platform) { + return router.isFromConfig(platform); + } + + /** + * 获取策略详细信息 + */ + public Map getStrategyStatus() { + return router.getFullStrategyStatus(); + } + + /** + * 获取当前生效的策略信息 + */ + public Map getActiveStrategyInfo() { + return router.getActiveStrategyInfo(); + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/core/FileWrapper.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/core/FileWrapper.java new file mode 100644 index 00000000..a4604746 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/core/FileWrapper.java @@ -0,0 +1,189 @@ +/* + * 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.core; + +import org.springframework.web.multipart.MultipartFile; +import top.continew.starter.json.jackson.util.JSONUtils; +import top.continew.starter.storage.exception.StorageException; +import top.continew.starter.storage.model.req.MockMultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * 文件包装器,用于统一处理不同类型的输入 + * + * @author echo + * @since 2.14.0 + */ +public class FileWrapper { + + private MultipartFile multipartFile; + private byte[] bytes; + private InputStream inputStream; + private String originalFilename; + private String contentType; + private long size; + + private FileWrapper() { + } + + /** + * 从 MultipartFile 创建 + */ + public static FileWrapper of(MultipartFile file) { + FileWrapper wrapper = new FileWrapper(); + wrapper.multipartFile = file; + wrapper.originalFilename = file.getOriginalFilename(); + wrapper.contentType = file.getContentType(); + wrapper.size = file.getSize(); + return wrapper; + } + + /** + * 从 byte[] 创建 + */ + public static FileWrapper of(byte[] bytes, String filename, String contentType) { + if (filename == null || filename.trim().isEmpty()) { + throw new StorageException("文件名不能为空"); + } + if (contentType == null || contentType.trim().isEmpty()) { + throw new StorageException("文件类型不能为空"); + } + + FileWrapper wrapper = new FileWrapper(); + wrapper.bytes = bytes; + wrapper.originalFilename = filename; + wrapper.contentType = contentType; + wrapper.size = bytes.length; + return wrapper; + } + + /** + * 从 InputStream 创建 + */ + public static FileWrapper of(InputStream inputStream, String filename, String contentType) { + if (filename == null || filename.trim().isEmpty()) { + throw new StorageException("文件名不能为空"); + } + if (contentType == null || contentType.trim().isEmpty()) { + throw new StorageException("文件类型不能为空"); + } + + FileWrapper wrapper = new FileWrapper(); + wrapper.inputStream = inputStream; + wrapper.originalFilename = filename; + wrapper.contentType = contentType; + wrapper.size = -1; + return wrapper; + } + + /** + * 从 Object 创建(智能识别) + */ + public static FileWrapper of(Object obj) { + return of(obj, null, null); + } + + /** + * 从 Object 创建,可指定文件名和类型 + */ + public static FileWrapper of(Object obj, String filename, String contentType) { + if (obj == null) { + throw new StorageException("对象不能为空"); + } + + // 如果是 MultipartFile,直接处理 + if (obj instanceof MultipartFile) { + return of((MultipartFile)obj); + } + + // 如果是 byte[] + if (obj instanceof byte[]) { + if (filename == null || contentType == null) { + throw new StorageException("byte[] 类型必须指定文件名和文件类型"); + } + return of((byte[])obj, filename, contentType); + } + + // 如果是 InputStream + if (obj instanceof InputStream) { + if (filename == null || contentType == null) { + throw new StorageException("InputStream 类型必须指定文件名和文件类型"); + } + return of((InputStream)obj, filename, contentType); + } + + // 其他对象,转换为 JSON + String json = convertToJson(obj); + byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8); + String finalFilename = filename != null ? filename : "data.json"; + String finalContentType = contentType != null ? contentType : "application/json"; + + return of(jsonBytes, finalFilename, finalContentType); + } + + /** + * 转换为 MultipartFile + */ + public MultipartFile toMultipartFile() { + if (multipartFile != null) { + return multipartFile; + } + + if (bytes != null) { + return new MockMultipartFile(getFilenameWithoutExtension(originalFilename), originalFilename, contentType, bytes); + } + + if (inputStream != null) { + try { + byte[] data = inputStream.readAllBytes(); + return new MockMultipartFile(getFilenameWithoutExtension(originalFilename), originalFilename, contentType, data); + } catch (IOException e) { + throw new StorageException("读取输入流失败", e); + } + } + + throw new IllegalStateException("无法转换为 MultipartFile"); + } + + private static String getFilenameWithoutExtension(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + return lastDotIndex > 0 ? filename.substring(0, lastDotIndex) : filename; + } + + private static String convertToJson(Object obj) { + try { + return JSONUtils.toJsonStr(obj); + } catch (Exception e) { + throw new StorageException("对象转换为 JSON 失败", e); + } + } + + public String getOriginalFilename() { + return originalFilename; + } + + public String getContentType() { + return contentType; + } + + public long getSize() { + return size; + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/core/ProcessorRegistry.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/core/ProcessorRegistry.java new file mode 100644 index 00000000..dd63d6b3 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/core/ProcessorRegistry.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.core; + +import top.continew.starter.storage.prehandle.*; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * 全局处理器注册表 + * + * @author echo + * @since 2.14.0 + */ +public class ProcessorRegistry { + + // 全局处理器 + private final Map globalNameGenerators = new ConcurrentHashMap<>(); + private final Map globalPathGenerators = new ConcurrentHashMap<>(); + private final Map globalThumbnailProcessors = new ConcurrentHashMap<>(); + private final List globalValidators = new CopyOnWriteArrayList<>(); + private final List globalCompleteProcessors = new CopyOnWriteArrayList<>(); + + // 平台特定处理器 + private final Map> platformNameGenerators = new ConcurrentHashMap<>(); + private final Map> platformPathGenerators = new ConcurrentHashMap<>(); + private final Map> platformThumbnailProcessors = new ConcurrentHashMap<>(); + private final Map> platformValidators = new ConcurrentHashMap<>(); + private final Map> platformCompleteProcessors = new ConcurrentHashMap<>(); + + /** + * 注册全局文件名生成器 + */ + public void registerGlobalNameGenerator(FileNameGenerator generator) { + globalNameGenerators.put(generator.getName(), generator); + } + + public void registerGlobalPathGenerator(FilePathGenerator generator) { + globalPathGenerators.put(generator.getName(), generator); + } + + /** + * 注册平台特定的文件名生成器 + */ + public void registerPlatformNameGenerator(String platform, FileNameGenerator generator) { + platformNameGenerators.computeIfAbsent(platform, k -> new ConcurrentHashMap<>()) + .put(generator.getName(), generator); + } + + public void registerPlatformPathGenerator(String platform, FilePathGenerator generator) { + platformPathGenerators.computeIfAbsent(platform, k -> new ConcurrentHashMap<>()) + .put(generator.getName(), generator); + } + + /** + * 注册全局缩略图处理器 + */ + public void registerGlobalThumbnailProcessor(ThumbnailProcessor processor) { + globalThumbnailProcessors.put(processor.getName(), processor); + } + + /** + * 注册平台特定的缩略图处理器 + */ + public void registerPlatformThumbnailProcessor(String platform, ThumbnailProcessor processor) { + platformThumbnailProcessors.computeIfAbsent(platform, k -> new ConcurrentHashMap<>()) + .put(processor.getName(), processor); + } + + /** + * 注册全局验证器 + */ + public void registerGlobalValidator(FileValidator validator) { + globalValidators.add(validator); + } + + /** + * 注册平台特定的验证器 + */ + public void registerPlatformValidator(String platform, FileValidator validator) { + platformValidators.computeIfAbsent(platform, k -> new CopyOnWriteArrayList<>()).add(validator); + } + + /** + * 注册全局完成处理器 + */ + public void registerGlobalCompleteProcessor(UploadCompleteProcessor processor) { + globalCompleteProcessors.add(processor); + } + + /** + * 注册平台特定的完成处理器 + */ + public void registerPlatformCompleteProcessor(String platform, UploadCompleteProcessor processor) { + platformCompleteProcessors.computeIfAbsent(platform, k -> new CopyOnWriteArrayList<>()).add(processor); + } + + /** + * 获取文件名生成器(平台 > 全局) + */ + public FileNameGenerator getNameGenerator(String platform) { + // 先查找平台特定的 + Map platformGenerators = platformNameGenerators.get(platform); + if (platformGenerators != null && !platformGenerators.isEmpty()) { + return platformGenerators.values() + .stream() + .max(Comparator.comparingInt(FileNameGenerator::getOrder)) + .orElse(null); + } + + // 再查找全局的 + return globalNameGenerators.values() + .stream() + .max(Comparator.comparingInt(FileNameGenerator::getOrder)) + .orElse(null); + } + + public FilePathGenerator getPathGenerator(String platform) { + // 先查找平台特定的 + Map platformGenerators = platformPathGenerators.get(platform); + if (platformGenerators != null && !platformGenerators.isEmpty()) { + return platformGenerators.values() + .stream() + .max(Comparator.comparingInt(FilePathGenerator::getOrder)) + .orElse(null); + } + + // 再查找全局的 + return globalPathGenerators.values() + .stream() + .max(Comparator.comparingInt(FilePathGenerator::getOrder)) + .orElse(null); + } + + /** + * 获取缩略图处理器(平台 > 全局) + */ + public ThumbnailProcessor getThumbnailProcessor(String platform) { + // 先查找平台特定的 + Map platformProcessors = platformThumbnailProcessors.get(platform); + if (platformProcessors != null && !platformProcessors.isEmpty()) { + return platformProcessors.values() + .stream() + .max(Comparator.comparingInt(ThumbnailProcessor::getOrder)) + .orElse(null); + } + + // 再查找全局的 + return globalThumbnailProcessors.values() + .stream() + .max(Comparator.comparingInt(ThumbnailProcessor::getOrder)) + .orElse(null); + } + + /** + * 获取验证器列表(合并全局和平台) + */ + public List getValidators(String platform) { + List validators = new ArrayList<>(); + + // 先添加全局验证器 + validators.addAll(globalValidators); + + // 再添加平台特定验证器 + List platformSpecific = platformValidators.get(platform); + if (platformSpecific != null) { + validators.addAll(platformSpecific); + } + + // 按优先级排序(优先级高的在前) + validators.sort(Comparator.comparingInt(FileValidator::getOrder).reversed()); + + return validators; + } + + /** + * 获取完成处理器列表(合并全局和平台) + */ + public List getCompleteProcessors(String platform) { + List processors = new ArrayList<>(); + + // 先添加全局处理器 + processors.addAll(globalCompleteProcessors); + + // 再添加平台特定处理器 + List platformSpecific = platformCompleteProcessors.get(platform); + if (platformSpecific != null) { + processors.addAll(platformSpecific); + } + + // 按优先级排序 + processors.sort(Comparator.comparingInt(UploadCompleteProcessor::getOrder).reversed()); + + return processors; + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/core/StrategyProxyFactory.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/core/StrategyProxyFactory.java new file mode 100644 index 00000000..88fcaae6 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/core/StrategyProxyFactory.java @@ -0,0 +1,171 @@ +/* + * 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.core; + +import top.continew.starter.storage.strategy.StorageStrategy; +import top.continew.starter.storage.strategy.StorageStrategyOverride; +import top.continew.starter.storage.strategy.impl.AbstractStorageStrategyOverride; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * 策略代理工厂 + * + * @author echo + * @since 2.14.0 + */ +public class StrategyProxyFactory { + + private final Map, List>> overrides = new ConcurrentHashMap<>(); + + /** + * 注册重写 + */ + public void registerOverride(StorageStrategyOverride override) { + overrides.computeIfAbsent(override.getTargetType(), k -> new CopyOnWriteArrayList<>()).add(override); + } + + /** + * 创建代理 + */ + @SuppressWarnings("unchecked") + public T createProxy(T target) { + List> targetOverrides = overrides.get(target.getClass()); + if (targetOverrides == null || targetOverrides.isEmpty()) { + return target; + } + + // 为每个重写对象设置原始目标 + for (StorageStrategyOverride override : targetOverrides) { + if (override instanceof AbstractStorageStrategyOverride) { + ((AbstractStorageStrategyOverride)override).setOriginalTarget(target); + } + } + + return (T)Proxy.newProxyInstance(target.getClass().getClassLoader(), target.getClass() + .getInterfaces(), new StrategyInvocationHandler<>(target, targetOverrides)); + } + + /** + * 改进的调用处理器 + */ + private static class StrategyInvocationHandler implements InvocationHandler { + private final T target; + private final List> overrides; + private final Map overrideMethodCache = new ConcurrentHashMap<>(); + + public StrategyInvocationHandler(T target, List> overrides) { + this.target = target; + this.overrides = overrides; + cacheOverrideMethods(); + } + + /** + * 缓存重写方法 + */ + private void cacheOverrideMethods() { + for (StorageStrategyOverride override : overrides) { + Class overrideClass = override.getClass(); + + // 获取目标策略类的所有方法 + Class targetClass = override.getTargetType(); + Method[] targetMethods = getAllMethods(targetClass); + + for (Method targetMethod : targetMethods) { + try { + // 查找重写类中是否有相同签名的方法 + Method overrideMethod = overrideClass.getMethod(targetMethod.getName(), targetMethod + .getParameterTypes()); + + // 检查方法是否真的被重写了(不是从接口继承的默认方法) + if (isMethodOverridden(overrideMethod, overrideClass)) { + overrideMethodCache.put(targetMethod + .getName() + getMethodSignature(targetMethod), overrideMethod); + } + } catch (NoSuchMethodException e) { + // 重写类中没有这个方法,忽略 + } + } + } + } + + /** + * 获取类及其所有接口的方法 + */ + private Method[] getAllMethods(Class clazz) { + + // 添加类本身的方法 + Set methods = new HashSet<>(Arrays.asList(clazz.getMethods())); + + // 添加所有接口的方法 + for (Class iface : clazz.getInterfaces()) { + methods.addAll(Arrays.asList(iface.getMethods())); + } + + return methods.toArray(new Method[0]); + } + + /** + * 检查方法是否真的被重写了 + */ + private boolean isMethodOverridden(Method method, Class overrideClass) { + // 如果方法声明在重写类中(而不是父类或接口),则认为是重写的 + return method.getDeclaringClass().equals(overrideClass) || (!method.getDeclaringClass() + .isInterface() && !method.getDeclaringClass().equals(AbstractStorageStrategyOverride.class) && !method + .getDeclaringClass() + .equals(Object.class)); + } + + /** + * 获取方法签名 + */ + private String getMethodSignature(Method method) { + StringBuilder sb = new StringBuilder("("); + for (Class paramType : method.getParameterTypes()) { + sb.append(paramType.getName()).append(","); + } + if (sb.length() > 1) { + sb.setLength(sb.length() - 1); + } + sb.append(")"); + return sb.toString(); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + String methodKey = method.getName() + getMethodSignature(method); + Method overrideMethod = overrideMethodCache.get(methodKey); + + if (overrideMethod != null) { + // 找到重写方法,调用重写逻辑 + for (StorageStrategyOverride override : overrides) { + if (overrideMethod.getDeclaringClass().equals(override.getClass())) { + return overrideMethod.invoke(override, args); + } + } + } + + // 没有重写,调用原方法 + return method.invoke(target, args); + } + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/core/UploadPretreatment.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/core/UploadPretreatment.java new file mode 100644 index 00000000..324409db --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/core/UploadPretreatment.java @@ -0,0 +1,295 @@ +/* + * 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.core; + +import org.springframework.web.multipart.MultipartFile; +import top.continew.starter.storage.model.context.UploadContext; +import top.continew.starter.storage.model.req.MockMultipartFile; +import top.continew.starter.storage.model.req.ThumbnailInfo; +import top.continew.starter.storage.model.req.ThumbnailSize; +import top.continew.starter.storage.model.resp.FileInfo; +import top.continew.starter.storage.prehandle.*; +import top.continew.starter.storage.util.StorageUtils; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * 上传预处理器,支持链式调用 + * + * @author echo + * @since 2.14.0 + */ +public class UploadPretreatment { + + private final FileStorageService storageService; + private final UploadContext context; + private final List validators = new ArrayList<>(); + private FileNameGenerator nameGenerator; + private FilePathGenerator pathGenerator; + private ThumbnailProcessor thumbnailProcessor; + private final List completeProcessors = new ArrayList<>(); + + public UploadPretreatment(FileStorageService storageService, MultipartFile file) { + this.storageService = storageService; + this.context = new UploadContext(); + this.context.setFile(file); + // 设置默认平台 + this.context.setPlatform(storageService.getDefaultPlatform()); + } + + /** + * 设置存储平台 + * + * @param platform 站台 + * @return {@link UploadPretreatment } + */ + public UploadPretreatment setPlatform(String platform) { + context.setPlatform(platform); + return this; + } + + /** + * 设置桶 + * + * @param bucket 桶 + * @return {@link UploadPretreatment } + */ + public UploadPretreatment setBucket(String bucket) { + context.setBucket(bucket); + return this; + } + + /** + * 设置路径 + */ + public UploadPretreatment setPath(String path) { + context.setPath(path); + return this; + } + + /** + * 设置文件名 + */ + public UploadPretreatment setFileName(String fileName) { + context.setFileName(fileName); + return this; + } + + /** + * 添加元数据 + */ + public UploadPretreatment addMetadata(String key, String value) { + context.getMetadata().put(key, value); + return this; + } + + /** + * 添加扩展属性 + */ + public UploadPretreatment addAttribute(String key, Object value) { + context.getAttributes().put(key, value); + return this; + } + + /** + * 启用缩略图 + */ + public UploadPretreatment enableThumbnail(int width, int height) { + context.setGenerateThumbnail(true); + context.setThumbnailSize(new ThumbnailSize(width, height)); + return this; + } + + /** + * 添加验证器 + */ + public UploadPretreatment addValidator(FileValidator validator) { + validators.add(validator); + return this; + } + + /** + * 设置文件名生成器 + */ + public UploadPretreatment setNameGenerator(FileNameGenerator generator) { + this.nameGenerator = generator; + return this; + } + + public UploadPretreatment setPathGenerator(FilePathGenerator generator) { + this.pathGenerator = generator; + return this; + } + + /** + * 设置缩略图处理器 + */ + public UploadPretreatment setThumbnailProcessor(ThumbnailProcessor processor) { + this.thumbnailProcessor = processor; + return this; + } + + /** + * 添加上传完成处理器 + */ + public UploadPretreatment addCompleteProcessor(UploadCompleteProcessor processor) { + completeProcessors.add(processor); + return this; + } + + /** + * 执行上传 + */ + public FileInfo upload() { + // 应用处理器 + applyProcessors(); + + // 执行验证 + validate(); + + // 生成默认存储桶(如果未设置) + if (context.getBucket() == null || context.getBucket().trim().isEmpty()) { + context.setBucket(generateDefaultBucket()); + } + + // 生成文件名 + if (context.getFileName() == null) { + context.setFileName(generateFileName()); + } + + // 生成文件路径 + if (context.getPath() == null) { + context.setPath(generateFilePath()); + } + + // 执行上传 + FileInfo fileInfo = storageService.doUpload(context); + + // 处理缩略图 + if (context.isGenerateThumbnail()) { + processThumbnail(fileInfo); + } + + // 触发完成事件 + triggerCompleteEvent(fileInfo); + + return fileInfo; + } + + /** + * 应用处理器 + */ + private void applyProcessors() { + // 从存储服务获取全局处理器 + ProcessorRegistry registry = storageService.getProcessorRegistry(); + + // 合并处理器:自定义 > 平台 > 全局 + if (nameGenerator == null) { + nameGenerator = registry.getNameGenerator(context.getPlatform()); + } + + if (pathGenerator == null) { + pathGenerator = registry.getPathGenerator(context.getPlatform()); + } + + if (thumbnailProcessor == null && context.isGenerateThumbnail()) { + thumbnailProcessor = registry.getThumbnailProcessor(context.getPlatform()); + } + + // 合并验证器 + validators.addAll(0, registry.getValidators(context.getPlatform())); + + // 合并完成处理器 + completeProcessors.addAll(0, registry.getCompleteProcessors(context.getPlatform())); + } + + /** + * 执行验证 + */ + private void validate() { + for (FileValidator validator : validators) { + if (validator.support(context)) { + validator.validate(context); + } + } + } + + /** + * 生成文件名 + */ + private String generateFileName() { + if (nameGenerator != null && nameGenerator.support(context)) { + return nameGenerator.generate(context); + } + return StorageUtils.generateFileName(context.getFile().getOriginalFilename()); + } + + private String generateFilePath() { + if (pathGenerator != null && pathGenerator.support(context)) { + return pathGenerator.path(context); + } + // 默认使用时间戳 + return StorageUtils.generatePath(); + } + + /** + * 生成默认存储桶 + */ + private String generateDefaultBucket() { + return storageService.getDefaultBucket(context.getPlatform()); + } + + /** + * 处理缩略图 + * + * @param fileInfo 文件信息 + */ + private void processThumbnail(FileInfo fileInfo) { + if (thumbnailProcessor != null && thumbnailProcessor.support(context)) { + try (InputStream is = storageService.download(context.getPlatform(), fileInfo.getPath())) { + ThumbnailInfo thumbnailInfo = thumbnailProcessor.process(context, is); + // 上传缩略图 + String thumbnailPath = fileInfo.getPath() + "_thumb." + thumbnailInfo.getFormat(); + // 创建模拟的文件信息 + MockMultipartFile thumbnailFile = new MockMultipartFile("thumbnail", "thumbnail." + thumbnailInfo + .getFormat(), "image/" + thumbnailInfo.getFormat(), thumbnailInfo.getData()); + + storageService.upload(context.getPlatform(), context.getBucket(), thumbnailPath, thumbnailFile); + fileInfo.setThumbnailPath(thumbnailPath); + } catch (Exception e) { + + } + } + } + + /** + * 触发完成事件 + */ + private void triggerCompleteEvent(FileInfo fileInfo) { + for (UploadCompleteProcessor processor : completeProcessors) { + if (processor.support(context)) { + try { + processor.onComplete(fileInfo); + } catch (Exception e) { + } + } + } + } + +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/exception/StorageException.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/exception/StorageException.java new file mode 100644 index 00000000..b78b43c5 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/exception/StorageException.java @@ -0,0 +1,48 @@ +/* + * 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.exception; + +import top.continew.starter.core.exception.BaseException; + +import java.io.Serial; + +/** + * 存储异常 + * + * @author echo + * @since 2.14.0 + */ +public class StorageException extends BaseException { + + @Serial + private static final long serialVersionUID = 1L; + + public StorageException() { + } + + public StorageException(String message) { + super(message); + } + + public StorageException(Throwable cause) { + super(cause); + } + + public StorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/model/context/UploadContext.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/context/UploadContext.java new file mode 100644 index 00000000..5ebb18be --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/context/UploadContext.java @@ -0,0 +1,156 @@ +/* + * 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.context; + +import org.springframework.web.multipart.MultipartFile; +import top.continew.starter.storage.model.req.ThumbnailSize; + +import java.util.HashMap; +import java.util.Map; + +/** + * 上传上下文,包含上传过程中的所有信息 + * + * @author echo + * @since 2.14.0 + */ +public class UploadContext { + + /** + * 原始文件 + */ + private MultipartFile file; + + /** + * 存储平台代码 + */ + private String platform; + + /** + * 桶名 + */ + private String bucket; + + /** + * 文件路径(不含文件名) + */ + private String path; + + /** + * 文件名 + */ + private String fileName; + + /** + * 是否生成缩略图 + */ + private boolean generateThumbnail; + + /** + * 缩略图尺寸 + */ + private ThumbnailSize thumbnailSize; + + /** + * 元数据 + */ + private Map metadata = new HashMap<>(); + + /** + * 扩展属性 + */ + private Map attributes = new HashMap<>(); + + /** + * 获取完整路径 + */ + public String getFullPath() { + return path + fileName; + } + + public MultipartFile getFile() { + return file; + } + + public void setFile(MultipartFile file) { + this.file = file; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public boolean isGenerateThumbnail() { + return generateThumbnail; + } + + public void setGenerateThumbnail(boolean generateThumbnail) { + this.generateThumbnail = generateThumbnail; + } + + public ThumbnailSize getThumbnailSize() { + return thumbnailSize; + } + + public void setThumbnailSize(ThumbnailSize thumbnailSize) { + this.thumbnailSize = thumbnailSize; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/model/req/MockMultipartFile.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/req/MockMultipartFile.java new file mode 100644 index 00000000..314cf11b --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/req/MockMultipartFile.java @@ -0,0 +1,89 @@ +/* + * 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; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; + +/** + * 内存中的 MultipartFile 实现,适用于无需真实 HTTP 上传场景。 + *

+ * 可用于接口调用中构造文件参数,如将字节数组、输入流包装成 MultipartFile, + * 以便复用上传逻辑或兼容已有的文件处理接口。 + * + * @author echo + * @since 2.14.0 + */ +public class MockMultipartFile implements MultipartFile { + private final String name; + private final String originalFilename; + private final String contentType; + private final byte[] content; + + public MockMultipartFile(String name, String originalFilename, String contentType, byte[] content) { + this.name = name; + this.originalFilename = originalFilename; + this.contentType = contentType; + this.content = content; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content == null || content.length == 0; + } + + @Override + public long getSize() { + return content != null ? content.length : 0; + } + + @Override + public byte[] getBytes() { + return content; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException { + Files.write(dest.toPath(), content); + } + +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/model/req/ThumbnailInfo.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/req/ThumbnailInfo.java new file mode 100644 index 00000000..a3fd3c0a --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/req/ThumbnailInfo.java @@ -0,0 +1,64 @@ +/* + * 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 + * @since 2.14.0 + */ +public class ThumbnailInfo { + private byte[] data; + private String format; + private int width; + private int height; + + public byte[] getData() { + return data; + } + + public void setData(byte[] data) { + this.data = data; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public int getWidth() { + return width; + } + + public void setWidth(int width) { + this.width = width; + } + + public int getHeight() { + return height; + } + + public void setHeight(int height) { + this.height = height; + } + + // getter/setter省略 +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/model/req/ThumbnailSize.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/req/ThumbnailSize.java new file mode 100644 index 00000000..a6414bca --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/req/ThumbnailSize.java @@ -0,0 +1,58 @@ +/* + * 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 + * @since 2.14.0 + */ +public class ThumbnailSize { + private int width; + private int height; + private boolean keepAspectRatio = true; + + public ThumbnailSize(int width, int height) { + this.width = width; + this.height = height; + } + + public int getWidth() { + return width; + } + + public void setWidth(int width) { + this.width = width; + } + + public int getHeight() { + return height; + } + + public void setHeight(int height) { + this.height = height; + } + + public boolean isKeepAspectRatio() { + return keepAspectRatio; + } + + public void setKeepAspectRatio(boolean keepAspectRatio) { + this.keepAspectRatio = keepAspectRatio; + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/FileInfo.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/FileInfo.java new file mode 100644 index 00000000..bc5de7f5 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/FileInfo.java @@ -0,0 +1,198 @@ +/* + * 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; +import java.util.Map; + +/** + * 文件信息 + * + * @author echo + * @since 2.14.0 + */ +public class FileInfo { + + /** + * 平台 + */ + private String platform; + + /** + * 文件id + */ + private String fileId; + + /** + * 存储桶 + */ + private String bucket; + + /** + * 路径 + */ + private String path; + + /** + * 原始文件名 + */ + private String originalFileName; + + /** + * 文件名 + */ + private String name; + + /** + * 缩略图路径 + */ + private String thumbnailPath; + + /** + * 完整路径 + */ + private String fullPath; + + /** + * 文件大小 + */ + private Long size; + + /** + * 内容类型 + */ + private String contentType; + + /** + * 访问 url + */ + private String url; + + /** + * 上传时间 + */ + private LocalDateTime uploadTime; + + /** + * 元数据 + */ + private Map metadata; + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getPath() { + return path; + } + + public String getFileId() { + return fileId; + } + + public void setFileId(String fileId) { + this.fileId = fileId; + } + + public void setPath(String path) { + this.path = path; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getOriginalFileName() { + return originalFileName; + } + + public void setOriginalFileName(String originalFileName) { + this.originalFileName = originalFileName; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getFullPath() { + return fullPath; + } + + public void setFullPath(String fullPath) { + this.fullPath = fullPath; + } + + public Long getSize() { + return size; + } + + public void setSize(Long size) { + this.size = size; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getThumbnailPath() { + return thumbnailPath; + } + + public void setThumbnailPath(String thumbnailPath) { + this.thumbnailPath = thumbnailPath; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public LocalDateTime getUploadTime() { + return uploadTime; + } + + public void setUploadTime(LocalDateTime uploadTime) { + this.uploadTime = uploadTime; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/FilePartInfo.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/FilePartInfo.java new file mode 100644 index 00000000..85dc39dd --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/FilePartInfo.java @@ -0,0 +1,157 @@ +/* + * 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 + * @since 2.14.0 + */ +public class FilePartInfo { + /** + * 文件ID + */ + private String fileId; + + /** + * 分片编号(从1开始) + */ + private Integer partNumber; + + /** + * 分片大小 + */ + private Long partSize; + + /** + * 分片MD5 + */ + private String partMd5; + + /** + * 分片ETag(S3返回的标识) + */ + private String partETag; + + /** + * 上传ID(S3分片上传标识) + */ + private String uploadId; + + /** + * 上传时间 + */ + private LocalDateTime uploadTime; + + /** + * 状态:UPLOADING, SUCCESS, FAILED + */ + private String status; + + /** + * 存储桶 + */ + private String bucket; + + /** + * 文件路径 + */ + private String path; + + public String getFileId() { + return fileId; + } + + public void setFileId(String fileId) { + this.fileId = fileId; + } + + public Integer getPartNumber() { + return partNumber; + } + + public void setPartNumber(Integer partNumber) { + this.partNumber = partNumber; + } + + public Long getPartSize() { + return partSize; + } + + public void setPartSize(Long partSize) { + this.partSize = partSize; + } + + public String getPartMd5() { + return partMd5; + } + + public void setPartMd5(String partMd5) { + this.partMd5 = partMd5; + } + + public String getPartETag() { + return partETag; + } + + public void setPartETag(String partETag) { + this.partETag = partETag; + } + + public String getUploadId() { + return uploadId; + } + + public void setUploadId(String uploadId) { + this.uploadId = uploadId; + } + + public LocalDateTime getUploadTime() { + return uploadTime; + } + + public void setUploadTime(LocalDateTime uploadTime) { + this.uploadTime = uploadTime; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/MultipartInitResp.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/MultipartInitResp.java new file mode 100644 index 00000000..124bd124 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/MultipartInitResp.java @@ -0,0 +1,116 @@ +/* + * 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 + * @since 2.14.0 + */ +public class MultipartInitResp { + /** + * 文件ID + */ + private String fileId; + + /** + * 上传ID(S3返回的uploadId) + */ + private String uploadId; + + /** + * 存储桶 + */ + private String bucket; + + /** + * 存储平台 + */ + private String platform; + + /** + * 文件路径 + */ + private String path; + + /** + * 分片大小 + */ + private Long partSize; + + /** + * 总分片数 + */ + private Integer totalParts; + + public String getFileId() { + return fileId; + } + + public void setFileId(String fileId) { + this.fileId = fileId; + } + + public String getUploadId() { + return uploadId; + } + + public void setUploadId(String uploadId) { + this.uploadId = uploadId; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public Long getPartSize() { + return partSize; + } + + public void setPartSize(Long partSize) { + this.partSize = partSize; + } + + public Integer getTotalParts() { + return totalParts; + } + + public void setTotalParts(Integer totalParts) { + this.totalParts = totalParts; + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/MultipartUploadResp.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/MultipartUploadResp.java new file mode 100644 index 00000000..e5e7d676 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/MultipartUploadResp.java @@ -0,0 +1,90 @@ +/* + * 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 + * @since 2.14.0 + */ +public class MultipartUploadResp { + /** + * 分片编号 + */ + private Integer partNumber; + + /** + * 分片ETag + */ + private String partETag; + + /** + * 分片大小 + */ + private Long partSize; + + /** + * 是否成功 + */ + private boolean success; + + /** + * 错误信息 + */ + private String errorMessage; + + public Integer getPartNumber() { + return partNumber; + } + + public void setPartNumber(Integer partNumber) { + this.partNumber = partNumber; + } + + public String getPartETag() { + return partETag; + } + + public void setPartETag(String partETag) { + this.partETag = partETag; + } + + public Long getPartSize() { + return partSize; + } + + public void setPartSize(Long partSize) { + this.partSize = partSize; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/StrategyStatus.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/StrategyStatus.java new file mode 100644 index 00000000..1e3592e1 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/model/resp/StrategyStatus.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.model.resp; + +/** + * 存储策略状态 + * + * @author echo + * @since 2.14.0 + */ +public class StrategyStatus { + + /** + * 平台 + */ + private String platform; + + /** + * 是否有配置文件策略 + */ + private boolean hasConfig; + /** + * 是否有动态策略 + */ + private boolean hasDynamic; + + /** + * 当前生效的类型:"CONFIG" 或 "DYNAMIC" + */ + private String activeType; + + /** + * 描述 + */ + private String description; + + public StrategyStatus(String platform, + boolean hasConfig, + boolean hasDynamic, + String activeType, + String description) { + this.platform = platform; + this.hasConfig = hasConfig; + this.hasDynamic = hasDynamic; + this.activeType = activeType; + this.description = description; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public boolean isHasConfig() { + return hasConfig; + } + + public void setHasConfig(boolean hasConfig) { + this.hasConfig = hasConfig; + } + + public boolean isHasDynamic() { + return hasDynamic; + } + + public void setHasDynamic(boolean hasDynamic) { + this.hasDynamic = hasDynamic; + } + + public String getActiveType() { + return activeType; + } + + public void setActiveType(String activeType) { + this.activeType = activeType; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} 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/src/main/java/top/continew/starter/storage/prehandle/FileNameGenerator.java similarity index 58% rename from continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/dao/impl/StorageDaoDefaultImpl.java rename to continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/FileNameGenerator.java index 8114b02b..9f5a4180 100644 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/dao/impl/StorageDaoDefaultImpl.java +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/FileNameGenerator.java @@ -14,21 +14,24 @@ * limitations under the License. */ -package top.continew.starter.storage.dao.impl; +package top.continew.starter.storage.prehandle; -import top.continew.starter.storage.dao.StorageDao; -import top.continew.starter.storage.model.resp.UploadResp; +import top.continew.starter.storage.model.context.UploadContext; +import top.continew.starter.storage.service.FileProcessor; /** - * 存储记录持久层接口默认实现 - *

此类并不能真正保存记录,只是用来脱离数据库运行,保证文件上传功能可以正常使用

+ * 文件名生成器 * * @author echo - * @since 2.9.0 + * @since 2.14.0 */ -public class StorageDaoDefaultImpl implements StorageDao { +public interface FileNameGenerator extends FileProcessor { - @Override - public void add(UploadResp uploadResp) { - } + /** + * 生成文件名 + * + * @param context 上传上下文 + * @return 生成的文件名 + */ + String generate(UploadContext context); } diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/FilePathGenerator.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/FilePathGenerator.java new file mode 100644 index 00000000..a1c7e86e --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/FilePathGenerator.java @@ -0,0 +1,37 @@ +/* + * 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.prehandle; + +import top.continew.starter.storage.model.context.UploadContext; +import top.continew.starter.storage.service.FileProcessor; + +/** + * 文件路径生成器 + * + * @author echo + * @since 2.14.0 + */ +public interface FilePathGenerator extends FileProcessor { + + /** + * 生成路径 + * + * @param context 上下文 + * @return {@link String } + */ + String path(UploadContext context); +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/FileValidator.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/FileValidator.java new file mode 100644 index 00000000..d08347a6 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/FileValidator.java @@ -0,0 +1,38 @@ +/* + * 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.prehandle; + +import top.continew.starter.storage.exception.StorageException; +import top.continew.starter.storage.model.context.UploadContext; +import top.continew.starter.storage.service.FileProcessor; + +/** + * 文件验证器 + * + * @author echo + * @since 2.14.0 + */ +public interface FileValidator extends FileProcessor { + + /** + * 验证文件 + * + * @param context 上传上下文 + * @throws StorageException 验证失败时抛出异常 + */ + void validate(UploadContext context) throws StorageException; +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/ThumbnailProcessor.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/ThumbnailProcessor.java new file mode 100644 index 00000000..c66717ae --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/ThumbnailProcessor.java @@ -0,0 +1,41 @@ +/* + * 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.prehandle; + +import top.continew.starter.storage.model.context.UploadContext; +import top.continew.starter.storage.model.req.ThumbnailInfo; +import top.continew.starter.storage.service.FileProcessor; + +import java.io.InputStream; + +/** + * 缩略图处理器 + * + * @author echo + * @since 2.14.0 + */ +public interface ThumbnailProcessor extends FileProcessor { + + /** + * 生成缩略图 + * + * @param context 上传上下文 + * @param sourceInputStream 原始文件流 + * @return 缩略图信息 + */ + ThumbnailInfo process(UploadContext context, InputStream sourceInputStream); +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/UploadCompleteProcessor.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/UploadCompleteProcessor.java new file mode 100644 index 00000000..ac34bb4e --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/UploadCompleteProcessor.java @@ -0,0 +1,36 @@ +/* + * 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.prehandle; + +import top.continew.starter.storage.model.resp.FileInfo; +import top.continew.starter.storage.service.FileProcessor; + +/** + * 上传完成处理器 + * + * @author echo + * @since 2.14.0 + */ +public interface UploadCompleteProcessor extends FileProcessor { + + /** + * 处理上传完成事件 + * + * @param fileInfo 文件信息 + */ + void onComplete(FileInfo fileInfo); +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/DefaultFileNameGenerator.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/DefaultFileNameGenerator.java new file mode 100644 index 00000000..fc32f683 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/DefaultFileNameGenerator.java @@ -0,0 +1,45 @@ +/* + * 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.prehandle.impl; + +import top.continew.starter.storage.model.context.UploadContext; +import top.continew.starter.storage.prehandle.FileNameGenerator; +import top.continew.starter.storage.util.StorageUtils; + +/** + * 默认文件名生成器 + * + * @author echo + * @since 2.14.0 + */ +public class DefaultFileNameGenerator implements FileNameGenerator { + + @Override + public String getName() { + return "defaultFileName"; + } + + @Override + public boolean support(UploadContext context) { + return true; + } + + @Override + public String generate(UploadContext context) { + return StorageUtils.generateFileName(context.getFile().getOriginalFilename()); + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/DefaultFilePathGenerator.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/DefaultFilePathGenerator.java new file mode 100644 index 00000000..6bcf3dd5 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/DefaultFilePathGenerator.java @@ -0,0 +1,47 @@ +/* + * 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.prehandle.impl; + +import cn.hutool.core.util.StrUtil; +import top.continew.starter.storage.model.context.UploadContext; +import top.continew.starter.storage.prehandle.FilePathGenerator; +import top.continew.starter.storage.util.StorageUtils; + +/** + * 默认文件路径生成器 + * + * @author echo + * @since 2.14.0 + */ +public class DefaultFilePathGenerator implements FilePathGenerator { + + @Override + public String getName() { + return "defaultFilePath"; + } + + @Override + public boolean support(UploadContext context) { + return true; + } + + @Override + public String path(UploadContext context) { + String path = context.getPath(); + return StrUtil.isNotBlank(path) ? path : StorageUtils.generatePath(); + } +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/DefaultThumbnailProcessor.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/DefaultThumbnailProcessor.java new file mode 100644 index 00000000..9a6393c1 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/DefaultThumbnailProcessor.java @@ -0,0 +1,73 @@ +/* + * 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.prehandle.impl; + +import net.coobird.thumbnailator.Thumbnails; +import top.continew.starter.storage.exception.StorageException; +import top.continew.starter.storage.model.context.UploadContext; +import top.continew.starter.storage.model.req.ThumbnailInfo; +import top.continew.starter.storage.model.req.ThumbnailSize; +import top.continew.starter.storage.prehandle.ThumbnailProcessor; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; + +/** + * 默认缩略图处理器 + * + * @author echo + * @since 2.14.0 + */ +public class DefaultThumbnailProcessor implements ThumbnailProcessor { + + @Override + public String getName() { + return "defaultThumbnail"; + } + + @Override + public boolean support(UploadContext context) { + String contentType = context.getFile().getContentType(); + return contentType != null && contentType.startsWith("image/"); + } + + @Override + public ThumbnailInfo process(UploadContext context, InputStream sourceInputStream) { + try { + ThumbnailSize size = context.getThumbnailSize(); + BufferedImage thumbnail = Thumbnails.of(sourceInputStream) + .size(size.getWidth(), size.getHeight()) + .keepAspectRatio(size.isKeepAspectRatio()) + .asBufferedImage(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(thumbnail, "jpg", baos); + + ThumbnailInfo info = new ThumbnailInfo(); + info.setData(baos.toByteArray()); + info.setFormat("jpg"); + info.setWidth(thumbnail.getWidth()); + info.setHeight(thumbnail.getHeight()); + + return info; + } catch (Exception e) { + throw new StorageException("生成缩略图失败", e); + } + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/FileSizeValidator.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/FileSizeValidator.java new file mode 100644 index 00000000..cf42c357 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/FileSizeValidator.java @@ -0,0 +1,101 @@ +/* + * 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.prehandle.impl; + +import cn.hutool.core.io.FileUtil; +import org.springframework.boot.autoconfigure.web.servlet.MultipartProperties; +import top.continew.starter.storage.exception.StorageException; +import top.continew.starter.storage.model.context.UploadContext; +import top.continew.starter.storage.prehandle.FileValidator; + +/** + * 文件大小验证器 + * + * @author echo + * @since 2.14.0 + */ +public class FileSizeValidator implements FileValidator { + + private final long maxSize; + + public FileSizeValidator(MultipartProperties multipartProperties) { + this.maxSize = multipartProperties.getMaxFileSize().toBytes(); + } + + public FileSizeValidator() { + // 提供默认大小 10MB + this(10 * 1024 * 1024L); + } + + public FileSizeValidator(long maxSize) { + this.maxSize = maxSize; + } + + @Override + public String getName() { + return "fileSize"; + } + + @Override + public boolean support(UploadContext context) { + return true; + } + + @Override + public void validate(UploadContext context) throws StorageException { + long fileSize = context.getFile().getSize(); + if (fileSize > maxSize) { + throw new StorageException(String.format("文件大小超过限制: %s (当前: %s)", FileUtil + .readableFileSize(maxSize), FileUtil.readableFileSize(fileSize))); + } + } + + /** + * 创建默认的文件大小验证器(10MB) + */ + public FileSizeValidator create() { + return new FileSizeValidator(maxSize); + } + + /** + * 创建指定大小的验证器 + */ + public static FileSizeValidator maxSize(long bytes) { + return new FileSizeValidator(bytes); + } + + /** + * 创建指定KB大小的验证器 + */ + public static FileSizeValidator maxKB(long kb) { + return new FileSizeValidator(kb * 1024); + } + + /** + * 创建指定MB大小的验证器 + */ + public static FileSizeValidator maxMB(long mb) { + return new FileSizeValidator(mb * 1024 * 1024); + } + + /** + * 创建指定GB大小的验证器 + */ + public static FileSizeValidator maxGB(long gb) { + return new FileSizeValidator(gb * 1024L * 1024 * 1024); + } +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/FileTypeValidator.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/FileTypeValidator.java new file mode 100644 index 00000000..30bdfc86 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/prehandle/impl/FileTypeValidator.java @@ -0,0 +1,78 @@ +/* + * 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.prehandle.impl; + +import cn.hutool.core.io.FileUtil; +import top.continew.starter.storage.exception.StorageException; +import top.continew.starter.storage.model.context.UploadContext; +import top.continew.starter.storage.prehandle.FileValidator; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * 文件类型验证器 + * + * @author echo + * @since 2.14.0 + */ +public class FileTypeValidator implements FileValidator { + + private final Set allowedExtensions; + + /** + * 默认构造函数,允许所有支持的文件类型 + */ + public FileTypeValidator() { + this.allowedExtensions = null; + } + + /** + * 指定允许的文件扩展名 + * + * @param extensions 允许的扩展名 + */ + public FileTypeValidator(String... extensions) { + this.allowedExtensions = new HashSet<>(Arrays.asList(extensions)); + } + + @Override + public String getName() { + return "fileType"; + } + + @Override + public boolean support(UploadContext context) { + return true; + } + + @Override + public void validate(UploadContext context) throws StorageException { + String filename = context.getFile().getOriginalFilename(); + if (filename != null) { + String extension = FileUtil.extName(filename).toLowerCase(); + // 如果指定了允许的扩展名,则只允许这些扩展名 + if (allowedExtensions != null) { + if (!allowedExtensions.contains(extension)) { + throw new StorageException("不支持的文件类型: " + extension + ",仅支持: " + String + .join(", ", allowedExtensions)); + } + } + } + } +} \ No newline at end of file diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/dao/StorageDao.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/router/StorageStrategyRegistrar.java similarity index 66% rename from continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/dao/StorageDao.java rename to continew-starter-storage/src/main/java/top/continew/starter/storage/router/StorageStrategyRegistrar.java index 5f4fe47c..6c1c9cff 100644 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/dao/StorageDao.java +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/router/StorageStrategyRegistrar.java @@ -14,22 +14,26 @@ * limitations under the License. */ -package top.continew.starter.storage.dao; +package top.continew.starter.storage.router; -import top.continew.starter.storage.model.resp.UploadResp; +import top.continew.starter.storage.strategy.StorageStrategy; + +import java.util.List; /** - * 存储记录持久层接口 + * 存储策略注册 + *

+ * 主要针对配置文件 + *

* * @author echo - * @since 2.9.0 + * @since 2.14.0 */ -public interface StorageDao { +public interface StorageStrategyRegistrar { /** - * 记录上传信息 - * - * @param uploadResp 上传信息 + * 注册策略到列表 */ - void add(UploadResp uploadResp); -} + void register(List strategies); + +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/router/StorageStrategyRouter.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/router/StorageStrategyRouter.java new file mode 100644 index 00000000..4d439b30 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/router/StorageStrategyRouter.java @@ -0,0 +1,171 @@ +/* + * 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.router; + +import top.continew.starter.storage.exception.StorageException; +import top.continew.starter.storage.model.resp.StrategyStatus; +import top.continew.starter.storage.strategy.StorageStrategy; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 存储策略路由器 + * + * @author echo + * @since 2.14.0 + */ +public class StorageStrategyRouter { + + /** + * 配置策略 + */ + private final Map configStrategies = new ConcurrentHashMap<>(); + + /** + * 动态策略 + */ + private final Map dynamicStrategies = new ConcurrentHashMap<>(); + + public StorageStrategyRouter(List registrars) { + List strategies = new ArrayList<>(); + for (StorageStrategyRegistrar registrar : registrars) { + registrar.register(strategies); + } + + // 配置文件加载的策略 + for (StorageStrategy strategy : strategies) { + configStrategies.put(strategy.getPlatform(), strategy); + } + } + + /** + * 存储选择 + * + * @param platform 代码 + * @return {@link StorageStrategy } + */ + public StorageStrategy route(String platform) { + // 动态注册的策略优先级更高 + StorageStrategy strategy = dynamicStrategies.get(platform); + if (strategy == null) { + strategy = configStrategies.get(platform); + } + + if (strategy == null) { + throw new StorageException("不支持存储编码: " + platform); + } + return strategy; + } + + /** + * 动态注册策略 + * + * @param strategy 存储策略 + * @throws StorageException 如果同一 platform 的动态策略已存在 + */ + public void registerDynamic(StorageStrategy strategy) { + String platform = strategy.getPlatform(); + if (dynamicStrategies.containsKey(platform)) { + throw new StorageException("动态策略 platform 已存在: " + platform); + } + // 如果配置文件中存在相同 platform,动态注册会覆盖(但不修改配置策略) + dynamicStrategies.put(platform, strategy); + } + + /** + * 卸载动态策略 + * + * @param platform 策略代码 + * @return 是否成功卸载 + */ + public boolean unloadDynamic(String platform) { + StorageStrategy strategy = dynamicStrategies.remove(platform); + if (strategy != null) { + try { + strategy.cleanup(); + return true; + } catch (Exception e) { + return false; + } + } + return false; + } + + /** + * 获取所有可用代码 + */ + public Set getAllPlatform() { + Set allPlatform = new HashSet<>(configStrategies.keySet()); + allPlatform.addAll(dynamicStrategies.keySet()); + return allPlatform; + } + + /** + * 检查是否为动态注册的策略 + */ + public boolean isDynamic(String platform) { + return dynamicStrategies.containsKey(platform); + } + + /** + * 检查是否为配置文件策略 + */ + public boolean isFromConfig(String platform) { + return configStrategies.containsKey(platform); + } + + /** + * 获取简化的策略信息(当前生效的) + */ + public Map getActiveStrategyInfo() { + Map info = new HashMap<>(); + + // 先添加配置文件策略 + configStrategies.keySet().forEach(platform -> info.put(platform, "CONFIG")); + + // 动态策略会覆盖同名的配置策略 + dynamicStrategies.keySet().forEach(platform -> info.put(platform, "DYNAMIC")); + + return info; + } + + /** + * 获取完整的策略状态 + */ + public Map getFullStrategyStatus() { + Map status = new HashMap<>(); + + // 所有唯一的 platform + Set appPlatform = new HashSet<>(); + appPlatform.addAll(configStrategies.keySet()); + appPlatform.addAll(dynamicStrategies.keySet()); + + for (String platform : appPlatform) { + boolean hasConfig = configStrategies.containsKey(platform); + boolean hasDynamic = dynamicStrategies.containsKey(platform); + + StrategyStatus strategyStatus = new StrategyStatus(platform, hasConfig, hasDynamic, hasDynamic + ? "DYNAMIC" + : "CONFIG", hasDynamic && hasConfig ? "配置策略被覆盖" : "正常"); + + status.put(platform, strategyStatus); + } + + return status; + } +} \ No newline at end of file diff --git a/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/autoconfigure/OssStorageAutoConfiguration.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/service/FileProcessor.java similarity index 51% rename from continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/autoconfigure/OssStorageAutoConfiguration.java rename to continew-starter-storage/src/main/java/top/continew/starter/storage/service/FileProcessor.java index a53eb701..fd3a277e 100644 --- a/continew-starter-storage/continew-starter-storage-oss/src/main/java/top/continew/starter/storage/autoconfigure/OssStorageAutoConfiguration.java +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/service/FileProcessor.java @@ -14,29 +14,39 @@ * limitations under the License. */ -package top.continew.starter.storage.autoconfigure; +package top.continew.starter.storage.service; -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; +import top.continew.starter.storage.model.context.UploadContext; /** - * 对象存储自动配置 + * 文件处理器接口 * * @author echo - * @since 2.9.0 + * @since 2.14.0 */ -@AutoConfiguration -public class OssStorageAutoConfiguration { +public interface FileProcessor { /** - * 存储记录持久层默认实现 + * 获取处理器名称 + * + * @return 处理器名称 */ - @Bean - @ConditionalOnMissingBean - public StorageDao storageDao() { - return new StorageDaoDefaultImpl(); + String getName(); + + /** + * 获取处理器优先级(数值越大优先级越高) + * + * @return 优先级 + */ + default int getOrder() { + return 0; } -} + + /** + * 是否支持该文件 + * + * @param context 上传上下文 + * @return 是否支持 + */ + boolean support(UploadContext context); +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/service/FileRecorder.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/service/FileRecorder.java new file mode 100644 index 00000000..83481741 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/service/FileRecorder.java @@ -0,0 +1,78 @@ +/* + * 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.service; + +import top.continew.starter.storage.model.resp.FileInfo; +import top.continew.starter.storage.model.resp.FilePartInfo; + +import java.util.List; + +/** + * 文件记录器接口,用于保存文件上传记录 + * + * @author echo + * @since 2.14.0 + */ +public interface FileRecorder { + + /** + * 保存文件记录 + * + * @param fileInfo 文件信息 + * @return 是否保存成功 + */ + boolean save(FileInfo fileInfo); + + /** + * 更新文件记录 + * + * @param fileInfo 文件信息 + * @return 是否更新成功 + */ + boolean update(FileInfo fileInfo); + + /** + * 删除文件记录 + * + * @param platform 存储平台 + * @param path 文件路径 + * @return 是否删除成功 + */ + boolean delete(String platform, String path); + + /** + * 保存文件分片信息 + * + * @param filePartInfo 文件分片信息 + */ + void saveFilePart(FilePartInfo filePartInfo); + + /** + * 获取文件所有分片信息 + * + * @param fileId 文件ID + * @return 分片信息列表 + */ + List getFileParts(String fileId); + + /** + * 删除文件分片信息 + * + * @param fileId 文件ID + */ + void deleteFileParts(String fileId); +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/service/impl/DefaultFileRecorder.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/service/impl/DefaultFileRecorder.java new file mode 100644 index 00000000..51f7ff90 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/service/impl/DefaultFileRecorder.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.service.impl; + +import top.continew.starter.storage.model.resp.FileInfo; +import top.continew.starter.storage.model.resp.FilePartInfo; +import top.continew.starter.storage.service.FileRecorder; + +import java.util.List; + +/** + * 默认文件记录器 + * + * @author echo + * @since 2.14.0 + */ +public class DefaultFileRecorder implements FileRecorder { + @Override + public boolean save(FileInfo fileInfo) { + return false; + } + + @Override + public boolean update(FileInfo fileInfo) { + return false; + } + + @Override + public boolean delete(String platform, String path) { + return false; + } + + @Override + public void saveFilePart(FilePartInfo filePartInfo) { + + } + + @Override + public List getFileParts(String fileId) { + return List.of(); + } + + @Override + public void deleteFileParts(String fileId) { + + } +} diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/StorageStrategy.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/StorageStrategy.java new file mode 100644 index 00000000..361d6586 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/StorageStrategy.java @@ -0,0 +1,177 @@ +/* + * 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 org.springframework.web.multipart.MultipartFile; +import top.continew.starter.storage.model.resp.FileInfo; +import top.continew.starter.storage.model.resp.MultipartInitResp; +import top.continew.starter.storage.model.resp.MultipartUploadResp; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; + +/** + * 存储策略 + * + * @author echo + * @since 2.14.0 + */ +public interface StorageStrategy { + + /** + * 上传 + * + * @param bucket 存储桶 + * @param path 路径 + * @param file 文件 + */ + void upload(String bucket, String path, MultipartFile file); + + /** + * 下载 + * + * @param bucket 存储桶 + * @param path 路径 + * @return {@link InputStream } + */ + InputStream download(String bucket, String path); + + /** + * 批量下载到zip + */ + InputStream batchDownload(String bucket, List paths); + + /** + * 删除 + */ + void delete(String bucket, String path); + + /** + * 批量删除 + */ + void batchDelete(String bucket, List paths); + + /** + * 是否存在 + */ + boolean exists(String bucket, String path); + + /** + * 获取文件信息 + */ + FileInfo getFileInfo(String bucket, String path); + + /** + * 列出文件 + */ + List list(String bucket, String prefix, int maxKeys); + + /** + * 复制文件 + */ + void copy(String sourceBucket, String targetBucket, String sourcePath, String targetPath); + + /** + * 移动文件 + */ + void move(String sourceBucket, String targetBucket, String sourcePath, String targetPath); + + /** + * 获取平台 + */ + String getPlatform(); + + /** + * 默认桶 + * + * @return {@link String } + */ + String defaultBucket(); + + /** + * 生成预签名URL + */ + String generatePresignedUrl(String bucket, String path, long expireSeconds); + + /** + * 初始化分片上传 + * + * @param bucket 存储桶 + * @param path 文件路径 + * @param contentType 内容类型 + * @param metadata 元数据 + * @return 初始化结果 + */ + MultipartInitResp initMultipartUpload(String bucket, String path, String contentType, Map metadata); + + /** + * 上传分片 + * + * @param uploadId 上传ID + * @param partNumber 分片编号 + * @param data 分片数据 + * @return 上传结果 + */ + MultipartUploadResp uploadPart(String bucket, String path, String uploadId, int partNumber, InputStream data); + + /** + * 完成分片上传 + * + * @param bucket 存储桶 + * @param path 文件路径 + * @param uploadId 上传ID + * @param parts 已上传的分片信息(从 FileRecorder 获取) + * @param verifyParts 是否验证分片(对象存储需要验证) + * @return 文件信息 + */ + FileInfo completeMultipartUpload(String bucket, + String path, + String uploadId, + List parts, + boolean verifyParts); + + // 保留原方法作为默认实现 + default FileInfo completeMultipartUpload(String bucket, + String path, + String uploadId, + List parts) { + return completeMultipartUpload(bucket, path, uploadId, parts, true); + } + + /** + * 取消分片上传 + * + * @param uploadId 上传ID + */ + void abortMultipartUpload(String bucket, String path, String uploadId); + + /** + * 列出已上传的分片 + * + * @param uploadId 上传ID + * @return 分片列表 + */ + List listParts(String bucket, String path, String uploadId); + + /** + * 清理 - 针对本地静态资源卸载 + */ + default void cleanup() { + // 默认不做任何清理 + } +} \ No newline at end of file diff --git a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/constant/StorageConstant.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/StorageStrategyOverride.java similarity index 58% rename from continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/constant/StorageConstant.java rename to continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/StorageStrategyOverride.java index b7b5fa52..e5222f27 100644 --- a/continew-starter-storage/continew-starter-storage-core/src/main/java/top/continew/starter/storage/constant/StorageConstant.java +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/StorageStrategyOverride.java @@ -14,29 +14,26 @@ * limitations under the License. */ -package top.continew.starter.storage.constant; +package top.continew.starter.storage.strategy; /** - * 存储常量 + * 存储策略重写接口 * * @author echo - * @since 2.9.0 + * @since 2.14.0 */ -public class StorageConstant { +public interface StorageStrategyOverride { /** - * 默认存储 Key + * 获取目标策略类型 */ - public static final String DEFAULT_KEY = "storage:default_config"; + Class getTargetType(); /** - * 云服务商 域名前缀 - *

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

+ * 获取原始目标对象(用于调用原始方法) */ - public static final String[] CLOUD_SERVICE_PREFIX = new String[] {"oss", "cos", "obs"}; - - /** - * 缩略图后缀 - */ - public static final String SMALL_SUFFIX = "small"; -} + default T getOriginalTarget() { + // 这个方法会在代理创建时被设置 + throw new UnsupportedOperationException("原始目标未设置"); + } +} \ No newline at end of file diff --git a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/autoconfigure/LocalStorageAutoConfiguration.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/impl/AbstractStorageStrategyOverride.java similarity index 51% rename from continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/autoconfigure/LocalStorageAutoConfiguration.java rename to continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/impl/AbstractStorageStrategyOverride.java index 63acd327..ae3b7950 100644 --- a/continew-starter-storage/continew-starter-storage-local/src/main/java/top/continew/starter/storage/autoconfigure/LocalStorageAutoConfiguration.java +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/impl/AbstractStorageStrategyOverride.java @@ -14,29 +14,30 @@ * limitations under the License. */ -package top.continew.starter.storage.autoconfigure; +package top.continew.starter.storage.strategy.impl; -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; +import top.continew.starter.storage.strategy.StorageStrategy; +import top.continew.starter.storage.strategy.StorageStrategyOverride; /** - * 本地存储自动配置 + * 抽象基类,提供原始目标对象的访问 * * @author echo - * @since 2.9.0 + * @since 2.14.0 */ -@AutoConfiguration -public class LocalStorageAutoConfiguration { +public abstract class AbstractStorageStrategyOverride implements StorageStrategyOverride { + + protected T originalTarget; /** - * 存储记录持久层默认实现 + * 设置原始目标对象(由代理工厂调用) */ - @Bean - @ConditionalOnMissingBean - public StorageDao storageDao() { - return new StorageDaoDefaultImpl(); + public void setOriginalTarget(T originalTarget) { + this.originalTarget = originalTarget; } -} + + @Override + public T getOriginalTarget() { + return originalTarget; + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/impl/LocalStorageStrategy.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/impl/LocalStorageStrategy.java new file mode 100644 index 00000000..d50bb5a0 --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/impl/LocalStorageStrategy.java @@ -0,0 +1,434 @@ +/* + * 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.impl; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.URLUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.multipart.MultipartFile; +import top.continew.starter.core.constant.StringConstants; +import top.continew.starter.core.util.SpringWebUtils; +import top.continew.starter.storage.autoconfigure.properties.LocalStorageConfig; +import top.continew.starter.storage.exception.StorageException; +import top.continew.starter.storage.model.resp.FileInfo; +import top.continew.starter.storage.model.resp.MultipartInitResp; +import top.continew.starter.storage.model.resp.MultipartUploadResp; +import top.continew.starter.storage.strategy.StorageStrategy; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 本地存储策略 + * + * @author echo + * @since 2.14.0 + */ +public class LocalStorageStrategy implements StorageStrategy { + + private static final Logger log = LoggerFactory.getLogger(LocalStorageStrategy.class); + + private final LocalStorageConfig config; + + // 分片上传临时目录 + private final String TEMP_DIR = ".multipart"; + + public LocalStorageStrategy(LocalStorageConfig config) { + this.config = config; + initTempDir(config.getBucketName()); + registerResources(config); + } + + /** + * 注册资源 + * + * @param config 配置 + */ + public void registerResources(LocalStorageConfig config) { + // 注册资源映射 + SpringWebUtils.registerResourceHandler(MapUtil.of(URLUtil.url(config.getEndpoint()).getPath(), config + .getBucketName())); + } + + /** + * 初始化临时目录 + */ + private void initTempDir(String bucket) { + Path tempPath = Paths.get(bucket, TEMP_DIR); + try { + Files.createDirectories(tempPath); + } catch (IOException e) { + log.error("创建临时目录失败", e); + } + } + + @Override + public void upload(String bucket, String path, MultipartFile file) { + Path filePath = Paths.get(bucket, path); + try { + // 创建目录 + Files.createDirectories(filePath.getParent()); + + // 复制文件 + Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + } catch (Exception e) { + throw new StorageException(e.getMessage(), e); + } + } + + /** + * 下载文件 + */ + @Override + public InputStream download(String bucket, String path) { + Path filePath = Paths.get(bucket, path); + try { + return Files.newInputStream(filePath); + } catch (IOException e) { + throw new StorageException("本地读取失败: " + e.getMessage(), e); + } + } + + @Override + public InputStream batchDownload(String bucket, List paths) { + return null; + } + + /** + * 删除文件 + */ + @Override + public void delete(String bucket, String path) { + Path filePath = Paths.get(bucket, path); + try { + Files.deleteIfExists(filePath); + // 尝试删除空目录 + deleteEmptyParentDirectories(filePath.getParent()); + + } catch (IOException e) { + throw new StorageException("删除失败: " + e.getMessage(), e); + } + } + + @Override + public void batchDelete(String bucket, List paths) { + + } + + /** + * 检查文件是否存在 + */ + @Override + public boolean exists(String bucket, String path) { + Path filePath = Paths.get(bucket, path); + return Files.exists(filePath); + } + + /** + * 获取文件信息 + */ + @Override + public FileInfo getFileInfo(String bucket, String path) { + Path filePath = Paths.get(bucket, path); + + if (!Files.exists(filePath)) { + return null; + } + + try { + BasicFileAttributes attrs = Files.readAttributes(filePath, BasicFileAttributes.class); + + FileInfo fileInfo = new FileInfo(); + fileInfo.setBucket(bucket); + fileInfo.setPlatform(config.getPlatform()); + fileInfo.setPath(path); + fileInfo.setFullPath(filePath.toString()); + fileInfo.setName(filePath.getFileName().toString()); + fileInfo.setSize(attrs.size()); + fileInfo.setUrl(config.getEndpoint() + StringConstants.SLASH + path); + fileInfo.setUploadTime(LocalDateTime.ofInstant(attrs.creationTime().toInstant(), java.time.ZoneId + .systemDefault())); + + // 添加元数据 + Map metadata = new HashMap<>(); + metadata.put("etag", calculateMD5(filePath)); + fileInfo.setMetadata(metadata); + + return fileInfo; + + } catch (Exception e) { + throw new StorageException("获取文件信息失败: " + e.getMessage(), e); + } + } + + /** + * 列出文件 + */ + @Override + public List list(String bucket, String prefix, int maxKeys) { + Path basePath = Paths.get(bucket); + Path searchPath = prefix != null ? basePath.resolve(prefix) : basePath; + + try (Stream stream = Files.walk(searchPath)) { + return stream.filter(Files::isRegularFile).limit(maxKeys).map(path -> { + String relativePath = basePath.relativize(path).toString(); + return getFileInfo(bucket, relativePath); + }).filter(Objects::nonNull).collect(Collectors.toList()); + + } catch (IOException e) { + throw new StorageException("列出文件失败: " + e.getMessage(), e); + } + } + + /** + * 复制文件 + */ + @Override + public void copy(String sourceBucket, String targetBucket, String sourcePath, String targetPath) { + Path source = Paths.get(sourceBucket, sourcePath); + Path target = Paths.get(targetBucket, targetPath); + + try { + Files.createDirectories(target.getParent()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new StorageException("复制文件失败: " + e.getMessage(), e); + } + } + + @Override + public void move(String sourceBucket, String targetBucket, String sourcePath, String targetPath) { + + } + + @Override + public String getPlatform() { + return config.getPlatform(); + } + + @Override + public String defaultBucket() { + return config.getBucketName(); + } + + /** + * 生成预签名URL(本地存储返回普通URL) + */ + @Override + public String generatePresignedUrl(String bucket, String path, long expireSeconds) { + // 本地存储不支持预签名URL,返回普通URL + return config.getEndpoint() + StringConstants.SLASH + path; + } + + /** + * 删除空的父目录 + */ + private void deleteEmptyParentDirectories(Path dir) { + if (dir == null || !dir.startsWith(config.getBucketName())) { + return; + } + + try { + if (Files.isDirectory(dir) && isDirectoryEmpty(dir)) { + Files.delete(dir); + deleteEmptyParentDirectories(dir.getParent()); + } + } catch (IOException e) { + // 忽略删除目录失败的错误 + } + } + + /** + * 检查目录是否为空 + */ + private boolean isDirectoryEmpty(Path dir) throws IOException { + try (DirectoryStream stream = Files.newDirectoryStream(dir)) { + return !stream.iterator().hasNext(); + } + } + + @Override + public MultipartInitResp initMultipartUpload(String bucket, + String path, + String contentType, + Map metadata) { + try { + String uploadId = UUID.randomUUID().toString(); + + // 创建临时目录 + Path tempUploadPath = Paths.get(bucket, TEMP_DIR, uploadId); + Files.createDirectories(tempUploadPath); + + // 构建结果 + MultipartInitResp result = new MultipartInitResp(); + result.setBucket(bucket); + result.setFileId(UUID.randomUUID().toString()); + result.setUploadId(uploadId); + result.setPlatform(config.getPlatform()); + result.setPath(path); + result.setPartSize(5 * 1024 * 1024L); + return result; + } catch (Exception e) { + throw new StorageException("初始化分片上传失败: " + e.getMessage(), e); + } + } + + @Override + public MultipartUploadResp uploadPart(String bucket, + String path, + String uploadId, + int partNumber, + InputStream data) { + MultipartUploadResp result = new MultipartUploadResp(); + result.setPartNumber(partNumber); + + try { + // 分片文件路径 + Path partPath = Paths.get(bucket, TEMP_DIR, uploadId, String.format("part_%05d", partNumber)); + + // 保存分片 + long size = Files.copy(data, partPath, StandardCopyOption.REPLACE_EXISTING); + + // 计算MD5作为ETag + String eTag = calculateMD5(partPath); + + result.setPartETag(eTag); + result.setPartSize(size); + result.setSuccess(true); + return result; + } catch (Exception e) { + result.setSuccess(false); + result.setErrorMessage(e.getMessage()); + return result; + } + } + + @Override + public FileInfo completeMultipartUpload(String bucket, + String path, + String uploadId, + List parts, + boolean verifyParts) { + try { + // 本地存储不需要验证,直接使用传入的分片信息 + Path targetPath = Paths.get(bucket, path); + Files.createDirectories(targetPath.getParent()); + + // 合并分片 + try (OutputStream out = Files + .newOutputStream(targetPath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + + // 按分片编号排序 + List sortedParts = parts.stream() + .filter(MultipartUploadResp::isSuccess) + .sorted(Comparator.comparingInt(MultipartUploadResp::getPartNumber)) + .toList(); + + // 逐个读取并写入 + for (MultipartUploadResp part : sortedParts) { + Path partPath = Paths.get(bucket, TEMP_DIR, uploadId, String.format("part_%05d", part + .getPartNumber())); + + if (!Files.exists(partPath)) { + throw new StorageException("分片文件不存在: part " + part.getPartNumber()); + } + + Files.copy(partPath, out); + } + } + + // 清理临时文件 + cleanupTempFiles(bucket, uploadId); + + // 获取文件信息 + return getFileInfo(bucket, path); + + } catch (Exception e) { + throw new StorageException("完成分片上传失败: " + e.getMessage(), e); + } + } + + /** + * 取消分片上传 + */ + @Override + public void abortMultipartUpload(String bucket, String path, String uploadId) { + try { + // 清理临时文件 + cleanupTempFiles(bucket, uploadId); + } catch (Exception e) { + log.error("取消分片上传失败: uploadId={}", uploadId, e); + } + } + + @Override + public List listParts(String bucket, String path, String uploadId) { + // 本地存储不再维护分片信息,返回空列表 + // 实际分片信息应该从 FileRecorder 获取 + return new ArrayList<>(); + } + + /** + * 清理临时文件 + */ + private void cleanupTempFiles(String bucket, String uploadId) { + Path tempUploadPath = Paths.get(bucket, TEMP_DIR, uploadId); + if (Files.exists(tempUploadPath)) { + try (Stream paths = Files.walk(tempUploadPath)) { + paths.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete); + } catch (IOException e) { + log.error("清理临时文件失败: uploadId={}", uploadId, e); + } + } + } + + /** + * 计算文件MD5 + */ + private String calculateMD5(Path path) throws Exception { + MessageDigest md = MessageDigest.getInstance("MD5"); + try (InputStream is = Files.newInputStream(path)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = is.read(buffer)) > 0) { + md.update(buffer, 0, read); + } + } + byte[] digest = md.digest(); + return HexFormat.of().formatHex(digest).toLowerCase(); + } + + @Override + public void cleanup() { + // 清理静态资源映射 + if (config != null) { + SpringWebUtils.deRegisterResourceHandler(MapUtil.of(URLUtil.url(config.getEndpoint()).getPath(), config + .getBucketName())); + } + } + +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/impl/OssStorageStrategy.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/impl/OssStorageStrategy.java new file mode 100644 index 00000000..e1676bcf --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/strategy/impl/OssStorageStrategy.java @@ -0,0 +1,619 @@ +/* + * 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.impl; + +import cn.hutool.core.util.StrUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import top.continew.starter.core.constant.StringConstants; +import top.continew.starter.storage.autoconfigure.properties.S3StorageConfig; +import top.continew.starter.storage.exception.StorageException; +import top.continew.starter.storage.model.resp.FileInfo; +import top.continew.starter.storage.model.resp.MultipartInitResp; +import top.continew.starter.storage.model.resp.MultipartUploadResp; +import top.continew.starter.storage.strategy.StorageStrategy; +import top.continew.starter.storage.util.StorageUtils; + +import java.io.InputStream; +import java.net.URI; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * oss存储策略 + * + * @author echo + * @since 2.14.0 + */ +public class OssStorageStrategy implements StorageStrategy { + + private static final Logger log = LoggerFactory.getLogger(OssStorageStrategy.class); + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + private final S3StorageConfig config; + + public OssStorageStrategy(S3StorageConfig config) { + this.config = config; + this.s3Client = createS3Client(config); + this.s3Presigner = createS3Presigner(config); + } + + /** + * 获取客户端 + * + * @return {@link S3Client } + */ + public S3Client getClient() { + return s3Client; + } + + /** + * 获取预签名者 + * + * @return {@link S3Presigner } + */ + public S3Presigner getPresigner() { + return s3Presigner; + } + + /** + * 创建S3客户端 + * + * @param config 配置 + * @return {@link S3Client } + */ + private S3Client createS3Client(S3StorageConfig config) { + // 登录认证账户密码 + StaticCredentialsProvider auth = StaticCredentialsProvider.create(AwsBasicCredentials.create(config + .getAccessKey(), config.getSecretKey())); + + return S3Client.builder() + .credentialsProvider(auth) + .endpointOverride(URI.create(config.getEndpoint())) + .region(StorageUtils.getRegion(config.getRegion())) + .build(); + } + + /** + * 创建S3预签名器 + * + * @param config 配置 + * @return {@link S3Presigner } + */ + private S3Presigner createS3Presigner(S3StorageConfig config) { + StaticCredentialsProvider auth = StaticCredentialsProvider.create(AwsBasicCredentials.create(config + .getAccessKey(), config.getSecretKey())); + + String domain = StrUtil.isNotBlank(config.getDomain()) ? config.getDomain() : config.getEndpoint(); + + return S3Presigner.builder() + .credentialsProvider(auth) + .endpointOverride(URI.create(domain)) + .region(StorageUtils.getRegion(config.getRegion())) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(config.isPathStyleAccessEnabled()) + .build()) + .build(); + } + + @Override + public void upload(String bucket, String path, MultipartFile file) { + // 构建上传请求 + PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder() + .bucket(bucket) + .key(path) + .contentType(file.getContentType()) + .contentLength(file.getSize()); + + try { + // 执行上传 + s3Client.putObject(requestBuilder.build(), RequestBody.fromInputStream(file.getInputStream(), file + .getSize())); + + } catch (Exception e) { + throw new StorageException("S3上传异常" + e.getMessage(), e); + } + } + + /** + * 下载文件 + */ + @Override + public InputStream download(String bucket, String path) { + try { + return s3Client.getObject(GetObjectRequest.builder().bucket(bucket).key(path).build()); + } catch (Exception e) { + throw new StorageException("S3下载失败: " + e.getMessage(), e); + } + } + + @Override + public InputStream batchDownload(String bucket, List paths) { + return null; + } + + /** + * 删除文件 + */ + @Override + public void delete(String bucket, String path) { + try { + s3Client.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(path).build()); + } catch (Exception e) { + throw new StorageException("S3删除失败: " + e.getMessage(), e); + } + } + + /** + * 批量删除(优化版) + */ + @Override + public void batchDelete(String bucket, List paths) { + if (paths.isEmpty()) { + return; + } + + try { + // S3支持批量删除,最多1000个 + List> batches = partition(paths, 1000); + + for (List batch : batches) { + List objects = batch.stream() + .map(path -> ObjectIdentifier.builder().key(path).build()) + .collect(Collectors.toList()); + + DeleteObjectsRequest deleteRequest = DeleteObjectsRequest.builder() + .bucket(bucket) + .delete(Delete.builder().objects(objects).build()) + .build(); + + s3Client.deleteObjects(deleteRequest); + } + } catch (Exception e) { + throw new StorageException("S3批量删除失败: " + e.getMessage(), e); + } + } + + /** + * 检查文件是否存在 + */ + @Override + public boolean exists(String bucket, String path) { + try { + s3Client.headObject(HeadObjectRequest.builder().bucket(bucket).key(path).build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } catch (Exception e) { + throw new StorageException("S3检查文件存在性失败: " + e.getMessage(), e); + } + } + + /** + * 获取文件信息 + */ + @Override + public FileInfo getFileInfo(String bucket, String path) { + try { + String bucketName = bucket; + HeadObjectResponse response = s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(path) + .build()); + + FileInfo fileInfo = new FileInfo(); + fileInfo.setBucket(bucketName); + fileInfo.setPlatform(config.getPlatform()); + fileInfo.setPath(path); + fileInfo.setFullPath(path); + fileInfo.setName(getFileName(path)); + fileInfo.setSize(response.contentLength()); + fileInfo.setContentType(response.contentType()); + fileInfo.setUrl(getFileUrl(path)); + fileInfo.setUploadTime(LocalDateTime.ofInstant(response.lastModified(), java.time.ZoneId.systemDefault())); + + // 添加元数据 + Map metadata = new HashMap<>(response.metadata()); + metadata.put("etag", response.eTag()); + if (response.versionId() != null) { + metadata.put("versionId", response.versionId()); + } + fileInfo.setMetadata(metadata); + + return fileInfo; + + } catch (NoSuchKeyException e) { + return null; + } catch (Exception e) { + throw new StorageException("S3获取文件信息失败: " + e.getMessage(), e); + } + } + + /** + * 列出文件 + */ + @Override + public List list(String bucket, String prefix, int maxKeys) { + try { + String bucketName = bucket; + ListObjectsV2Request request = ListObjectsV2Request.builder() + .bucket(bucketName) + .prefix(prefix) + .maxKeys(maxKeys) + .build(); + + ListObjectsV2Response response = s3Client.listObjectsV2(request); + + return response.contents().stream().map(s3Object -> { + FileInfo fileInfo = new FileInfo(); + fileInfo.setBucket(bucketName); + fileInfo.setPlatform(config.getPlatform()); + fileInfo.setPath(s3Object.key()); + fileInfo.setFullPath(s3Object.key()); + fileInfo.setName(getFileName(s3Object.key())); + fileInfo.setSize(s3Object.size()); + fileInfo.setUrl(getFileUrl(s3Object.key())); + fileInfo.setUploadTime(LocalDateTime.ofInstant(s3Object.lastModified(), java.time.ZoneId + .systemDefault())); + + Map metadata = new HashMap<>(); + metadata.put("etag", s3Object.eTag()); + metadata.put("storageClass", s3Object.storageClassAsString()); + fileInfo.setMetadata(metadata); + + return fileInfo; + }).collect(Collectors.toList()); + + } catch (Exception e) { + throw new StorageException("S3列出文件失败: " + e.getMessage(), e); + } + } + + /** + * 复制文件 + */ + @Override + public void copy(String sourceBucket, String targetBucket, String sourcePath, String targetPath) { + try { + CopyObjectRequest copyRequest = CopyObjectRequest.builder() + .sourceBucket(sourceBucket) + .sourceKey(sourcePath) + .destinationBucket(targetBucket) + .destinationKey(targetPath) + .build(); + + s3Client.copyObject(copyRequest); + + } catch (Exception e) { + throw new StorageException("S3复制文件失败: " + e.getMessage(), e); + } + } + + @Override + public void move(String sourceBucket, String targetBucket, String sourcePath, String targetPath) { + + } + + @Override + public String getPlatform() { + return config.getPlatform(); + } + + @Override + public String defaultBucket() { + return config.getBucketName(); + } + + /** + * 生成预签名URL + */ + @Override + public String generatePresignedUrl(String bucket, String path, long expireSeconds) { + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder().bucket(bucket).key(path).build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(expireSeconds)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + + return presignedRequest.url().toString(); + + } catch (Exception e) { + throw new StorageException("S3生成预签名URL失败: " + e.getMessage(), e); + } + } + + /** + * 获取文件URL + */ + private String getFileUrl(String path) { + if (config.getEndpoint() != null && !config.getEndpoint().isEmpty()) { + return config.getEndpoint() + StringConstants.SLASH + path; + } else { + return String.format("%s/%s/%s", config.getEndpoint(), config.getBucketName(), path); + } + } + + /** + * 获取文件名 + */ + private String getFileName(String path) { + int lastSlashIndex = path.lastIndexOf(StringConstants.SLASH); + return lastSlashIndex >= 0 ? path.substring(lastSlashIndex + 1) : path; + } + + /** + * 分割列表 + */ + private List> partition(List list, int size) { + List> partitions = new ArrayList<>(); + for (int i = 0; i < list.size(); i += size) { + partitions.add(list.subList(i, Math.min(i + size, list.size()))); + } + return partitions; + } + + /** + * 初始化分片上传 + */ + @Override + public MultipartInitResp initMultipartUpload(String bucket, + String path, + String contentType, + Map metadata) { + try { + // 构建请求 + CreateMultipartUploadRequest.Builder requestBuilder = CreateMultipartUploadRequest.builder() + .bucket(bucket) + .key(path) + .contentType(contentType); + + // 添加元数据 + if (metadata != null && !metadata.isEmpty()) { + requestBuilder.metadata(metadata); + } + + // 设置ACL + if (config.getDefaultAcl() != null) { + requestBuilder.acl(config.getDefaultAcl()); + } + + // 执行请求 + CreateMultipartUploadResponse response = s3Client.createMultipartUpload(requestBuilder.build()); + + // 构建返回结果 + MultipartInitResp result = new MultipartInitResp(); + result.setBucket(bucket); + result.setFileId(UUID.randomUUID().toString()); + result.setUploadId(response.uploadId()); + result.setPlatform(config.getPlatform()); + result.setPath(path); + result.setPartSize(config.getMultipartUploadPartSize()); + return result; + + } catch (Exception e) { + throw new StorageException("S3初始化分片上传失败: " + e.getMessage(), e); + } + } + + /** + * 上传分片 + */ + @Override + public MultipartUploadResp uploadPart(String bucket, + String path, + String uploadId, + int partNumber, + InputStream data) { + try { + + if (path == null) { + throw new StorageException("无效的uploadId: " + uploadId); + } + + // 读取数据到内存(注意:实际使用时可能需要优化大文件处理) + byte[] bytes = data.readAllBytes(); + + // 构建请求 + UploadPartRequest request = UploadPartRequest.builder() + .bucket(bucket) + .key(path) + .uploadId(uploadId) + .partNumber(partNumber) + .contentLength((long)bytes.length) + .build(); + + // 执行上传 + UploadPartResponse response = s3Client.uploadPart(request, RequestBody.fromBytes(bytes)); + + // 构建返回结果 + MultipartUploadResp result = new MultipartUploadResp(); + result.setPartNumber(partNumber); + result.setPartETag(response.eTag()); + result.setSuccess(true); + return result; + + } catch (Exception e) { + MultipartUploadResp result = new MultipartUploadResp(); + result.setPartNumber(partNumber); + result.setSuccess(false); + result.setErrorMessage(e.getMessage()); + return result; + } + } + + /** + * 完成分片上传 + */ + @Override + public FileInfo completeMultipartUpload(String bucket, + String path, + String uploadId, + List parts, + boolean verifyParts) { + try { + if (path == null) { + throw new StorageException("无效的uploadId: " + uploadId); + } + + // 如果需要验证,比较本地记录和S3的分片信息 + if (verifyParts) { + List s3Parts = listParts(bucket, path, uploadId); + validateParts(parts, s3Parts); + } + + // 构建已完成的分片列表 + List completedParts = parts.stream() + .filter(MultipartUploadResp::isSuccess) + .map(part -> CompletedPart.builder().partNumber(part.getPartNumber()).eTag(part.getPartETag()).build()) + .sorted(Comparator.comparingInt(CompletedPart::partNumber)) + .collect(Collectors.toList()); + + // 构建请求 + CompleteMultipartUploadRequest request = CompleteMultipartUploadRequest.builder() + .bucket(bucket) + .key(path) + .uploadId(uploadId) + .multipartUpload(CompletedMultipartUpload.builder().parts(completedParts).build()) + .build(); + + // 完成上传 + s3Client.completeMultipartUpload(request); + + // 获取文件信息 + return getFileInfo(bucket, path); + + } catch (Exception e) { + throw new StorageException("S3完成分片上传失败: " + e.getMessage(), e); + } + } + + /** + * 取消分片上传 + */ + @Override + public void abortMultipartUpload(String bucket, String path, String uploadId) { + try { + if (path == null) { + log.warn("无效的uploadId,可能已经完成或取消: {}", uploadId); + return; + } + + // 构建请求 + AbortMultipartUploadRequest request = AbortMultipartUploadRequest.builder() + .bucket(bucket) + .key(path) + .uploadId(uploadId) + .build(); + + // 执行取消 + s3Client.abortMultipartUpload(request); + } catch (Exception e) { + log.error("取消分片上传失败: uploadId={}", uploadId, e); + throw new StorageException("S3取消分片上传失败: " + e.getMessage(), e); + } + } + + /** + * 列出已上传的分片 + */ + @Override + public List listParts(String bucket, String path, String uploadId) { + try { + if (path == null) { + throw new StorageException("无效的uploadId: " + uploadId); + } + + // 构建请求 + ListPartsRequest request = ListPartsRequest.builder().bucket(bucket).key(path).uploadId(uploadId).build(); + + // 获取分片列表 + ListPartsResponse response = s3Client.listParts(request); + + // 转换结果 + return response.parts().stream().map(part -> { + MultipartUploadResp result = new MultipartUploadResp(); + result.setPartNumber(part.partNumber()); + result.setPartETag(part.eTag()); + result.setSuccess(true); + return result; + }).collect(Collectors.toList()); + + } catch (Exception e) { + throw new StorageException("S3列出分片失败: " + e.getMessage(), e); + } + } + + /** + * 验证分片一致性 + * + * @param recordParts 记录部件 + * @param s3Parts s3零件 + */ + private void validateParts(List recordParts, List s3Parts) { + Map recordMap = recordParts.stream() + .collect(Collectors.toMap(MultipartUploadResp::getPartNumber, MultipartUploadResp::getPartETag)); + + Map s3Map = s3Parts.stream() + .collect(Collectors.toMap(MultipartUploadResp::getPartNumber, MultipartUploadResp::getPartETag)); + + // 检查分片数量 + if (recordMap.size() != s3Map.size()) { + throw new StorageException(String.format("分片数量不一致: 本地记录=%d, S3=%d", recordMap.size(), s3Map.size())); + } + + // 检查每个分片 + List missingParts = new ArrayList<>(); + List mismatchParts = new ArrayList<>(); + + for (Map.Entry entry : recordMap.entrySet()) { + Integer partNumber = entry.getKey(); + String recordETag = entry.getValue(); + String s3ETag = s3Map.get(partNumber); + + if (s3ETag == null) { + missingParts.add(partNumber); + } else if (!recordETag.equals(s3ETag)) { + mismatchParts.add(partNumber); + } + } + + if (!missingParts.isEmpty()) { + throw new StorageException("S3缺失分片: " + missingParts); + } + + if (!mismatchParts.isEmpty()) { + throw new StorageException("分片ETag不匹配: " + mismatchParts); + } + } +} \ No newline at end of file diff --git a/continew-starter-storage/src/main/java/top/continew/starter/storage/util/StorageUtils.java b/continew-starter-storage/src/main/java/top/continew/starter/storage/util/StorageUtils.java new file mode 100644 index 00000000..ba02f5ed --- /dev/null +++ b/continew-starter-storage/src/main/java/top/continew/starter/storage/util/StorageUtils.java @@ -0,0 +1,79 @@ +/* + * 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.date.DateUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; +import software.amazon.awssdk.regions.Region; + +import java.time.LocalDate; + +/** + * 存储工具 + * + * @author echo + * @since 2.14.0 + */ +public class StorageUtils { + + /** + * 获取区域 + */ + public static Region getRegion(String region) { + return StrUtil.isEmpty(region) ? Region.US_EAST_1 : Region.of(region); + } + + /** + * 生成默认路径:年/月/日/ + */ + public static String generatePath() { + LocalDate date = LocalDate.now(); + return String.format("%d/%d/%d/", date.getYear(), date.getMonthValue(), date.getDayOfMonth()); + } + + /** + * 生成文件名:时间戳.扩展名 + */ + public static String generateFileName(String originalFilename) { + return generateFileName(originalFilename, null, false); + } + + /** + * 生成文件名:前缀_时间戳.扩展名 + */ + public static String generateFileName(String originalFilename, String prefix) { + return generateFileName(originalFilename, prefix, false); + } + + /** + * 生成文件名 + * + * @param originalFilename 原始文件名 + * @param prefix 前缀(可为null) + * @param useMillis 是否使用毫秒时间戳,false则使用格式化时间戳 + */ + public static String generateFileName(String originalFilename, String prefix, boolean useMillis) { + String extension = FileUtil.getSuffix(originalFilename); + String timestamp = useMillis + ? String.valueOf(System.currentTimeMillis()) + : DateUtil.format(DateUtil.date(), "yyyyMMddHHmmssSSS"); + + String fileName = StrUtil.isNotBlank(prefix) ? prefix + "_" + timestamp : timestamp; + return fileName + (StrUtil.isNotBlank(extension) ? "." + extension : ""); + } +} diff --git a/continew-starter-storage/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/continew-starter-storage/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..d88d912f --- /dev/null +++ b/continew-starter-storage/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +top.continew.starter.storage.autoconfigure.StorageAutoConfiguration \ No newline at end of file