zhaojinyu 3 месяцев назад
Сommit
9660e8b554
100 измененных файлов с 5194 добавлено и 0 удалено
  1. 8 0
      .idea/.gitignore
  2. 18 0
      .idea/compiler.xml
  3. 7 0
      .idea/encodings.xml
  4. 20 0
      .idea/jarRepositories.xml
  5. 12 0
      .idea/misc.xml
  6. 6 0
      .idea/vcs.xml
  7. 83 0
      README.md
  8. 172 0
      pom.xml
  9. 138 0
      src/main/java/cn/xuyanwu/spring/file/storage/Downloader.java
  10. 15 0
      src/main/java/cn/xuyanwu/spring/file/storage/EnableFileStorage.java
  11. 104 0
      src/main/java/cn/xuyanwu/spring/file/storage/FileInfo.java
  12. 412 0
      src/main/java/cn/xuyanwu/spring/file/storage/FileStorageAutoConfiguration.java
  13. 526 0
      src/main/java/cn/xuyanwu/spring/file/storage/FileStorageProperties.java
  14. 348 0
      src/main/java/cn/xuyanwu/spring/file/storage/FileStorageService.java
  15. 97 0
      src/main/java/cn/xuyanwu/spring/file/storage/MockMultipartFile.java
  16. 100 0
      src/main/java/cn/xuyanwu/spring/file/storage/MultipartFileWrapper.java
  17. 37 0
      src/main/java/cn/xuyanwu/spring/file/storage/PathUtil.java
  18. 61 0
      src/main/java/cn/xuyanwu/spring/file/storage/ProgressInputStream.java
  19. 25 0
      src/main/java/cn/xuyanwu/spring/file/storage/ProgressListener.java
  20. 250 0
      src/main/java/cn/xuyanwu/spring/file/storage/UploadPretreatment.java
  21. 36 0
      src/main/java/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChain.java
  22. 12 0
      src/main/java/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChainCallback.java
  23. 37 0
      src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChain.java
  24. 14 0
      src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChainCallback.java
  25. 37 0
      src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChain.java
  26. 14 0
      src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChainCallback.java
  27. 35 0
      src/main/java/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChain.java
  28. 11 0
      src/main/java/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChainCallback.java
  29. 52 0
      src/main/java/cn/xuyanwu/spring/file/storage/aspect/FileStorageAspect.java
  30. 37 0
      src/main/java/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChain.java
  31. 13 0
      src/main/java/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChainCallback.java
  32. 25 0
      src/main/java/cn/xuyanwu/spring/file/storage/exception/FileStorageRuntimeException.java
  33. 183 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/AliyunOssFileStorage.java
  34. 136 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/AwsS3FileStorage.java
  35. 131 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/BaiduBosFileStorage.java
  36. 111 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/FileStorage.java
  37. 164 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/FtpFileStorage.java
  38. 179 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/HuaweiObsFileStorage.java
  39. 91 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/LocalFileStorage.java
  40. 137 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/LocalPlusFileStorage.java
  41. 208 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/MinIOFileStorage.java
  42. 247 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/QiniuKodoFileStorage.java
  43. 178 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/SftpFileStorage.java
  44. 199 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/TencentCosFileStorage.java
  45. 152 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/UpyunUssFileStorage.java
  46. 160 0
      src/main/java/cn/xuyanwu/spring/file/storage/platform/WebDavFileStorage.java
  47. 23 0
      src/main/java/cn/xuyanwu/spring/file/storage/recorder/DefaultFileRecorder.java
  48. 24 0
      src/main/java/cn/xuyanwu/spring/file/storage/recorder/FileRecorder.java
  49. 109 0
      target/classes/META-INF/spring-configuration-metadata.json
  50. BIN
      target/classes/cn/xuyanwu/spring/file/storage/Downloader$1.class
  51. BIN
      target/classes/cn/xuyanwu/spring/file/storage/Downloader.class
  52. BIN
      target/classes/cn/xuyanwu/spring/file/storage/EnableFileStorage.class
  53. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileInfo.class
  54. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageAutoConfiguration.class
  55. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$AliyunOss.class
  56. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$AwsS3.class
  57. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$BaiduBos.class
  58. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$FTP.class
  59. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$HuaweiObs.class
  60. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$Local.class
  61. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$LocalPlus.class
  62. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$MinIO.class
  63. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$QiniuKodo.class
  64. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$SFTP.class
  65. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$TencentCos.class
  66. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$UpyunUSS.class
  67. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$WebDAV.class
  68. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties.class
  69. BIN
      target/classes/cn/xuyanwu/spring/file/storage/FileStorageService.class
  70. BIN
      target/classes/cn/xuyanwu/spring/file/storage/MockMultipartFile.class
  71. BIN
      target/classes/cn/xuyanwu/spring/file/storage/MultipartFileWrapper.class
  72. BIN
      target/classes/cn/xuyanwu/spring/file/storage/PathUtil.class
  73. BIN
      target/classes/cn/xuyanwu/spring/file/storage/ProgressInputStream.class
  74. BIN
      target/classes/cn/xuyanwu/spring/file/storage/ProgressListener.class
  75. BIN
      target/classes/cn/xuyanwu/spring/file/storage/UploadPretreatment.class
  76. BIN
      target/classes/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChain.class
  77. BIN
      target/classes/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChainCallback.class
  78. BIN
      target/classes/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChain.class
  79. BIN
      target/classes/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChainCallback.class
  80. BIN
      target/classes/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChain.class
  81. BIN
      target/classes/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChainCallback.class
  82. BIN
      target/classes/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChain.class
  83. BIN
      target/classes/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChainCallback.class
  84. BIN
      target/classes/cn/xuyanwu/spring/file/storage/aspect/FileStorageAspect.class
  85. BIN
      target/classes/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChain.class
  86. BIN
      target/classes/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChainCallback.class
  87. BIN
      target/classes/cn/xuyanwu/spring/file/storage/exception/FileStorageRuntimeException.class
  88. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/AliyunOssFileStorage.class
  89. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/AwsS3FileStorage.class
  90. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/BaiduBosFileStorage.class
  91. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/FileStorage.class
  92. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/FtpFileStorage.class
  93. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/HuaweiObsFileStorage.class
  94. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/LocalFileStorage.class
  95. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/LocalPlusFileStorage.class
  96. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/MinIOFileStorage.class
  97. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/QiniuKodoFileStorage$QiniuKodoClient.class
  98. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/QiniuKodoFileStorage.class
  99. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/SftpFileStorage.class
  100. BIN
      target/classes/cn/xuyanwu/spring/file/storage/platform/TencentCosFileStorage.class

+ 8 - 0
.idea/.gitignore

@@ -0,0 +1,8 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml

+ 18 - 0
.idea/compiler.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="CompilerConfiguration">
+    <annotationProcessing>
+      <profile name="Maven default annotation processors profile" enabled="true">
+        <sourceOutputDir name="target/generated-sources/annotations" />
+        <sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
+        <outputRelativeToContentRoot value="true" />
+        <module name="jnpf-file-core-starter" />
+      </profile>
+    </annotationProcessing>
+  </component>
+  <component name="JavacSettings">
+    <option name="ADDITIONAL_OPTIONS_OVERRIDE">
+      <module name="jnpf-file-core-starter" options="-parameters" />
+    </option>
+  </component>
+</project>

+ 7 - 0
.idea/encodings.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="Encoding">
+    <file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
+    <file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
+  </component>
+</project>

+ 20 - 0
.idea/jarRepositories.xml

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="RemoteRepositoriesConfiguration">
+    <remote-repository>
+      <option name="id" value="central" />
+      <option name="name" value="Central Repository" />
+      <option name="url" value="http://127.0.0.1:9999/repository/maven-public/" />
+    </remote-repository>
+    <remote-repository>
+      <option name="id" value="central" />
+      <option name="name" value="Maven Central repository" />
+      <option name="url" value="https://repo1.maven.org/maven2" />
+    </remote-repository>
+    <remote-repository>
+      <option name="id" value="jboss.community" />
+      <option name="name" value="JBoss Community repository" />
+      <option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
+    </remote-repository>
+  </component>
+</project>

+ 12 - 0
.idea/misc.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ExternalStorageConfigurationManager" enabled="true" />
+  <component name="MavenProjectsManager">
+    <option name="originalFiles">
+      <list>
+        <option value="$PROJECT_DIR$/pom.xml" />
+      </list>
+    </option>
+  </component>
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK" />
+</project>

+ 6 - 0
.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
+  </component>
+</project>

+ 83 - 0
README.md

@@ -0,0 +1,83 @@
+> 特别说明:源码、JDK、数据库、Redis等安装或存放路径禁止包含中文、空格、特殊字符等
+
+## 一 项目结构
+
+```text
+jnpf-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工具 |
+
+## 三 关联项目
+> 为以下项目提供基础依赖
+
+| 项目 |  分支 | 说明 |
+| --- |  --- | --- |
+| jnpf-common | v5.2.x-stable | Java基础依赖项目源码 |
+| jnpf-java-boot  | v5.2.x-stable | Java单体后端项目源码 |
+| jnpf-java-cloud | v5.2.x-stable | Java微服务后端项目源码 |
+
+## 四 使用方式
+
+### 4.1 前置条件
+
+#### 4.1.1 本地安装jnpf-common-core
+
+IDEA中打开 `jnpf-common` 项目, 双击右侧 `Maven` 中 `jnpf-common` > `jnpf-boot-common` > `jnpf-common-core` > `Lifecycle` > `install`,将 `jnpf-common-core` 包安装至本地
+
+#### 4.1.2 本地安装dependencies
+
+IDEA中打开 `jnpf-common` 项目,双击右侧 `Maven` 中 `jnpf-common` > `jnpf-dependencies` > `Lifecycle` > `install`,将 `jnpf-dependencies` 包安装至本地
+
+### 4.2 本地安装
+
+在IDEA中,双击右侧 `Maven` 中`jnpf-file-core-starter` > `Lifecycle` > `install`,将`jnpf-file-core-starter`包安装至本地
+
+### 4.3 私服发布
+> 若无Maven私服,忽略本节内容
+
+#### 4.3.1 配置Maven
+
+打开Maven安装目录中的 `conf/setttings.xml` ,
+
+在 `<servers></servers>`节点增加 `<server></server>` ,如下所示:
+
+```xml
+  <!-- 发布版 -->
+  <server>
+    <id>maven-releases</id>
+    <username>jnpf-user(账号,结合私服配置设置)</username>
+    <password>123456(密码,结合私服配置设置)</password>
+  </server>
+```
+#### 4.3.2 配置项目
+
+> 注意:pom.xml里 `<id>` 和 setting.xml 配置里 `<id>` 对应。
+
+IDEA打开 `jnpf-common` 项目, 修改 `jnpf-dependencies/pom.xml` 文件中私服配置
+
+```xml
+<distributionManagement>
+  <repository>
+    <id>maven-releases</id>
+    <name>maven-releases</name>
+    <url>http://nexus.jnpfsoft.com/repository/maven-releases/</url>
+  </repository>
+</distributionManagement>
+```
+
+#### 4.3.3 发布到私服
+
+在IDEA中,双击右侧 `Maven` 中 `jnpf-file-core-starter` > `Lifecycle` > `deploy` 发布至私服。

+ 172 - 0
pom.xml

@@ -0,0 +1,172 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+    <parent>
+        <artifactId>jnpf-dependencies</artifactId>
+        <groupId>com.jnpf</groupId>
+        <version>5.2.0-RELEASE</version>
+        <relativePath/>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.jnpf</groupId>
+
+    <artifactId>jnpf-file-core-starter</artifactId>
+    <version>5.2.0-RELEASE</version>
+
+    <properties>
+    </properties>
+
+    <dependencies>
+
+        <!-- WebDAV -->
+        <dependency>
+            <groupId>com.github.lookfirst</groupId>
+            <artifactId>sardine</artifactId>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- SFTP -->
+        <dependency>
+            <groupId>com.jcraft</groupId>
+            <artifactId>jsch</artifactId>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- FTP -->
+        <dependency>
+            <groupId>commons-net</groupId>
+            <artifactId>commons-net</artifactId>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+
+        <!--糊涂工具类扩展-->
+        <dependency>
+            <groupId>cn.hutool</groupId>
+            <artifactId>hutool-extra</artifactId>
+            <version>${hutool.version}</version>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- AWS S3 -->
+        <dependency>
+            <groupId>com.amazonaws</groupId>
+            <artifactId>aws-java-sdk-s3</artifactId>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+
+
+        <!-- MinIO -->
+        <dependency>
+            <groupId>io.minio</groupId>
+            <artifactId>minio</artifactId>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- 又拍云 USS -->
+        <dependency>
+            <groupId>com.upyun</groupId>
+            <artifactId>java-sdk</artifactId>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- 百度云 BOS -->
+        <dependency>
+            <groupId>com.baidubce</groupId>
+            <artifactId>bce-java-sdk</artifactId>
+            <scope>provided</scope>
+            <optional>true</optional>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.springframework</groupId>
+                    <artifactId>spring-core</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>jdk.tools</groupId>
+                    <artifactId>jdk.tools</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
+        <!-- 腾讯云 COS -->
+        <dependency>
+            <groupId>com.qcloud</groupId>
+            <artifactId>cos_api</artifactId>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- 七牛云 Kodo -->
+        <dependency>
+            <groupId>com.qiniu</groupId>
+            <artifactId>qiniu-java-sdk</artifactId>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- 阿里云 OSS -->
+        <dependency>
+            <groupId>com.aliyun.oss</groupId>
+            <artifactId>aliyun-sdk-oss</artifactId>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- 华为云 OBS -->
+        <dependency>
+            <groupId>com.huaweicloud</groupId>
+            <artifactId>esdk-obs-java</artifactId>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+
+        <!--糊涂工具类核心-->
+        <dependency>
+            <groupId>cn.hutool</groupId>
+            <artifactId>hutool-core</artifactId>
+            <version>${hutool.version}</version>
+        </dependency>
+
+        <!-- 图片处理 https://github.com/coobird/thumbnailator -->
+        <dependency>
+            <groupId>net.coobird</groupId>
+            <artifactId>thumbnailator</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-configuration-processor</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <dependency>
+            <groupId>com.jnpf</groupId>
+            <artifactId>jnpf-common-core</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
+    </dependencies>
+
+
+</project>

+ 138 - 0
src/main/java/cn/xuyanwu/spring/file/storage/Downloader.java

@@ -0,0 +1,138 @@
+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<FileStorageAspect> 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<FileStorageAspect> aspectList,FileStorage fileStorage,Integer target) {
+        this.fileStorage = fileStorage;
+        this.aspectList = aspectList;
+        this.fileInfo = fileInfo;
+        this.target = target;
+    }
+
+    /**
+     * 设置下载进度监听器
+     * @param progressListener 提供一个参数,表示已传输字节数
+     */
+    public Downloader setProgressMonitor(Consumer<Long> progressListener) {
+        return setProgressMonitor((progressSize,allSize) -> progressListener.accept(progressSize));
+    }
+
+    /**
+     * 设置下载进度监听器
+     * @param progressListener 提供两个参数,第一个是 progressSize已传输字节数,第二个是 allSize总字节数
+     */
+    public Downloader setProgressMonitor(BiConsumer<Long,Long> 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<InputStream> 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));
+    }
+
+
+}

+ 15 - 0
src/main/java/cn/xuyanwu/spring/file/storage/EnableFileStorage.java

@@ -0,0 +1,15 @@
+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 {
+}

+ 104 - 0
src/main/java/cn/xuyanwu/spring/file/storage/FileInfo.java

@@ -0,0 +1,104 @@
+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;
+}

+ 412 - 0
src/main/java/cn/xuyanwu/spring/file/storage/FileStorageAutoConfiguration.java

@@ -0,0 +1,412 @@
+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<LocalFileStorage> 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<LocalPlusFileStorage> 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<HuaweiObsFileStorage> 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<AliyunOssFileStorage> 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<QiniuKodoFileStorage> 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<TencentCosFileStorage> 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<BaiduBosFileStorage> 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<UpyunUssFileStorage> 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<MinIOFileStorage> 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<AwsS3FileStorage> 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<FtpFileStorage> 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<SftpFileStorage> 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<WebDavFileStorage> 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<List<? extends FileStorage>> fileStorageLists,
+                                                 List<FileStorageAspect> 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;
+        }
+    }
+
+}

+ 526 - 0
src/main/java/cn/xuyanwu/spring/file/storage/FileStorageProperties.java

@@ -0,0 +1,526 @@
+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> local = new ArrayList<>();
+    /**
+     * 本地存储
+     */
+    private List<LocalPlus> localPlus = new ArrayList<>();
+    /**
+     * 华为云 OBS
+     */
+    private List<HuaweiObs> huaweiObs = new ArrayList<>();
+    /**
+     * 阿里云 OSS
+     */
+    private List<AliyunOss> aliyunOss = new ArrayList<>();
+    /**
+     * 七牛云 Kodo
+     */
+    private List<QiniuKodo> qiniuKodo = new ArrayList<>();
+    /**
+     * 腾讯云 COS
+     */
+    private List<TencentCos> tencentCos = new ArrayList<>();
+    /**
+     * 百度云 BOS
+     */
+    private List<BaiduBos> baiduBos = new ArrayList<>();
+    /**
+     * 又拍云 USS
+     */
+    private List<UpyunUSS> upyunUSS = new ArrayList<>();
+    /**
+     * MinIO USS
+     */
+    private List<MinIO> minio = new ArrayList<>();
+
+    /**
+     * AWS S3
+     */
+    private List<AwsS3> awsS3 = new ArrayList<>();
+
+    /**
+     * FTP
+     */
+    private List<FTP> ftp = new ArrayList<>();
+
+    /**
+     * FTP
+     */
+    private List<SFTP> sftp = new ArrayList<>();
+
+    /**
+     * WebDAV
+     */
+    private List<WebDAV> 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 = "/";
+    }
+}

+ 348 - 0
src/main/java/cn/xuyanwu/spring/file/storage/FileStorageService.java

@@ -0,0 +1,348 @@
+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<FileStorage> fileStorageList;
+    private FileStorageProperties properties;
+    private CopyOnWriteArrayList<FileStorageAspect> 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<FileInfo> predicate) {
+        return delete(getFileInfoByUrl(url),predicate);
+    }
+
+    /**
+     * 根据条件
+     */
+    public boolean delete(FileInfo fileInfo) {
+        return delete(fileInfo,null);
+    }
+
+    /**
+     * 根据条件删除文件
+     */
+    public boolean delete(FileInfo fileInfo,Predicate<FileInfo> 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);
+            }
+        }
+    }
+}

+ 97 - 0
src/main/java/cn/xuyanwu/spring/file/storage/MockMultipartFile.java

@@ -0,0 +1,97 @@
+/*
+ * Copyright 2002-2018 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package 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);
+    }
+
+}

+ 100 - 0
src/main/java/cn/xuyanwu/spring/file/storage/MultipartFileWrapper.java

@@ -0,0 +1,100 @@
+/*
+ * Copyright 2002-2018 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package 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);
+    }
+}

+ 37 - 0
src/main/java/cn/xuyanwu/spring/file/storage/PathUtil.java

@@ -0,0 +1,37 @@
+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();
+    }
+}

+ 61 - 0
src/main/java/cn/xuyanwu/spring/file/storage/ProgressInputStream.java

@@ -0,0 +1,61 @@
+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();
+        }
+    }
+
+
+}

+ 25 - 0
src/main/java/cn/xuyanwu/spring/file/storage/ProgressListener.java

@@ -0,0 +1,25 @@
+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();
+}

+ 250 - 0
src/main/java/cn/xuyanwu/spring/file/storage/UploadPretreatment.java

@@ -0,0 +1,250 @@
+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<Thumbnails.Builder<? extends InputStream>> consumer) {
+        try (InputStream in = fileWrapper.getInputStream()) {
+            Thumbnails.Builder<? extends InputStream> 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<Thumbnails.Builder<? extends InputStream>> 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<Thumbnails.Builder<? extends InputStream>> consumer,MultipartFile file) {
+        try {
+            return thumbnail(consumer,file.getInputStream());
+        } catch (IOException e) {
+            throw new FileStorageRuntimeException("生成缩略图失败!",e);
+        }
+    }
+
+    /**
+     * 通过指定 InputStream 生成缩略图并进行图片处理,
+     * 可以进行裁剪、旋转、缩放、水印等操作,默认输出图片格式通过 thumbnailSuffix 获取,
+     * 操作完成后会自动关闭 InputStream
+     */
+    public UploadPretreatment thumbnail(Consumer<Thumbnails.Builder<? extends InputStream>> consumer,InputStream in) {
+        try {
+            Thumbnails.Builder<? extends InputStream> 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);
+    }
+}

+ 36 - 0
src/main/java/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChain.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 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<FileStorageAspect> aspectIterator;
+
+    public DeleteAspectChain(Iterable<FileStorageAspect> 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);
+        }
+    }
+}

+ 12 - 0
src/main/java/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChainCallback.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;
+import cn.xuyanwu.spring.file.storage.recorder.FileRecorder;
+
+/**
+ * 删除切面调用链结束回调
+ */
+public interface DeleteAspectChainCallback {
+    boolean run(FileInfo fileInfo,FileStorage fileStorage,FileRecorder fileRecorder);
+}

+ 37 - 0
src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChain.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 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<FileStorageAspect> aspectIterator;
+
+    public DownloadAspectChain(Iterable<FileStorageAspect> aspects,DownloadAspectChainCallback callback) {
+        this.aspectIterator = aspects.iterator();
+        this.callback = callback;
+    }
+
+    /**
+     * 调用下一个切面
+     */
+    public void next(FileInfo fileInfo,FileStorage fileStorage,Consumer<InputStream> consumer) {
+        if (aspectIterator.hasNext()) {//还有下一个
+            aspectIterator.next().downloadAround(this,fileInfo,fileStorage,consumer);
+        } else {
+            callback.run(fileInfo,fileStorage,consumer);
+        }
+    }
+}

+ 14 - 0
src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChainCallback.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.platform.FileStorage;
+
+import java.io.InputStream;
+import java.util.function.Consumer;
+
+/**
+ * 下载切面调用链结束回调
+ */
+public interface DownloadAspectChainCallback {
+    void run(FileInfo fileInfo,FileStorage fileStorage,Consumer<InputStream> consumer);
+}

+ 37 - 0
src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChain.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 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<FileStorageAspect> aspectIterator;
+
+    public DownloadThAspectChain(Iterable<FileStorageAspect> aspects,DownloadThAspectChainCallback callback) {
+        this.aspectIterator = aspects.iterator();
+        this.callback = callback;
+    }
+
+    /**
+     * 调用下一个切面
+     */
+    public void next(FileInfo fileInfo,FileStorage fileStorage,Consumer<InputStream> consumer) {
+        if (aspectIterator.hasNext()) {//还有下一个
+            aspectIterator.next().downloadThAround(this,fileInfo,fileStorage,consumer);
+        } else {
+            callback.run(fileInfo,fileStorage,consumer);
+        }
+    }
+}

+ 14 - 0
src/main/java/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChainCallback.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.platform.FileStorage;
+
+import java.io.InputStream;
+import java.util.function.Consumer;
+
+/**
+ * 下载缩略图切面调用链结束回调
+ */
+public interface DownloadThAspectChainCallback {
+    void run(FileInfo fileInfo,FileStorage fileStorage,Consumer<InputStream> consumer);
+}

+ 35 - 0
src/main/java/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChain.java

@@ -0,0 +1,35 @@
+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<FileStorageAspect> aspectIterator;
+
+    public ExistsAspectChain(Iterable<FileStorageAspect> 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);
+        }
+    }
+}

+ 11 - 0
src/main/java/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChainCallback.java

@@ -0,0 +1,11 @@
+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);
+}

+ 52 - 0
src/main/java/cn/xuyanwu/spring/file/storage/aspect/FileStorageAspect.java

@@ -0,0 +1,52 @@
+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<InputStream> consumer) {
+        chain.next(fileInfo,fileStorage,consumer);
+    }
+
+    /**
+     * 下载缩略图文件,成功返回文件内容
+     */
+    default void downloadThAround(DownloadThAspectChain chain,FileInfo fileInfo,FileStorage fileStorage,Consumer<InputStream> consumer) {
+        chain.next(fileInfo,fileStorage,consumer);
+    }
+}

+ 37 - 0
src/main/java/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChain.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.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<FileStorageAspect> aspectIterator;
+
+    public UploadAspectChain(Iterable<FileStorageAspect> 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);
+        }
+    }
+}

+ 13 - 0
src/main/java/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChainCallback.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.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);
+}

+ 25 - 0
src/main/java/cn/xuyanwu/spring/file/storage/exception/FileStorageRuntimeException.java

@@ -0,0 +1,25 @@
+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);
+    }
+}

+ 183 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/AliyunOssFileStorage.java

@@ -0,0 +1,183 @@
+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 jnpf.model.FileListVO;
+import jnpf.model.FileModel;
+import jnpf.util.DateUtil;
+import jnpf.util.FileUtil;
+import jnpf.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<InputStream> 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<InputStream> 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<OSSObjectSummary> list = new ArrayList<>();
+        try {
+            ObjectListing objectListing = null;
+            ListObjectsRequest listObjectsRequest = new ListObjectsRequest(bucketName);
+            listObjectsRequest.setPrefix(this.getBasePath() + folderName);
+            objectListing = getClient().listObjects(listObjectsRequest);
+            List<OSSObjectSummary> sums = objectListing.getObjectSummaries();
+            for (OSSObjectSummary result : sums) {
+                list.add(result);
+            }
+        } catch (Exception e) {
+            throw new FileStorageRuntimeException("文件获取失败!platform:" + platform, e);
+        }
+        return list;
+    }
+
+    @Override
+    public List<FileListVO> conversionList(String folderName) {
+        List fileList = getFileList(folderName);
+        List<FileListVO> 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);
+        }
+    }
+}

+ 136 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/AwsS3FileStorage.java

@@ -0,0 +1,136 @@
+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<InputStream> 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<InputStream> 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);
+        }
+    }
+}

+ 131 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/BaiduBosFileStorage.java

@@ -0,0 +1,131 @@
+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<InputStream> 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<InputStream> 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);
+        }
+    }
+}

+ 111 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/FileStorage.java

@@ -0,0 +1,111 @@
+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 jnpf.model.FileListVO;
+import jnpf.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<InputStream> consumer);
+
+    /**
+     * 下载缩略图文件
+     */
+    void downloadTh(FileInfo fileInfo,Consumer<InputStream> 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<FileListVO> conversionList(String folderName) {
+        return Collections.EMPTY_LIST;
+    }
+
+    /**
+     * 下载到本地
+     *
+     * @param folderName  文件夹名
+     * @param filePath   下载到本地文件路径
+     * @param objectName 文件名
+     */
+    default void downLocal(String folderName, String filePath, String objectName) {}
+
+}

+ 164 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/FtpFileStorage.java

@@ -0,0 +1,164 @@
+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<InputStream> 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<InputStream> 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);
+        }
+    }
+}

+ 179 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/HuaweiObsFileStorage.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.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 jnpf.model.FileListVO;
+import jnpf.model.FileModel;
+import jnpf.util.DateUtil;
+import jnpf.util.FileUtil;
+import jnpf.util.JsonUtil;
+import jnpf.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<InputStream> 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<InputStream> 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<FileListVO> conversionList(String folderName) {
+        List fileList = getFileList(folderName);
+        List<FileListVO> 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;
+    }
+
+}

+ 91 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/LocalFileStorage.java

@@ -0,0 +1,91 @@
+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<InputStream> 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<InputStream> 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);
+        }
+    }
+}

+ 137 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/LocalPlusFileStorage.java

@@ -0,0 +1,137 @@
+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 jnpf.model.FileListVO;
+import jnpf.model.FileModel;
+import jnpf.util.JsonUtil;
+import jnpf.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<InputStream> 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<InputStream> 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<FileModel> data = new ArrayList<>();
+        File filePath = new File(XSSEscape.escapePath(getLocalPath() + folderName));
+        List<File> files = jnpf.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(jnpf.util.FileUtil.getFileType(item));
+                fileModel.setFileSize(jnpf.util.FileUtil.getSize(String.valueOf(item.length())));
+                fileModel.setFileTime(jnpf.util.FileUtil.getCreateTime(filePath + item.getName()));
+                data.add(fileModel);
+            }
+        }
+        return data;
+    }
+
+    @Override
+    public List<FileListVO> conversionList(String folderName) {
+        List fileList = getFileList(folderName);
+        List<FileListVO> listVOS = new ArrayList<>(fileList.size());
+        if (fileList.size() > 0 && fileList.get(0) instanceof FileModel) {
+            return JsonUtil.getJsonToList(fileList, FileListVO.class);
+        }
+        return new ArrayList<>();
+    }
+}

+ 208 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/MinIOFileStorage.java

@@ -0,0 +1,208 @@
+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 jnpf.model.FileListVO;
+import jnpf.model.FileModel;
+import jnpf.util.DateUtil;
+import jnpf.util.FileUtil;
+import jnpf.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<InputStream> 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<InputStream> 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<Item> list = new ArrayList<>();
+        try {
+            Iterable<Result<Item>> results = null;
+            results = getClient().listObjects(
+                    ListObjectsArgs.builder().bucket(bucketName).prefix(this.getBasePath() + folderName).recursive(true).build());
+            for (Result<Item> result : results) {
+                Item item = result.get();
+                list.add(item);
+            }
+        }catch (Exception e){
+            throw new FileStorageRuntimeException("文件获取失败!platform:" + platform, e);
+        }
+        return list;
+    }
+
+    @Override
+    public List<FileListVO> conversionList(String folderName) {
+        List fileList = getFileList(folderName);
+        List<FileListVO> 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);
+        }
+    }
+}

+ 247 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/QiniuKodoFileStorage.java

@@ -0,0 +1,247 @@
+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 jnpf.model.FileListVO;
+import jnpf.model.FileModel;
+import jnpf.util.DateUtil;
+import jnpf.util.FileUtil;
+import jnpf.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<InputStream> 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<InputStream> 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<com.qiniu.storage.model.FileInfo> 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<FileListVO> conversionList(String folderName) {
+        List fileList = getFileList(folderName);
+        List<FileListVO> 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);
+        }
+    }
+}

+ 178 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/SftpFileStorage.java

@@ -0,0 +1,178 @@
+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<InputStream> 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<InputStream> 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);
+        }
+    }
+}

+ 199 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/TencentCosFileStorage.java

@@ -0,0 +1,199 @@
+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 jnpf.model.FileListVO;
+import jnpf.model.FileModel;
+import jnpf.util.DateUtil;
+import jnpf.util.FileUtil;
+import jnpf.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<InputStream> 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<InputStream> 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<COSObjectSummary> 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<String> commonPrefixs = objectListing.getCommonPrefixes();
+            // object summary表示所有列出的object列表
+            List<COSObjectSummary> 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<FileListVO> conversionList(String folderName) {
+        List fileList = getFileList(folderName);
+        List<FileListVO> 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);
+        }
+    }
+}

+ 152 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/UpyunUssFileStorage.java

@@ -0,0 +1,152 @@
+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<String,String> 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<String,String> 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<InputStream> 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<InputStream> 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);
+        }
+    }
+}

+ 160 - 0
src/main/java/cn/xuyanwu/spring/file/storage/platform/WebDavFileStorage.java

@@ -0,0 +1,160 @@
+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<InputStream> 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<InputStream> 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);
+        }
+    }
+}

+ 23 - 0
src/main/java/cn/xuyanwu/spring/file/storage/recorder/DefaultFileRecorder.java

@@ -0,0 +1,23 @@
+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;
+    }
+}

+ 24 - 0
src/main/java/cn/xuyanwu/spring/file/storage/recorder/FileRecorder.java

@@ -0,0 +1,24 @@
+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);
+}

+ 109 - 0
target/classes/META-INF/spring-configuration-metadata.json

@@ -0,0 +1,109 @@
+{
+  "groups": [
+    {
+      "name": "config.file-storage",
+      "type": "cn.xuyanwu.spring.file.storage.FileStorageProperties",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    }
+  ],
+  "properties": [
+    {
+      "name": "config.file-storage.aliyun-oss",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$AliyunOss>",
+      "description": "阿里云 OSS",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.aws-s3",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$AwsS3>",
+      "description": "AWS S3",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.baidu-bos",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$BaiduBos>",
+      "description": "百度云 BOS",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.default-platform",
+      "type": "java.lang.String",
+      "description": "默认存储平台",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties",
+      "defaultValue": "local"
+    },
+    {
+      "name": "config.file-storage.ftp",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$FTP>",
+      "description": "FTP",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.huawei-obs",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$HuaweiObs>",
+      "description": "华为云 OBS",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.local",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$Local>",
+      "description": "本地存储",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.local-plus",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$LocalPlus>",
+      "description": "本地存储",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.minio",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$MinIO>",
+      "description": "MinIO USS",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.qiniu-kodo",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$QiniuKodo>",
+      "description": "七牛云 Kodo",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.sftp",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$SFTP>",
+      "description": "FTP",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.tencent-cos",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$TencentCos>",
+      "description": "腾讯云 COS",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.thumbnail-suffix",
+      "type": "java.lang.String",
+      "description": "缩略图后缀,例如【.min.jpg】【.png】",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties",
+      "defaultValue": ".min.jpg"
+    },
+    {
+      "name": "config.file-storage.upyun-u-s-s",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$UpyunUSS>",
+      "description": "又拍云 USS",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.web-dav",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$WebDAV>",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    },
+    {
+      "name": "config.file-storage.web-dav",
+      "type": "java.util.List<cn.xuyanwu.spring.file.storage.FileStorageProperties$WebDAV>",
+      "description": "WebDAV",
+      "sourceType": "cn.xuyanwu.spring.file.storage.FileStorageProperties"
+    }
+  ],
+  "hints": []
+}

BIN
target/classes/cn/xuyanwu/spring/file/storage/Downloader$1.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/Downloader.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/EnableFileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileInfo.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageAutoConfiguration.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$AliyunOss.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$AwsS3.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$BaiduBos.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$FTP.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$HuaweiObs.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$Local.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$LocalPlus.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$MinIO.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$QiniuKodo.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$SFTP.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$TencentCos.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$UpyunUSS.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties$WebDAV.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageProperties.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/FileStorageService.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/MockMultipartFile.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/MultipartFileWrapper.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/PathUtil.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/ProgressInputStream.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/ProgressListener.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/UploadPretreatment.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChain.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/aspect/DeleteAspectChainCallback.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChain.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/aspect/DownloadAspectChainCallback.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChain.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/aspect/DownloadThAspectChainCallback.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChain.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/aspect/ExistsAspectChainCallback.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/aspect/FileStorageAspect.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChain.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/aspect/UploadAspectChainCallback.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/exception/FileStorageRuntimeException.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/AliyunOssFileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/AwsS3FileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/BaiduBosFileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/FileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/FtpFileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/HuaweiObsFileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/LocalFileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/LocalPlusFileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/MinIOFileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/QiniuKodoFileStorage$QiniuKodoClient.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/QiniuKodoFileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/SftpFileStorage.class


BIN
target/classes/cn/xuyanwu/spring/file/storage/platform/TencentCosFileStorage.class


Некоторые файлы не были показаны из-за большого количества измененных файлов