diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0c8926 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# IDEA artifacts and output dirs +*.iml +*.ipr +*.iws +.idea +out +test-output +atlassian-ide-plugin.xml +.gradletasknamecache +classes/ +target/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* diff --git a/README b/README deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fdd5466 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +> 特别说明:源码、JDK、数据库、Redis等安装或存放路径禁止包含中文、空格、特殊字符等 + +## 一 项目结构 + +```text +yunzhupaas-file-core-starter + ├── aspect- 切面层 + ├── exception- 自定义异常 + ├── platform - 存储平台实现层 + └── recorder- 记录器 +``` + +## 二 环境要求 + +| 类目 | 版本或建议 | +|-------|---------| +| 硬件 | 开发电脑建议使用I3及以上CPU,16G及以上内存 | +| 操作系统 | Windows 10/11,MacOS | +| JDK | 默认使用JDK 21,兼容JDK 8/11、JDK17(需调整部分代码),推荐使用 `OpenJDK`,如 `Liberica JDK`、`Eclipse Temurin`、`Alibaba Dragonwell`、`BiSheng` 等发行版; | +| Maven | 依赖管理工具,推荐使用 `3.6.3` 及以上版本 | +| IDE | 代码集成开发环境,推荐使用 `IDEA2024` 及以上版本,兼容 `Eclipse`、 `Spring Tool Suite` 等IDE工具 | + +## 三 关联项目 +> 为以下项目提供基础依赖 + +| 项目 | 分支 | 说明 | +| --- | --- | --- | +| yunzhupaas-common | v5.2.x-stable | Java基础依赖项目源码 | +| yunzhupaas-java-boot | v5.2.x-stable | Java单体后端项目源码 | +| yunzhupaas-java-cloud | v5.2.x-stable | Java微服务后端项目源码 | + +## 四 使用方式 + +### 4.1 前置条件 + +#### 4.1.1 本地安装yunzhupaas-common-core + +IDEA中打开 `yunzhupaas-common` 项目, 双击右侧 `Maven` 中 `yunzhupaas-common` > `yunzhupaas-boot-common` > `yunzhupaas-common-core` > `Lifecycle` > `install`,将 `yunzhupaas-common-core` 包安装至本地 + +#### 4.1.2 本地安装dependencies + +IDEA中打开 `yunzhupaas-common` 项目,双击右侧 `Maven` 中 `yunzhupaas-common` > `yunzhupaas-dependencies` > `Lifecycle` > `install`,将 `yunzhupaas-dependencies` 包安装至本地 + +### 4.2 本地安装 + +在IDEA中,双击右侧 `Maven` 中`yunzhupaas-file-core-starter` > `Lifecycle` > `install`,将`yunzhupaas-file-core-starter`包安装至本地 + +### 4.3 私服发布 +> 若无Maven私服,忽略本节内容 + +#### 4.3.1 配置Maven + +打开Maven安装目录中的 `conf/setttings.xml` , + +在 ``节点增加 `` ,如下所示: + +```xml + + + maven-releases + admin(账号,结合私服配置设置) + 123456(密码,结合私服配置设置) + +``` +#### 4.3.2 配置项目 + +> 注意:pom.xml里 `` 和 setting.xml 配置里 `` 对应。 + +IDEA打开 `yunzhupaas-common` 项目, 修改 `yunzhupaas-dependencies/pom.xml` 文件中私服配置 + +```xml + + + maven-releases + maven-releases + http://nexus.yunzhupaas.com/repository/maven-releases/ + + +``` + +#### 4.3.3 发布到私服 + +在IDEA中,双击右侧 `Maven` 中 `yunzhupaas-file-core-starter` > `Lifecycle` > `deploy` 发布至私服。 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..0d9a3b2 --- /dev/null +++ b/pom.xml @@ -0,0 +1,172 @@ + + + + + yunzhupaas-dependencies + com.yunzhupaas + 5.2.0-RELEASE + + + 4.0.0 + + com.yunzhupaas + + yunzhupaas-file-core-starter + 5.2.0-RELEASE + + + + + + + + + com.github.lookfirst + sardine + provided + true + + + + + com.jcraft + jsch + provided + true + + + + + commons-net + commons-net + provided + true + + + + + cn.hutool + hutool-extra + ${hutool.version} + provided + true + + + + + com.amazonaws + aws-java-sdk-s3 + provided + true + + + + + + io.minio + minio + provided + true + + + + + com.upyun + java-sdk + provided + true + + + + + com.baidubce + bce-java-sdk + provided + true + + + org.springframework + spring-core + + + jdk.tools + jdk.tools + + + + + + + com.qcloud + cos_api + provided + true + + + + + com.qiniu + qiniu-java-sdk + provided + true + + + + + com.aliyun.oss + aliyun-sdk-oss + provided + true + + + + + com.huaweicloud + esdk-obs-java + provided + true + + + + + cn.hutool + hutool-core + ${hutool.version} + + + + + net.coobird + thumbnailator + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-web + provided + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + com.yunzhupaas + yunzhupaas-common-core + ${project.version} + provided + + + + + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/Downloader.java b/src/main/java/cn/xuyanwu/spring/file/storage/Downloader.java new file mode 100644 index 0000000..39e15b5 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/Downloader.java @@ -0,0 +1,139 @@ +package cn.xuyanwu.spring.file.storage; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.IoUtil; +import cn.xuyanwu.spring.file.storage.aspect.DownloadAspectChain; +import cn.xuyanwu.spring.file.storage.aspect.DownloadThAspectChain; +import cn.xuyanwu.spring.file.storage.aspect.FileStorageAspect; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; + +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * 下载器 + */ +public class Downloader { + /** + * 下载目标:文件 + */ + public static final int TARGET_FILE = 1; + /** + * 下载目标:缩略图文件 + */ + public static final int TARGET_TH_FILE = 2; + + private final FileStorage fileStorage; + private final List aspectList; + private final FileInfo fileInfo; + private final Integer target; + private ProgressListener progressListener; + + /** + * 构造下载器 + * + * @param target 下载目标:{@link Downloader#TARGET_FILE}下载文件,{@link Downloader#TARGET_TH_FILE}下载缩略图文件 + */ + public Downloader(FileInfo fileInfo,List aspectList,FileStorage fileStorage,Integer target) { + this.fileStorage = fileStorage; + this.aspectList = aspectList; + this.fileInfo = fileInfo; + this.target = target; + } + + /** + * 设置下载进度监听器 + * @param progressListener 提供一个参数,表示已传输字节数 + */ + public Downloader setProgressMonitor(Consumer progressListener) { + return setProgressMonitor((progressSize,allSize) -> progressListener.accept(progressSize)); + } + + /** + * 设置下载进度监听器 + * @param progressListener 提供两个参数,第一个是 progressSize已传输字节数,第二个是 allSize总字节数 + */ + public Downloader setProgressMonitor(BiConsumer progressListener) { + return setProgressMonitor(new ProgressListener() { + @Override + public void start() { + } + + @Override + public void progress(long progressSize,long allSize) { + progressListener.accept(progressSize,allSize); + } + + @Override + public void finish() { + } + }); + } + + /** + * 设置下载进度监听器 + */ + public Downloader setProgressMonitor(ProgressListener progressListener) { + this.progressListener = progressListener; + return this; + } + + /** + * 获取 InputStream ,在此方法结束后会自动关闭 InputStream + */ + public void inputStream(Consumer consumer) { + if (target == TARGET_FILE) { //下载文件 + new DownloadAspectChain(aspectList,(_fileInfo,_fileStorage,_consumer) -> + _fileStorage.download(_fileInfo,_consumer) + ).next(fileInfo,fileStorage,in -> + consumer.accept(progressListener == null ? in : new ProgressInputStream(in,progressListener,fileInfo.getSize())) + ); + } else if (target == TARGET_TH_FILE) { //下载缩略图文件 + new DownloadThAspectChain(aspectList,(_fileInfo,_fileStorage,_consumer) -> + _fileStorage.downloadTh(_fileInfo,_consumer) + ).next(fileInfo,fileStorage,in -> + consumer.accept(progressListener == null ? in : new ProgressInputStream(in,progressListener,fileInfo.getThSize())) + ); + } else { + throw new FileStorageRuntimeException("没找到对应的下载目标,请设置 target 参数!"); + } + } + + /** + * 下载 byte 数组 + */ + public byte[] bytes() { + byte[][] bytes = new byte[1][]; + inputStream(in -> bytes[0] = IoUtil.readBytes(in)); + return bytes[0]; + } + + /** + * 下载到指定文件 + */ + public void file(File file) { + inputStream(in -> FileUtil.writeFromStream(in,file)); + } + + /** + * 下载到指定文件 + */ + public void file(String filename) { + inputStream(in -> FileUtil.writeFromStream(in,filename)); + } + + /** + * 下载到指定输出流 + */ + public void outputStream(OutputStream out) { + inputStream(in -> IoUtil.copy(in,out)); + } + + +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/EnableFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/EnableFileStorage.java new file mode 100644 index 0000000..9a3b255 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/EnableFileStorage.java @@ -0,0 +1,16 @@ +package cn.xuyanwu.spring.file.storage; + +import org.springframework.context.annotation.Import; + +import java.lang.annotation.*; + +/** + * 启用文件存储,会自动根据配置文件进行加载 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +@Documented +@Import({FileStorageAutoConfiguration.class,FileStorageProperties.class}) +public @interface EnableFileStorage { +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/FileInfo.java b/src/main/java/cn/xuyanwu/spring/file/storage/FileInfo.java new file mode 100644 index 0000000..d1b2352 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/FileInfo.java @@ -0,0 +1,105 @@ +package cn.xuyanwu.spring.file.storage; + + +import cn.hutool.core.lang.Dict; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +public class FileInfo implements Serializable { + + /** + * 文件id + */ + private String id; + + /** + * 文件访问地址 + */ + private String url; + + /** + * 文件大小,单位字节 + */ + private Long size; + + /** + * 文件名称 + */ + private String filename; + + /** + * 原始文件名 + */ + private String originalFilename; + + /** + * 基础存储路径 + */ + private String basePath; + + /** + * 存储路径 + */ + private String path; + + /** + * 文件扩展名 + */ + private String ext; + + /** + * MIME 类型 + */ + private String contentType; + + /** + * 存储平台 + */ + private String platform; + + /** + * 缩略图访问路径 + */ + private String thUrl; + + /** + * 缩略图名称 + */ + private String thFilename; + + /** + * 缩略图大小,单位字节 + */ + private Long thSize; + + /** + * 缩略图 MIME 类型 + */ + private String thContentType; + + /** + * 文件所属对象id + */ + private String objectId; + + /** + * 文件所属对象类型,例如用户头像,评价图片 + */ + private String objectType; + + /** + * 附加属性字典 + */ + private Dict attr; + + /** + * 创建时间 + */ + private Date createTime; + + private static final long serialVersionUID = 1L; +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/FileStorageAutoConfiguration.java b/src/main/java/cn/xuyanwu/spring/file/storage/FileStorageAutoConfiguration.java new file mode 100644 index 0000000..bfb3506 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/FileStorageAutoConfiguration.java @@ -0,0 +1,413 @@ +package cn.xuyanwu.spring.file.storage; + +import cn.hutool.core.collection.CollUtil; +import cn.xuyanwu.spring.file.storage.aspect.FileStorageAspect; +import cn.xuyanwu.spring.file.storage.platform.*; +import cn.xuyanwu.spring.file.storage.recorder.DefaultFileRecorder; +import cn.xuyanwu.spring.file.storage.recorder.FileRecorder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +@Slf4j +@Configuration +@ConditionalOnMissingBean(FileStorageService.class) +public class FileStorageAutoConfiguration implements WebMvcConfigurer { + + @Autowired + private FileStorageProperties properties; + @Autowired + private ApplicationContext applicationContext; + + + /** + * 配置本地存储的访问地址 + */ + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + for (FileStorageProperties.Local local : properties.getLocal()) { + if (local.getEnableAccess()) { + registry.addResourceHandler(local.getPathPatterns()).addResourceLocations("file:" + local.getBasePath()); + } + } + for (FileStorageProperties.LocalPlus local : properties.getLocalPlus()) { + if (local.getEnableAccess()) { + registry.addResourceHandler(local.getPathPatterns()).addResourceLocations("file:" + local.getStoragePath()); + } + } + } + + /** + * 本地存储 Bean + */ + @Bean + public List localFileStorageList() { + return properties.getLocal().stream().map(local -> { + if (!local.getEnableStorage()) return null; + log.info("加载存储平台:{}",local.getPlatform()); + LocalFileStorage localFileStorage = new LocalFileStorage(); + localFileStorage.setPlatform(local.getPlatform()); + localFileStorage.setBasePath(local.getBasePath()); + localFileStorage.setDomain(local.getDomain()); + return localFileStorage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * 本地存储升级版 Bean + */ + @Bean + public List localPlusFileStorageList() { + return properties.getLocalPlus().stream().map(local -> { + if (!local.getEnableStorage()) return null; + log.info("加载存储平台:{}",local.getPlatform()); + LocalPlusFileStorage localFileStorage = new LocalPlusFileStorage(); + localFileStorage.setPlatform(local.getPlatform()); + localFileStorage.setBasePath(local.getBasePath()); + localFileStorage.setDomain(local.getDomain()); + localFileStorage.setStoragePath(local.getStoragePath()); + return localFileStorage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * 华为云 OBS 存储 Bean + */ + @Bean + @ConditionalOnClass(name = "com.obs.services.ObsClient") + public List huaweiObsFileStorageList() { + return properties.getHuaweiObs().stream().map(obs -> { + if (!obs.getEnableStorage()) return null; + log.info("加载存储平台:{}",obs.getPlatform()); + HuaweiObsFileStorage storage = new HuaweiObsFileStorage(); + storage.setPlatform(obs.getPlatform()); + storage.setAccessKey(obs.getAccessKey()); + storage.setSecretKey(obs.getSecretKey()); + storage.setEndPoint(obs.getEndPoint()); + storage.setBucketName(obs.getBucketName()); + storage.setDomain(obs.getDomain()); + storage.setBasePath(obs.getBasePath()); + return storage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * 阿里云 OSS 存储 Bean + */ + @Bean + @ConditionalOnClass(name = "com.aliyun.oss.OSS") + public List aliyunOssFileStorageList() { + return properties.getAliyunOss().stream().map(oss -> { + if (!oss.getEnableStorage()) return null; + log.info("加载存储平台:{}",oss.getPlatform()); + AliyunOssFileStorage storage = new AliyunOssFileStorage(); + storage.setPlatform(oss.getPlatform()); + storage.setAccessKey(oss.getAccessKey()); + storage.setSecretKey(oss.getSecretKey()); + storage.setEndPoint(oss.getEndPoint()); + storage.setBucketName(oss.getBucketName()); + storage.setDomain(oss.getDomain()); + storage.setBasePath(oss.getBasePath()); + return storage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * 七牛云 Kodo 存储 Bean + */ + @Bean + @ConditionalOnClass(name = "com.qiniu.storage.UploadManager") + public List qiniuKodoFileStorageList() { + return properties.getQiniuKodo().stream().map(kodo -> { + if (!kodo.getEnableStorage()) return null; + log.info("加载存储平台:{}",kodo.getPlatform()); + QiniuKodoFileStorage storage = new QiniuKodoFileStorage(); + storage.setPlatform(kodo.getPlatform()); + storage.setAccessKey(kodo.getAccessKey()); + storage.setSecretKey(kodo.getSecretKey()); + storage.setBucketName(kodo.getBucketName()); + storage.setDomain(kodo.getDomain()); + storage.setBasePath(kodo.getBasePath()); + return storage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * 腾讯云 COS 存储 Bean + */ + @Bean + @ConditionalOnClass(name = "com.qcloud.cos.COSClient") + public List tencentCosFileStorageList() { + return properties.getTencentCos().stream().map(cos -> { + if (!cos.getEnableStorage()) return null; + log.info("加载存储平台:{}",cos.getPlatform()); + TencentCosFileStorage storage = new TencentCosFileStorage(); + storage.setPlatform(cos.getPlatform()); + storage.setSecretId(cos.getSecretId()); + storage.setSecretKey(cos.getSecretKey()); + storage.setRegion(cos.getRegion()); + storage.setBucketName(cos.getBucketName()); + storage.setDomain(cos.getDomain()); + storage.setBasePath(cos.getBasePath()); + return storage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * 百度云 BOS 存储 Bean + */ + @Bean + @ConditionalOnClass(name = "com.baidubce.services.bos.BosClient") + public List baiduBosFileStorageList() { + return properties.getBaiduBos().stream().map(bos -> { + if (!bos.getEnableStorage()) return null; + log.info("加载存储平台:{}",bos.getPlatform()); + BaiduBosFileStorage storage = new BaiduBosFileStorage(); + storage.setPlatform(bos.getPlatform()); + storage.setAccessKey(bos.getAccessKey()); + storage.setSecretKey(bos.getSecretKey()); + storage.setEndPoint(bos.getEndPoint()); + storage.setBucketName(bos.getBucketName()); + storage.setDomain(bos.getDomain()); + storage.setBasePath(bos.getBasePath()); + return storage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * 又拍云 USS 存储 Bean + */ + @Bean + @ConditionalOnClass(name = "com.upyun.RestManager") + public List upyunUssFileStorageList() { + return properties.getUpyunUSS().stream().map(uss -> { + if (!uss.getEnableStorage()) return null; + log.info("加载存储平台:{}",uss.getPlatform()); + UpyunUssFileStorage storage = new UpyunUssFileStorage(); + storage.setPlatform(uss.getPlatform()); + storage.setUsername(uss.getUsername()); + storage.setPassword(uss.getPassword()); + storage.setBucketName(uss.getBucketName()); + storage.setDomain(uss.getDomain()); + storage.setBasePath(uss.getBasePath()); + return storage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * MinIO 存储 Bean + */ + @Bean + @ConditionalOnClass(name = "io.minio.MinioClient") + public List minioFileStorageList() { + return properties.getMinio().stream().map(minio -> { + if (!minio.getEnableStorage()) return null; + log.info("加载存储平台:{}",minio.getPlatform()); + MinIOFileStorage storage = new MinIOFileStorage(); + storage.setPlatform(minio.getPlatform()); + storage.setAccessKey(minio.getAccessKey()); + storage.setSecretKey(minio.getSecretKey()); + storage.setEndPoint(minio.getEndPoint()); + storage.setBucketName(minio.getBucketName()); + storage.setDomain(minio.getDomain()); + storage.setBasePath(minio.getBasePath()); + return storage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * AWS 存储 Bean + */ + @Bean + @ConditionalOnClass(name = "com.amazonaws.services.s3.AmazonS3") + public List amazonS3FileStorageList() { + return properties.getAwsS3().stream().map(s3 -> { + if (!s3.getEnableStorage()) return null; + log.info("加载存储平台:{}",s3.getPlatform()); + AwsS3FileStorage storage = new AwsS3FileStorage(); + storage.setPlatform(s3.getPlatform()); + storage.setAccessKey(s3.getAccessKey()); + storage.setSecretKey(s3.getSecretKey()); + storage.setRegion(s3.getRegion()); + storage.setEndPoint(s3.getEndPoint()); + storage.setBucketName(s3.getBucketName()); + storage.setDomain(s3.getDomain()); + storage.setBasePath(s3.getBasePath()); + return storage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * FTP 存储 Bean + */ + @Bean + @ConditionalOnClass(name = {"org.apache.commons.net.ftp.FTPClient","cn.hutool.extra.ftp.Ftp"}) + public List ftpFileStorageList() { + return properties.getFtp().stream().map(ftp -> { + if (!ftp.getEnableStorage()) return null; + log.info("加载存储平台:{}",ftp.getPlatform()); + FtpFileStorage storage = new FtpFileStorage(); + storage.setPlatform(ftp.getPlatform()); + storage.setHost(ftp.getHost()); + storage.setPort(ftp.getPort()); + storage.setUser(ftp.getUser()); + storage.setPassword(ftp.getPassword()); + storage.setCharset(ftp.getCharset()); + storage.setConnectionTimeout(ftp.getConnectionTimeout()); + storage.setSoTimeout(ftp.getSoTimeout()); + storage.setServerLanguageCode(ftp.getServerLanguageCode()); + storage.setSystemKey(ftp.getSystemKey()); + storage.setIsActive(ftp.getIsActive()); + storage.setDomain(ftp.getDomain()); + storage.setBasePath(ftp.getBasePath()); + storage.setStoragePath(ftp.getStoragePath()); + return storage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * SFTP 存储 Bean + */ + @Bean + @ConditionalOnClass(name = {"com.jcraft.jsch.ChannelSftp","cn.hutool.extra.ftp.Ftp"}) + public List sftpFileStorageList() { + return properties.getSftp().stream().map(sftp -> { + if (!sftp.getEnableStorage()) return null; + log.info("加载存储平台:{}",sftp.getPlatform()); + SftpFileStorage storage = new SftpFileStorage(); + storage.setPlatform(sftp.getPlatform()); + storage.setHost(sftp.getHost()); + storage.setPort(sftp.getPort()); + storage.setUser(sftp.getUser()); + storage.setPassword(sftp.getPassword()); + storage.setPrivateKeyPath(sftp.getPrivateKeyPath()); + storage.setCharset(sftp.getCharset()); + storage.setConnectionTimeout(sftp.getConnectionTimeout()); + storage.setDomain(sftp.getDomain()); + storage.setBasePath(sftp.getBasePath()); + storage.setStoragePath(sftp.getStoragePath()); + return storage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * WebDAV 存储 Bean + */ + @Bean + @ConditionalOnClass(name = "com.github.sardine.Sardine") + public List webDavFileStorageList() { + return properties.getWebDav().stream().map(sftp -> { + if (!sftp.getEnableStorage()) return null; + log.info("加载存储平台:{}",sftp.getPlatform()); + WebDavFileStorage storage = new WebDavFileStorage(); + storage.setPlatform(sftp.getPlatform()); + storage.setServer(sftp.getServer()); + storage.setUser(sftp.getUser()); + storage.setPassword(sftp.getPassword()); + storage.setDomain(sftp.getDomain()); + storage.setBasePath(sftp.getBasePath()); + storage.setStoragePath(sftp.getStoragePath()); + return storage; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * 当没有找到 FileRecorder 时使用默认的 FileRecorder + */ + @Bean + @ConditionalOnMissingBean(FileRecorder.class) + public FileRecorder fileRecorder() { + log.warn("没有找到 FileRecorder 的实现类,文件上传之外的部分功能无法正常使用,必须实现该接口才能使用完整功能!"); + return new DefaultFileRecorder(); + } + + /** + * 文件存储服务 + */ + @Bean + public FileStorageService fileStorageService(FileRecorder fileRecorder, + List> fileStorageLists, + List aspectList) { + this.initDetect(); + FileStorageService service = new FileStorageService(); + service.setFileStorageList(new CopyOnWriteArrayList<>()); + fileStorageLists.forEach(service.getFileStorageList()::addAll); + service.setFileRecorder(fileRecorder); + service.setProperties(properties); + service.setAspectList(new CopyOnWriteArrayList<>(aspectList)); + return service; + } + + /** + * 对 FileStorageService 注入自己的代理对象,不然会导致针对 FileStorageService 的代理方法不生效 + */ + @EventListener(ContextRefreshedEvent.class) + public void onContextRefreshedEvent() { + FileStorageService service = applicationContext.getBean(FileStorageService.class); + service.setSelf(service); + } + + public void initDetect() { + String template = "检测到{}配置,但是没有找到对应的依赖库,所以无法加载此存储平台!配置参考地址:https://spring-file-storage.xuyanwu.cn/#/%E5%BF%AB%E9%80%9F%E5%85%A5%E9%97%A8"; + if (CollUtil.isNotEmpty(properties.getHuaweiObs()) && doesNotExistClass("com.obs.services.ObsClient")) { + log.warn(template,"华为云 OBS "); + } + if (CollUtil.isNotEmpty(properties.getAliyunOss()) && doesNotExistClass("com.aliyun.oss.OSS")) { + log.warn(template,"阿里云 OSS "); + } + if (CollUtil.isNotEmpty(properties.getQiniuKodo()) && doesNotExistClass("com.qiniu.storage.UploadManager")) { + log.warn(template,"七牛云 Kodo "); + } + if (CollUtil.isNotEmpty(properties.getTencentCos()) && doesNotExistClass("com.qcloud.cos.COSClient")) { + log.warn(template,"腾讯云 COS "); + } + if (CollUtil.isNotEmpty(properties.getBaiduBos()) && doesNotExistClass("com.baidubce.services.bos.BosClient")) { + log.warn(template,"百度云 BOS "); + } + if (CollUtil.isNotEmpty(properties.getUpyunUSS()) && doesNotExistClass("com.upyun.RestManager")) { + log.warn(template,"又拍云 USS "); + } + if (CollUtil.isNotEmpty(properties.getMinio()) && doesNotExistClass("io.minio.MinioClient")) { + log.warn(template," MinIO "); + } + if (CollUtil.isNotEmpty(properties.getAwsS3()) && doesNotExistClass("com.amazonaws.services.s3.AmazonS3")) { + log.warn(template," AmazonS3 "); + } + if (CollUtil.isNotEmpty(properties.getFtp()) && (doesNotExistClass("org.apache.commons.net.ftp.FTPClient") || doesNotExistClass("cn.hutool.extra.ftp.Ftp"))) { + log.warn(template," FTP "); + } + if (CollUtil.isNotEmpty(properties.getFtp()) && (doesNotExistClass("com.jcraft.jsch.ChannelSftp") || doesNotExistClass("cn.hutool.extra.ftp.Ftp"))) { + log.warn(template," SFTP "); + } + if (CollUtil.isNotEmpty(properties.getAwsS3()) && doesNotExistClass("com.github.sardine.Sardine")) { + log.warn(template," WebDAV "); + } + } + + /** + * 判断是否没有引入指定 Class + */ + public static boolean doesNotExistClass(String name) { + try { + Class.forName(name); + return false; + } catch (ClassNotFoundException e) { + return true; + } + } + +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/FileStorageProperties.java b/src/main/java/cn/xuyanwu/spring/file/storage/FileStorageProperties.java new file mode 100644 index 0000000..ea8fe12 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/FileStorageProperties.java @@ -0,0 +1,527 @@ +package cn.xuyanwu.spring.file.storage; + +import lombok.Data; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +@Data +@Component +@ConditionalOnMissingBean(FileStorageProperties.class) +@ConfigurationProperties(prefix = "config.file-storage") +public class FileStorageProperties { + + /** + * 默认存储平台 + */ + private String defaultPlatform = "local"; + /** + * 缩略图后缀,例如【.min.jpg】【.png】 + */ + private String thumbnailSuffix = ".min.jpg"; + /** + * 本地存储 + */ + private List local = new ArrayList<>(); + /** + * 本地存储 + */ + private List localPlus = new ArrayList<>(); + /** + * 华为云 OBS + */ + private List huaweiObs = new ArrayList<>(); + /** + * 阿里云 OSS + */ + private List aliyunOss = new ArrayList<>(); + /** + * 七牛云 Kodo + */ + private List qiniuKodo = new ArrayList<>(); + /** + * 腾讯云 COS + */ + private List tencentCos = new ArrayList<>(); + /** + * 百度云 BOS + */ + private List baiduBos = new ArrayList<>(); + /** + * 又拍云 USS + */ + private List upyunUSS = new ArrayList<>(); + /** + * MinIO USS + */ + private List minio = new ArrayList<>(); + + /** + * AWS S3 + */ + private List awsS3 = new ArrayList<>(); + + /** + * FTP + */ + private List ftp = new ArrayList<>(); + + /** + * FTP + */ + private List sftp = new ArrayList<>(); + + /** + * WebDAV + */ + private List WebDav = new ArrayList<>(); + + /** + * 本地存储 + */ + @Data + public static class Local { + /** + * 本地存储路径 + */ + private String basePath = ""; + /** + * 本地存储访问路径 + */ + private String[] pathPatterns = new String[0]; + /** + * 启用本地存储 + */ + private Boolean enableStorage = false; + /** + * 启用本地访问 + */ + private Boolean enableAccess = false; + /** + * 存储平台 + */ + private String platform = "local"; + /** + * 访问域名 + */ + private String domain = ""; + } + + /** + * 本地存储升级版 + */ + @Data + public static class LocalPlus { + /** + * 基础路径 + */ + private String basePath = ""; + /** + * 存储路径,上传的文件都会存储在这个路径下面,默认“/”,注意“/”结尾 + */ + private String storagePath = "/"; + /** + * 本地存储访问路径 + */ + private String[] pathPatterns = new String[0]; + /** + * 启用本地存储 + */ + private Boolean enableStorage = false; + /** + * 启用本地访问 + */ + private Boolean enableAccess = false; + /** + * 存储平台 + */ + private String platform = "local"; + /** + * 访问域名 + */ + private String domain = ""; + } + + /** + * 华为云 OBS + */ + @Data + public static class HuaweiObs { + private String accessKey; + private String secretKey; + private String endPoint; + private String bucketName; + /** + * 访问域名 + */ + private String domain = ""; + /** + * 启用存储 + */ + private Boolean enableStorage = false; + /** + * 存储平台 + */ + private String platform = ""; + /** + * 基础路径 + */ + private String basePath = ""; + } + + /** + * 阿里云 OSS + */ + @Data + public static class AliyunOss { + private String accessKey; + private String secretKey; + private String endPoint; + private String bucketName; + /** + * 访问域名 + */ + private String domain = ""; + /** + * 启用存储 + */ + private Boolean enableStorage = false; + /** + * 存储平台 + */ + private String platform = ""; + /** + * 基础路径 + */ + private String basePath = ""; + } + + /** + * 七牛云 Kodo + */ + @Data + public static class QiniuKodo { + private String accessKey; + private String secretKey; + private String bucketName; + /** + * 访问域名 + */ + private String domain = ""; + /** + * 启用存储 + */ + private Boolean enableStorage = false; + /** + * 存储平台 + */ + private String platform = ""; + /** + * 基础路径 + */ + private String basePath = ""; + } + + /** + * 腾讯云 COS + */ + @Data + public static class TencentCos { + private String secretId; + private String secretKey; + private String region; + private String bucketName; + /** + * 访问域名 + */ + private String domain = ""; + /** + * 启用存储 + */ + private Boolean enableStorage = false; + /** + * 存储平台 + */ + private String platform = ""; + /** + * 基础路径 + */ + private String basePath = ""; + } + + /** + * 百度云 BOS + */ + @Data + public static class BaiduBos { + private String accessKey; + private String secretKey; + private String endPoint; + private String bucketName; + /** + * 访问域名 + */ + private String domain = ""; + /** + * 启用存储 + */ + private Boolean enableStorage = false; + /** + * 存储平台 + */ + private String platform = ""; + /** + * 基础路径 + */ + private String basePath = ""; + } + + /** + * 又拍云 USS + */ + @Data + public static class UpyunUSS { + private String username; + private String password; + private String bucketName; + /** + * 访问域名 + */ + private String domain = ""; + /** + * 启用存储 + */ + private Boolean enableStorage = false; + /** + * 存储平台 + */ + private String platform = ""; + /** + * 基础路径 + */ + private String basePath = ""; + } + + /** + * MinIO + */ + @Data + public static class MinIO { + private String accessKey; + private String secretKey; + private String endPoint; + private String bucketName; + /** + * 访问域名 + */ + private String domain = ""; + /** + * 启用存储 + */ + private Boolean enableStorage = false; + /** + * 存储平台 + */ + private String platform = ""; + /** + * 基础路径 + */ + private String basePath = ""; + } + + /** + * AWS S3 + */ + @Data + public static class AwsS3 { + private String accessKey; + private String secretKey; + private String region; + private String endPoint; + private String bucketName; + /** + * 访问域名 + */ + private String domain = ""; + /** + * 启用存储 + */ + private Boolean enableStorage = false; + /** + * 存储平台 + */ + private String platform = ""; + /** + * 基础路径 + */ + private String basePath = ""; + } + + /** + * FTP + */ + @Data + public static class FTP { + /** + * 主机 + */ + private String host; + /** + * 端口,默认21 + */ + private int port = 21; + /** + * 用户名,默认 anonymous(匿名) + */ + private String user = "anonymous"; + /** + * 密码,默认空 + */ + private String password = ""; + /** + * 编码,默认UTF-8 + */ + private Charset charset = StandardCharsets.UTF_8; + /** + * 连接超时时长,单位毫秒,默认10秒 {@link org.apache.commons.net.SocketClient#setConnectTimeout(int)} + */ + private long connectionTimeout = 10 * 1000; + /** + * Socket连接超时时长,单位毫秒,默认10秒 {@link org.apache.commons.net.SocketClient#setSoTimeout(int)} + */ + private long soTimeout = 10 * 1000; + /** + * 设置服务器语言,默认空,{@link org.apache.commons.net.ftp.FTPClientConfig#setServerLanguageCode(String)} + */ + private String serverLanguageCode; + /** + * 服务器标识,默认空,{@link org.apache.commons.net.ftp.FTPClientConfig#FTPClientConfig(String)} + * 例如:org.apache.commons.net.ftp.FTPClientConfig.SYST_NT + */ + private String systemKey; + /** + * 是否主动模式,默认被动模式 + */ + private Boolean isActive = false; + /** + * 访问域名 + */ + private String domain = ""; + /** + * 启用存储 + */ + private Boolean enableStorage = false; + /** + * 存储平台 + */ + private String platform = ""; + /** + * 基础路径 + */ + private String basePath = ""; + /** + * 存储路径,上传的文件都会存储在这个路径下面,默认“/”,注意“/”结尾 + */ + private String storagePath = "/"; + } + + /** + * SFTP + */ + @Data + public static class SFTP { + /** + * 主机 + */ + private String host; + /** + * 端口,默认22 + */ + private int port = 22; + /** + * 用户名 + */ + private String user; + /** + * 密码 + */ + private String password; + /** + * 私钥路径 + */ + private String privateKeyPath; + /** + * 编码,默认UTF-8 + */ + private Charset charset = StandardCharsets.UTF_8; + /** + * 连接超时时长,单位毫秒,默认10秒 + */ + private long connectionTimeout = 10 * 1000; + /** + * 访问域名 + */ + private String domain = ""; + /** + * 启用存储 + */ + private Boolean enableStorage = false; + /** + * 存储平台 + */ + private String platform = ""; + /** + * 基础路径 + */ + private String basePath = ""; + /** + * 存储路径,上传的文件都会存储在这个路径下面,默认“/”,注意“/”结尾 + */ + private String storagePath = "/"; + } + + /** + * WebDAV + */ + @Data + public static class WebDAV { + /** + * 服务器地址,注意“/”结尾,例如:http://192.168.1.105:8405/ + */ + private String server; + /** + * 用户名 + */ + private String user; + /** + * 密码 + */ + private String password; + /** + * 访问域名 + */ + private String domain = ""; + /** + * 启用存储 + */ + private Boolean enableStorage = false; + /** + * 存储平台 + */ + private String platform = ""; + /** + * 基础路径 + */ + private String basePath = ""; + /** + * 存储路径,上传的文件都会存储在这个路径下面,默认“/”,注意“/”结尾 + */ + private String storagePath = "/"; + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/FileStorageService.java b/src/main/java/cn/xuyanwu/spring/file/storage/FileStorageService.java new file mode 100644 index 0000000..ba74bd0 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/FileStorageService.java @@ -0,0 +1,349 @@ +package cn.xuyanwu.spring.file.storage; + +import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ReUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.URLUtil; +import cn.xuyanwu.spring.file.storage.aspect.DeleteAspectChain; +import cn.xuyanwu.spring.file.storage.aspect.ExistsAspectChain; +import cn.xuyanwu.spring.file.storage.aspect.FileStorageAspect; +import cn.xuyanwu.spring.file.storage.aspect.UploadAspectChain; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; +import cn.xuyanwu.spring.file.storage.recorder.FileRecorder; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Date; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Predicate; + + +/** + * 用来处理文件存储,对接多个平台 + */ +@Slf4j +@Getter +@Setter +public class FileStorageService implements DisposableBean { + + private FileStorageService self; + private FileRecorder fileRecorder; + private CopyOnWriteArrayList fileStorageList; + private FileStorageProperties properties; + private CopyOnWriteArrayList aspectList; + + + /** + * 获取默认的存储平台 + */ + public FileStorage getFileStorage() { + return getFileStorage(properties.getDefaultPlatform()); + } + + /** + * 获取对应的存储平台 + */ + public FileStorage getFileStorage(String platform) { + for (FileStorage fileStorage : fileStorageList) { + if (fileStorage.getPlatform().equals(platform)) { + return fileStorage; + } + } + return null; + } + + /** + * 获取对应的存储平台,如果存储平台不存在则抛出异常 + */ + public FileStorage getFileStorageVerify(FileInfo fileInfo) { + FileStorage fileStorage = getFileStorage(fileInfo.getPlatform()); + if (fileStorage == null) throw new FileStorageRuntimeException("没有找到对应的存储平台!"); + return fileStorage; + } + + /** + * 上传文件,成功返回文件信息,失败返回 null + */ + public FileInfo upload(UploadPretreatment pre) { + MultipartFile file = pre.getFileWrapper(); + if (file == null) throw new FileStorageRuntimeException("文件不允许为 null !"); + if (pre.getPlatform() == null) throw new FileStorageRuntimeException("platform 不允许为 null !"); + + FileInfo fileInfo = new FileInfo(); + fileInfo.setCreateTime(new Date()); + fileInfo.setSize(file.getSize()); + fileInfo.setOriginalFilename(file.getOriginalFilename()); + fileInfo.setExt(FileNameUtil.getSuffix(file.getOriginalFilename())); + fileInfo.setObjectId(pre.getObjectId()); + fileInfo.setObjectType(pre.getObjectType()); + fileInfo.setPath(pre.getPath()); + fileInfo.setPlatform(pre.getPlatform()); + fileInfo.setAttr(pre.getAttr()); + if (StrUtil.isNotBlank(pre.getSaveFilename())) { + fileInfo.setFilename(pre.getSaveFilename()); + } else { + fileInfo.setFilename(IdUtil.objectId() + (StrUtil.isEmpty(fileInfo.getExt()) ? StrUtil.EMPTY : "." + fileInfo.getExt())); + } + if (pre.getContentType() != null) { + fileInfo.setContentType(pre.getContentType()); + } else if (pre.getFileWrapper().getContentType() != null) { + fileInfo.setContentType(pre.getFileWrapper().getContentType()); + } else { + String contentType = URLConnection.guessContentTypeFromName(fileInfo.getFilename()); + fileInfo.setContentType(contentType != null ? contentType : "application/octet-stream"); + } + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { + fileInfo.setThSize((long) thumbnailBytes.length); + if (StrUtil.isNotBlank(pre.getSaveThFilename())) { + fileInfo.setThFilename(pre.getSaveThFilename() + pre.getThumbnailSuffix()); + } else { + fileInfo.setThFilename(fileInfo.getFilename() + pre.getThumbnailSuffix()); + } + String contentType = URLConnection.guessContentTypeFromName(fileInfo.getThFilename()); + fileInfo.setThContentType(contentType != null ? contentType : "application/octet-stream"); + } + + FileStorage fileStorage = getFileStorage(pre.getPlatform()); + if (fileStorage == null) throw new FileStorageRuntimeException("没有找到对应的存储平台!"); + + //处理切面 + return new UploadAspectChain(aspectList,(_fileInfo,_pre,_fileStorage,_fileRecorder) -> { + //真正开始保存 + if (_fileStorage.save(_fileInfo,_pre)) { + if (_fileRecorder.record(_fileInfo)) { + return _fileInfo; + } + } + return null; + }).next(fileInfo,pre,fileStorage,fileRecorder); + } + + /** + * 根据 url 获取 FileInfo + */ + public FileInfo getFileInfoByUrl(String url) { + return fileRecorder.getByUrl(url); + } + + /** + * 根据 url 删除文件 + */ + public boolean delete(String url) { + return delete(getFileInfoByUrl(url)); + } + + /** + * 根据 url 删除文件 + */ + public boolean delete(String url,Predicate predicate) { + return delete(getFileInfoByUrl(url),predicate); + } + + /** + * 根据条件 + */ + public boolean delete(FileInfo fileInfo) { + return delete(fileInfo,null); + } + + /** + * 根据条件删除文件 + */ + public boolean delete(FileInfo fileInfo,Predicate predicate) { + if (fileInfo == null) return true; + if (predicate != null && !predicate.test(fileInfo)) return false; + FileStorage fileStorage = getFileStorage(fileInfo.getPlatform()); + if (fileStorage == null) throw new FileStorageRuntimeException("没有找到对应的存储平台!"); + + return new DeleteAspectChain(aspectList,(_fileInfo,_fileStorage,_fileRecorder) -> { + if (_fileStorage.delete(_fileInfo)) { //删除文件 + return _fileRecorder.delete(_fileInfo.getUrl()); //删除文件记录 + } + return false; + }).next(fileInfo,fileStorage,fileRecorder); + } + + /** + * 文件是否存在 + */ + public boolean exists(String url) { + return exists(getFileInfoByUrl(url)); + } + + /** + * 文件是否存在 + */ + public boolean exists(FileInfo fileInfo) { + if (fileInfo == null) return false; + return new ExistsAspectChain(aspectList,(_fileInfo,_fileStorage) -> + _fileStorage.exists(_fileInfo) + ).next(fileInfo,getFileStorageVerify(fileInfo)); + } + + + /** + * 获取文件下载器 + */ + public Downloader download(FileInfo fileInfo) { + return new Downloader(fileInfo,aspectList,getFileStorageVerify(fileInfo),Downloader.TARGET_FILE); + } + + /** + * 获取文件下载器 + */ + public Downloader download(String url) { + return download(getFileInfoByUrl(url)); + } + + /** + * 获取缩略图文件下载器 + */ + public Downloader downloadTh(FileInfo fileInfo) { + return new Downloader(fileInfo,aspectList,getFileStorageVerify(fileInfo),Downloader.TARGET_TH_FILE); + } + + /** + * 获取缩略图文件下载器 + */ + public Downloader downloadTh(String url) { + return downloadTh(getFileInfoByUrl(url)); + } + + + /** + * 创建上传预处理器 + */ + public UploadPretreatment of() { + UploadPretreatment pre = new UploadPretreatment(); + pre.setFileStorageService(self); + pre.setPlatform(properties.getDefaultPlatform()); + pre.setThumbnailSuffix(properties.getThumbnailSuffix()); + return pre; + } + + /** + * 根据 MultipartFile 创建上传预处理器 + */ + public UploadPretreatment of(MultipartFile file) { + UploadPretreatment pre = of(); + pre.setFileWrapper(new MultipartFileWrapper(file)); + return pre; + } + + /** + * 根据 byte[] 创建上传预处理器,name 为空字符串 + */ + public UploadPretreatment of(byte[] bytes) { + UploadPretreatment pre = of(); + pre.setFileWrapper(new MultipartFileWrapper(new MockMultipartFile("",bytes))); + return pre; + } + + /** + * 根据 InputStream 创建上传预处理器,originalFilename 为空字符串 + */ + public UploadPretreatment of(InputStream in) { + try { + UploadPretreatment pre = of(); + pre.setFileWrapper(new MultipartFileWrapper(new MockMultipartFile("",in))); + return pre; + } catch (Exception e) { + throw new FileStorageRuntimeException("根据 InputStream 创建上传预处理器失败!",e); + } + } + + /** + * 根据 File 创建上传预处理器,originalFilename 为 file 的 name + */ + public UploadPretreatment of(File file) { + try { + UploadPretreatment pre = of(); + pre.setFileWrapper(new MultipartFileWrapper(new MockMultipartFile(file.getName(),file.getName(),URLConnection.guessContentTypeFromName(file.getName()),Files.newInputStream(file.toPath())))); + return pre; + } catch (Exception e) { + throw new FileStorageRuntimeException("根据 File 创建上传预处理器失败!",e); + } + } + + /** + * 根据 URL 创建上传预处理器,originalFilename 将尝试自动识别,识别不到则为空字符串 + */ + public UploadPretreatment of(URL url) { + try { + UploadPretreatment pre = of(); + + URLConnection conn = url.openConnection(); + + //尝试获取文件名 + String name = ""; + String disposition = conn.getHeaderField("Content-Disposition"); + if (StrUtil.isNotBlank(disposition)) { + name = ReUtil.get("filename=\"(.*?)\"",disposition,1); + if (StrUtil.isBlank(name)) { + name = StrUtil.subAfter(disposition,"filename=",true); + } + } + if (StrUtil.isBlank(name)) { + final String path = url.getPath(); + name = StrUtil.subSuf(path,path.lastIndexOf('/') + 1); + if (StrUtil.isNotBlank(name)) { + name = URLUtil.decode(name,StandardCharsets.UTF_8); + } + } + + pre.setFileWrapper(new MultipartFileWrapper(new MockMultipartFile(url.toString(),name,conn.getContentType(),conn.getInputStream()))); + return pre; + } catch (Exception e) { + throw new FileStorageRuntimeException("根据 URL 创建上传预处理器失败!",e); + } + } + + /** + * 根据 URI 创建上传预处理器,originalFilename 将尝试自动识别,识别不到则为空字符串 + */ + public UploadPretreatment of(URI uri) { + try { + return of(uri.toURL()); + } catch (Exception e) { + throw new FileStorageRuntimeException("根据 URI 创建上传预处理器失败!",e); + } + } + + /** + * 根据 url 字符串创建上传预处理器,兼容Spring的ClassPath路径、文件路径、HTTP路径等,originalFilename 将尝试自动识别,识别不到则为空字符串 + */ + public UploadPretreatment of(String url) { + try { + return of(URLUtil.url(url)); + } catch (Exception e) { + throw new FileStorageRuntimeException("根据 url:" + url + " 创建上传预处理器失败!",e); + } + } + + @Override + public void destroy() { + for (FileStorage fileStorage : fileStorageList) { + try { + fileStorage.close(); + log.error("销毁存储平台 {} 成功",fileStorage.getPlatform()); + } catch (Exception e) { + log.error("销毁存储平台 {} 失败,{}",fileStorage.getPlatform(),e.getMessage(),e); + } + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/MockMultipartFile.java b/src/main/java/cn/xuyanwu/spring/file/storage/MockMultipartFile.java new file mode 100644 index 0000000..7af15ff --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/MockMultipartFile.java @@ -0,0 +1,81 @@ +package cn.xuyanwu.spring.file.storage; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.IoUtil; +import lombok.Getter; +import org.springframework.lang.Nullable; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; + +/** + * 一个模拟 MultipartFile 的类 + */ +@Getter +public class MockMultipartFile implements MultipartFile { + + /** + * 文件名 + */ + private final String name; + + /** + * 原始文件名 + */ + private final String originalFilename; + + /** + * 内容类型 + */ + @Nullable + private final String contentType; + + /** + * 文件内容 + */ + private final byte[] bytes; + + + public MockMultipartFile(String name,InputStream in) { + this(name,"",null,IoUtil.readBytes(in)); + } + + public MockMultipartFile(String name,@Nullable byte[] bytes) { + this(name,"",null,bytes); + } + + public MockMultipartFile(String name,@Nullable String originalFilename,@Nullable String contentType,InputStream in) { + this(name,originalFilename,contentType,IoUtil.readBytes(in)); + } + + public MockMultipartFile(@Nullable String name,@Nullable String originalFilename,@Nullable String contentType,@Nullable byte[] bytes) { + this.name = (name != null ? name : ""); + this.originalFilename = (originalFilename != null ? originalFilename : ""); + this.contentType = contentType; + this.bytes = (bytes != null ? bytes : new byte[0]); + } + + + @Override + public boolean isEmpty() { + return (this.bytes.length == 0); + } + + @Override + public long getSize() { + return this.bytes.length; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(this.bytes); + } + + @Override + public void transferTo(File dest) { + FileUtil.writeBytes(bytes,dest); + } + +} diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/MultipartFileWrapper.java b/src/main/java/cn/xuyanwu/spring/file/storage/MultipartFileWrapper.java new file mode 100644 index 0000000..0bb54ca --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/MultipartFileWrapper.java @@ -0,0 +1,84 @@ +package cn.xuyanwu.spring.file.storage; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.core.io.Resource; +import org.springframework.lang.Nullable; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; + +/** + * MultipartFile 的包装类 + */ +public class MultipartFileWrapper implements MultipartFile { + + @Setter + private String name; + @Setter + private String originalFilename; + @Setter + private String contentType; + @Setter + @Getter + private MultipartFile multipartFile; + + public MultipartFileWrapper(MultipartFile multipartFile) { + this.multipartFile = multipartFile; + } + + @Override + public String getName() { + return name != null ? name : multipartFile.getName(); + } + + @Override + public String getOriginalFilename() { + return originalFilename != null ? originalFilename : multipartFile.getOriginalFilename(); + } + + @Override + @Nullable + public String getContentType() { + return contentType != null ? contentType : multipartFile.getContentType(); + } + + @Override + public boolean isEmpty() { + return multipartFile.isEmpty(); + } + + @Override + public long getSize() { + return multipartFile.getSize(); + } + + @Override + public byte[] getBytes() throws IOException { + return multipartFile.getBytes(); + } + + @Override + public InputStream getInputStream() throws IOException { + return multipartFile.getInputStream(); + } + + + @Override + public Resource getResource() { + return multipartFile.getResource(); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + multipartFile.transferTo(dest); + } + + @Override + public void transferTo(Path dest) throws IOException, IllegalStateException { + multipartFile.transferTo(dest); + } +} diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/PathUtil.java b/src/main/java/cn/xuyanwu/spring/file/storage/PathUtil.java new file mode 100644 index 0000000..058592b --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/PathUtil.java @@ -0,0 +1,38 @@ +package cn.xuyanwu.spring.file.storage; + +public class PathUtil { + + + /** + * 获取父路径 + */ + public static String getParent(String path) { + if (path.endsWith("/") || path.endsWith("\\")) { + path = path.substring(0,path.length() - 1); + } + int endIndex = Math.max(path.lastIndexOf("/"),path.lastIndexOf("\\")); + return endIndex > -1 ? path.substring(0,endIndex) : null; + } + + /** + * 合并路径 + */ + public static String join(String... paths) { + StringBuilder sb = new StringBuilder(); + for (String path : paths) { + String left = sb.toString(); + boolean leftHas = left.endsWith("/") || left.endsWith("\\"); + boolean rightHas = path.endsWith("/") || path.endsWith("\\"); + + if (leftHas && rightHas) { + sb.append(path.substring(1)); + } else if (!left.isEmpty() && !leftHas && !rightHas) { + sb.append("/").append(path); + } else { + sb.append(path); + } + } + return sb.toString(); + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/ProgressInputStream.java b/src/main/java/cn/xuyanwu/spring/file/storage/ProgressInputStream.java new file mode 100644 index 0000000..204645b --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/ProgressInputStream.java @@ -0,0 +1,62 @@ +package cn.xuyanwu.spring.file.storage; + +import java.io.*; + +/** + * 带进度通知的 InputStream 包装类 + */ +public class ProgressInputStream extends FilterInputStream { + + private boolean readFlag; + private long progressSize; + private final long allSize; + private final ProgressListener listener; + + public ProgressInputStream(InputStream in,ProgressListener listener,long allSize) { + super(in); + this.listener = listener; + this.allSize = allSize; + } + + @Override + public long skip(long n) throws IOException { + long skip = super.skip(n); + progress(skip); + return skip; + } + + + @Override + public int read() throws IOException { + int b = super.read(); + progress(b == -1 ? -1 : 1); + return b; + } + + @Override + public int read(byte[] b,int off,int len) throws IOException { + if (!this.readFlag) { + this.readFlag = true; + this.listener.start(); + } + int bytes = super.read(b,off,len); + progress(bytes); + return bytes; + } + + @Override + public boolean markSupported() { + return false; + } + + protected void progress(long size) { + if (size > 0) { + this.listener.progress(progressSize += size,allSize); + } else if (size < 0) { + this.listener.finish(); + } + } + + +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/ProgressListener.java b/src/main/java/cn/xuyanwu/spring/file/storage/ProgressListener.java new file mode 100644 index 0000000..0697926 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/ProgressListener.java @@ -0,0 +1,26 @@ +package cn.xuyanwu.spring.file.storage; + +/** + * 进度监听器 + */ +public interface ProgressListener { + + /** + * 开始 + */ + void start(); + + /** + * 进行中 + * + * @param progressSize 已经进行的大小 + * @param allSize 总大小,来自 fileInfo.getSize() + */ + void progress(long progressSize,long allSize); + + /** + * 结束 + */ + void finish(); +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/UploadPretreatment.java b/src/main/java/cn/xuyanwu/spring/file/storage/UploadPretreatment.java new file mode 100644 index 0000000..1b83789 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/UploadPretreatment.java @@ -0,0 +1,251 @@ +package cn.xuyanwu.spring.file.storage; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.core.lang.Dict; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import net.coobird.thumbnailator.Thumbnails; +import org.springframework.web.multipart.MultipartFile; + +import java.io.*; +import java.util.function.Consumer; + +/** + * 文件上传预处理对象 + */ +@Getter +@Setter +@Accessors(chain = true) +public class UploadPretreatment { + private FileStorageService fileStorageService; + /** + * 要上传到的平台 + */ + private String platform; + /** + * 要上传的文件包装类 + */ + private MultipartFileWrapper fileWrapper; + /** + * 要上传文件的缩略图 + */ + private byte[] thumbnailBytes; + /** + * 缩略图后缀,不是扩展名但包含扩展名,例如【.min.jpg】【.png】。 + * 只能在缩略图生成前进行修改后缀中的扩展名部分。 + * 例如当前是【.min.jpg】那么扩展名就是【jpg】,当缩略图未生成的情况下可以随意修改(扩展名必须是 thumbnailator 支持的图片格式), + * 一旦缩略图生成后,扩展名之外的部分可以随意改变 ,扩展名部分不能改变,除非你在 {@link UploadPretreatment#thumbnail} 方法中修改了输出格式。 + */ + private String thumbnailSuffix; + /** + * 文件所属对象id + */ + private String objectId; + /** + * 文件所属对象类型 + */ + private String objectType; + /** + * 文件存储路径 + */ + private String path = ""; + + /** + * 保存文件名,如果不设置则自动生成 + */ + private String saveFilename; + + /** + * 缩略图的保存文件名,注意此文件名不含后缀,后缀用 {@link UploadPretreatment#thumbnailSuffix} 属性控制 + */ + private String saveThFilename; + + /** + * MIME 类型,如果不设置则在上传文件根据 {@link MultipartFileWrapper#getContentType()} 和文件名自动识别 + */ + private String contentType; + + /** + * 缩略图 MIME 类型,如果不设置则在上传文件根据缩略图文件名自动识别 + */ + private String thContentType; + + /** + * 附加属性字典 + */ + private Dict attr; + + /** + * 设置文件所属对象id + * + * @param objectId 如果不是 String 类型会自动调用 toString() 方法 + */ + public UploadPretreatment setObjectId(Object objectId) { + this.objectId = objectId == null ? null : objectId.toString(); + return this; + } + + /** + * 获取文件名 + */ + public String getName() { + return fileWrapper.getName(); + } + + /** + * 设置文件名 + */ + public UploadPretreatment setName(String name) { + fileWrapper.setName(name); + return this; + } + + /** + * 获取原始文件名 + */ + public String getOriginalFilename() { + return fileWrapper.getOriginalFilename(); + } + + /** + * 设置原始文件名 + */ + public UploadPretreatment setOriginalFilename(String originalFilename) { + fileWrapper.setOriginalFilename(originalFilename); + return this; + } + + /** + * 获取附加属性字典 + */ + public Dict getAttr() { + if (attr == null) attr = new Dict(); + return attr; + } + + /** + * 设置附加属性 + */ + public UploadPretreatment putAttr(String key,Object value) { + getAttr().put(key,value); + return this; + } + + + /** + * 进行图片处理,可以进行裁剪、旋转、缩放、水印等操作 + */ + public UploadPretreatment image(Consumer> consumer) { + try (InputStream in = fileWrapper.getInputStream()) { + Thumbnails.Builder builder = Thumbnails.of(in); + consumer.accept(builder); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + builder.toOutputStream(out); + MultipartFile mf = fileWrapper.getMultipartFile(); + fileWrapper.setMultipartFile(new MockMultipartFile(mf.getName(),mf.getOriginalFilename(),null,out.toByteArray())); + return this; + } catch (IOException e) { + throw new FileStorageRuntimeException("图片处理失败!",e); + } + } + + /** + * 缩放到指定大小 + */ + public UploadPretreatment image(int width,int height) { + return image(th -> th.size(width,height)); + } + + /** + * 缩放到 200*200 大小 + */ + public UploadPretreatment image() { + return image(th -> th.size(200,200)); + } + + /** + * 清空缩略图 + */ + public UploadPretreatment clearThumbnail() { + thumbnailBytes = null; + return this; + } + + + /** + * 生成缩略图并进行图片处理,如果缩略图已存在则使用已有的缩略图进行处理, + * 可以进行裁剪、旋转、缩放、水印等操作,默认输出图片格式通过 thumbnailSuffix 获取 + */ + public UploadPretreatment thumbnail(Consumer> consumer) { + try { + return thumbnail(consumer,thumbnailBytes != null ? new ByteArrayInputStream(thumbnailBytes) : fileWrapper.getInputStream()); + } catch (IOException e) { + throw new FileStorageRuntimeException("生成缩略图失败!",e); + } + } + + /** + * 通过指定 MultipartFile 生成缩略图并进行图片处理, + * 可以进行裁剪、旋转、缩放、水印等操作,默认输出图片格式通过 thumbnailSuffix 获取, + */ + public UploadPretreatment thumbnail(Consumer> consumer,MultipartFile file) { + try { + return thumbnail(consumer,file.getInputStream()); + } catch (IOException e) { + throw new FileStorageRuntimeException("生成缩略图失败!",e); + } + } + + /** + * 通过指定 InputStream 生成缩略图并进行图片处理, + * 可以进行裁剪、旋转、缩放、水印等操作,默认输出图片格式通过 thumbnailSuffix 获取, + * 操作完成后会自动关闭 InputStream + */ + public UploadPretreatment thumbnail(Consumer> consumer,InputStream in) { + try { + Thumbnails.Builder builder = Thumbnails.of(in); + builder.outputFormat(FileNameUtil.extName(thumbnailSuffix)); + consumer.accept(builder); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + builder.toOutputStream(out); + thumbnailBytes = out.toByteArray(); + return this; + } catch (IOException e) { + throw new FileStorageRuntimeException("生成缩略图失败!",e); + } finally { + IoUtil.close(in); + } + } + + /** + * 生成缩略图并缩放到指定大小,默认输出图片格式通过 thumbnailSuffix 获取 + */ + public UploadPretreatment thumbnail(int width,int height) { + return thumbnail(th -> th.size(width,height)); + } + + /** + * 生成缩略图并缩放到 200*200 大小,默认输出图片格式通过 thumbnailSuffix 获取 + */ + public UploadPretreatment thumbnail() { + return thumbnail(200,200); + } + + /** + * 上传文件,成功返回文件信息,失败返回null + */ + public FileInfo upload() { + UploadPretreatment thumbnail = null; + try { + thumbnail = thumbnail(120, 120); + } catch (Exception e) { + thumbnail = this; + thumbnail.setThumbnailBytes(null); + } + return fileStorageService.upload(thumbnail); + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChain.java b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChain.java new file mode 100644 index 0000000..77e1d8d --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChain.java @@ -0,0 +1,37 @@ +package cn.xuyanwu.spring.file.storage.aspect; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; +import cn.xuyanwu.spring.file.storage.recorder.FileRecorder; +import lombok.Getter; +import lombok.Setter; + +import java.util.Iterator; + +/** + * 删除的切面调用链 + */ +@Getter +@Setter +public class DeleteAspectChain { + + private DeleteAspectChainCallback callback; + private Iterator aspectIterator; + + public DeleteAspectChain(Iterable aspects,DeleteAspectChainCallback callback) { + this.aspectIterator = aspects.iterator(); + this.callback = callback; + } + + /** + * 调用下一个切面 + */ + public boolean next(FileInfo fileInfo,FileStorage fileStorage,FileRecorder fileRecorder) { + if (aspectIterator.hasNext()) {//还有下一个 + return aspectIterator.next().deleteAround(this,fileInfo,fileStorage,fileRecorder); + } else { + return callback.run(fileInfo,fileStorage,fileRecorder); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChainCallback.java b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChainCallback.java new file mode 100644 index 0000000..4786e04 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChainCallback.java @@ -0,0 +1,13 @@ +package cn.xuyanwu.spring.file.storage.aspect; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; +import cn.xuyanwu.spring.file.storage.recorder.FileRecorder; + +/** + * 删除切面调用链结束回调 + */ +public interface DeleteAspectChainCallback { + boolean run(FileInfo fileInfo,FileStorage fileStorage,FileRecorder fileRecorder); +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChain.java b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChain.java new file mode 100644 index 0000000..9fe7fca --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChain.java @@ -0,0 +1,38 @@ +package cn.xuyanwu.spring.file.storage.aspect; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; +import lombok.Getter; +import lombok.Setter; + +import java.io.InputStream; +import java.util.Iterator; +import java.util.function.Consumer; + +/** + * 下载的切面调用链 + */ +@Getter +@Setter +public class DownloadAspectChain { + + private DownloadAspectChainCallback callback; + private Iterator aspectIterator; + + public DownloadAspectChain(Iterable aspects,DownloadAspectChainCallback callback) { + this.aspectIterator = aspects.iterator(); + this.callback = callback; + } + + /** + * 调用下一个切面 + */ + public void next(FileInfo fileInfo,FileStorage fileStorage,Consumer consumer) { + if (aspectIterator.hasNext()) {//还有下一个 + aspectIterator.next().downloadAround(this,fileInfo,fileStorage,consumer); + } else { + callback.run(fileInfo,fileStorage,consumer); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChainCallback.java b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChainCallback.java new file mode 100644 index 0000000..b1493d7 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChainCallback.java @@ -0,0 +1,15 @@ +package cn.xuyanwu.spring.file.storage.aspect; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; + +import java.io.InputStream; +import java.util.function.Consumer; + +/** + * 下载切面调用链结束回调 + */ +public interface DownloadAspectChainCallback { + void run(FileInfo fileInfo,FileStorage fileStorage,Consumer consumer); +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChain.java b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChain.java new file mode 100644 index 0000000..6fc746f --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChain.java @@ -0,0 +1,38 @@ +package cn.xuyanwu.spring.file.storage.aspect; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; +import lombok.Getter; +import lombok.Setter; + +import java.io.InputStream; +import java.util.Iterator; +import java.util.function.Consumer; + +/** + * 下载缩略图的切面调用链 + */ +@Getter +@Setter +public class DownloadThAspectChain { + + private DownloadThAspectChainCallback callback; + private Iterator aspectIterator; + + public DownloadThAspectChain(Iterable aspects,DownloadThAspectChainCallback callback) { + this.aspectIterator = aspects.iterator(); + this.callback = callback; + } + + /** + * 调用下一个切面 + */ + public void next(FileInfo fileInfo,FileStorage fileStorage,Consumer consumer) { + if (aspectIterator.hasNext()) {//还有下一个 + aspectIterator.next().downloadThAround(this,fileInfo,fileStorage,consumer); + } else { + callback.run(fileInfo,fileStorage,consumer); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChainCallback.java b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChainCallback.java new file mode 100644 index 0000000..91049d8 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChainCallback.java @@ -0,0 +1,15 @@ +package cn.xuyanwu.spring.file.storage.aspect; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; + +import java.io.InputStream; +import java.util.function.Consumer; + +/** + * 下载缩略图切面调用链结束回调 + */ +public interface DownloadThAspectChainCallback { + void run(FileInfo fileInfo,FileStorage fileStorage,Consumer consumer); +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChain.java b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChain.java new file mode 100644 index 0000000..e6e4269 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChain.java @@ -0,0 +1,36 @@ +package cn.xuyanwu.spring.file.storage.aspect; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; +import lombok.Getter; +import lombok.Setter; + +import java.util.Iterator; + +/** + * 文件是否存在的切面调用链 + */ +@Getter +@Setter +public class ExistsAspectChain { + + private ExistsAspectChainCallback callback; + private Iterator aspectIterator; + + public ExistsAspectChain(Iterable aspects,ExistsAspectChainCallback callback) { + this.aspectIterator = aspects.iterator(); + this.callback = callback; + } + + /** + * 调用下一个切面 + */ + public boolean next(FileInfo fileInfo,FileStorage fileStorage) { + if (aspectIterator.hasNext()) {//还有下一个 + return aspectIterator.next().existsAround(this,fileInfo,fileStorage); + } else { + return callback.run(fileInfo,fileStorage); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChainCallback.java b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChainCallback.java new file mode 100644 index 0000000..65c5b32 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChainCallback.java @@ -0,0 +1,12 @@ +package cn.xuyanwu.spring.file.storage.aspect; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; + +/** + * 文件是否存在切面调用链结束回调 + */ +public interface ExistsAspectChainCallback { + boolean run(FileInfo fileInfo,FileStorage fileStorage); +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/aspect/FileStorageAspect.java b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/FileStorageAspect.java new file mode 100644 index 0000000..941b9fc --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/FileStorageAspect.java @@ -0,0 +1,53 @@ +package cn.xuyanwu.spring.file.storage.aspect; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; +import cn.xuyanwu.spring.file.storage.recorder.FileRecorder; + +import java.io.InputStream; +import java.util.function.Consumer; + +/** + * 文件服务切面接口,用来干预文件上传,删除等 + */ +public interface FileStorageAspect { + + + /** + * 上传,成功返回文件信息,失败返回 null + */ + default FileInfo uploadAround(UploadAspectChain chain,FileInfo fileInfo,UploadPretreatment pre,FileStorage fileStorage,FileRecorder fileRecorder) { + return chain.next(fileInfo,pre,fileStorage,fileRecorder); + } + + + /** + * 删除文件,成功返回 true + */ + default boolean deleteAround(DeleteAspectChain chain,FileInfo fileInfo,FileStorage fileStorage,FileRecorder fileRecorder) { + return chain.next(fileInfo,fileStorage,fileRecorder); + } + + /** + * 文件是否存在,成功返回文件内容 + */ + default boolean existsAround(ExistsAspectChain chain,FileInfo fileInfo,FileStorage fileStorage) { + return chain.next(fileInfo,fileStorage); + } + + /** + * 下载文件,成功返回文件内容 + */ + default void downloadAround(DownloadAspectChain chain,FileInfo fileInfo,FileStorage fileStorage,Consumer consumer) { + chain.next(fileInfo,fileStorage,consumer); + } + + /** + * 下载缩略图文件,成功返回文件内容 + */ + default void downloadThAround(DownloadThAspectChain chain,FileInfo fileInfo,FileStorage fileStorage,Consumer consumer) { + chain.next(fileInfo,fileStorage,consumer); + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChain.java b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChain.java new file mode 100644 index 0000000..d66b885 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChain.java @@ -0,0 +1,38 @@ +package cn.xuyanwu.spring.file.storage.aspect; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; +import cn.xuyanwu.spring.file.storage.recorder.FileRecorder; +import lombok.Getter; +import lombok.Setter; + +import java.util.Iterator; + +/** + * 上传的切面调用链 + */ +@Getter +@Setter +public class UploadAspectChain { + + private UploadAspectChainCallback callback; + private Iterator aspectIterator; + + public UploadAspectChain(Iterable aspects,UploadAspectChainCallback callback) { + this.aspectIterator = aspects.iterator(); + this.callback = callback; + } + + /** + * 调用下一个切面 + */ + public FileInfo next(FileInfo fileInfo,UploadPretreatment pre,FileStorage fileStorage,FileRecorder fileRecorder) { + if (aspectIterator.hasNext()) {//还有下一个 + return aspectIterator.next().uploadAround(this,fileInfo,pre,fileStorage,fileRecorder); + } else { + return callback.run(fileInfo,pre,fileStorage,fileRecorder); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChainCallback.java b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChainCallback.java new file mode 100644 index 0000000..a8fa9b7 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChainCallback.java @@ -0,0 +1,14 @@ +package cn.xuyanwu.spring.file.storage.aspect; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.platform.FileStorage; +import cn.xuyanwu.spring.file.storage.recorder.FileRecorder; + +/** + * 上传切面调用链结束回调 + */ +public interface UploadAspectChainCallback { + FileInfo run(FileInfo fileInfo,UploadPretreatment pre,FileStorage fileStorage,FileRecorder fileRecorder); +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/exception/FileStorageRuntimeException.java b/src/main/java/cn/xuyanwu/spring/file/storage/exception/FileStorageRuntimeException.java new file mode 100644 index 0000000..bd8d2d1 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/exception/FileStorageRuntimeException.java @@ -0,0 +1,26 @@ +package cn.xuyanwu.spring.file.storage.exception; + +/** + * FileStorage 运行时异常 + */ +public class FileStorageRuntimeException extends RuntimeException { + public FileStorageRuntimeException() { + } + + public FileStorageRuntimeException(String message) { + super(message); + } + + public FileStorageRuntimeException(String message,Throwable cause) { + super(message,cause); + } + + public FileStorageRuntimeException(Throwable cause) { + super(cause); + } + + public FileStorageRuntimeException(String message,Throwable cause,boolean enableSuppression,boolean writableStackTrace) { + super(message,cause,enableSuppression,writableStackTrace); + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/AliyunOssFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/AliyunOssFileStorage.java new file mode 100644 index 0000000..d4da685 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/AliyunOssFileStorage.java @@ -0,0 +1,189 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.util.StrUtil; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.model.*; +import com.yunzhupaas.model.FileListVO; +import com.yunzhupaas.model.FileModel; +import com.yunzhupaas.util.DateUtil; +import com.yunzhupaas.util.FileUtil; +import com.yunzhupaas.util.JsonUtil; +import lombok.Getter; +import lombok.Setter; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * 阿里云 OSS 存储 + */ +@Getter +@Setter +public class AliyunOssFileStorage implements FileStorage { + + /* 存储平台 */ + private String platform; + private String accessKey; + private String secretKey; + private String endPoint; + private String bucketName; + private String domain; + private String basePath; + private OSS client; + + /** + * 单例模式运行,不需要每次使用完再销毁了 + */ + public OSS getClient() { + if (client == null) { + client = new OSSClientBuilder().build(endPoint, accessKey, secretKey); + } + return client; + } + + /** + * 仅在移除这个存储平台时调用 + */ + @Override + public void close() { + if (client != null) { + client.shutdown(); + client = null; + } + } + + @Override + public boolean save(FileInfo fileInfo, UploadPretreatment pre) { + String newFileKey = basePath + fileInfo.getPath() + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + bucketName + "/" + newFileKey); + + OSS client = getClient(); + try (InputStream in = pre.getFileWrapper().getInputStream()) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(fileInfo.getSize()); + metadata.setContentType(fileInfo.getContentType()); + client.putObject(bucketName, newFileKey, in, metadata); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { // 上传缩略图 + String newThFileKey = basePath + fileInfo.getPath() + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + ObjectMetadata thMetadata = new ObjectMetadata(); + thMetadata.setContentLength(thumbnailBytes.length); + thMetadata.setContentType(fileInfo.getThContentType()); + client.putObject(bucketName, newThFileKey, new ByteArrayInputStream(thumbnailBytes), thMetadata); + } + + return true; + } catch (IOException e) { + client.deleteObject(bucketName, newFileKey); + throw new FileStorageRuntimeException( + "文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(), e); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + OSS client = getClient(); + if (fileInfo.getThFilename() != null) { // 删除缩略图 + client.deleteObject(bucketName, fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + } + client.deleteObject(bucketName, fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + return true; + } + + @Override + public boolean exists(FileInfo fileInfo) { + return getClient().doesObjectExist(bucketName, + fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + } + + @Override + public void download(FileInfo fileInfo, Consumer consumer) { + OSSObject object = getClient().getObject(bucketName, + fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + try (InputStream in = object.getObjectContent()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo, e); + } + + } + + @Override + public void downloadTh(FileInfo fileInfo, Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + OSSObject object = getClient().getObject(bucketName, + fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + try (InputStream in = object.getObjectContent()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo, e); + } + } + + @Override + public List getFileList(String folderName) { + List list = new ArrayList<>(); + try { + ObjectListing objectListing = null; + ListObjectsRequest listObjectsRequest = new ListObjectsRequest(bucketName); + listObjectsRequest.setPrefix(this.getBasePath() + folderName); + objectListing = getClient().listObjects(listObjectsRequest); + List sums = objectListing.getObjectSummaries(); + for (OSSObjectSummary result : sums) { + list.add(result); + } + } catch (Exception e) { + throw new FileStorageRuntimeException("文件获取失败!platform:" + platform, e); + } + return list; + } + + @Override + public List conversionList(String folderName) { + List fileList = getFileList(folderName); + List listVOS = new ArrayList<>(fileList.size()); + if (fileList.size() > 0 && fileList.get(0) instanceof FileModel) { + return JsonUtil.getJsonToList(fileList, FileListVO.class); + } + for (int i = 0; i < fileList.size(); i++) { + FileListVO fileListVO = new FileListVO(); + fileListVO.setFileId(i + ""); + // 阿里云 + OSSObjectSummary summary = (OSSObjectSummary) fileList.get(i); + String objectName = summary.getKey().replace(this.getBasePath() + folderName + "/", ""); + fileListVO.setFileName(objectName); + fileListVO.setFileType(FileUtil.getFileType(objectName)); + fileListVO.setFileSize(FileUtil.getSize(String.valueOf(summary.getSize()))); + fileListVO.setFileTime(DateUtil.dateFormat(summary.getLastModified())); + listVOS.add(fileListVO); + } + return listVOS; + } + + @Override + public void downLocal(String folderName, String filePath, String objectName) { + // 判断存储桶是否存在 + try { + getClient().getObject(new GetObjectRequest(bucketName, this.getBasePath() + folderName + objectName), + new File(filePath + objectName)); + } catch (Exception e) { + throw new FileStorageRuntimeException( + "文件下载失败!platform:" + platform + ",下载路径:" + filePath + ",文件夹名称:" + folderName + ",文件名:" + objectName, + e); + } + } +} diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/AwsS3FileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/AwsS3FileStorage.java new file mode 100644 index 0000000..41aea15 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/AwsS3FileStorage.java @@ -0,0 +1,137 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.util.StrUtil; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.S3Object; +import lombok.Getter; +import lombok.Setter; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.function.Consumer; + +/** + * AWS S3 存储 + */ +@Getter +@Setter +public class AwsS3FileStorage implements FileStorage { + + /* 存储平台 */ + private String platform; + private String accessKey; + private String secretKey; + private String region; + private String endPoint; + private String bucketName; + private String domain; + private String basePath; + private AmazonS3 client; + + /** + * 单例模式运行,不需要每次使用完再销毁了 + */ + public AmazonS3 getClient() { + if (client == null) { + AmazonS3ClientBuilder builder = AmazonS3ClientBuilder.standard() + .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey,secretKey))); + if (StrUtil.isNotBlank(endPoint)) { + builder.withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endPoint,region)); + } else if (StrUtil.isNotBlank(region)) { + builder.withRegion(region); + } + client = builder.build(); + } + return client; + } + + /** + * 仅在移除这个存储平台时调用 + */ + @Override + public void close() { + if (client != null) { + client.shutdown(); + client = null; + } + } + + @Override + public boolean save(FileInfo fileInfo,UploadPretreatment pre) { + String newFileKey = basePath + fileInfo.getPath() + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + newFileKey); + + AmazonS3 client = getClient(); + try (InputStream in = pre.getFileWrapper().getInputStream()) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(fileInfo.getSize()); + metadata.setContentType(fileInfo.getContentType()); + client.putObject(bucketName,newFileKey,in,metadata); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { //上传缩略图 + String newThFileKey = basePath + fileInfo.getPath() + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + ObjectMetadata thMetadata = new ObjectMetadata(); + thMetadata.setContentLength(thumbnailBytes.length); + thMetadata.setContentType(fileInfo.getThContentType()); + client.putObject(bucketName,newThFileKey,new ByteArrayInputStream(thumbnailBytes),thMetadata); + } + + return true; + } catch (IOException e) { + client.deleteObject(bucketName,newFileKey); + throw new FileStorageRuntimeException("文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(),e); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + AmazonS3 client = getClient(); + if (fileInfo.getThFilename() != null) { //删除缩略图 + client.deleteObject(bucketName,fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + } + client.deleteObject(bucketName,fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + return true; + } + + + @Override + public boolean exists(FileInfo fileInfo) { + return getClient().doesObjectExist(bucketName,fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + } + + @Override + public void download(FileInfo fileInfo,Consumer consumer) { + S3Object object = getClient().getObject(bucketName,fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + try (InputStream in = object.getObjectContent()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo,e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo,Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + S3Object object = getClient().getObject(bucketName,fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + try (InputStream in = object.getObjectContent()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo,e); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/BaiduBosFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/BaiduBosFileStorage.java new file mode 100644 index 0000000..604ed50 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/BaiduBosFileStorage.java @@ -0,0 +1,132 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.util.StrUtil; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import com.baidubce.Protocol; +import com.baidubce.auth.DefaultBceCredentials; +import com.baidubce.services.bos.BosClient; +import com.baidubce.services.bos.BosClientConfiguration; +import com.baidubce.services.bos.model.BosObject; +import com.baidubce.services.bos.model.ObjectMetadata; +import lombok.Getter; +import lombok.Setter; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.function.Consumer; + +/** + * 百度云 BOS 存储 + */ +@Getter +@Setter +public class BaiduBosFileStorage implements FileStorage { + + /* 存储平台 */ + private String platform; + private String accessKey; + private String secretKey; + private String endPoint; + private String bucketName; + private String domain; + private String basePath; + private BosClient client; + + /** + * 单例模式运行,不需要每次使用完再销毁了 + */ + public BosClient getClient() { + if (client == null) { + BosClientConfiguration config = new BosClientConfiguration(); + config.setCredentials(new DefaultBceCredentials(accessKey,secretKey)); + config.setEndpoint(endPoint); + config.setProtocol(Protocol.HTTPS); + client = new BosClient(config); + } + return client; + } + + /** + * 仅在移除这个存储平台时调用 + */ + @Override + public void close() { + if (client != null) { + client.shutdown(); + client = null; + } + } + + @Override + public boolean save(FileInfo fileInfo,UploadPretreatment pre) { + String newFileKey = basePath + fileInfo.getPath() + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + newFileKey); + + BosClient client = getClient(); + try (InputStream in = pre.getFileWrapper().getInputStream()) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(fileInfo.getSize()); + metadata.setContentType(fileInfo.getContentType()); + client.putObject(bucketName,newFileKey,in,metadata); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { //上传缩略图 + String newThFileKey = basePath + fileInfo.getPath() + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + ObjectMetadata thMetadata = new ObjectMetadata(); + thMetadata.setContentLength(thumbnailBytes.length); + thMetadata.setContentType(fileInfo.getThContentType()); + client.putObject(bucketName,newThFileKey,new ByteArrayInputStream(thumbnailBytes),thMetadata); + } + + return true; + } catch (IOException e) { + client.deleteObject(bucketName,newFileKey); + throw new FileStorageRuntimeException("文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(),e); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + BosClient client = getClient(); + if (fileInfo.getThFilename() != null) { //删除缩略图 + client.deleteObject(bucketName,fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + } + client.deleteObject(bucketName,fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + return true; + } + + + @Override + public boolean exists(FileInfo fileInfo) { + return getClient().doesObjectExist(bucketName,fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + } + + @Override + public void download(FileInfo fileInfo,Consumer consumer) { + BosObject object = getClient().getObject(bucketName,fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + try (InputStream in = object.getObjectContent()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo,e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo,Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + BosObject object = getClient().getObject(bucketName,fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + try (InputStream in = object.getObjectContent()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo,e); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/FileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/FileStorage.java new file mode 100644 index 0000000..edfe635 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/FileStorage.java @@ -0,0 +1,112 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.FileStorageProperties; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import com.yunzhupaas.model.FileListVO; +import com.yunzhupaas.util.context.SpringContext; + +import java.io.InputStream; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +/** + * 文件存储接口,对应各个平台 + */ +public interface FileStorage extends AutoCloseable { + + /** + * 获取平台 + */ + String getPlatform(); + + /** + * 获取地址 + */ + String getBasePath(); + + /** + * 获取地址 + */ + String getDomain(); + + /** + * 获取命名空间 + */ + default String getBucketName() { + return ""; + } + + /** + * 设置平台 + */ + void setPlatform(String platform); + + /** + * 保存文件 + */ + boolean save(FileInfo fileInfo, UploadPretreatment pre); + + /** + * 删除文件 + */ + boolean delete(FileInfo fileInfo); + + /** + * 文件是否存在 + */ + boolean exists(FileInfo fileInfo); + + /** + * 下载文件 + */ + void download(FileInfo fileInfo, Consumer consumer); + + /** + * 下载缩略图文件 + */ + void downloadTh(FileInfo fileInfo, Consumer consumer); + + /** + * 释放相关资源 + */ + void close(); + + /** + * 获取本地储存路径 + */ + default String getLocalPath() { + FileStorageProperties fileStorageProperties = SpringContext.getBean(FileStorageProperties.class); + if (fileStorageProperties.getLocalPlus().size() < 1) { + return null; + } + String storagePath = fileStorageProperties.getLocalPlus().get(0).getBasePath(); + return storagePath; + } + + /** + * 获取文件列表 + */ + default List getFileList(String folderName) { + return Collections.EMPTY_LIST; + } + + /** + * 返回值统一泛型 + */ + default List conversionList(String folderName) { + return Collections.EMPTY_LIST; + } + + /** + * 下载到本地 + * + * @param folderName 文件夹名 + * @param filePath 下载到本地文件路径 + * @param objectName 文件名 + */ + default void downLocal(String folderName, String filePath, String objectName) { + } + +} diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/FtpFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/FtpFileStorage.java new file mode 100644 index 0000000..8ca5131 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/FtpFileStorage.java @@ -0,0 +1,165 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.io.IORuntimeException; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.ftp.Ftp; +import cn.hutool.extra.ftp.FtpConfig; +import cn.hutool.extra.ftp.FtpMode; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import lombok.Getter; +import lombok.Setter; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.function.Consumer; + +/** + * FTP 存储 + */ +@Getter +@Setter +public class FtpFileStorage implements FileStorage { + + /* 主机 */ + private String host; + /* 端口,默认21 */ + private int port; + /* 用户名,默认 anonymous(匿名) */ + private String user; + /* 密码,默认空 */ + private String password; + /* 编码,默认UTF-8 */ + private Charset charset; + /* 连接超时时长,单位毫秒,默认10秒 {@link org.apache.commons.net.SocketClient#setConnectTimeout(int)} */ + private long connectionTimeout; + /* Socket连接超时时长,单位毫秒,默认10秒 {@link org.apache.commons.net.SocketClient#setSoTimeout(int)} */ + private long soTimeout; + /* 设置服务器语言,默认空,{@link org.apache.commons.net.ftp.FTPClientConfig#setServerLanguageCode(String)} */ + private String serverLanguageCode; + /** + * 服务器标识,默认空,{@link org.apache.commons.net.ftp.FTPClientConfig#FTPClientConfig(String)} + * 例如:org.apache.commons.net.ftp.FTPClientConfig.SYST_NT + */ + private String systemKey; + /* 是否主动模式,默认被动模式 */ + private Boolean isActive = false; + /* 存储平台 */ + private String platform; + private String domain; + private String basePath; + private String storagePath; + + /** + * 不支持单例模式运行,每次使用完了需要销毁 + */ + public Ftp getClient() { + FtpConfig config = FtpConfig.create().setHost(host).setPort(port).setUser(user).setPassword(password).setCharset(charset) + .setConnectionTimeout(connectionTimeout).setSoTimeout(soTimeout).setServerLanguageCode(serverLanguageCode) + .setSystemKey(systemKey); + return new Ftp(config,isActive ? FtpMode.Active : FtpMode.Passive); + } + + + @Override + public void close() { + } + + /** + * 获取远程绝对路径 + */ + public String getAbsolutePath(String path) { + return storagePath + path; + } + + @Override + public boolean save(FileInfo fileInfo,UploadPretreatment pre) { + String newFileKey = basePath + fileInfo.getPath() + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + newFileKey); + + Ftp client = getClient(); + try (InputStream in = pre.getFileWrapper().getInputStream()) { + client.upload(getAbsolutePath(basePath + fileInfo.getPath()),fileInfo.getFilename(),in); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { //上传缩略图 + String newThFileKey = basePath + fileInfo.getPath() + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + client.upload(getAbsolutePath(basePath + fileInfo.getPath()),fileInfo.getThFilename(),new ByteArrayInputStream(thumbnailBytes)); + } + + return true; + } catch (IOException | IORuntimeException e) { + try { + client.delFile(getAbsolutePath(newFileKey)); + } catch (IORuntimeException ignored) { + } + throw new FileStorageRuntimeException("文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(),e); + } finally { + IoUtil.close(client); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + try (Ftp client = getClient()) { + if (fileInfo.getThFilename() != null) { //删除缩略图 + client.delFile(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename())); + } + client.delFile(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename())); + return true; + } catch (IOException | IORuntimeException e) { + throw new FileStorageRuntimeException("文件删除失败!fileInfo:" + fileInfo,e); + } + } + + + @Override + public boolean exists(FileInfo fileInfo) { + try (Ftp client = getClient()) { + return client.existFile(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename())); + } catch (IOException | IORuntimeException e) { + throw new FileStorageRuntimeException("查询文件是否存在失败!fileInfo:" + fileInfo,e); + } + } + + @Override + public void download(FileInfo fileInfo,Consumer consumer) { + try (Ftp client = getClient()) { + client.cd(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath())); + try (InputStream in = client.getClient().retrieveFileStream(fileInfo.getFilename())) { + if (in == null) { + throw new FileStorageRuntimeException("文件下载失败,文件不存在!platform:" + fileInfo); + } + consumer.accept(in); + } + } catch (IOException | IORuntimeException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo,e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo,Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + + try (Ftp client = getClient()) { + client.cd(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath())); + try (InputStream in = client.getClient().retrieveFileStream(fileInfo.getThFilename())) { + if (in == null) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!platform:" + fileInfo); + } + consumer.accept(in); + } + } catch (IOException | IORuntimeException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo,e); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/HuaweiObsFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/HuaweiObsFileStorage.java new file mode 100644 index 0000000..50e575c --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/HuaweiObsFileStorage.java @@ -0,0 +1,184 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import com.obs.services.ObsClient; +import com.obs.services.model.ListObjectsRequest; +import com.obs.services.model.ObjectListing; +import com.obs.services.model.ObjectMetadata; +import com.obs.services.model.ObsObject; +import com.yunzhupaas.model.FileListVO; +import com.yunzhupaas.model.FileModel; +import com.yunzhupaas.util.DateUtil; +import com.yunzhupaas.util.FileUtil; +import com.yunzhupaas.util.JsonUtil; +import com.yunzhupaas.util.StringUtil; +import lombok.Cleanup; +import lombok.Getter; +import lombok.Setter; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +/** + * 华为云 OBS 存储 + */ +@Getter +@Setter +public class HuaweiObsFileStorage implements FileStorage { + + /* 存储平台 */ + private String platform; + private String accessKey; + private String secretKey; + private String endPoint; + private String bucketName; + private String domain; + private String basePath; + private ObsClient client; + + /** + * 单例模式运行,不需要每次使用完再销毁了 + */ + public ObsClient getClient() { + if (client == null) { + client = new ObsClient(accessKey, secretKey, endPoint); + } + return client; + } + + /** + * 仅在移除这个存储平台时调用 + */ + @Override + public void close() { + IoUtil.close(client); + } + + @Override + public boolean save(FileInfo fileInfo, UploadPretreatment pre) { + String newFileKey = basePath + pre.getPath() + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + newFileKey); + + ObsClient client = getClient(); + try (InputStream in = pre.getFileWrapper().getInputStream()) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(fileInfo.getSize()); + metadata.setContentType(fileInfo.getContentType()); + client.putObject(bucketName, newFileKey, in, metadata); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { // 上传缩略图 + String newThFileKey = basePath + fileInfo.getPath() + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + ObjectMetadata thMetadata = new ObjectMetadata(); + thMetadata.setContentLength((long) thumbnailBytes.length); + thMetadata.setContentType(fileInfo.getThContentType()); + client.putObject(bucketName, newThFileKey, new ByteArrayInputStream(thumbnailBytes), thMetadata); + } + + return true; + } catch (IOException e) { + client.deleteObject(bucketName, newFileKey); + throw new FileStorageRuntimeException( + "文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(), e); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + ObsClient client = getClient(); + if (fileInfo.getThFilename() != null) { // 删除缩略图 + client.deleteObject(bucketName, fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + } + client.deleteObject(bucketName, fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + return true; + } + + @Override + public boolean exists(FileInfo fileInfo) { + return getClient().doesObjectExist(bucketName, + fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + } + + @Override + public void download(FileInfo fileInfo, Consumer consumer) { + ObsObject object = getClient().getObject(bucketName, + fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + try (InputStream in = object.getObjectContent()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo, e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo, Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + ObsObject object = getClient().getObject(bucketName, + fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + try (InputStream in = object.getObjectContent()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo, e); + } + } + + @Override + public void downLocal(String folderName, String filePath, String objectName) { + try { + ObsObject obsObject = getClient().getObject(bucketName, this.getBasePath() + folderName + objectName + "/"); + @Cleanup + InputStream stream = obsObject.getObjectContent(); + FileUtil.write(stream, filePath, objectName); + } catch (Exception e) { + throw new FileStorageRuntimeException("文件获取失败!platform:" + platform, e); + } + } + + @Override + public List getFileList(String folderName) { + ListObjectsRequest listObjectsRequest = new ListObjectsRequest(bucketName); + listObjectsRequest.setPrefix(this.getBasePath() + folderName); + ObjectListing objectListing = getClient().listObjects(listObjectsRequest); + return objectListing.getObjects() != null ? objectListing.getObjects() : Collections.EMPTY_LIST; + } + + @Override + public List conversionList(String folderName) { + List fileList = getFileList(folderName); + List listVOS = new ArrayList<>(fileList.size()); + if (fileList.size() > 0 && fileList.get(0) instanceof FileModel) { + return JsonUtil.getJsonToList(fileList, FileListVO.class); + } + for (int i = 0; i < fileList.size(); i++) { + FileListVO fileListVO = new FileListVO(); + fileListVO.setFileId(i + ""); + ObsObject obsObject = (ObsObject) fileList.get(i); + String objectName = obsObject.getObjectKey(); + if (StringUtil.isEmpty(objectName) + // || objectName.split("/").length <= 1 || objectName.split("/").length > 2 + ) { + continue; + } + fileListVO.setFileName(objectName); + fileListVO.setFileType(FileUtil.getFileType(objectName)); + fileListVO.setFileSize(FileUtil.getSize(String.valueOf(obsObject.getMetadata().getContentLength()))); + fileListVO.setFileTime(DateUtil.dateFormat(obsObject.getMetadata().getLastModified())); + listVOS.add(fileListVO); + } + return listVOS; + } + +} diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/LocalFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/LocalFileStorage.java new file mode 100644 index 0000000..388482a --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/LocalFileStorage.java @@ -0,0 +1,92 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import lombok.Getter; +import lombok.Setter; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.function.Consumer; + +/** + * 本地文件存储 + */ +@Getter +@Setter +public class LocalFileStorage implements FileStorage { + + /* 本地存储路径*/ + private String basePath; + /* 存储平台 */ + private String platform; + /* 访问域名 */ + private String domain; + + @Override + public void close() { + } + + @Override + public boolean save(FileInfo fileInfo,UploadPretreatment pre) { + String path = fileInfo.getPath(); + + File newFile = FileUtil.touch(basePath + path,fileInfo.getFilename()); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + path + fileInfo.getFilename()); + + try { + pre.getFileWrapper().transferTo(newFile); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { //上传缩略图 + fileInfo.setThUrl(domain + path + fileInfo.getThFilename()); + FileUtil.writeBytes(thumbnailBytes,basePath + path + fileInfo.getThFilename()); + } + return true; + } catch (IOException e) { + FileUtil.del(newFile); + throw new FileStorageRuntimeException("文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(),e); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + if (fileInfo.getThFilename() != null) { //删除缩略图 + FileUtil.del(new File(fileInfo.getBasePath() + fileInfo.getPath(),fileInfo.getThFilename())); + } + return FileUtil.del(new File(fileInfo.getBasePath() + fileInfo.getPath(),fileInfo.getFilename())); + } + + + @Override + public boolean exists(FileInfo fileInfo) { + return new File(fileInfo.getBasePath() + fileInfo.getPath(),fileInfo.getFilename()).exists(); + } + + @Override + public void download(FileInfo fileInfo,Consumer consumer) { + try (InputStream in = FileUtil.getInputStream(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename())) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo,e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo,Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + try (InputStream in = FileUtil.getInputStream(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename())) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo,e); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/LocalPlusFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/LocalPlusFileStorage.java new file mode 100644 index 0000000..db494fd --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/LocalPlusFileStorage.java @@ -0,0 +1,138 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import com.yunzhupaas.model.FileListVO; +import com.yunzhupaas.model.FileModel; +import com.yunzhupaas.util.JsonUtil; +import com.yunzhupaas.util.XSSEscape; +import lombok.Getter; +import lombok.Setter; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * 本地文件存储升级版 + */ +@Getter +@Setter +public class LocalPlusFileStorage implements FileStorage { + + /* 基础路径 */ + private String basePath; + /* 本地存储路径*/ + private String storagePath; + /* 存储平台 */ + private String platform; + /* 访问域名 */ + private String domain; + + @Override + public void close() { + } + + /** + * 获取本地绝对路径 + */ + public String getAbsolutePath(String path) { + return storagePath + path; + } + + @Override + public boolean save(FileInfo fileInfo,UploadPretreatment pre) { + + String newFileKey = basePath + fileInfo.getPath() + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + newFileKey); + + try { + File newFile = FileUtil.touch(getAbsolutePath(newFileKey)); + pre.getFileWrapper().transferTo(newFile); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { //上传缩略图 + String newThFileKey = basePath + fileInfo.getPath() + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + FileUtil.writeBytes(thumbnailBytes,getAbsolutePath(newThFileKey)); + } + return true; + } catch (IOException e) { + FileUtil.del(getAbsolutePath(newFileKey)); + throw new FileStorageRuntimeException("文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(),e); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + if (fileInfo.getThFilename() != null) { //删除缩略图 + FileUtil.del(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename())); + } + return FileUtil.del(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename())); + } + + + @Override + public boolean exists(FileInfo fileInfo) { + return new File(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename())).exists(); + } + + @Override + public void download(FileInfo fileInfo,Consumer consumer) { + try (InputStream in = FileUtil.getInputStream(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()))) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo,e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo,Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + try (InputStream in = FileUtil.getInputStream(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()))) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo,e); + } + } + + @Override + public List getFileList(String folderName) { + List data = new ArrayList<>(); + File filePath = new File(XSSEscape.escapePath(getLocalPath() + folderName)); + List files = com.yunzhupaas.util.FileUtil.getFile(filePath); + if (files != null) { + for (int i = 0; i < files.size(); i++) { + File item = files.get(i); + FileModel fileModel = new FileModel(); + fileModel.setFileId(i + ""); + fileModel.setFileName(folderName + item.getName()); + fileModel.setFileType(com.yunzhupaas.util.FileUtil.getFileType(item)); + fileModel.setFileSize(com.yunzhupaas.util.FileUtil.getSize(String.valueOf(item.length()))); + fileModel.setFileTime(com.yunzhupaas.util.FileUtil.getCreateTime(filePath + item.getName())); + data.add(fileModel); + } + } + return data; + } + + @Override + public List conversionList(String folderName) { + List fileList = getFileList(folderName); + List listVOS = new ArrayList<>(fileList.size()); + if (fileList.size() > 0 && fileList.get(0) instanceof FileModel) { + return JsonUtil.getJsonToList(fileList, FileListVO.class); + } + return new ArrayList<>(); + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/MinIOFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/MinIOFileStorage.java new file mode 100644 index 0000000..bf57869 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/MinIOFileStorage.java @@ -0,0 +1,215 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.util.StrUtil; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import io.minio.*; +import io.minio.errors.*; +import io.minio.messages.Item; +import com.yunzhupaas.model.FileListVO; +import com.yunzhupaas.model.FileModel; +import com.yunzhupaas.util.DateUtil; +import com.yunzhupaas.util.FileUtil; +import com.yunzhupaas.util.JsonUtil; +import lombok.Cleanup; +import lombok.Getter; +import lombok.Setter; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * MinIO 存储 + */ +@Getter +@Setter +public class MinIOFileStorage implements FileStorage { + + /* 存储平台 */ + private String platform; + private String accessKey; + private String secretKey; + private String endPoint; + private String bucketName; + private String domain; + private String basePath; + private MinioClient client; + + /** + * 单例模式运行,不需要每次使用完再销毁了 + */ + public MinioClient getClient() { + if (client == null) { + client = new MinioClient.Builder().credentials(accessKey, secretKey).endpoint(endPoint).build(); + } + return client; + } + + /** + * 仅在移除这个存储平台时调用 + */ + @Override + public void close() { + client = null; + } + + @Override + public boolean save(FileInfo fileInfo, UploadPretreatment pre) { + String newFileKey = basePath + fileInfo.getPath() + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + bucketName + "/" + newFileKey); + + MinioClient client = getClient(); + try (InputStream in = pre.getFileWrapper().getInputStream()) { + client.putObject(PutObjectArgs.builder().bucket(bucketName).object(newFileKey) + .stream(in, pre.getFileWrapper().getSize(), -1) + .contentType(fileInfo.getContentType()).build()); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { // 上传缩略图 + String newThFileKey = basePath + fileInfo.getPath() + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + client.putObject(PutObjectArgs.builder().bucket(bucketName).object(newThFileKey) + .stream(new ByteArrayInputStream(thumbnailBytes), thumbnailBytes.length, -1) + .contentType(fileInfo.getThContentType()).build()); + } + + return true; + } catch (ErrorResponseException | InsufficientDataException | InternalException | ServerException + | InvalidKeyException | InvalidResponseException | IOException | NoSuchAlgorithmException + | XmlParserException e) { + try { + client.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(newFileKey).build()); + } catch (Exception ignored) { + } + throw new FileStorageRuntimeException( + "文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(), e); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + MinioClient client = getClient(); + try { + if (fileInfo.getThFilename() != null) { // 删除缩略图 + client.removeObject(RemoveObjectArgs.builder().bucket(bucketName) + .object(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()).build()); + } + client.removeObject(RemoveObjectArgs.builder().bucket(bucketName) + .object(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()).build()); + return true; + } catch (ErrorResponseException | InsufficientDataException | InternalException | ServerException + | InvalidKeyException | InvalidResponseException | IOException | NoSuchAlgorithmException + | XmlParserException e) { + throw new FileStorageRuntimeException("文件删除失败!fileInfo:" + fileInfo, e); + } + } + + @Override + public boolean exists(FileInfo fileInfo) { + MinioClient client = getClient(); + try { + StatObjectResponse stat = client.statObject(StatObjectArgs.builder().bucket(bucketName) + .object(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()).build()); + return stat != null && stat.lastModified() != null; + } catch (ErrorResponseException e) { + String code = e.errorResponse().code(); + if ("NoSuchKey".equals(code)) { + return false; + } + throw new FileStorageRuntimeException("查询文件是否存在失败!", e); + } catch (InsufficientDataException | InternalException | ServerException | InvalidKeyException + | InvalidResponseException | IOException | NoSuchAlgorithmException | XmlParserException e) { + throw new FileStorageRuntimeException("查询文件是否存在失败!", e); + } + } + + @Override + public void download(FileInfo fileInfo, Consumer consumer) { + MinioClient client = getClient(); + try (InputStream in = client.getObject(GetObjectArgs.builder().bucket(bucketName) + .object(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()).build())) { + consumer.accept(in); + } catch (ErrorResponseException | InsufficientDataException | InternalException | ServerException + | InvalidKeyException | InvalidResponseException | IOException | NoSuchAlgorithmException + | XmlParserException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo, e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo, Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + MinioClient client = getClient(); + try (InputStream in = client.getObject(GetObjectArgs.builder().bucket(bucketName) + .object(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()).build())) { + consumer.accept(in); + } catch (ErrorResponseException | InsufficientDataException | InternalException | ServerException + | InvalidKeyException | InvalidResponseException | IOException | NoSuchAlgorithmException + | XmlParserException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo, e); + } + + } + + @Override + public List getFileList(String folderName) { + List list = new ArrayList<>(); + try { + Iterable> results = null; + results = getClient().listObjects( + ListObjectsArgs.builder().bucket(bucketName).prefix(this.getBasePath() + folderName).recursive(true) + .build()); + for (Result result : results) { + Item item = result.get(); + list.add(item); + } + } catch (Exception e) { + throw new FileStorageRuntimeException("文件获取失败!platform:" + platform, e); + } + return list; + } + + @Override + public List conversionList(String folderName) { + List fileList = getFileList(folderName); + List listVOS = new ArrayList<>(fileList.size()); + if (fileList.size() > 0 && fileList.get(0) instanceof FileModel) { + return JsonUtil.getJsonToList(fileList, FileListVO.class); + } + for (int i = 0; i < fileList.size(); i++) { + FileListVO fileListVO = new FileListVO(); + fileListVO.setFileId(i + ""); + Item item = (Item) fileList.get(i); + String objectName = item.objectName(); + fileListVO.setFileName(objectName); + fileListVO.setFileType(FileUtil.getFileType(objectName)); + fileListVO.setFileSize(FileUtil.getSize(String.valueOf(item.size()))); + fileListVO.setFileTime(DateUtil.getZonedDateTimeToString(item.lastModified())); + listVOS.add(fileListVO); + } + return listVOS; + } + + @Override + public void downLocal(String folderName, String filePath, String objectName) { + try { + @Cleanup + InputStream stream = getClient().getObject( + GetObjectArgs.builder().bucket(bucketName).object(this.getBasePath() + folderName + objectName) + .build()); + FileUtil.write(stream, filePath, objectName); + } catch (Exception e) { + throw new FileStorageRuntimeException("文件获取失败!platform:" + platform, e); + } + } +} diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/QiniuKodoFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/QiniuKodoFileStorage.java new file mode 100644 index 0000000..bd36c3a --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/QiniuKodoFileStorage.java @@ -0,0 +1,254 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.util.StrUtil; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import com.qiniu.common.QiniuException; +import com.qiniu.storage.BucketManager; +import com.qiniu.storage.Configuration; +import com.qiniu.storage.Region; +import com.qiniu.storage.UploadManager; +import com.qiniu.util.Auth; +import com.yunzhupaas.model.FileListVO; +import com.yunzhupaas.model.FileModel; +import com.yunzhupaas.util.DateUtil; +import com.yunzhupaas.util.FileUtil; +import com.yunzhupaas.util.JsonUtil; +import lombok.Cleanup; +import lombok.Getter; +import lombok.Setter; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * 七牛云 Kodo 存储 + */ +@Getter +@Setter +public class QiniuKodoFileStorage implements FileStorage { + + /* 存储平台 */ + private String platform; + private String accessKey; + private String secretKey; + private String bucketName; + private String domain; + private String basePath; + private Region region; + private QiniuKodoClient client; + + /** + * 单例模式运行,不需要每次使用完再销毁了 + */ + public QiniuKodoClient getClient() { + if (client == null) { + client = new QiniuKodoClient(accessKey, secretKey); + } + return client; + } + + /** + * 仅在移除这个存储平台时调用 + */ + @Override + public void close() { + client = null; + } + + @Override + public boolean save(FileInfo fileInfo, UploadPretreatment pre) { + String newFileKey = basePath + fileInfo.getPath() + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + bucketName + "/" + newFileKey); + + try (InputStream in = pre.getFileWrapper().getInputStream()) { + QiniuKodoClient client = getClient(); + UploadManager uploadManager = client.getUploadManager(); + String token = client.getAuth().uploadToken(bucketName); + uploadManager.put(in, newFileKey, token, null, fileInfo.getContentType()); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { // 上传缩略图 + String newThFileKey = basePath + fileInfo.getPath() + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + uploadManager.put(new ByteArrayInputStream(thumbnailBytes), newThFileKey, token, null, + fileInfo.getThContentType()); + } + + return true; + } catch (IOException e) { + try { + client.getBucketManager().delete(bucketName, newFileKey); + } catch (QiniuException ignored) { + } + throw new FileStorageRuntimeException( + "文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(), e); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + BucketManager manager = getClient().getBucketManager(); + try { + if (fileInfo.getThFilename() != null) { // 删除缩略图 + delete(manager, fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + } + delete(manager, fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + } catch (QiniuException e) { + throw new FileStorageRuntimeException("删除文件失败!" + e.code() + "," + e.response.toString(), e); + } + return true; + } + + public void delete(BucketManager manager, String filename) throws QiniuException { + try { + manager.delete(bucketName, filename); + } catch (QiniuException e) { + if (!(e.response != null && e.response.statusCode == 612)) { + throw e; + } + } + } + + @Override + public boolean exists(FileInfo fileInfo) { + BucketManager manager = getClient().getBucketManager(); + try { + com.qiniu.storage.model.FileInfo stat = manager.stat(bucketName, + fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + if (stat != null && stat.md5 != null) + return true; + } catch (QiniuException e) { + throw new FileStorageRuntimeException("查询文件是否存在失败!" + e.code() + "," + e.response.toString(), e); + } + return false; + } + + @Override + public void download(FileInfo fileInfo, Consumer consumer) { + String url = getClient().getAuth().privateDownloadUrl(this.getDomain() + + this.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + try (InputStream in = new URL(url).openStream()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo, e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo, Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThUrl())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + String url = getClient().getAuth().privateDownloadUrl(this.getDomain() + + this.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + try (InputStream in = new URL(url).openStream()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo, e); + } + } + + @Getter + @Setter + public static class QiniuKodoClient { + private String accessKey; + private String secretKey; + private Auth auth; + private BucketManager bucketManager; + private UploadManager uploadManager; + + public QiniuKodoClient(String accessKey, String secretKey) { + this.accessKey = accessKey; + this.secretKey = secretKey; + } + + public Auth getAuth() { + if (auth == null) { + auth = Auth.create(accessKey, secretKey); + } + return auth; + } + + public BucketManager getBucketManager() { + if (bucketManager == null) { + bucketManager = new BucketManager(getAuth(), new Configuration(Region.autoRegion())); + } + return bucketManager; + } + + public UploadManager getUploadManager() { + if (uploadManager == null) { + uploadManager = new UploadManager(new Configuration(Region.autoRegion())); + } + return uploadManager; + } + } + + @Override + public List getFileList(String folderName) { + // 判断存储桶是否存在 + List list = new ArrayList<>(); + try { + BucketManager.FileListIterator fileListIterator = getClient().getBucketManager() + .createFileListIterator(this.getBasePath() + bucketName, folderName, 1000, ""); + while (fileListIterator.hasNext()) { + // 处理获取的file list结果 + com.qiniu.storage.model.FileInfo[] items = fileListIterator.next(); + for (com.qiniu.storage.model.FileInfo item : items) { + list.add(item); + } + } + } catch (Exception e) { + throw new FileStorageRuntimeException("文件获取失败!platform:" + platform, e); + } + return list; + } + + @Override + public List conversionList(String folderName) { + List fileList = getFileList(folderName); + List listVOS = new ArrayList<>(fileList.size()); + if (fileList.size() > 0 && fileList.get(0) instanceof FileModel) { + return JsonUtil.getJsonToList(fileList, FileListVO.class); + } + for (int i = 0; i < fileList.size(); i++) { + FileListVO fileListVO = new FileListVO(); + fileListVO.setFileId(i + ""); + // 七牛 + com.qiniu.storage.model.FileInfo fileInfo = (com.qiniu.storage.model.FileInfo) fileList.get(i); + String objectName = fileInfo.key.replace(this.getBasePath() + folderName + "/", ""); + fileListVO.setFileName(objectName); + fileListVO.setFileType(FileUtil.getFileType(objectName)); + fileListVO.setFileSize(FileUtil.getSize(String.valueOf(fileInfo.fsize))); + fileListVO.setFileTime(DateUtil.daFormat(fileInfo.putTime)); + listVOS.add(fileListVO); + } + return listVOS; + } + + @Override + public void downLocal(String folderName, String filePath, String objectName) { + try { + String encodedFileName = URLEncoder.encode(this.getBasePath() + folderName + objectName, "utf-8") + .replace("+", "%20"); + String finalUrl = String.format("%s/%s", domain, encodedFileName); + String downloadUrl = getClient().getAuth().privateDownloadUrl(finalUrl); + @Cleanup + InputStream inputStream = new URL(downloadUrl).openStream(); + FileUtil.write(inputStream, filePath, objectName); + } catch (Exception e) { + throw new FileStorageRuntimeException( + "文件下载失败!platform:" + platform + ",下载路径:" + filePath + ",文件夹名称:" + folderName + ",文件名:" + objectName, + e); + } + } +} diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/SftpFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/SftpFileStorage.java new file mode 100644 index 0000000..33ee320 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/SftpFileStorage.java @@ -0,0 +1,179 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.URLUtil; +import cn.hutool.extra.ssh.JschRuntimeException; +import cn.hutool.extra.ssh.JschUtil; +import cn.hutool.extra.ssh.Sftp; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import com.jcraft.jsch.SftpException; +import lombok.Getter; +import lombok.Setter; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +import static com.jcraft.jsch.ChannelSftp.SSH_FX_NO_SUCH_FILE; + +/** + * SFTP 存储 + */ +@Getter +@Setter +public class SftpFileStorage implements FileStorage { + + /* 主机 */ + private String host; + /* 端口,默认22 */ + private int port; + /* 用户名 */ + private String user; + /* 密码,默认空 */ + private String password; + /* 私钥路径,默认空 */ + private String privateKeyPath; + /* 编码,默认UTF-8 */ + private Charset charset; + /* 连接超时时长,单位毫秒,默认10秒 */ + private long connectionTimeout; + /* 存储平台 */ + private String platform; + private String domain; + private String basePath; + private String storagePath; + + /** + * 不支持单例模式运行,每次使用完了需要销毁 + */ + public Sftp getClient() { + Session session = null; + try { + if (StrUtil.isNotBlank(privateKeyPath)) { + //使用秘钥连接,这里手动读取 byte 进行构造用于兼容Spring的ClassPath路径、文件路径、HTTP路径等 + byte[] passphrase = StrUtil.isBlank(password) ? null : password.getBytes(StandardCharsets.UTF_8); + JSch jsch = new JSch(); + byte[] privateKey = IoUtil.readBytes(URLUtil.url(privateKeyPath).openStream()); + jsch.addIdentity(privateKeyPath,privateKey,null,passphrase); + session = JschUtil.createSession(jsch,host,port,user); + session.connect((int) connectionTimeout); + } else { + session = JschUtil.openSession(host,port,user,password,(int) connectionTimeout); + } + return new Sftp(session,charset,connectionTimeout); + } catch (Exception e) { + JschUtil.close(session); + throw new FileStorageRuntimeException("SFTP连接失败!platform:" + platform,e); + } + } + + + @Override + public void close() { + } + + /** + * 获取远程绝对路径 + */ + public String getAbsolutePath(String path) { + return storagePath + path; + } + + @Override + public boolean save(FileInfo fileInfo,UploadPretreatment pre) { + String newFileKey = basePath + fileInfo.getPath() + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + newFileKey); + + Sftp client = getClient(); + try (InputStream in = pre.getFileWrapper().getInputStream()) { + String path = getAbsolutePath(basePath + fileInfo.getPath()); + if (!client.exist(path)) { + client.mkDirs(path); + } + client.upload(path,fileInfo.getFilename(),in); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { //上传缩略图 + String newThFileKey = basePath + fileInfo.getPath() + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + client.upload(path,fileInfo.getThFilename(),new ByteArrayInputStream(thumbnailBytes)); + } + + return true; + } catch (IOException | JschRuntimeException e) { + try { + client.delFile(getAbsolutePath(newFileKey)); + } catch (JschRuntimeException ignored) { + } + throw new FileStorageRuntimeException("文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(),e); + } finally { + IoUtil.close(client); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + try (Sftp client = getClient()) { + if (fileInfo.getThFilename() != null) { //删除缩略图 + delFile(client,getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename())); + } + delFile(client,getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename())); + return true; + } catch (JschRuntimeException e) { + throw new FileStorageRuntimeException("文件删除失败!fileInfo:" + fileInfo,e); + } + } + + public void delFile(Sftp client,String filename) { + try { + client.delFile(filename); + } catch (JschRuntimeException e) { + if (!(e.getCause() instanceof SftpException && ((SftpException) e.getCause()).id == SSH_FX_NO_SUCH_FILE)) { + throw e; + } + } + } + + + @Override + public boolean exists(FileInfo fileInfo) { + try (Sftp client = getClient()) { + return client.exist(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename())); + } catch (JschRuntimeException e) { + throw new FileStorageRuntimeException("查询文件是否存在失败!fileInfo:" + fileInfo,e); + } + } + + @Override + public void download(FileInfo fileInfo,Consumer consumer) { + try (Sftp client = getClient(); + InputStream in = client.getClient().get(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()))) { + consumer.accept(in); + } catch (IOException | JschRuntimeException | SftpException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo,e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo,Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + + try (Sftp client = getClient(); InputStream in = client.getClient().get(getAbsolutePath(fileInfo.getBasePath() + fileInfo.getPath()) + fileInfo.getThFilename())) { + consumer.accept(in); + } catch (IOException | JschRuntimeException | SftpException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo,e); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/TencentCosFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/TencentCosFileStorage.java new file mode 100644 index 0000000..8bedcb2 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/TencentCosFileStorage.java @@ -0,0 +1,205 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.util.StrUtil; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.ClientConfig; +import com.qcloud.cos.auth.BasicCOSCredentials; +import com.qcloud.cos.auth.COSCredentials; +import com.qcloud.cos.http.HttpProtocol; +import com.qcloud.cos.model.*; +import com.qcloud.cos.region.Region; +import com.yunzhupaas.model.FileListVO; +import com.yunzhupaas.model.FileModel; +import com.yunzhupaas.util.DateUtil; +import com.yunzhupaas.util.FileUtil; +import com.yunzhupaas.util.JsonUtil; +import lombok.Getter; +import lombok.Setter; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * 腾讯云 COS 存储 + */ +@Getter +@Setter +public class TencentCosFileStorage implements FileStorage { + + /* 存储平台 */ + private String platform; + private String secretId; + private String secretKey; + private String region; + private String bucketName; + private String domain; + private String basePath; + private COSClient client; + + /** + * 单例模式运行,不需要每次使用完再销毁了 + */ + public COSClient getClient() { + if (client == null) { + COSCredentials cred = new BasicCOSCredentials(secretId, secretKey); + ClientConfig clientConfig = new ClientConfig(new Region(region)); + clientConfig.setHttpProtocol(HttpProtocol.https); + client = new COSClient(cred, clientConfig); + } + return client; + } + + /** + * 仅在移除这个存储平台时调用 + */ + @Override + public void close() { + if (client != null) { + client.shutdown(); + client = null; + } + } + + @Override + public boolean save(FileInfo fileInfo, UploadPretreatment pre) { + String newFileKey = basePath + fileInfo.getPath() + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + bucketName + "/" + newFileKey); + + COSClient client = getClient(); + try (InputStream in = pre.getFileWrapper().getInputStream()) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(fileInfo.getSize()); + metadata.setContentType(fileInfo.getContentType()); + client.putObject(bucketName, newFileKey, in, metadata); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { // 上传缩略图 + String newThFileKey = basePath + fileInfo.getPath() + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + ObjectMetadata thMetadata = new ObjectMetadata(); + thMetadata.setContentLength(thumbnailBytes.length); + thMetadata.setContentType(fileInfo.getThContentType()); + client.putObject(bucketName, newThFileKey, new ByteArrayInputStream(thumbnailBytes), thMetadata); + } + + return true; + } catch (IOException e) { + client.deleteObject(bucketName, newFileKey); + throw new FileStorageRuntimeException( + "文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(), e); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + COSClient client = getClient(); + if (fileInfo.getThFilename() != null) { // 删除缩略图 + client.deleteObject(bucketName, fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + } + client.deleteObject(bucketName, fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + return true; + } + + @Override + public boolean exists(FileInfo fileInfo) { + return getClient().doesObjectExist(bucketName, + fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + } + + @Override + public void download(FileInfo fileInfo, Consumer consumer) { + COSObject object = getClient().getObject(bucketName, + fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + try (InputStream in = object.getObjectContent()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo, e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo, Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + COSObject object = getClient().getObject(bucketName, + fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + try (InputStream in = object.getObjectContent()) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo, e); + } + } + + @Override + public List getFileList(String folderName) { + List list = new ArrayList<>(); + ListObjectsRequest listObjectsRequest = new ListObjectsRequest(); + listObjectsRequest.setBucketName(bucketName); + listObjectsRequest.setPrefix(this.getBasePath() + folderName); + // deliter表示分隔符, 设置为/表示列出当前目录下的object, 设置为空表示列出所有的object + listObjectsRequest.setDelimiter("/"); + listObjectsRequest.setMaxKeys(1000); + ObjectListing objectListing = null; + do { + try { + objectListing = getClient().listObjects(listObjectsRequest); + } catch (Exception e) { + e.printStackTrace(); + } + // common prefix表示表示被delimiter截断的路径, 如delimter设置为/, common prefix则表示所有子目录的路径 + // List commonPrefixs = objectListing.getCommonPrefixes(); + // object summary表示所有列出的object列表 + List cosObjectSummaries = objectListing.getObjectSummaries(); + for (COSObjectSummary cosObjectSummary : cosObjectSummaries) { + list.add(cosObjectSummary); + } + String nextMarker = objectListing.getNextMarker(); + listObjectsRequest.setMarker(nextMarker); + } while (objectListing.isTruncated()); + return list; + } + + @Override + public List conversionList(String folderName) { + List fileList = getFileList(folderName); + List listVOS = new ArrayList<>(fileList.size()); + if (fileList.size() > 0 && fileList.get(0) instanceof FileModel) { + return JsonUtil.getJsonToList(fileList, FileListVO.class); + } + for (int i = 0; i < fileList.size(); i++) { + FileListVO fileListVO = new FileListVO(); + fileListVO.setFileId(i + "");// 腾讯 + COSObjectSummary cosObjectSummary = (COSObjectSummary) fileList.get(i); + String objectName = cosObjectSummary.getKey().replace(this.getBasePath() + folderName + "/", ""); + fileListVO.setFileName(objectName); + fileListVO.setFileType(FileUtil.getFileType(objectName)); + fileListVO.setFileSize(FileUtil.getSize(String.valueOf(cosObjectSummary.getSize()))); + fileListVO.setFileTime(DateUtil.daFormat(cosObjectSummary.getLastModified())); + listVOS.add(fileListVO); + } + return listVOS; + } + + @Override + public void downLocal(String folderName, String filePath, String objectName) { + try { + GetObjectRequest getObjectRequest = new GetObjectRequest(bucketName, + this.getBasePath() + folderName + objectName); + getClient().getObject(getObjectRequest, new File(filePath + objectName)); + } catch (Exception e) { + throw new FileStorageRuntimeException( + "文件下载失败!platform:" + platform + ",下载路径:" + filePath + ",文件夹名称:" + folderName + ",文件名:" + objectName, + e); + } + } +} diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/UpyunUssFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/UpyunUssFileStorage.java new file mode 100644 index 0000000..ab6fdf1 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/UpyunUssFileStorage.java @@ -0,0 +1,153 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import com.upyun.RestManager; +import com.upyun.UpException; +import lombok.Getter; +import lombok.Setter; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.function.Consumer; + +/** + * 又拍云 USS 存储 + */ +@Getter +@Setter +public class UpyunUssFileStorage implements FileStorage { + + /* 存储平台 */ + private String platform; + private String username; + private String password; + private String bucketName; + private String domain; + private String basePath; + private RestManager client; + + public RestManager getClient() { + if (client == null) { + client = new RestManager(bucketName,username,password); + } + return client; + } + + /** + * 仅在移除这个存储平台时调用 + */ + @Override + public void close() { + client = null; + } + + @Override + public boolean save(FileInfo fileInfo,UploadPretreatment pre) { + + + String newFileKey = basePath + fileInfo.getPath() + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + newFileKey); + + RestManager manager = getClient(); + try (InputStream in = pre.getFileWrapper().getInputStream()) { + HashMap params = new HashMap<>(); + params.put(RestManager.PARAMS.CONTENT_TYPE.getValue(),fileInfo.getContentType()); + try (Response result = manager.writeFile(newFileKey,in,params)) { + if (!result.isSuccessful()) { + throw new UpException(result.toString()); + } + } + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { //上传缩略图 + String newThFileKey = basePath + fileInfo.getPath() + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + HashMap thParams = new HashMap<>(); + thParams.put(RestManager.PARAMS.CONTENT_TYPE.getValue(),fileInfo.getThContentType()); + Response thResult = manager.writeFile(newThFileKey,new ByteArrayInputStream(thumbnailBytes),thParams); + if (!thResult.isSuccessful()) { + throw new UpException(thResult.toString()); + } + } + + return true; + } catch (IOException | UpException e) { + try { + manager.deleteFile(newFileKey,null).close(); + } catch (IOException | UpException ignored) { + } + throw new FileStorageRuntimeException("文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(),e); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + RestManager manager = getClient(); + String file = fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename(); + String thFile = fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename(); + + try (Response ignored = fileInfo.getThFilename() != null ? manager.deleteFile(thFile,null) : null; + Response ignored2 = manager.deleteFile(file,null)) { + return true; + } catch (IOException | UpException e) { + throw new FileStorageRuntimeException("文件删除失败!fileInfo:" + fileInfo,e); + } + } + + @Override + public boolean exists(FileInfo fileInfo) { + try (Response response = getClient().getFileInfo(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename())) { + return StrUtil.isNotBlank(response.header("x-upyun-file-size")); + } catch (IOException | UpException e) { + throw new FileStorageRuntimeException("判断文件是否存在失败!fileInfo:" + fileInfo,e); + } + } + + @Override + public void download(FileInfo fileInfo,Consumer consumer) { + try (Response response = getClient().readFile(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()); + ResponseBody body = response.body(); + InputStream in = body == null ? null : body.byteStream()) { + if (body == null) { + throw new FileStorageRuntimeException("文件下载失败,结果为 null !fileInfo:" + fileInfo); + } + if (!response.isSuccessful()) { + throw new UpException(IoUtil.read(in,StandardCharsets.UTF_8)); + } + consumer.accept(in); + } catch (IOException | UpException e) { + throw new FileStorageRuntimeException("文件下载失败!fileInfo:" + fileInfo,e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo,Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + try (Response response = getClient().readFile(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename()); + ResponseBody body = response.body(); + InputStream in = body == null ? null : body.byteStream()) { + if (body == null) { + throw new FileStorageRuntimeException("缩略图文件下载失败,结果为 null !fileInfo:" + fileInfo); + } + if (!response.isSuccessful()) { + throw new UpException(IoUtil.read(in,StandardCharsets.UTF_8)); + } + consumer.accept(in); + } catch (IOException | UpException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo,e); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/platform/WebDavFileStorage.java b/src/main/java/cn/xuyanwu/spring/file/storage/platform/WebDavFileStorage.java new file mode 100644 index 0000000..5a70a84 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/platform/WebDavFileStorage.java @@ -0,0 +1,161 @@ +package cn.xuyanwu.spring.file.storage.platform; + +import cn.hutool.core.io.IORuntimeException; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.xuyanwu.spring.file.storage.FileInfo; +import cn.xuyanwu.spring.file.storage.PathUtil; +import cn.xuyanwu.spring.file.storage.UploadPretreatment; +import cn.xuyanwu.spring.file.storage.exception.FileStorageRuntimeException; +import com.github.sardine.Sardine; +import com.github.sardine.SardineFactory; +import com.github.sardine.impl.SardineException; +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.function.Consumer; + +/** + * WebDav 存储 + */ +@Getter +@Setter +public class WebDavFileStorage implements FileStorage { + + private String server; + private String user; + private String password; + private String platform; + private String domain; + private String basePath; + private String storagePath; + private Sardine client; + + /** + * 不支持单例模式运行,每次使用完了需要销毁 + */ + public Sardine getClient() { + if (client == null) { + client = SardineFactory.begin(user,password); + } + return client; + } + + /** + * 仅在移除这个存储平台时调用 + */ + @SneakyThrows + @Override + public void close() { + if (client != null) { + client.shutdown(); + client = null; + } + } + + /** + * 获取远程绝对路径 + */ + public String getUrl(String path) { + return PathUtil.join(server,storagePath + path); + } + + /** + * 递归创建目录 + */ + public void createDirectory(Sardine client,String path) throws IOException { + if (!client.exists(path)) { + createDirectory(client,PathUtil.join(PathUtil.getParent(path),"/")); + client.createDirectory(path); + } + } + + @Override + public boolean save(FileInfo fileInfo,UploadPretreatment pre) { + String path = basePath + fileInfo.getPath(); + String newFileKey = path + fileInfo.getFilename(); + fileInfo.setBasePath(basePath); + fileInfo.setUrl(domain + newFileKey); + + Sardine client = getClient(); + try (InputStream in = pre.getFileWrapper().getInputStream()) { + byte[] bytes = IoUtil.readBytes(in); + createDirectory(client,getUrl(path)); + client.put(getUrl(newFileKey),bytes); + + byte[] thumbnailBytes = pre.getThumbnailBytes(); + if (thumbnailBytes != null) { //上传缩略图 + String newThFileKey = path + fileInfo.getThFilename(); + fileInfo.setThUrl(domain + newThFileKey); + client.put(getUrl(newThFileKey),thumbnailBytes); + } + + return true; + } catch (IOException | IORuntimeException e) { + try { + client.delete(getUrl(newFileKey)); + } catch (IOException ignored) { + } + throw new FileStorageRuntimeException("文件上传失败!platform:" + platform + ",filename:" + fileInfo.getOriginalFilename(),e); + } + } + + @Override + public boolean delete(FileInfo fileInfo) { + Sardine client = getClient(); + try { + if (fileInfo.getThFilename() != null) { //删除缩略图 + try { + client.delete(getUrl(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getThFilename())); + } catch (SardineException e) { + if (e.getStatusCode() != 404) throw e; + } + } + try { + client.delete(getUrl(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename())); + } catch (SardineException e) { + if (e.getStatusCode() != 404) throw e; + } + return true; + } catch (IOException e) { + throw new FileStorageRuntimeException("文件删除失败!fileInfo:" + fileInfo,e); + } + } + + + @Override + public boolean exists(FileInfo fileInfo) { + try { + return getClient().exists(getUrl(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename())); + } catch (IOException e) { + throw new FileStorageRuntimeException("查询文件是否存在失败!fileInfo:" + fileInfo,e); + } + } + + @Override + public void download(FileInfo fileInfo,Consumer consumer) { + try (InputStream in = getClient().get(getUrl(fileInfo.getBasePath() + fileInfo.getPath() + fileInfo.getFilename()))) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("文件下载失败!platform:" + fileInfo,e); + } + } + + @Override + public void downloadTh(FileInfo fileInfo,Consumer consumer) { + if (StrUtil.isBlank(fileInfo.getThFilename())) { + throw new FileStorageRuntimeException("缩略图文件下载失败,文件不存在!fileInfo:" + fileInfo); + } + + try (InputStream in = getClient().get(getUrl(fileInfo.getBasePath() + fileInfo.getPath()) + fileInfo.getThFilename())) { + consumer.accept(in); + } catch (IOException e) { + throw new FileStorageRuntimeException("缩略图文件下载失败!fileInfo:" + fileInfo,e); + } + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/recorder/DefaultFileRecorder.java b/src/main/java/cn/xuyanwu/spring/file/storage/recorder/DefaultFileRecorder.java new file mode 100644 index 0000000..732fb54 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/recorder/DefaultFileRecorder.java @@ -0,0 +1,24 @@ +package cn.xuyanwu.spring.file.storage.recorder; + +import cn.xuyanwu.spring.file.storage.FileInfo; + +/** + * 默认的文件记录者类,此类并不能真正保存、查询、删除记录,只是用来脱离数据库运行,保证文件上传功能可以正常使用 + */ +public class DefaultFileRecorder implements FileRecorder { + @Override + public boolean record(FileInfo fileInfo) { + return true; + } + + @Override + public FileInfo getByUrl(String url) { + return null; + } + + @Override + public boolean delete(String url) { + return true; + } +} + diff --git a/src/main/java/cn/xuyanwu/spring/file/storage/recorder/FileRecorder.java b/src/main/java/cn/xuyanwu/spring/file/storage/recorder/FileRecorder.java new file mode 100644 index 0000000..7d02799 --- /dev/null +++ b/src/main/java/cn/xuyanwu/spring/file/storage/recorder/FileRecorder.java @@ -0,0 +1,25 @@ +package cn.xuyanwu.spring.file.storage.recorder; + +import cn.xuyanwu.spring.file.storage.FileInfo; + +/** + * 文件记录记录者接口 + */ +public interface FileRecorder { + + /** + * 保存文件记录 + */ + boolean record(FileInfo fileInfo); + + /** + * 根据 url 获取文件记录 + */ + FileInfo getByUrl(String url); + + /** + * 根据 url 删除文件记录 + */ + boolean delete(String url); +} +