Bläddra i källkod

!98 文件存储的功能,支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、FTP、数据库等
Merge pull request !98 from 芋道源码/feature/1.6.1

芋道源码 3 år sedan
förälder
incheckning
d2075d5c18
93 ändrade filer med 3084 tillägg och 239 borttagningar
  1. 2 2
      README.md
  2. 2 2
      sql/ruoyi-vue-pro.sql
  3. 24 0
      yudao-dependencies/pom.xml
  4. 1 0
      yudao-framework/pom.xml
  5. 29 2
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/io/FileUtils.java
  6. 17 3
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java
  7. 2 2
      yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/validation/ValidationUtils.java
  8. 1 0
      yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/config/YudaoPayAutoConfiguration.java
  9. 4 11
      yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/AbstractPayClient.java
  10. 5 5
      yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/PayClientFactoryImpl.java
  11. 0 1
      yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/PayChannelEnum.java
  12. 78 0
      yudao-framework/yudao-spring-boot-starter-file/pom.xml
  13. 21 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/config/YudaoFileAutoConfiguration.java
  14. 69 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/AbstractFileClient.java
  15. 41 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClient.java
  16. 16 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientConfig.java
  17. 22 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientFactory.java
  18. 56 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientFactoryImpl.java
  19. 48 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileClient.java
  20. 24 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileClientConfig.java
  21. 36 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileContentFrameworkDAO.java
  22. 73 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClient.java
  23. 59 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClientConfig.java
  24. 52 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClient.java
  25. 30 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClientConfig.java
  26. 103 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClient.java
  27. 85 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClientConfig.java
  28. 36 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3ModifyPathInterceptor.java
  29. 61 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClient.java
  30. 52 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClientConfig.java
  31. 55 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/enums/FileStorageEnum.java
  32. 2 0
      yudao-framework/yudao-spring-boot-starter-file/src/main/resources/META-INF/spring.factories
  33. 4 0
      yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/config/package-info.java
  34. 37 0
      yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClientTest.java
  35. 25 0
      yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClientTest.java
  36. 90 0
      yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClientTest.java
  37. 35 0
      yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClientTest.java
  38. 4 0
      yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/enums/package-info.java
  39. 0 0
      yudao-framework/yudao-spring-boot-starter-file/src/test/resources/file/erweima.jpg
  40. 3 0
      yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java
  41. 5 2
      yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java
  42. 7 0
      yudao-module-infra/yudao-module-infra-impl/pom.xml
  43. 45 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileConfigController.http
  44. 89 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileConfigController.java
  45. 16 11
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java
  46. 0 22
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/FileRespVO.java
  47. 22 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigBaseVO.java
  48. 26 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigCreateReqVO.java
  49. 35 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigPageReqVO.java
  50. 36 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigRespVO.java
  51. 26 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigUpdateReqVO.java
  52. 2 2
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FilePageReqVO.java
  53. 31 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileRespVO.java
  54. 36 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/convert/file/FileConfigConvert.java
  55. 1 1
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/convert/file/FileConvert.java
  56. 56 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileConfigDO.java
  57. 45 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileContentDO.java
  58. 19 7
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileDO.java
  59. 32 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileConfigMapper.java
  60. 41 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileContentDAOImpl.java
  61. 9 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileContentMapper.java
  62. 2 17
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileMapper.java
  63. 0 12
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/FileConfiguration.java
  64. 0 22
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/FileProperties.java
  65. 0 16
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/framework/file/package-info.java
  66. 1 1
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/framework/security/config/SecurityConfiguration.java
  67. 29 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/consumer/file/FileConfigRefreshConsumer.java
  68. 0 1
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/consumer/package-info.java
  69. 17 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/message/file/FileConfigRefreshMessage.java
  70. 0 1
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/message/package-info.java
  71. 26 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/producer/file/FileConfigProducer.java
  72. 0 1
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/producer/package-info.java
  73. 6 10
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/service/codegen/CodegenServiceImpl.java
  74. 102 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigService.java
  75. 241 0
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImpl.java
  76. 6 5
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java
  77. 33 19
      yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java
  78. BIN
      yudao-module-infra/yudao-module-infra-impl/src/main/resources/file/erweima.jpg
  79. 256 0
      yudao-module-infra/yudao-module-infra-impl/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImplTest.java
  80. 51 36
      yudao-module-infra/yudao-module-infra-impl/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceTest.java
  81. 3 2
      yudao-module-infra/yudao-module-infra-impl/src/test/resources/application-unit-test.yaml
  82. 1 0
      yudao-module-infra/yudao-module-infra-impl/src/test/resources/sql/clean.sql
  83. 20 2
      yudao-module-infra/yudao-module-infra-impl/src/test/resources/sql/create_tables.sql
  84. 4 0
      yudao-module-pay/yudao-module-pay-impl/src/main/java/cn/iocoder/yudao/module/pay/service/merchant/PayChannelServiceImpl.java
  85. 1 2
      yudao-module-system/yudao-module-system-impl/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/permission/RoleMenuMapper.java
  86. 0 2
      yudao-server/src/main/resources/application-dev.yaml
  87. 0 2
      yudao-server/src/main/resources/application-local.yaml
  88. 3 1
      yudao-server/src/main/resources/application.yaml
  89. 59 0
      yudao-ui-admin/src/api/infra/fileConfig.js
  90. 1 1
      yudao-ui-admin/src/components/DictTag/index.vue
  91. 2 0
      yudao-ui-admin/src/utils/dict.js
  92. 24 13
      yudao-ui-admin/src/views/infra/file/index.vue
  93. 313 0
      yudao-ui-admin/src/views/infra/fileConfig/index.vue

+ 2 - 2
README.md

@@ -116,7 +116,7 @@ ps:核心功能已经实现,正在对接微信小程序中...
 |     | 表单构建     | 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件         |
 | 🚀  | 配置管理     | 对系统动态配置常用参数,支持 SpringBoot 加载                 |
 | ⭐️  | 定时任务     | 在线(添加、修改、删除)任务调度包含执行结果日志                     |
-| 🚀  | 文件服务     | 支持本地文件存储,同时支持兼容 Amazon S3 协议的云服务、开源组件        | 
+| 🚀  | 文件服务     | 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等      | 
 | 🚀  | API 日志   | 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题   |
 |     | MySQL 监控 | 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈              |
 |     | Redis 监控 | 监控 Redis 数据库的使用情况,使用的 Redis Key 管理           |
@@ -218,7 +218,7 @@ ps:核心功能已经实现,正在对接微信小程序中...
 |---------------|----------------------------------------------------------------------|--------------------------------------------------------------------|------------------------------------------------------------------|
 | 代码生成          | ![代码生成](https://static.iocoder.cn/images/ruoyi-vue-pro/代码生成.jpg)     | ![生成效果](https://static.iocoder.cn/images/ruoyi-vue-pro/生成效果.jpg)   | -                                                                |
 | 文档            | ![系统接口](https://static.iocoder.cn/images/ruoyi-vue-pro/系统接口.jpg)     | ![数据库文档](https://static.iocoder.cn/images/ruoyi-vue-pro/数据库文档.jpg) | -                                                                |
-| 文件 & 配置       | ![文件管理](https://static.iocoder.cn/images/ruoyi-vue-pro/文件管理.jpg)     | ![配置管理](https://static.iocoder.cn/images/ruoyi-vue-pro/配置管理.jpg)   | -                                                                |
+| 文件 & 配置       | ![文件配置](https://static.iocoder.cn/images/ruoyi-vue-pro/文件配置.jpg) | ![文件管理](https://static.iocoder.cn/images/ruoyi-vue-pro/文件管理2.jpg)     | ![配置管理](https://static.iocoder.cn/images/ruoyi-vue-pro/配置管理.jpg)   |
 | 定时任务          | ![定时任务](https://static.iocoder.cn/images/ruoyi-vue-pro/定时任务.jpg)     | ![任务日志](https://static.iocoder.cn/images/ruoyi-vue-pro/任务日志.jpg)   | -                                                                |
 | API 日志        | ![访问日志](https://static.iocoder.cn/images/ruoyi-vue-pro/访问日志.jpg)     | ![错误日志](https://static.iocoder.cn/images/ruoyi-vue-pro/错误日志.jpg)   | -                                                                |
 | MySQL & Redis | ![MySQL](https://static.iocoder.cn/images/ruoyi-vue-pro/MySQL.jpg)   | ![Redis](https://static.iocoder.cn/images/ruoyi-vue-pro/Redis.jpg) | -                                                                |

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 2 - 2
sql/ruoyi-vue-pro.sql


+ 24 - 0
yudao-dependencies/pom.xml

@@ -53,7 +53,10 @@
         <screw.version>1.0.5</screw.version>
         <guava.version>30.1.1-jre</guava.version>
         <transmittable-thread-local.version>2.12.2</transmittable-thread-local.version>
+        <commons-net.version>3.8.0</commons-net.version>
+        <jsch.version>0.1.55</jsch.version>
         <!-- 三方云服务相关 -->
+        <s3.version>2.17.147</s3.version>
         <aliyun-java-sdk-core.version>4.5.25</aliyun-java-sdk-core.version>
         <aliyun-java-sdk-dysmsapi.version>2.1.0</aliyun-java-sdk-dysmsapi.version>
         <yunpian-java-sdk.version>1.2.7</yunpian-java-sdk.version>
@@ -493,7 +496,28 @@
                 <version>${transmittable-thread-local.version}</version>
             </dependency>
 
+            <dependency>
+                <groupId>commons-net</groupId>
+                <artifactId>commons-net</artifactId> <!-- 解决 ftp 连接 -->
+                <version>${commons-net.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.jcraft</groupId>
+                <artifactId>jsch</artifactId> <!-- 解决 sftp 连接 -->
+                <version>${jsch.version}</version>
+            </dependency>
+
             <!-- 三方云服务相关 -->
+            <dependency>
+                <groupId>cn.iocoder.boot</groupId>
+                <artifactId>yudao-spring-boot-starter-file</artifactId>
+                <version>${revision}</version>
+            </dependency>
+            <dependency>
+                <groupId>software.amazon.awssdk</groupId>
+                <artifactId>s3</artifactId>
+                <version>${s3.version}</version>
+            </dependency>
 
             <!-- SMS SDK begin -->
             <dependency>

+ 1 - 0
yudao-framework/pom.xml

@@ -16,6 +16,7 @@
         <module>yudao-spring-boot-starter-web</module>
         <module>yudao-spring-boot-starter-security</module>
 
+        <module>yudao-spring-boot-starter-file</module>
         <module>yudao-spring-boot-starter-monitor</module>
         <module>yudao-spring-boot-starter-protection</module>
         <module>yudao-spring-boot-starter-config</module>

+ 29 - 2
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/io/FileUtils.java

@@ -22,12 +22,39 @@ public class FileUtils {
      */
     @SneakyThrows
     public static File createTempFile(String data) {
+        File file = createTempFile();
+        // 写入内容
+        FileUtil.writeUtf8String(data, file);
+        return file;
+    }
+
+    /**
+     * 创建临时文件
+     * 该文件会在 JVM 退出时,进行删除
+     *
+     * @param data 文件内容
+     * @return 文件
+     */
+    @SneakyThrows
+    public static File createTempFile(byte[] data) {
+        File file = createTempFile();
+        // 写入内容
+        FileUtil.writeBytes(data, file);
+        return file;
+    }
+
+    /**
+     * 创建临时文件,无内容
+     * 该文件会在 JVM 退出时,进行删除
+     *
+     * @return 文件
+     */
+    @SneakyThrows
+    public static File createTempFile() {
         // 创建文件,通过 UUID 保证唯一
         File file = File.createTempFile(IdUtil.simpleUUID(), null);
         // 标记 JVM 退出时,自动删除
         file.deleteOnExit();
-        // 写入内容
-        FileUtil.writeUtf8String(data, file);
         return file;
     }
 

+ 17 - 3
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java

@@ -2,6 +2,7 @@ package cn.iocoder.yudao.framework.common.util.json;
 
 import cn.hutool.core.util.ArrayUtil;
 import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONUtil;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -55,7 +56,6 @@ public class JsonUtils {
         if (StrUtil.isEmpty(text)) {
             return null;
         }
-
         try {
             return objectMapper.readValue(text, clazz);
         } catch (IOException e) {
@@ -64,11 +64,26 @@ public class JsonUtils {
         }
     }
 
+    /**
+     * 将字符串解析成指定类型的对象
+     * 使用 {@link #parseObject(String, Class)} 时,在@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) 的场景下,
+     * 如果 text 没有 class 属性,则会报错。此时,使用这个方法,可以解决。
+     *
+     * @param text 字符串
+     * @param clazz 类型
+     * @return 对象
+     */
+    public static <T> T parseObject2(String text, Class<T> clazz) {
+        if (StrUtil.isEmpty(text)) {
+            return null;
+        }
+        return JSONUtil.toBean(text, clazz);
+    }
+
     public static <T> T parseObject(byte[] bytes, Class<T> clazz) {
         if (ArrayUtil.isEmpty(bytes)) {
             return null;
         }
-
         try {
             return objectMapper.readValue(bytes, clazz);
         } catch (IOException e) {
@@ -90,7 +105,6 @@ public class JsonUtils {
         if (StrUtil.isEmpty(text)) {
             return new ArrayList<>();
         }
-
         try {
             return objectMapper.readValue(text, objectMapper.getTypeFactory().constructCollectionType(List.class, clazz));
         } catch (IOException e) {

+ 2 - 2
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/validation/ValidationUtils.java

@@ -39,8 +39,8 @@ public class ValidationUtils {
                 && PATTERN_XML_NCNAME.matcher(str).matches();
     }
 
-    public static void validate(Validator validator, Object reqVO, Class<?>... groups) {
-        Set<ConstraintViolation<Object>> constraintViolations = validator.validate(reqVO, groups);
+    public static void validate(Validator validator, Object object, Class<?>... groups) {
+        Set<ConstraintViolation<Object>> constraintViolations = validator.validate(object, groups);
         if (CollUtil.isNotEmpty(constraintViolations)) {
             throw new ConstraintViolationException(constraintViolations);
         }

+ 1 - 0
yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/config/YudaoPayAutoConfiguration.java

@@ -11,6 +11,7 @@ import org.springframework.context.annotation.Configuration;
  *
  * @author 芋道源码
  */
+@Configuration
 @EnableConfigurationProperties(PayProperties.class)
 public class YudaoPayAutoConfiguration {
 

+ 4 - 11
yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/AbstractPayClient.java

@@ -1,7 +1,6 @@
 package cn.iocoder.yudao.framework.pay.core.client.impl;
 
 import cn.hutool.extra.validation.ValidationUtil;
-import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
 import cn.iocoder.yudao.framework.pay.core.client.AbstractPayCodeMapping;
 import cn.iocoder.yudao.framework.pay.core.client.PayClient;
 import cn.iocoder.yudao.framework.pay.core.client.PayClientConfig;
@@ -11,7 +10,6 @@ import cn.iocoder.yudao.framework.pay.core.client.dto.PayRefundUnifiedReqDTO;
 import cn.iocoder.yudao.framework.pay.core.client.dto.PayRefundUnifiedRespDTO;
 import lombok.extern.slf4j.Slf4j;
 
-import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString;
 
 /**
@@ -26,7 +24,6 @@ public abstract class AbstractPayClient<Config extends PayClientConfig> implemen
      * 渠道编号
      */
     private final Long channelId;
-
     /**
      * 渠道编码
      */
@@ -40,10 +37,6 @@ public abstract class AbstractPayClient<Config extends PayClientConfig> implemen
      */
     protected Config config;
 
-    protected Double calculateAmount(Long amount) {
-        return amount / 100.0;
-    }
-
     public AbstractPayClient(Long channelId, String channelCode, Config config, AbstractPayCodeMapping codeMapping) {
         this.channelId = channelId;
         this.channelCode = channelCode;
@@ -75,6 +68,10 @@ public abstract class AbstractPayClient<Config extends PayClientConfig> implemen
         this.init();
     }
 
+    protected Double calculateAmount(Long amount) {
+        return amount / 100.0;
+    }
+
     @Override
     public Long getId() {
         return channelId;
@@ -96,12 +93,9 @@ public abstract class AbstractPayClient<Config extends PayClientConfig> implemen
         return result;
     }
 
-
-
     protected abstract PayCommonResult<?> doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO)
             throws Throwable;
 
-
     @Override
     public PayCommonResult<PayRefundUnifiedRespDTO> unifiedRefund(PayRefundUnifiedReqDTO reqDTO) {
         PayCommonResult<PayRefundUnifiedRespDTO> resp;
@@ -115,7 +109,6 @@ public abstract class AbstractPayClient<Config extends PayClientConfig> implemen
         return resp;
     }
 
-
     protected abstract PayCommonResult<PayRefundUnifiedRespDTO> doUnifiedRefund(PayRefundUnifiedReqDTO reqDTO) throws Throwable;
 
 }

+ 5 - 5
yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/PayClientFactoryImpl.java

@@ -27,11 +27,11 @@ public class PayClientFactoryImpl implements PayClientFactory {
      * 支付客户端 Map
      * key:渠道编号
      */
-    private final ConcurrentMap<Long, AbstractPayClient<?>> channelIdClients = new ConcurrentHashMap<>();
+    private final ConcurrentMap<Long, AbstractPayClient<?>> clients = new ConcurrentHashMap<>();
 
     @Override
     public PayClient getPayClient(Long channelId) {
-        AbstractPayClient<?> client = channelIdClients.get(channelId);
+        AbstractPayClient<?> client = clients.get(channelId);
         if (client == null) {
             log.error("[getPayClient][渠道编号({}) 找不到客户端]", channelId);
         }
@@ -42,11 +42,11 @@ public class PayClientFactoryImpl implements PayClientFactory {
     @SuppressWarnings("unchecked")
     public <Config extends PayClientConfig> void createOrUpdatePayClient(Long channelId, String channelCode,
                                                                          Config config) {
-        AbstractPayClient<Config> client = (AbstractPayClient<Config>) channelIdClients.get(channelId);
+        AbstractPayClient<Config> client = (AbstractPayClient<Config>) clients.get(channelId);
         if (client == null) {
             client = this.createPayClient(channelId, channelCode, config);
             client.init();
-            channelIdClients.put(client.getId(), client);
+            clients.put(client.getId(), client);
         } else {
             client.refresh(config);
         }
@@ -69,7 +69,7 @@ public class PayClientFactoryImpl implements PayClientFactory {
             case ALIPAY_PC: return (AbstractPayClient<Config>) new AlipayQrPayClient(channelId, (AlipayPayClientConfig) config);
         }
         // 创建失败,错误日志 + 抛出异常
-        log.error("[createSmsClient][配置({}) 找不到合适的客户端实现]", config);
+        log.error("[createPayClient][配置({}) 找不到合适的客户端实现]", config);
         throw new IllegalArgumentException(String.format("配置(%s) 找不到合适的客户端实现", config));
     }
 

+ 0 - 1
yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/enums/PayChannelEnum.java

@@ -56,5 +56,4 @@ public enum PayChannelEnum {
         return ArrayUtil.firstMatch(o -> o.getCode().equals(code), values());
     }
 
-
 }

+ 78 - 0
yudao-framework/yudao-spring-boot-starter-file/pom.xml

@@ -0,0 +1,78 @@
+<?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>yudao-framework</artifactId>
+        <groupId>cn.iocoder.boot</groupId>
+        <version>${revision}</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+    <artifactId>yudao-spring-boot-starter-file</artifactId>
+
+    <name>${project.artifactId}</name>
+    <description>文件客户端,支持多种存储器
+        1. file:本地磁盘
+        2. ftp:FTP 服务器
+        2. sftp:SFTP 服务器
+        4. db:数据库
+        5. s3:支持 S3 协议的云存储服务,例如说 MinIO、阿里云、华为云、腾讯云、七牛云等等
+    </description>
+    <url>https://github.com/YunaiV/ruoyi-vue-pro</url>
+
+    <dependencies>
+        <dependency>
+            <groupId>cn.iocoder.boot</groupId>
+            <artifactId>yudao-common</artifactId>
+        </dependency>
+
+        <!-- Spring 核心 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter</artifactId>
+        </dependency>
+
+        <!-- 工具类相关 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-core</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>commons-net</groupId>
+            <artifactId>commons-net</artifactId> <!-- 解决 ftp 连接 -->
+        </dependency>
+        <dependency>
+            <groupId>com.jcraft</groupId>
+            <artifactId>jsch</artifactId> <!-- 解决 sftp 连接 -->
+        </dependency>
+
+        <!-- 三方云服务相关 -->
+        <dependency>
+            <groupId>software.amazon.awssdk</groupId>
+            <artifactId>s3</artifactId>
+        </dependency>
+
+        <!-- Test 测试相关 -->
+        <dependency>
+            <groupId>cn.iocoder.boot</groupId>
+            <artifactId>yudao-spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+</project>

+ 21 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/config/YudaoFileAutoConfiguration.java

@@ -0,0 +1,21 @@
+package cn.iocoder.yudao.framework.file.config;
+
+import cn.iocoder.yudao.framework.file.core.client.FileClientFactory;
+import cn.iocoder.yudao.framework.file.core.client.FileClientFactoryImpl;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 文件配置类
+ *
+ * @author 芋道源码
+ */
+@Configuration
+public class YudaoFileAutoConfiguration {
+
+    @Bean
+    public FileClientFactory fileClientFactory() {
+        return new FileClientFactoryImpl();
+    }
+
+}

+ 69 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/AbstractFileClient.java

@@ -0,0 +1,69 @@
+package cn.iocoder.yudao.framework.file.core.client;
+
+import cn.hutool.core.util.StrUtil;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 文件客户端的抽象类,提供模板方法,减少子类的冗余代码
+ *
+ * @author 芋道源码
+ */
+@Slf4j
+public abstract class AbstractFileClient<Config extends FileClientConfig> implements FileClient {
+
+    /**
+     * 配置编号
+     */
+    private final Long id;
+    /**
+     * 文件配置
+     */
+    protected Config config;
+
+    public AbstractFileClient(Long id, Config config) {
+        this.id = id;
+        this.config = config;
+    }
+
+    /**
+     * 初始化
+     */
+    public final void init() {
+        doInit();
+        log.info("[init][配置({}) 初始化完成]", config);
+    }
+
+    /**
+     * 自定义初始化
+     */
+    protected abstract void doInit();
+
+    public final void refresh(Config config) {
+        // 判断是否更新
+        if (config.equals(this.config)) {
+            return;
+        }
+        log.info("[refresh][配置({})发生变化,重新初始化]", config);
+        this.config = config;
+        // 初始化
+        this.init();
+    }
+
+    @Override
+    public Long getId() {
+        return id;
+    }
+
+    /**
+     * 格式化文件的 URL 访问地址
+     * 使用场景:local、ftp、db,通过 FileController 的 getFile 来获取文件内容
+     *
+     * @param domain 自定义域名
+     * @param path 文件路径
+     * @return URL 访问地址
+     */
+    protected String formatFileUrl(String domain, String path) {
+        return StrUtil.format("{}/admin-api/infra/file/{}/get/{}", domain, getId(), path);
+    }
+
+}

+ 41 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClient.java

@@ -0,0 +1,41 @@
+package cn.iocoder.yudao.framework.file.core.client;
+
+/**
+ * 文件客户端
+ *
+ * @author 芋道源码
+ */
+public interface FileClient {
+
+    /**
+     * 获得客户端编号
+     *
+     * @return 客户端编号
+     */
+    Long getId();
+
+    /**
+     * 上传文件
+     *
+     * @param content 文件流
+     * @param path 相对路径
+     * @return 完整路径,即 HTTP 访问地址
+     */
+    String upload(byte[] content, String path);
+
+    /**
+     * 删除文件
+     *
+     * @param path 相对路径
+     */
+    void delete(String path);
+
+    /**
+     * 获得文件的内容
+     *
+     * @param path 相对路径
+     * @return 文件的内容
+     */
+    byte[] getContent(String path);
+
+}

+ 16 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientConfig.java

@@ -0,0 +1,16 @@
+package cn.iocoder.yudao.framework.file.core.client;
+
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+/**
+ * 文件客户端的配置
+ * 不同实现的客户端,需要不同的配置,通过子类来定义
+ *
+ * @author 芋道源码
+ */
+@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
+// @JsonTypeInfo 注解的作用,Jackson 多态
+// 1. 序列化到时数据库时,增加 @class 属性。
+// 2. 反序列化到内存对象时,通过 @class 属性,可以创建出正确的类型
+public interface FileClientConfig {
+}

+ 22 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientFactory.java

@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.framework.file.core.client;
+
+public interface FileClientFactory {
+
+    /**
+     * 获得文件客户端
+     *
+     * @param configId 配置编号
+     * @return 文件客户端
+     */
+    FileClient getFileClient(Long configId);
+
+    /**
+     * 创建文件客户端
+     *
+     * @param configId 配置编号
+     * @param storage 存储器的枚举 {@link cn.iocoder.yudao.framework.file.core.enums.FileStorageEnum}
+     * @param config 文件配置
+     */
+    <Config extends FileClientConfig> void createOrUpdateFileClient(Long configId, Integer storage, Config config);
+
+}

+ 56 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientFactoryImpl.java

@@ -0,0 +1,56 @@
+package cn.iocoder.yudao.framework.file.core.client;
+
+import cn.hutool.core.lang.Assert;
+import cn.hutool.core.util.ReflectUtil;
+import cn.iocoder.yudao.framework.file.core.enums.FileStorageEnum;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * 文件客户端的工厂实现类
+ *
+ * @author 芋道源码
+ */
+@Slf4j
+public class FileClientFactoryImpl implements FileClientFactory {
+
+    /**
+     * 文件客户端 Map
+     * key:配置编号
+     */
+    private final ConcurrentMap<Long, AbstractFileClient<?>> clients = new ConcurrentHashMap<>();
+
+    @Override
+    public FileClient getFileClient(Long configId) {
+        AbstractFileClient<?> client = clients.get(configId);
+        if (client == null) {
+            log.error("[getFileClient][配置编号({}) 找不到客户端]", configId);
+        }
+        return client;
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <Config extends FileClientConfig> void createOrUpdateFileClient(Long configId, Integer storage, Config config) {
+        AbstractFileClient<Config> client = (AbstractFileClient<Config>) clients.get(configId);
+        if (client == null) {
+            client = this.createFileClient(configId, storage, config);
+            client.init();
+            clients.put(client.getId(), client);
+        } else {
+            client.refresh(config);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private <Config extends FileClientConfig> AbstractFileClient<Config> createFileClient(
+            Long configId, Integer storage, Config config) {
+        FileStorageEnum storageEnum = FileStorageEnum.getByStorage(storage);
+        Assert.notNull(storageEnum, String.format("文件配置(%s) 为空", storageEnum));
+        // 创建客户端
+        return (AbstractFileClient<Config>) ReflectUtil.newInstance(storageEnum.getClientClass(), configId, config);
+    }
+
+}

+ 48 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileClient.java

@@ -0,0 +1,48 @@
+package cn.iocoder.yudao.framework.file.core.client.db;
+
+import cn.hutool.extra.spring.SpringUtil;
+import cn.iocoder.yudao.framework.file.core.client.AbstractFileClient;
+
+/**
+ * 基于 DB 存储的文件客户端的配置类
+ *
+ * @author 芋道源码
+ */
+public class DBFileClient extends AbstractFileClient<DBFileClientConfig> {
+
+    private DBFileContentFrameworkDAO dao;
+
+    public DBFileClient(Long id, DBFileClientConfig config) {
+        super(id, config);
+    }
+
+    @Override
+    protected void doInit() {
+    }
+
+    @Override
+    public String upload(byte[] content, String path) {
+        getDao().insert(getId(), path, content);
+        // 拼接返回路径
+        return super.formatFileUrl(config.getDomain(), path);
+    }
+
+    @Override
+    public void delete(String path) {
+        getDao().delete(getId(), path);
+    }
+
+    @Override
+    public byte[] getContent(String path) {
+        return getDao().selectContent(getId(), path);
+    }
+
+    private DBFileContentFrameworkDAO getDao() {
+        // 延迟获取,因为 SpringUtil 初始化太慢
+        if (dao == null) {
+            dao = SpringUtil.getBean(DBFileContentFrameworkDAO.class);
+        }
+        return dao;
+    }
+
+}

+ 24 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileClientConfig.java

@@ -0,0 +1,24 @@
+package cn.iocoder.yudao.framework.file.core.client.db;
+
+import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
+import lombok.Data;
+import org.hibernate.validator.constraints.URL;
+
+import javax.validation.constraints.NotEmpty;
+
+/**
+ * 基于 DB 存储的文件客户端的配置类
+ *
+ * @author 芋道源码
+ */
+@Data
+public class DBFileClientConfig implements FileClientConfig {
+
+    /**
+     * 自定义域名
+     */
+    @NotEmpty(message = "domain 不能为空")
+    @URL(message = "domain 必须是 URL 格式")
+    private String domain;
+
+}

+ 36 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/db/DBFileContentFrameworkDAO.java

@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.framework.file.core.client.db;
+
+/**
+ * 文件内容 Framework DAO 接口
+ *
+ * @author 芋道源码
+ */
+public interface DBFileContentFrameworkDAO {
+
+    /**
+     * 插入文件内容
+     *
+     * @param configId 配置编号
+     * @param path 路径
+     * @param content 内容
+     */
+    void insert(Long configId, String path, byte[] content);
+
+    /**
+     * 删除文件内容
+     *
+     * @param configId 配置编号
+     * @param path 路径
+     */
+    void delete(Long configId, String path);
+
+    /**
+     * 获得文件内容
+     *
+     * @param configId 配置编号
+     * @param path 路径
+     * @return 内容
+     */
+    byte[] selectContent(Long configId, String path);
+
+}

+ 73 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClient.java

@@ -0,0 +1,73 @@
+package cn.iocoder.yudao.framework.file.core.client.ftp;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.util.CharsetUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.extra.ftp.Ftp;
+import cn.hutool.extra.ftp.FtpException;
+import cn.hutool.extra.ftp.FtpMode;
+import cn.iocoder.yudao.framework.file.core.client.AbstractFileClient;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+
+/**
+ * Ftp 文件客户端
+ *
+ * @author 芋道源码
+ */
+public class FtpFileClient extends AbstractFileClient<FtpFileClientConfig> {
+
+    private Ftp ftp;
+
+    public FtpFileClient(Long id, FtpFileClientConfig config) {
+        super(id, config);
+    }
+
+    @Override
+    protected void doInit() {
+        // 补全风格。例如说 Linux 是 /,Windows 是 \
+        if (!config.getBasePath().endsWith(File.separator)) {
+            config.setBasePath(config.getBasePath() + File.separator);
+        }
+        // 初始化 Ftp 对象
+        this.ftp = new Ftp(config.getHost(), config.getPort(), config.getUsername(), config.getPassword(),
+                CharsetUtil.CHARSET_UTF_8, null, null, FtpMode.valueOf(config.getMode()));
+    }
+
+    @Override
+    public String upload(byte[] content, String path) {
+        // 执行写入
+        String filePath = getFilePath(path);
+        String fileName = FileUtil.getName(filePath);
+        String dir = StrUtil.removeSuffix(filePath, fileName);
+        boolean success = ftp.upload(dir, fileName, new ByteArrayInputStream(content));
+        if (!success) {
+            throw new FtpException(StrUtil.format("上海文件到目标目录 ({}) 失败", filePath));
+        }
+        // 拼接返回路径
+        return super.formatFileUrl(config.getDomain(), path);
+    }
+
+    @Override
+    public void delete(String path) {
+        String filePath = getFilePath(path);
+        ftp.delFile(filePath);
+    }
+
+    @Override
+    public byte[] getContent(String path) {
+        String filePath = getFilePath(path);
+        String fileName = FileUtil.getName(filePath);
+        String dir = StrUtil.removeSuffix(path, fileName);
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        ftp.download(dir, fileName, out);
+        return out.toByteArray();
+    }
+
+    private String getFilePath(String path) {
+        return config.getBasePath() + path;
+    }
+
+}

+ 59 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClientConfig.java

@@ -0,0 +1,59 @@
+package cn.iocoder.yudao.framework.file.core.client.ftp;
+
+import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
+import lombok.Data;
+import org.hibernate.validator.constraints.URL;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+
+/**
+ * Ftp 文件客户端的配置类
+ *
+ * @author 芋道源码
+ */
+@Data
+public class FtpFileClientConfig implements FileClientConfig {
+
+    /**
+     * 基础路径
+     */
+    @NotEmpty(message = "基础路径不能为空")
+    private String basePath;
+
+    /**
+     * 自定义域名
+     */
+    @NotEmpty(message = "domain 不能为空")
+    @URL(message = "domain 必须是 URL 格式")
+    private String domain;
+
+    /**
+     * 主机地址
+     */
+    @NotEmpty(message = "host 不能为空")
+    private String host;
+    /**
+     * 主机端口
+     */
+    @NotNull(message = "port 不能为空")
+    private Integer port;
+    /**
+     * 用户名
+     */
+    @NotEmpty(message = "用户名不能为空")
+    private String username;
+    /**
+     * 密码
+     */
+    @NotEmpty(message = "密码不能为空")
+    private String password;
+    /**
+     * 连接模式
+     *
+     * 使用 {@link  cn.hutool.extra.ftp.FtpMode} 对应的字符串
+     */
+    @NotEmpty(message = "连接模式不能为空")
+    private String mode;
+
+}

+ 52 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClient.java

@@ -0,0 +1,52 @@
+package cn.iocoder.yudao.framework.file.core.client.local;
+
+import cn.hutool.core.io.FileUtil;
+import cn.iocoder.yudao.framework.file.core.client.AbstractFileClient;
+
+import java.io.File;
+
+/**
+ * 本地文件客户端
+ *
+ * @author 芋道源码
+ */
+public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
+
+    public LocalFileClient(Long id, LocalFileClientConfig config) {
+        super(id, config);
+    }
+
+    @Override
+    protected void doInit() {
+        // 补全风格。例如说 Linux 是 /,Windows 是 \
+        if (!config.getBasePath().endsWith(File.separator)) {
+            config.setBasePath(config.getBasePath() + File.separator);
+        }
+    }
+
+    @Override
+    public String upload(byte[] content, String path) {
+        // 执行写入
+        String filePath = getFilePath(path);
+        FileUtil.writeBytes(content, filePath);
+        // 拼接返回路径
+        return super.formatFileUrl(config.getDomain(), path);
+    }
+
+    @Override
+    public void delete(String path) {
+        String filePath = getFilePath(path);
+        FileUtil.del(filePath);
+    }
+
+    @Override
+    public byte[] getContent(String path) {
+        String filePath = getFilePath(path);
+        return FileUtil.readBytes(filePath);
+    }
+
+    private String getFilePath(String path) {
+        return config.getBasePath() + path;
+    }
+
+}

+ 30 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClientConfig.java

@@ -0,0 +1,30 @@
+package cn.iocoder.yudao.framework.file.core.client.local;
+
+import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
+import lombok.Data;
+import org.hibernate.validator.constraints.URL;
+
+import javax.validation.constraints.NotEmpty;
+
+/**
+ * 本地文件客户端的配置类
+ *
+ * @author 芋道源码
+ */
+@Data
+public class LocalFileClientConfig implements FileClientConfig {
+
+    /**
+     * 基础路径
+     */
+    @NotEmpty(message = "基础路径不能为空")
+    private String basePath;
+
+    /**
+     * 自定义域名
+     */
+    @NotEmpty(message = "domain 不能为空")
+    @URL(message = "domain 必须是 URL 格式")
+    private String domain;
+
+}

+ 103 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClient.java

@@ -0,0 +1,103 @@
+package cn.iocoder.yudao.framework.file.core.client.s3;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.file.core.client.AbstractFileClient;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+
+import java.net.URI;
+
+import static cn.iocoder.yudao.framework.file.core.client.s3.S3FileClientConfig.ENDPOINT_QINIU;
+
+/**
+ * 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务
+ *
+ * S3 协议的客户端,采用亚马逊提供的 software.amazon.awssdk.s3 库
+ *
+ * @author 芋道源码
+ */
+public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
+
+    private S3Client client;
+
+    public S3FileClient(Long id, S3FileClientConfig config) {
+        super(id, config);
+    }
+
+    @Override
+    protected void doInit() {
+        // 补全 domain
+        if (StrUtil.isEmpty(config.getDomain())) {
+            config.setDomain(createDomain());
+        }
+        // 初始化客户端
+        client = S3Client.builder()
+                .serviceConfiguration(sb -> sb.pathStyleAccessEnabled(false) // 关闭路径风格
+                .chunkedEncodingEnabled(false)) // 禁用 chunk
+                .endpointOverride(createURI()) // 上传地址
+                .region(Region.of(config.getRegion())) // Region
+                .credentialsProvider(StaticCredentialsProvider.create( // 认证密钥
+                        AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret())))
+                .overrideConfiguration(cb -> cb.addExecutionInterceptor(new S3ModifyPathInterceptor(config.getBucket())))
+                .build();
+    }
+
+    /**
+     * 基于 endpoint 构建调用云服务的 URI 地址
+     *
+     * @return URI 地址
+     */
+    private URI createURI() {
+        String uri;
+        // 如果是七牛,无需拼接 bucket
+        if (config.getEndpoint().contains(ENDPOINT_QINIU)) {
+            uri = StrUtil.format("https://{}", config.getEndpoint());
+        } else {
+            uri = StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint());
+        }
+        return URI.create(uri);
+    }
+
+    /**
+     * 基于 bucket + endpoint 构建访问的 Domain 地址
+     *
+     * @return Domain 地址
+     */
+    private String createDomain() {
+        return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint());
+    }
+
+    @Override
+    public String upload(byte[] content, String path) {
+        // 执行上传
+        PutObjectRequest.Builder request = PutObjectRequest.builder()
+                .bucket(config.getBucket()) // bucket 必须传递
+                .key(path); // 相对路径作为 key
+        client.putObject(request.build(), RequestBody.fromBytes(content));
+        // 拼接返回路径
+        return config.getDomain() + "/" + path;
+    }
+
+    @Override
+    public void delete(String path) {
+        DeleteObjectRequest.Builder request = DeleteObjectRequest.builder()
+                .bucket(config.getBucket()) // bucket 必须传递
+                .key(path); // 相对路径作为 key
+        client.deleteObject(request.build());
+    }
+
+    @Override
+    public byte[] getContent(String path) {
+        GetObjectRequest.Builder request = GetObjectRequest.builder()
+                .bucket(config.getBucket()) // bucket 必须传递
+                .key(path); // 相对路径作为 key
+        return client.getObjectAsBytes(request.build()).asByteArray();
+    }
+
+}

+ 85 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClientConfig.java

@@ -0,0 +1,85 @@
+package cn.iocoder.yudao.framework.file.core.client.s3;
+
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.Data;
+import org.hibernate.validator.constraints.URL;
+
+import javax.validation.constraints.AssertTrue;
+import javax.validation.constraints.NotNull;
+
+/**
+ * S3 文件客户端的配置类
+ *
+ * @author 芋道源码
+ */
+@Data
+public class S3FileClientConfig implements FileClientConfig {
+
+    public static final String ENDPOINT_QINIU = "qiniucs.com";
+
+    /**
+     * 节点地址
+     * 1. MinIO:
+     * 2. 阿里云:https://help.aliyun.com/document_detail/31837.html
+     * 3. 腾讯云:
+     * 4. 七牛云:https://developer.qiniu.com/kodo/4088/s3-access-domainname
+     * 5. 华为云:
+     */
+    @NotNull(message = "endpoint 不能为空")
+    private String endpoint;
+    /**
+     * 自定义域名
+     * 1. MinIO:
+     * 2. 阿里云:https://help.aliyun.com/document_detail/31836.html
+     * 3. 腾讯云:https://cloud.tencent.com/document/product/436/11142
+     * 4. 七牛云:https://developer.qiniu.com/kodo/8556/set-the-custom-source-domain-name
+     * 5. 华为云:
+     */
+    @URL(message = "domain 必须是 URL 格式")
+    private String domain;
+    /**
+     * 区域
+     * 1. MinIO:
+     * 2. 阿里云:https://help.aliyun.com/document_detail/31837.html
+     * 3. 腾讯云:
+     * 4. 七牛云:https://developer.qiniu.com/kodo/4088/s3-access-domainname
+     * 5. 华为云:
+     */
+    @NotNull(message = "region 不能为空")
+    private String region;
+    /**
+     * 存储 Bucket
+     */
+    @NotNull(message = "bucket 不能为空")
+    private String bucket;
+
+    /**
+     * 访问 Key
+     * 1. MinIO:
+     * 2. 阿里云:
+     * 3. 腾讯云:https://console.cloud.tencent.com/cam/capi
+     * 4. 七牛云:https://portal.qiniu.com/user/key
+     * 5. 华为云:
+     */
+    @NotNull(message = "accessKey 不能为空")
+    private String accessKey;
+    /**
+     * 访问 Secret
+     */
+    @NotNull(message = "accessSecret 不能为空")
+    private String accessSecret;
+
+    @SuppressWarnings("RedundantIfStatement")
+    @AssertTrue(message = "domain 不能为空")
+    @JsonIgnore
+    public boolean isDomainValid() {
+        // 如果是七牛,必须带有 domain
+        if (StrUtil.contains(endpoint, ENDPOINT_QINIU) && StrUtil.isEmpty(domain)) {
+            return false;
+        }
+        return true;
+    }
+
+}

+ 36 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/s3/S3ModifyPathInterceptor.java

@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.framework.file.core.client.s3;
+
+import software.amazon.awssdk.core.interceptor.Context;
+import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
+import software.amazon.awssdk.core.interceptor.ExecutionInterceptor;
+import software.amazon.awssdk.http.SdkHttpRequest;
+
+/**
+ * S3 修改路径的拦截器,移除多余的 Bucket 前缀。
+ * 如果不使用该拦截器,希望上传的路径是 /tudou.jpg 时,会被添加成 /bucket/tudou.jpg
+ *
+ * @author 芋道源码
+ */
+public class S3ModifyPathInterceptor implements ExecutionInterceptor {
+
+	private final String bucket;
+
+	public S3ModifyPathInterceptor(String bucket) {
+		this.bucket = "/" + bucket;
+	}
+
+	@Override
+	public SdkHttpRequest modifyHttpRequest(Context.ModifyHttpRequest context, ExecutionAttributes executionAttributes) {
+		SdkHttpRequest request = context.httpRequest();
+		SdkHttpRequest.Builder rb = SdkHttpRequest.builder().protocol(request.protocol()).host(request.host()).port(request.port())
+				.method(request.method()).headers(request.headers()).rawQueryParameters(request.rawQueryParameters());
+		// 移除 path 前的 bucket 路径
+		if (request.encodedPath().startsWith(bucket)) {
+			rb.encodedPath(request.encodedPath().substring(bucket.length()));
+		} else {
+			rb.encodedPath(request.encodedPath());
+		}
+		return rb.build();
+	}
+
+}

+ 61 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClient.java

@@ -0,0 +1,61 @@
+package cn.iocoder.yudao.framework.file.core.client.sftp;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.extra.ssh.Sftp;
+import cn.iocoder.yudao.framework.common.util.io.FileUtils;
+import cn.iocoder.yudao.framework.file.core.client.AbstractFileClient;
+
+import java.io.File;
+
+/**
+ * Sftp 文件客户端
+ *
+ * @author 芋道源码
+ */
+public class SftpFileClient extends AbstractFileClient<SftpFileClientConfig> {
+
+    private Sftp sftp;
+
+    public SftpFileClient(Long id, SftpFileClientConfig config) {
+        super(id, config);
+    }
+
+    @Override
+    protected void doInit() {
+        // 补全风格。例如说 Linux 是 /,Windows 是 \
+        if (!config.getBasePath().endsWith(File.separator)) {
+            config.setBasePath(config.getBasePath() + File.separator);
+        }
+        // 初始化 Ftp 对象
+        this.sftp = new Sftp(config.getHost(), config.getPort(), config.getUsername(), config.getPassword());
+    }
+
+    @Override
+    public String upload(byte[] content, String path) {
+        // 执行写入
+        String filePath = getFilePath(path);
+        File file = FileUtils.createTempFile(content);
+        sftp.upload(filePath, file);
+        // 拼接返回路径
+        return super.formatFileUrl(config.getDomain(), path);
+    }
+
+    @Override
+    public void delete(String path) {
+        String filePath = getFilePath(path);
+        sftp.delFile(filePath);
+    }
+
+    @Override
+    public byte[] getContent(String path) {
+        String filePath = getFilePath(path);
+        File destFile = FileUtils.createTempFile();
+        sftp.download(filePath, destFile);
+        return FileUtil.readBytes(destFile);
+    }
+
+    private String getFilePath(String path) {
+        return config.getBasePath() + path;
+    }
+
+}

+ 52 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClientConfig.java

@@ -0,0 +1,52 @@
+package cn.iocoder.yudao.framework.file.core.client.sftp;
+
+import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
+import lombok.Data;
+import org.hibernate.validator.constraints.URL;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+
+/**
+ * Sftp 文件客户端的配置类
+ *
+ * @author 芋道源码
+ */
+@Data
+public class SftpFileClientConfig implements FileClientConfig {
+
+    /**
+     * 基础路径
+     */
+    @NotEmpty(message = "基础路径不能为空")
+    private String basePath;
+
+    /**
+     * 自定义域名
+     */
+    @NotEmpty(message = "domain 不能为空")
+    @URL(message = "domain 必须是 URL 格式")
+    private String domain;
+
+    /**
+     * 主机地址
+     */
+    @NotEmpty(message = "host 不能为空")
+    private String host;
+    /**
+     * 主机端口
+     */
+    @NotNull(message = "port 不能为空")
+    private Integer port;
+    /**
+     * 用户名
+     */
+    @NotEmpty(message = "用户名不能为空")
+    private String username;
+    /**
+     * 密码
+     */
+    @NotEmpty(message = "密码不能为空")
+    private String password;
+
+}

+ 55 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/enums/FileStorageEnum.java

@@ -0,0 +1,55 @@
+package cn.iocoder.yudao.framework.file.core.enums;
+
+import cn.hutool.core.util.ArrayUtil;
+import cn.iocoder.yudao.framework.file.core.client.FileClient;
+import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
+import cn.iocoder.yudao.framework.file.core.client.db.DBFileClient;
+import cn.iocoder.yudao.framework.file.core.client.db.DBFileClientConfig;
+import cn.iocoder.yudao.framework.file.core.client.ftp.FtpFileClient;
+import cn.iocoder.yudao.framework.file.core.client.ftp.FtpFileClientConfig;
+import cn.iocoder.yudao.framework.file.core.client.local.LocalFileClient;
+import cn.iocoder.yudao.framework.file.core.client.local.LocalFileClientConfig;
+import cn.iocoder.yudao.framework.file.core.client.s3.S3FileClient;
+import cn.iocoder.yudao.framework.file.core.client.s3.S3FileClientConfig;
+import cn.iocoder.yudao.framework.file.core.client.sftp.SftpFileClient;
+import cn.iocoder.yudao.framework.file.core.client.sftp.SftpFileClientConfig;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 文件存储器枚举
+ *
+ * @author 芋道源码
+ */
+@AllArgsConstructor
+@Getter
+public enum FileStorageEnum {
+
+    DB(1, DBFileClientConfig.class, DBFileClient.class),
+
+    LOCAL(10, LocalFileClientConfig.class, LocalFileClient.class),
+    FTP(11, FtpFileClientConfig.class, FtpFileClient.class),
+    SFTP(12, SftpFileClientConfig.class, SftpFileClient.class),
+
+    S3(20, S3FileClientConfig.class, S3FileClient.class),
+    ;
+
+    /**
+     * 存储器
+     */
+    private final Integer storage;
+
+    /**
+     * 配置类
+     */
+    private final Class<? extends FileClientConfig> configClass;
+    /**
+     * 客户端类
+     */
+    private final Class<? extends FileClient> clientClass;
+
+    public static FileStorageEnum getByStorage(Integer storage) {
+        return ArrayUtil.firstMatch(o -> o.getStorage().equals(storage), values());
+    }
+
+}

+ 2 - 0
yudao-framework/yudao-spring-boot-starter-file/src/main/resources/META-INF/spring.factories

@@ -0,0 +1,2 @@
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+  cn.iocoder.yudao.framework.file.config.YudaoFileAutoConfiguration

+ 4 - 0
yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/config/package-info.java

@@ -0,0 +1,4 @@
+/**
+ * 占位,避免 package 无法提交到 Git 仓库
+ */
+package cn.iocoder.yudao.framework.file.config;

+ 37 - 0
yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/ftp/FtpFileClientTest.java

@@ -0,0 +1,37 @@
+package cn.iocoder.yudao.framework.file.core.client.ftp;
+
+import cn.hutool.core.io.resource.ResourceUtil;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.extra.ftp.FtpMode;
+import org.junit.jupiter.api.Test;
+
+public class FtpFileClientTest {
+
+    @Test
+    public void test() {
+        // 创建客户端
+        FtpFileClientConfig config = new FtpFileClientConfig();
+        config.setDomain("http://127.0.0.1:48080");
+        config.setBasePath("/home/ftp");
+        config.setHost("kanchai.club");
+        config.setPort(221);
+        config.setUsername("");
+        config.setPassword("");
+        config.setMode(FtpMode.Passive.name());
+        FtpFileClient client = new FtpFileClient(0L, config);
+        client.init();
+        // 上传文件
+        String path = IdUtil.fastSimpleUUID() + ".jpg";
+        byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
+        String fullPath = client.upload(content, path);
+        System.out.println("访问地址:" + fullPath);
+        if (false) {
+            byte[] bytes = client.getContent(path);
+            System.out.println("文件内容:" + bytes);
+        }
+        if (false) {
+            client.delete(path);
+        }
+    }
+
+}

+ 25 - 0
yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/local/LocalFileClientTest.java

@@ -0,0 +1,25 @@
+package cn.iocoder.yudao.framework.file.core.client.local;
+
+import cn.hutool.core.io.resource.ResourceUtil;
+import cn.hutool.core.util.IdUtil;
+import org.junit.jupiter.api.Test;
+
+public class LocalFileClientTest {
+
+    @Test
+    public void test() {
+        // 创建客户端
+        LocalFileClientConfig config = new LocalFileClientConfig();
+        config.setDomain("http://127.0.0.1:48080");
+        config.setBasePath("/Users/yunai/file_test");
+        LocalFileClient client = new LocalFileClient(0L, config);
+        client.init();
+        // 上传文件
+        String path = IdUtil.fastSimpleUUID() + ".jpg";
+        byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
+        String fullPath = client.upload(content, path);
+        System.out.println("访问地址:" + fullPath);
+        client.delete(path);
+    }
+
+}

+ 90 - 0
yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClientTest.java

@@ -0,0 +1,90 @@
+package cn.iocoder.yudao.framework.file.core.client.s3;
+
+import cn.hutool.core.io.resource.ResourceUtil;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import javax.validation.Validation;
+
+public class S3FileClientTest {
+
+    @Test
+    @Disabled // 阿里云 OSS,如果要集成测试,可以注释本行
+    public void testAliyun() {
+        S3FileClientConfig config = new S3FileClientConfig();
+        // 配置成你自己的
+        config.setAccessKey(System.getenv("ALIYUN_ACCESS_KEY"));
+        config.setAccessSecret(System.getenv("ALIYUN_SECRET_KEY"));
+        config.setBucket("yunai-aoteman");
+        config.setDomain(null); // 如果有自定义域名,则可以设置。http://ali-oss.iocoder.cn
+        // 默认北京的 endpoint
+        config.setEndpoint("oss-cn-beijing.aliyuncs.com");
+
+        // 执行上传
+        testExecuteUpload(config);
+    }
+
+    @Test
+    @Disabled // 腾讯云 COS,如果要集成测试,可以注释本行
+    public void testQCloud() {
+        S3FileClientConfig config = new S3FileClientConfig();
+        // 配置成你自己的
+        config.setAccessKey(System.getenv("QCLOUD_ACCESS_KEY"));
+        config.setAccessSecret(System.getenv("QCLOUD_SECRET_KEY"));
+        config.setBucket("aoteman-1255880240");
+        config.setDomain(null); // 如果有自定义域名,则可以设置。http://tengxun-oss.iocoder.cn
+        // 默认上海的 endpoint
+        config.setEndpoint("cos.ap-shanghai.myqcloud.com");
+        config.setRegion("ap-shanghai");
+
+        // 执行上传
+        testExecuteUpload(config);
+    }
+
+    @Test
+    @Disabled // 七牛云存储,如果要集成测试,可以注释本行
+    public void testQiniu() {
+        S3FileClientConfig config = new S3FileClientConfig();
+        // 配置成你自己的
+//        config.setAccessKey(System.getenv("QINIU_ACCESS_KEY"));
+//        config.setAccessSecret(System.getenv("QINIU_SECRET_KEY"));
+        config.setAccessKey("b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8");
+        config.setAccessSecret("kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP");
+        config.setBucket("ruoyi-vue-pro");
+        config.setDomain("http://test.yudao.iocoder.cn"); // 如果有自定义域名,则可以设置。http://static.yudao.iocoder.cn
+        // 默认上海的 endpoint
+        config.setEndpoint("s3-cn-south-1.qiniucs.com");
+
+        // 执行上传
+        testExecuteUpload(config);
+    }
+
+    private void testExecuteUpload(S3FileClientConfig config) {
+        // 补全配置
+        if (config.getRegion() == null) {
+            config.setRegion(StrUtil.subBefore(config.getEndpoint(), '.', false));
+        }
+        ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config);
+        // 创建 Client
+        S3FileClient client = new S3FileClient(0L, config);
+        client.init();
+        // 上传文件
+        String path = IdUtil.fastSimpleUUID() + ".jpg";
+        byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
+        String fullPath = client.upload(content, path);
+        System.out.println("访问地址:" + fullPath);
+        // 读取文件
+        if (false) {
+            byte[] bytes = client.getContent(path);
+            System.out.println("文件内容:" + bytes);
+        }
+        // 删除文件
+        if (false) {
+            client.delete(path);
+        }
+    }
+
+}

+ 35 - 0
yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/sftp/SftpFileClientTest.java

@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.framework.file.core.client.sftp;
+
+import cn.hutool.core.io.resource.ResourceUtil;
+import cn.hutool.core.util.IdUtil;
+import org.junit.jupiter.api.Test;
+
+public class SftpFileClientTest {
+
+    @Test
+    public void test() {
+        // 创建客户端
+        SftpFileClientConfig config = new SftpFileClientConfig();
+        config.setDomain("http://127.0.0.1:48080");
+        config.setBasePath("/home/ftp");
+        config.setHost("kanchai.club");
+        config.setPort(222);
+        config.setUsername("");
+        config.setPassword("");
+        SftpFileClient client = new SftpFileClient(0L, config);
+        client.init();
+        // 上传文件
+        String path = IdUtil.fastSimpleUUID() + ".jpg";
+        byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
+        String fullPath = client.upload(content, path);
+        System.out.println("访问地址:" + fullPath);
+        if (false) {
+            byte[] bytes = client.getContent(path);
+            System.out.println("文件内容:" + bytes);
+        }
+        if (false) {
+            client.delete(path);
+        }
+    }
+
+}

+ 4 - 0
yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/enums/package-info.java

@@ -0,0 +1,4 @@
+/**
+ * 占位,避免 package 无法提交到 Git 仓库
+ */
+package cn.iocoder.yudao.framework.file.core.enums;

+ 0 - 0
yudao-module-infra/yudao-module-infra-impl/src/test/resources/file/erweima.jpg → yudao-framework/yudao-spring-boot-starter-file/src/test/resources/file/erweima.jpg


+ 3 - 0
yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/mapper/BaseMapperX.java

@@ -80,4 +80,7 @@ public interface BaseMapperX<T> extends BaseMapper<T> {
         entities.forEach(this::insert);
     }
 
+    default void updateBatch(T update) {
+        update(update, new QueryWrapper<>());
+    }
 }

+ 5 - 2
yudao-module-infra/yudao-module-infra-api/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java

@@ -42,8 +42,11 @@ public interface ErrorCodeConstants {
     ErrorCode CODEGEN_SYNC_COLUMNS_NULL = new ErrorCode(1003001006, "同步的字段不存在");
     ErrorCode CODEGEN_SYNC_NONE_CHANGE = new ErrorCode(1003001007, "同步失败,不存在改变");
 
-    // ========== 字典类型(测试) 1003000000 ==========
-    ErrorCode TEST_DEMO_NOT_EXISTS = new ErrorCode(1003000000, "测试示例不存在");
+    // ========== 字典类型(测试)1001005000 ==========
+    ErrorCode TEST_DEMO_NOT_EXISTS = new ErrorCode(1001005000, "测试示例不存在");
 
+    // ========== 文件配置 1001006000 ==========
+    ErrorCode FILE_CONFIG_NOT_EXISTS = new ErrorCode(1001006000, "文件配置不存在");
+    ErrorCode FILE_CONFIG_DELETE_FAIL_MASTER = new ErrorCode(1001006001, "该文件配置不允许删除,原因:它是主配置,删除会导致无法上传文件");
 
 }

+ 7 - 0
yudao-module-infra/yudao-module-infra-impl/pom.xml

@@ -109,6 +109,13 @@
             <groupId>de.codecentric</groupId>
             <artifactId>spring-boot-admin-starter-server</artifactId> <!-- 实现 Spring Boot Admin Server 服务端 -->
         </dependency>
+
+        <!-- 三方云服务相关 -->
+        <dependency>
+            <groupId>cn.iocoder.boot</groupId>
+            <artifactId>yudao-spring-boot-starter-file</artifactId>
+        </dependency>
+
     </dependencies>
 
 </project>

+ 45 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileConfigController.http

@@ -0,0 +1,45 @@
+### 请求 /infra/file-config/create 接口 => 成功
+POST {{baseUrl}}/infra/file-config/create
+Content-Type: application/json
+tenant-id: {{adminTenentId}}
+Authorization: Bearer {{token}}
+
+{
+  "name": "S3 - 七牛云",
+  "remark": "",
+  "storage": 20,
+  "config": {
+    "accessKey": "b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8",
+    "accessSecret": "kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP",
+    "bucket": "ruoyi-vue-pro",
+    "endpoint": "s3-cn-south-1.qiniucs.com",
+    "domain": "http://test.yudao.iocoder.cn",
+    "region": "oss-cn-beijing"
+  }
+}
+
+### 请求 /infra/file-config/update 接口 => 成功
+PUT {{baseUrl}}/infra/file-config/update
+Content-Type: application/json
+tenant-id: {{adminTenentId}}
+Authorization: Bearer {{token}}
+
+{
+  "id": 2,
+  "name": "S3 - 七牛云",
+  "remark": "",
+  "config": {
+    "accessKey": "b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8",
+    "accessSecret": "kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP",
+    "bucket": "ruoyi-vue-pro",
+    "endpoint": "s3-cn-south-1.qiniucs.com",
+    "domain": "http://test.yudao.iocoder.cn",
+    "region": "oss-cn-beijing"
+  }
+}
+
+### 请求 /infra/file-config/test 接口 => 成功
+GET {{baseUrl}}/infra/file-config/test?id=2
+Content-Type: application/json
+tenant-id: {{adminTenentId}}
+Authorization: Bearer {{token}}

+ 89 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileConfigController.java

@@ -0,0 +1,89 @@
+package cn.iocoder.yudao.module.infra.controller.admin.file;
+
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigCreateReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigPageReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigRespVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigUpdateReqVO;
+import cn.iocoder.yudao.module.infra.convert.file.FileConfigConvert;
+import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileConfigDO;
+import cn.iocoder.yudao.module.infra.service.file.FileConfigService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.validation.Valid;
+
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+@Api(tags = "管理后台 - 文件配置")
+@RestController
+@RequestMapping("/infra/file-config")
+@Validated
+public class FileConfigController {
+
+    @Resource
+    private FileConfigService fileConfigService;
+
+    @PostMapping("/create")
+    @ApiOperation("创建文件配置")
+    @PreAuthorize("@ss.hasPermission('infra:file-config:create')")
+    public CommonResult<Long> createFileConfig(@Valid @RequestBody FileConfigCreateReqVO createReqVO) {
+        return success(fileConfigService.createFileConfig(createReqVO));
+    }
+
+    @PutMapping("/update")
+    @ApiOperation("更新文件配置")
+    @PreAuthorize("@ss.hasPermission('infra:file-config:update')")
+    public CommonResult<Boolean> updateFileConfig(@Valid @RequestBody FileConfigUpdateReqVO updateReqVO) {
+        fileConfigService.updateFileConfig(updateReqVO);
+        return success(true);
+    }
+
+    @PutMapping("/update-master")
+    @ApiOperation("更新文件配置为 Master")
+    @PreAuthorize("@ss.hasPermission('infra:file-config:update')")
+    public CommonResult<Boolean> updateFileConfigMaster(@RequestParam("id") Long id) {
+        fileConfigService.updateFileConfigMaster(id);
+        return success(true);
+    }
+
+    @DeleteMapping("/delete")
+    @ApiOperation("删除文件配置")
+    @ApiImplicitParam(name = "id", value = "编号", required = true, dataTypeClass = Long.class)
+    @PreAuthorize("@ss.hasPermission('infra:file-config:delete')")
+    public CommonResult<Boolean> deleteFileConfig(@RequestParam("id") Long id) {
+        fileConfigService.deleteFileConfig(id);
+        return success(true);
+    }
+
+    @GetMapping("/get")
+    @ApiOperation("获得文件配置")
+    @ApiImplicitParam(name = "id", value = "编号", required = true, example = "1024", dataTypeClass = Long.class)
+    @PreAuthorize("@ss.hasPermission('infra:file-config:query')")
+    public CommonResult<FileConfigRespVO> getFileConfig(@RequestParam("id") Long id) {
+        FileConfigDO fileConfig = fileConfigService.getFileConfig(id);
+        return success(FileConfigConvert.INSTANCE.convert(fileConfig));
+    }
+
+    @GetMapping("/page")
+    @ApiOperation("获得文件配置分页")
+    @PreAuthorize("@ss.hasPermission('infra:file-config:query')")
+    public CommonResult<PageResult<FileConfigRespVO>> getFileConfigPage(@Valid FileConfigPageReqVO pageVO) {
+        PageResult<FileConfigDO> pageResult = fileConfigService.getFileConfigPage(pageVO);
+        return success(FileConfigConvert.INSTANCE.convertPage(pageResult));
+    }
+
+    @GetMapping("/test")
+    @ApiOperation("测试文件配置是否正确")
+    @PreAuthorize("@ss.hasPermission('infra:file-config:query')")
+    public CommonResult<String> testFileConfig(@RequestParam("id") Long id) {
+        String url = fileConfigService.testFileConfig(id);
+        return success(url);
+    }
+}

+ 16 - 11
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java

@@ -4,8 +4,8 @@ import cn.hutool.core.io.IoUtil;
 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
-import cn.iocoder.yudao.module.infra.controller.admin.file.vo.FilePageReqVO;
-import cn.iocoder.yudao.module.infra.controller.admin.file.vo.FileRespVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileRespVO;
 import cn.iocoder.yudao.module.infra.convert.file.FileConvert;
 import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
 import cn.iocoder.yudao.module.infra.service.file.FileService;
@@ -50,24 +50,29 @@ public class FileController {
 
     @DeleteMapping("/delete")
     @ApiOperation("删除文件")
-    @ApiImplicitParam(name = "id", value = "编号", required = true, dataTypeClass = String.class)
+    @ApiImplicitParam(name = "id", value = "编号", required = true, dataTypeClass = Long.class)
     @PreAuthorize("@ss.hasPermission('infra:file:delete')")
-    public CommonResult<Boolean> deleteFile(@RequestParam("id") String id) {
+    public CommonResult<Boolean> deleteFile(@RequestParam("id") Long id) {
         fileService.deleteFile(id);
         return success(true);
     }
 
-    @GetMapping("/get/{path}")
+    @GetMapping("/{configId}/get/{path}")
     @ApiOperation("下载文件")
-    @ApiImplicitParam(name = "path", value = "文件附件", required = true, dataTypeClass = MultipartFile.class)
-    public void getFile(HttpServletResponse response, @PathVariable("path") String path) throws IOException {
-        FileDO file = fileService.getFile(path);
-        if (file == null) {
-            log.warn("[getFile][path({}) 文件不存在]", path);
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "configId", value = "配置编号",  required = true, dataTypeClass = Long.class),
+            @ApiImplicitParam(name = "path", value = "文件路径", required = true, dataTypeClass = String.class)
+    })
+    public void getFileContent(HttpServletResponse response,
+                               @PathVariable("configId") Long configId,
+                               @PathVariable("path") String path) throws IOException {
+        byte[] content = fileService.getFileContent(configId, path);
+        if (content == null) {
+            log.warn("[getFileContent][configId({}) path({}) 文件不存在]", configId, path);
             response.setStatus(HttpStatus.NOT_FOUND.value());
             return;
         }
-        ServletUtils.writeAttachment(response, path, file.getContent());
+        ServletUtils.writeAttachment(response, path, content);
     }
 
     @GetMapping("/page")

+ 0 - 22
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/FileRespVO.java

@@ -1,22 +0,0 @@
-package cn.iocoder.yudao.module.infra.controller.admin.file.vo;
-
-import io.swagger.annotations.ApiModel;
-import io.swagger.annotations.ApiModelProperty;
-import lombok.Data;
-
-import java.util.Date;
-
-@ApiModel(value = "管理后台 - 文件 Response VO", description = "不返回 content 字段,太大")
-@Data
-public class FileRespVO {
-
-    @ApiModelProperty(value = "文件路径", required = true, example = "yudao.jpg")
-    private String id;
-
-    @ApiModelProperty(value = "文件类型", required = true, example = "jpg")
-    private String type;
-
-    @ApiModelProperty(value = "创建时间", required = true)
-    private Date createTime;
-
-}

+ 22 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigBaseVO.java

@@ -0,0 +1,22 @@
+package cn.iocoder.yudao.module.infra.controller.admin.file.vo.config;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+/**
+* 文件配置 Base VO,提供给添加、修改、详细的子 VO 使用
+* 如果子 VO 存在差异的字段,请不要添加到这里,影响 Swagger 文档生成
+*/
+@Data
+public class FileConfigBaseVO {
+
+    @ApiModelProperty(value = "配置名", required = true, example = "S3 - 阿里云")
+    @NotNull(message = "配置名不能为空")
+    private String name;
+
+    @ApiModelProperty(value = "备注", example = "我是备注")
+    private String remark;
+
+}

+ 26 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigCreateReqVO.java

@@ -0,0 +1,26 @@
+package cn.iocoder.yudao.module.infra.controller.admin.file.vo.config;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import javax.validation.constraints.NotNull;
+import java.util.Map;
+
+@ApiModel("管理后台 - 文件配置创建 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class FileConfigCreateReqVO extends FileConfigBaseVO {
+
+    @ApiModelProperty(value = "存储器", required = true, example = "1", notes = "参见 FileStorageEnum 枚举类")
+    @NotNull(message = "存储器不能为空")
+    private Integer storage;
+
+    @ApiModelProperty(value = "存储配置", required = true, notes = "配置是动态参数,所以使用 Map 接收")
+    @NotNull(message = "存储配置不能为空")
+    private Map<String, Object> config;
+
+}

+ 35 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigPageReqVO.java

@@ -0,0 +1,35 @@
+package cn.iocoder.yudao.module.infra.controller.admin.file.vo.config;
+
+import cn.iocoder.yudao.framework.common.pojo.PageParam;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.util.Date;
+
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
+
+@ApiModel("管理后台 - 文件配置分页 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class FileConfigPageReqVO extends PageParam {
+
+    @ApiModelProperty(value = "配置名", example = "S3 - 阿里云")
+    private String name;
+
+    @ApiModelProperty(value = "存储器", example = "1")
+    private Integer storage;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "开始创建时间")
+    private Date beginCreateTime;
+
+    @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
+    @ApiModelProperty(value = "结束创建时间")
+    private Date endCreateTime;
+
+}

+ 36 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigRespVO.java

@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.module.infra.controller.admin.file.vo.config;
+
+import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import javax.validation.constraints.NotNull;
+import java.util.Date;
+
+@ApiModel("管理后台 - 文件配置 Response VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class FileConfigRespVO extends FileConfigBaseVO {
+
+    @ApiModelProperty(value = "编号", required = true, example = "1")
+    private Long id;
+
+    @ApiModelProperty(value = "存储器", required = true, example = "1", notes = "参见 FileStorageEnum 枚举类")
+    @NotNull(message = "存储器不能为空")
+    private Integer storage;
+
+    @ApiModelProperty(value = "是否为主配置", required = true, example = "true")
+    @NotNull(message = "是否为主配置不能为空")
+    private Boolean master;
+
+    @ApiModelProperty(value = "存储配置", required = true)
+    private FileClientConfig config;
+
+    @ApiModelProperty(value = "创建时间", required = true)
+    private Date createTime;
+
+}

+ 26 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/config/FileConfigUpdateReqVO.java

@@ -0,0 +1,26 @@
+package cn.iocoder.yudao.module.infra.controller.admin.file.vo.config;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+import javax.validation.constraints.NotNull;
+import java.util.Map;
+
+@ApiModel("管理后台 - 文件配置更新 Request VO")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class FileConfigUpdateReqVO extends FileConfigBaseVO {
+
+    @ApiModelProperty(value = "编号", required = true, example = "1")
+    @NotNull(message = "编号不能为空")
+    private Long id;
+
+    @ApiModelProperty(value = "存储配置", required = true, notes = "配置是动态参数,所以使用 Map 接收")
+    @NotNull(message = "存储配置不能为空")
+    private Map<String, Object> config;
+
+}

+ 2 - 2
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/FilePageReqVO.java → yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FilePageReqVO.java

@@ -1,4 +1,4 @@
-package cn.iocoder.yudao.module.infra.controller.admin.file.vo;
+package cn.iocoder.yudao.module.infra.controller.admin.file.vo.file;
 
 import cn.iocoder.yudao.framework.common.pojo.PageParam;
 import io.swagger.annotations.ApiModel;
@@ -19,7 +19,7 @@ import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_
 public class FilePageReqVO extends PageParam {
 
     @ApiModelProperty(value = "文件路径", example = "yudao", notes = "模糊匹配")
-    private String id;
+    private String path;
 
     @ApiModelProperty(value = "文件类型", example = "jpg", notes = "模糊匹配")
     private String type;

+ 31 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileRespVO.java

@@ -0,0 +1,31 @@
+package cn.iocoder.yudao.module.infra.controller.admin.file.vo.file;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+@ApiModel(value = "管理后台 - 文件 Response VO", description = "不返回 content 字段,太大")
+@Data
+public class FileRespVO {
+
+    @ApiModelProperty(value = "文件编号", required = true, example = "1024")
+    private Long id;
+
+    @ApiModelProperty(value = "文件路径", required = true, example = "yudao.jpg")
+    private String path;
+
+    @ApiModelProperty(value = "文件 URL", required = true, example = "https://www.iocoder.cn/yudao.jpg")
+    private String url;
+
+    @ApiModelProperty(value = "文件类型", example = "jpg")
+    private String type;
+
+    @ApiModelProperty(value = "文件大小", example = "2048", required = true)
+    private Integer size;
+
+    @ApiModelProperty(value = "创建时间", required = true)
+    private Date createTime;
+
+}

+ 36 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/convert/file/FileConfigConvert.java

@@ -0,0 +1,36 @@
+package cn.iocoder.yudao.module.infra.convert.file;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigCreateReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigRespVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigUpdateReqVO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileConfigDO;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.factory.Mappers;
+
+import java.util.List;
+
+/**
+ * 文件配置 Convert
+ *
+ * @author 芋道源码
+ */
+@Mapper
+public interface FileConfigConvert {
+
+    FileConfigConvert INSTANCE = Mappers.getMapper(FileConfigConvert.class);
+
+    @Mapping(target = "config", ignore = true)
+    FileConfigDO convert(FileConfigCreateReqVO bean);
+
+    @Mapping(target = "config", ignore = true)
+    FileConfigDO convert(FileConfigUpdateReqVO bean);
+
+    FileConfigRespVO convert(FileConfigDO bean);
+
+    List<FileConfigRespVO> convertList(List<FileConfigDO> list);
+
+    PageResult<FileConfigRespVO> convertPage(PageResult<FileConfigDO> page);
+
+}

+ 1 - 1
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/convert/file/FileConvert.java

@@ -1,7 +1,7 @@
 package cn.iocoder.yudao.module.infra.convert.file;
 
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
-import cn.iocoder.yudao.module.infra.controller.admin.file.vo.FileRespVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileRespVO;
 import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
 import org.mapstruct.Mapper;
 import org.mapstruct.factory.Mappers;

+ 56 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileConfigDO.java

@@ -0,0 +1,56 @@
+package cn.iocoder.yudao.module.infra.dal.dataobject.file;
+
+import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
+import cn.iocoder.yudao.framework.file.core.enums.FileStorageEnum;
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
+import lombok.*;
+
+/**
+ * 文件配置表
+ *
+ * @author 芋道源码
+ */
+@Data
+@TableName(value = "infra_file_config", autoResultMap = true)
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class FileConfigDO extends BaseDO {
+
+    /**
+     * 配置编号,数据库自增
+     */
+    private Long id;
+    /**
+     * 配置名
+     */
+    private String name;
+    /**
+     * 存储器
+     *
+     * 枚举 {@link FileStorageEnum}
+     */
+    private Integer storage;
+    /**
+     * 备注
+     */
+    private String remark;
+    /**
+     * 是否为主配置
+     *
+     * 由于我们可以配置多个文件配置,默认情况下,使用主配置进行文件的上传
+     */
+    private Boolean master;
+
+    /**
+     * 支付渠道配置
+     */
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private FileClientConfig config;
+
+}

+ 45 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileContentDO.java

@@ -0,0 +1,45 @@
+package cn.iocoder.yudao.module.infra.dal.dataobject.file;
+
+import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.*;
+
+/**
+ * 文件内容表
+ *
+ * 专门用于存储 {@link cn.iocoder.yudao.framework.file.core.client.db.DBFileClient} 的文件内容
+ *
+ * @author 芋道源码
+ */
+@Data
+@TableName("infra_file_content")
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class FileContentDO extends BaseDO {
+
+    /**
+     * 编号,数据库自增
+     */
+    @TableId(type = IdType.INPUT)
+    private String id;
+    /**
+     * 配置编号
+     *
+     * 关联 {@link FileConfigDO#getId()}
+     */
+    private Long configId;
+    /**
+     * 路径,即文件名
+     */
+    private String path;
+    /**
+     * 文件内容
+     */
+    private byte[] content;
+
+}

+ 19 - 7
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileDO.java

@@ -1,9 +1,7 @@
 package cn.iocoder.yudao.module.infra.dal.dataobject.file;
 
 import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
-import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableField;
-import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.*;
 
@@ -11,6 +9,7 @@ import java.io.InputStream;
 
 /**
  * 文件表
+ * 每次文件上传,都会记录一条记录到该表中
  *
  * @author 芋道源码
  */
@@ -24,10 +23,23 @@ import java.io.InputStream;
 public class FileDO extends BaseDO {
 
     /**
-     * 文件路径
+     * 编号,数据库自增
      */
-    @TableId(type = IdType.INPUT)
-    private String id;
+    private Long id;
+    /**
+     * 配置编号
+     *
+     * 关联 {@link FileConfigDO#getId()}
+     */
+    private Long configId;
+    /**
+     * 路径,即文件名
+     */
+    private String path;
+    /**
+     * 访问地址
+     */
+    private String url;
     /**
      * 文件类型
      *
@@ -36,8 +48,8 @@ public class FileDO extends BaseDO {
     @TableField(value = "`type`")
     private String type;
     /**
-     * 文件内容
+     * 文件大小
      */
-    private byte[] content;
+    private Integer size;
 
 }

+ 32 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileConfigMapper.java

@@ -0,0 +1,32 @@
+package cn.iocoder.yudao.module.infra.dal.mysql.file;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigPageReqVO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileConfigDO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.Date;
+
+/**
+ * 文件配置 Mapper
+ *
+ * @author 芋道源码
+ */
+@Mapper
+public interface FileConfigMapper extends BaseMapperX<FileConfigDO> {
+
+    default PageResult<FileConfigDO> selectPage(FileConfigPageReqVO reqVO) {
+        return selectPage(reqVO, new LambdaQueryWrapperX<FileConfigDO>()
+                .likeIfPresent(FileConfigDO::getName, reqVO.getName())
+                .eqIfPresent(FileConfigDO::getStorage, reqVO.getStorage())
+                .betweenIfPresent(FileConfigDO::getCreateTime, reqVO.getBeginCreateTime(), reqVO.getEndCreateTime())
+                .orderByDesc(FileConfigDO::getId));
+    }
+
+    @Select("SELECT id FROM infra_file_config WHERE update_time > #{maxUpdateTime} LIMIT 1")
+    Long selectExistsByUpdateTimeAfter(Date maxUpdateTime);
+
+}

+ 41 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileContentDAOImpl.java

@@ -0,0 +1,41 @@
+package cn.iocoder.yudao.module.infra.dal.mysql.file;
+
+import cn.iocoder.yudao.framework.file.core.client.db.DBFileContentFrameworkDAO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileContentDO;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.springframework.stereotype.Repository;
+
+import javax.annotation.Resource;
+
+@Repository
+public class FileContentDAOImpl implements DBFileContentFrameworkDAO {
+
+    @Resource
+    private FileContentMapper fileContentMapper;
+
+    @Override
+    public void insert(Long configId, String path, byte[] content) {
+        FileContentDO entity = new FileContentDO().setConfigId(configId)
+                .setPath(path).setContent(content);
+        fileContentMapper.insert(entity);
+    }
+
+    @Override
+    public void delete(Long configId, String path) {
+        fileContentMapper.delete(buildQuery(configId, path));
+    }
+
+    @Override
+    public byte[] selectContent(Long configId, String path) {
+        FileContentDO fileContentDO = fileContentMapper.selectOne(
+                buildQuery(configId, path).select(FileContentDO::getContent));
+        return fileContentDO != null ? fileContentDO.getContent() : null;
+    }
+
+    private LambdaQueryWrapper<FileContentDO> buildQuery(Long configId, String path) {
+        return new LambdaQueryWrapper<FileContentDO>()
+                .eq(FileContentDO::getConfigId, configId)
+                .eq(FileContentDO::getPath, path);
+    }
+
+}

+ 9 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileContentMapper.java

@@ -0,0 +1,9 @@
+package cn.iocoder.yudao.module.infra.dal.mysql.file;
+
+import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileContentDO;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface FileContentMapper extends BaseMapper<FileContentDO> {
+}

+ 2 - 17
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileMapper.java

@@ -3,7 +3,7 @@ package cn.iocoder.yudao.module.infra.dal.mysql.file;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
 import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX;
-import cn.iocoder.yudao.module.infra.controller.admin.file.vo.FilePageReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
 import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
 import org.apache.ibatis.annotations.Mapper;
 
@@ -17,25 +17,10 @@ public interface FileMapper extends BaseMapperX<FileDO> {
 
     default PageResult<FileDO> selectPage(FilePageReqVO reqVO) {
         return selectPage(reqVO, new QueryWrapperX<FileDO>()
-                .likeIfPresent("id", reqVO.getId())
+                .likeIfPresent("path", reqVO.getPath())
                 .likeIfPresent("type", reqVO.getType())
                 .betweenIfPresent("create_time", reqVO.getBeginCreateTime(), reqVO.getEndCreateTime())
                 .orderByDesc("create_time"));
     }
 
-    default Long selectCountById(String id) {
-        return selectCount(FileDO::getId, id);
-    }
-
-    /**
-     * 基于 Path 获取文件
-     * 实际上,是基于 ID 查询
-     *
-     * @param path 路径
-     * @return 文件
-     */
-    default FileDO selectByPath(String path) {
-        return selectById(path);
-    }
-
 }

+ 0 - 12
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/FileConfiguration.java

@@ -1,12 +0,0 @@
-package cn.iocoder.yudao.module.infra.framework.file.config;
-
-import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.context.annotation.Configuration;
-
-/**
- * 文件 配置类
- */
-@Configuration
-@EnableConfigurationProperties(FileProperties.class)
-public class FileConfiguration {
-}

+ 0 - 22
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/framework/file/config/FileProperties.java

@@ -1,22 +0,0 @@
-package cn.iocoder.yudao.module.infra.framework.file.config;
-
-import lombok.Data;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.validation.annotation.Validated;
-
-import javax.validation.constraints.NotNull;
-
-@ConfigurationProperties(prefix = "yudao.file")
-@Validated
-@Data
-public class FileProperties {
-
-    /**
-     * 对应 FileController 的 getFile 方法
-     */
-    @NotNull(message = "基础文件路径不能为空")
-    private String basePath;
-
-    // TODO 七牛、等等
-
-}

+ 0 - 16
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/framework/file/package-info.java

@@ -1,16 +0,0 @@
-/**
- * 文件的存储,推荐使用七牛、阿里云、华为云、腾讯云等文件服务
- *
- * 在不采用云服务的情况下,我们有几种技术选型:
- * 方案 1. 使用自建的文件服务,例如说 minIO、FastDFS 等等
- * 方案 2. 使用服务器的文件系统存储
- * 方案 3. 使用数据库进行存储
- *
- * 如果考虑额外在搭建服务,推荐方案 1。
- * 对于方案 2 来说,如果要实现文件存储的高可用,需要多台服务器之间做实时同步,可以基于 rsync + inotify 来做
- * 对于方案 3 的话,实现起来最简单,但是数据库本身不适合存储海量的文件
- *
- * 综合考虑,暂时使用方案 3 的方式,比较适合这样一个 all in one 的项目。
- * 随着文件的量级大了之后,还是推荐采用云服务。
- */
-package cn.iocoder.yudao.module.infra.framework.file;

+ 1 - 1
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/framework/security/config/SecurityConfiguration.java

@@ -36,7 +36,7 @@ public class SecurityConfiguration {
                 registry.antMatchers(adminSeverContextPath).anonymous()
                         .antMatchers(adminSeverContextPath + "/**").anonymous();
                 // 文件的获取接口,可匿名访问
-                registry.antMatchers(buildAdminApi("/infra/file/get/**"), buildAppApi("/infra/file/get/**")).anonymous();
+                registry.antMatchers(buildAdminApi("/infra/file/*/get/**"), buildAppApi("/infra/file/get/**")).permitAll();
             }
 
         };

+ 29 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/consumer/file/FileConfigRefreshConsumer.java

@@ -0,0 +1,29 @@
+package cn.iocoder.yudao.module.infra.mq.consumer.file;
+
+import cn.iocoder.yudao.framework.mq.core.pubsub.AbstractChannelMessageListener;
+import cn.iocoder.yudao.module.infra.mq.message.file.FileConfigRefreshMessage;
+import cn.iocoder.yudao.module.infra.service.file.FileConfigService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 针对 {@link FileConfigRefreshMessage} 的消费者
+ *
+ * @author 芋道源码
+ */
+@Component
+@Slf4j
+public class FileConfigRefreshConsumer extends AbstractChannelMessageListener<FileConfigRefreshMessage> {
+
+    @Resource
+    private FileConfigService fileConfigService;
+
+    @Override
+    public void onMessage(FileConfigRefreshMessage message) {
+        log.info("[onMessage][收到 FileConfig 刷新消息]");
+        fileConfigService.initFileClients();
+    }
+
+}

+ 0 - 1
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/consumer/package-info.java

@@ -1 +0,0 @@
-package cn.iocoder.yudao.module.infra.mq.consumer;

+ 17 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/message/file/FileConfigRefreshMessage.java

@@ -0,0 +1,17 @@
+package cn.iocoder.yudao.module.infra.mq.message.file;
+
+import cn.iocoder.yudao.framework.mq.core.pubsub.AbstractChannelMessage;
+import lombok.Data;
+
+/**
+ * 文件配置数据刷新 Message
+ */
+@Data
+public class FileConfigRefreshMessage extends AbstractChannelMessage {
+
+    @Override
+    public String getChannel() {
+        return "infra.file-config.refresh";
+    }
+
+}

+ 0 - 1
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/message/package-info.java

@@ -1 +0,0 @@
-package cn.iocoder.yudao.module.infra.mq.message;

+ 26 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/producer/file/FileConfigProducer.java

@@ -0,0 +1,26 @@
+package cn.iocoder.yudao.module.infra.mq.producer.file;
+
+import cn.iocoder.yudao.framework.mq.core.RedisMQTemplate;
+import cn.iocoder.yudao.module.infra.mq.message.file.FileConfigRefreshMessage;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 文件配置相关消息的 Producer
+ */
+@Component
+public class FileConfigProducer {
+
+    @Resource
+    private RedisMQTemplate redisMQTemplate;
+
+    /**
+     * 发送 {@link FileConfigRefreshMessage} 消息
+     */
+    public void sendFileConfigRefreshMessage() {
+        FileConfigRefreshMessage message = new FileConfigRefreshMessage();
+        redisMQTemplate.send(message);
+    }
+
+}

+ 0 - 1
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/mq/producer/package-info.java

@@ -1 +0,0 @@
-package cn.iocoder.yudao.module.infra.mq.producer;

+ 6 - 10
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/service/codegen/CodegenServiceImpl.java

@@ -15,7 +15,6 @@ import cn.iocoder.yudao.module.infra.dal.mysql.codegen.CodegenTableMapper;
 import cn.iocoder.yudao.module.infra.dal.mysql.codegen.SchemaColumnMapper;
 import cn.iocoder.yudao.module.infra.dal.mysql.codegen.SchemaTableMapper;
 import cn.iocoder.yudao.module.infra.enums.codegen.CodegenImportTypeEnum;
-import cn.iocoder.yudao.module.infra.enums.codegen.CodegenSceneEnum;
 import cn.iocoder.yudao.module.infra.framework.codegen.config.CodegenProperties;
 import cn.iocoder.yudao.module.infra.service.codegen.inner.CodegenBuilder;
 import cn.iocoder.yudao.module.infra.service.codegen.inner.CodegenEngine;
@@ -26,7 +25,10 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -81,10 +83,7 @@ public class CodegenServiceImpl implements CodegenService {
         codegenTableMapper.insert(table);
         // 构建 CodegenColumnDO 数组,插入到 DB 中
         List<CodegenColumnDO> columns = codegenBuilder.buildColumns(schemaColumns);
-        columns.forEach(column -> {
-            column.setTableId(table.getId());
-            codegenColumnMapper.insert(column); // TODO 批量插入
-        });
+        codegenColumnMapper.insertBatch(columns);
         return table.getId();
     }
 
@@ -198,10 +197,7 @@ public class CodegenServiceImpl implements CodegenService {
 
         // 插入新增的字段
         List<CodegenColumnDO> columns = codegenBuilder.buildColumns(schemaColumns);
-        columns.forEach(column -> {
-            column.setTableId(tableId);
-            codegenColumnMapper.insert(column); // TODO 批量插入
-        });
+        codegenColumnMapper.insertBatch(columns);
         // 删除不存在的字段
         if (CollUtil.isNotEmpty(deleteColumnIds)) {
             codegenColumnMapper.deleteBatchIds(deleteColumnIds);

+ 102 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigService.java

@@ -0,0 +1,102 @@
+package cn.iocoder.yudao.module.infra.service.file;
+
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.file.core.client.FileClient;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigCreateReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigPageReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigUpdateReqVO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileConfigDO;
+
+import javax.validation.Valid;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 文件配置 Service 接口
+ *
+ * @author 芋道源码
+ */
+public interface FileConfigService {
+
+    /**
+     * 初始化文件客户端
+     */
+    void initFileClients();
+
+    /**
+     * 创建文件配置
+     *
+     * @param createReqVO 创建信息
+     * @return 编号
+     */
+    Long createFileConfig(@Valid FileConfigCreateReqVO createReqVO);
+
+    /**
+     * 更新文件配置
+     *
+     * @param updateReqVO 更新信息
+     */
+    void updateFileConfig(@Valid FileConfigUpdateReqVO updateReqVO);
+
+    /**
+     * 更新文件配置为 Master
+     *
+     * @param id 编号
+     */
+    void updateFileConfigMaster(Long id);
+
+    /**
+     * 删除文件配置
+     *
+     * @param id 编号
+     */
+    void deleteFileConfig(Long id);
+
+    /**
+     * 获得文件配置
+     *
+     * @param id 编号
+     * @return 文件配置
+     */
+    FileConfigDO getFileConfig(Long id);
+
+    /**
+     * 获得文件配置列表
+     *
+     * @param ids 编号
+     * @return 文件配置列表
+     */
+    List<FileConfigDO> getFileConfigList(Collection<Long> ids);
+
+    /**
+     * 获得文件配置分页
+     *
+     * @param pageReqVO 分页查询
+     * @return 文件配置分页
+     */
+    PageResult<FileConfigDO> getFileConfigPage(FileConfigPageReqVO pageReqVO);
+
+    /**
+     * 测试文件配置是否正确,通过上传文件
+     *
+     * @param id 编号
+     * @return 文件 URL
+     */
+    String testFileConfig(Long id);
+
+    /**
+     * 获得指定编号的文件客户端
+     *
+     * @param id 配置编号
+     * @return 文件客户端
+     */
+    FileClient getFileClient(Long id);
+
+    /**
+     * 获得 Master 文件客户端
+     *
+     * @return 文件客户端
+     */
+    FileClient getMasterFileClient();
+
+}

+ 241 - 0
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImpl.java

@@ -0,0 +1,241 @@
+package cn.iocoder.yudao.module.infra.service.file;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.io.resource.ResourceUtil;
+import cn.hutool.core.util.IdUtil;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
+import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
+import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
+import cn.iocoder.yudao.framework.file.core.client.FileClient;
+import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
+import cn.iocoder.yudao.framework.file.core.client.FileClientFactory;
+import cn.iocoder.yudao.framework.file.core.enums.FileStorageEnum;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigCreateReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigPageReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigUpdateReqVO;
+import cn.iocoder.yudao.module.infra.convert.file.FileConfigConvert;
+import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileConfigDO;
+import cn.iocoder.yudao.module.infra.dal.mysql.file.FileConfigMapper;
+import cn.iocoder.yudao.module.infra.mq.producer.file.FileConfigProducer;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
+import javax.validation.Validator;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_CONFIG_DELETE_FAIL_MASTER;
+import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_CONFIG_NOT_EXISTS;
+
+/**
+ * 文件配置 Service 实现类
+ *
+ * @author 芋道源码
+ */
+@Service
+@Validated
+@Slf4j
+public class FileConfigServiceImpl implements FileConfigService {
+
+    /**
+     * 定时执行 {@link #schedulePeriodicRefresh()} 的周期
+     * 因为已经通过 Redis Pub/Sub 机制,所以频率不需要高
+     */
+    private static final long SCHEDULER_PERIOD = 5 * 60 * 1000L;
+
+    /**
+     * 缓存菜单的最大更新时间,用于后续的增量轮询,判断是否有更新
+     */
+    @Getter
+    private volatile Date maxUpdateTime;
+
+    @Resource
+    private FileClientFactory fileClientFactory;
+    /**
+     * Master FileClient 对象,有且仅有一个,即 {@link FileConfigDO#getMaster()} 对应的
+     */
+    @Getter
+    private FileClient masterFileClient;
+
+    @Resource
+    private FileConfigMapper fileConfigMapper;
+
+    @Resource
+    private FileConfigProducer fileConfigProducer;
+
+    @Resource
+    private Validator validator;
+
+    @Resource
+    @Lazy // 注入自己,所以延迟加载
+    private FileConfigService self;
+
+    @Override
+    @PostConstruct
+    public void initFileClients() {
+        // 获取文件配置,如果有更新
+        List<FileConfigDO> configs = loadFileConfigIfUpdate(maxUpdateTime);
+        if (CollUtil.isEmpty(configs)) {
+            return;
+        }
+
+        // 创建或更新支付 Client
+        configs.forEach(config -> {
+            fileClientFactory.createOrUpdateFileClient(config.getId(), config.getStorage(), config.getConfig());
+            // 如果是 master,进行设置
+            if (Boolean.TRUE.equals(config.getMaster())) {
+                masterFileClient = fileClientFactory.getFileClient(config.getId());
+            }
+        });
+
+        // 写入缓存
+        maxUpdateTime = CollectionUtils.getMaxValue(configs, FileConfigDO::getUpdateTime);
+        log.info("[initFileClients][初始化 FileConfig 数量为 {}]", configs.size());
+    }
+
+    @Scheduled(fixedDelay = SCHEDULER_PERIOD, initialDelay = SCHEDULER_PERIOD)
+    public void schedulePeriodicRefresh() {
+        self.initFileClients();
+    }
+
+    /**
+     * 如果文件配置发生变化,从数据库中获取最新的全量文件配置。
+     * 如果未发生变化,则返回空
+     *
+     * @param maxUpdateTime 当前文件配置的最大更新时间
+     * @return 文件配置列表
+     */
+    private List<FileConfigDO> loadFileConfigIfUpdate(Date maxUpdateTime) {
+        // 第一步,判断是否要更新。
+        if (maxUpdateTime == null) { // 如果更新时间为空,说明 DB 一定有新数据
+            log.info("[loadFileConfigIfUpdate][首次加载全量文件配置]");
+        } else { // 判断数据库中是否有更新的文件配置
+            if (fileConfigMapper.selectExistsByUpdateTimeAfter(maxUpdateTime) == null) {
+                return null;
+            }
+            log.info("[loadFileConfigIfUpdate][增量加载全量文件配置]");
+        }
+        // 第二步,如果有更新,则从数据库加载所有文件配置
+        return fileConfigMapper.selectList();
+    }
+
+    @Override
+    public Long createFileConfig(FileConfigCreateReqVO createReqVO) {
+        // 插入
+        FileConfigDO fileConfig = FileConfigConvert.INSTANCE.convert(createReqVO)
+                .setConfig(parseClientConfig(createReqVO.getStorage(), createReqVO.getConfig()))
+                .setMaster(false); // 默认非 master
+        fileConfigMapper.insert(fileConfig);
+        // 发送刷新配置的消息
+        fileConfigProducer.sendFileConfigRefreshMessage();
+        // 返回
+        return fileConfig.getId();
+    }
+
+    @Override
+    public void updateFileConfig(FileConfigUpdateReqVO updateReqVO) {
+        // 校验存在
+        FileConfigDO config = this.validateFileConfigExists(updateReqVO.getId());
+        // 更新
+        FileConfigDO updateObj = FileConfigConvert.INSTANCE.convert(updateReqVO)
+                .setConfig(parseClientConfig(config.getStorage(), updateReqVO.getConfig()));
+        fileConfigMapper.updateById(updateObj);
+        // 发送刷新配置的消息
+        fileConfigProducer.sendFileConfigRefreshMessage();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void updateFileConfigMaster(Long id) {
+        // 校验存在
+        this.validateFileConfigExists(id);
+        // 更新其它为非 master
+        fileConfigMapper.updateBatch(new FileConfigDO().setMaster(false));
+        // 更新
+        fileConfigMapper.updateById(new FileConfigDO().setId(id).setMaster(true));
+        // 发送刷新配置的消息
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+
+            @Override
+            public void afterCommit() {
+                fileConfigProducer.sendFileConfigRefreshMessage();
+            }
+
+        });
+    }
+
+    private FileClientConfig parseClientConfig(Integer storage, Map<String, Object> config) {
+        // 获取配置类
+        Class<? extends FileClientConfig> configClass = FileStorageEnum.getByStorage(storage)
+                .getConfigClass();
+        FileClientConfig clientConfig = JsonUtils.parseObject2(JsonUtils.toJsonString(config), configClass);
+        // 参数校验
+        ValidationUtils.validate(validator, clientConfig);
+        // 设置参数
+        return clientConfig;
+    }
+
+    @Override
+    public void deleteFileConfig(Long id) {
+        // 校验存在
+        FileConfigDO config = this.validateFileConfigExists(id);
+        if (Boolean.TRUE.equals(config.getMaster())) {
+             throw exception(FILE_CONFIG_DELETE_FAIL_MASTER);
+        }
+        // 删除
+        fileConfigMapper.deleteById(id);
+        // 发送刷新配置的消息
+        fileConfigProducer.sendFileConfigRefreshMessage();
+    }
+
+    private FileConfigDO validateFileConfigExists(Long id) {
+        FileConfigDO config = fileConfigMapper.selectById(id);
+        if (config == null) {
+            throw exception(FILE_CONFIG_NOT_EXISTS);
+        }
+        return config;
+    }
+
+    @Override
+    public FileConfigDO getFileConfig(Long id) {
+        return fileConfigMapper.selectById(id);
+    }
+
+    @Override
+    public List<FileConfigDO> getFileConfigList(Collection<Long> ids) {
+        return fileConfigMapper.selectBatchIds(ids);
+    }
+
+    @Override
+    public PageResult<FileConfigDO> getFileConfigPage(FileConfigPageReqVO pageReqVO) {
+        return fileConfigMapper.selectPage(pageReqVO);
+    }
+
+    @Override
+    public String testFileConfig(Long id) {
+        // 校验存在
+        this.validateFileConfigExists(id);
+        // 上传文件
+        byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
+        return fileClientFactory.getFileClient(id).upload(content, IdUtil.fastSimpleUUID() + ".jpg");
+    }
+
+    @Override
+    public FileClient getFileClient(Long id) {
+        return fileClientFactory.getFileClient(id);
+    }
+
+}

+ 6 - 5
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java

@@ -1,6 +1,6 @@
 package cn.iocoder.yudao.module.infra.service.file;
 
-import cn.iocoder.yudao.module.infra.controller.admin.file.vo.FilePageReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
 
@@ -33,14 +33,15 @@ public interface FileService {
      *
      * @param id 编号
      */
-    void deleteFile(String id);
+    void deleteFile(Long id);
 
     /**
-     * 获得文件
+     * 获得文件内容
      *
+     * @param configId 配置编号
      * @param path 文件路径
-     * @return 文件
+     * @return 文件内容
      */
-    FileDO getFile(String path);
+    byte[] getFileContent(Long configId, String path);
 
 }

+ 33 - 19
yudao-module-infra/yudao-module-infra-impl/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java

@@ -1,18 +1,19 @@
 package cn.iocoder.yudao.module.infra.service.file;
 
 import cn.hutool.core.io.FileTypeUtil;
+import cn.hutool.core.lang.Assert;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
-import cn.iocoder.yudao.module.infra.controller.admin.file.vo.FilePageReqVO;
+import cn.iocoder.yudao.framework.file.core.client.FileClient;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
 import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
 import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
-import cn.iocoder.yudao.module.infra.framework.file.config.FileProperties;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.io.ByteArrayInputStream;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
-import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.*;
+import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS;
 
 /**
  * 文件 Service 实现类
@@ -23,10 +24,10 @@ import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.*;
 public class FileServiceImpl implements FileService {
 
     @Resource
-    private FileMapper fileMapper;
+    private FileConfigService fileConfigService;
 
     @Resource
-    private FileProperties fileProperties;
+    private FileMapper fileMapper;
 
     @Override
     public PageResult<FileDO> getFilePage(FilePageReqVO pageReqVO) {
@@ -35,36 +36,49 @@ public class FileServiceImpl implements FileService {
 
     @Override
     public String createFile(String path, byte[] content) {
-        if (fileMapper.selectCountById(path) > 0) {
-            throw exception(FILE_PATH_EXISTS);
-        }
+        // 上传到文件存储器
+        FileClient client = fileConfigService.getMasterFileClient();
+        Assert.notNull(client, "客户端(master) 不能为空");
+        String url = client.upload(content, path);
+
         // 保存到数据库
         FileDO file = new FileDO();
-        file.setId(path);
+        file.setConfigId(client.getId());
+        file.setPath(path);
+        file.setUrl(url);
         file.setType(FileTypeUtil.getType(new ByteArrayInputStream(content)));
-        file.setContent(content);
+        file.setSize(content.length);
         fileMapper.insert(file);
-        // 拼接路径返回
-        return fileProperties.getBasePath() + path;
+        return url;
     }
 
     @Override
-    public void deleteFile(String id) {
+    public void deleteFile(Long id) {
         // 校验存在
-        this.validateFileExists(id);
-        // 更新
+        FileDO file = this.validateFileExists(id);
+
+        // 从文件存储器中删除
+        FileClient client = fileConfigService.getFileClient(file.getConfigId());
+        Assert.notNull(client, "客户端({}) 不能为空", file.getConfigId());
+        client.delete(file.getPath());
+
+        // 删除记录
         fileMapper.deleteById(id);
     }
 
-    private void validateFileExists(String id) {
-        if (fileMapper.selectById(id) == null) {
+    private FileDO validateFileExists(Long id) {
+        FileDO fileDO = fileMapper.selectById(id);
+        if (fileDO == null) {
             throw exception(FILE_NOT_EXISTS);
         }
+        return fileDO;
     }
 
     @Override
-    public FileDO getFile(String path) {
-        return fileMapper.selectByPath(path);
+    public byte[] getFileContent(Long configId, String path) {
+        FileClient client = fileConfigService.getFileClient(configId);
+        Assert.notNull(client, "客户端({}) 不能为空", configId);
+        return client.getContent(path);
     }
 
 }

BIN
yudao-module-infra/yudao-module-infra-impl/src/main/resources/file/erweima.jpg


+ 256 - 0
yudao-module-infra/yudao-module-infra-impl/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileConfigServiceImplTest.java

@@ -0,0 +1,256 @@
+package cn.iocoder.yudao.module.infra.service.file;
+
+import cn.hutool.core.map.MapUtil;
+import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.file.core.client.FileClient;
+import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
+import cn.iocoder.yudao.framework.file.core.client.FileClientFactory;
+import cn.iocoder.yudao.framework.file.core.client.local.LocalFileClientConfig;
+import cn.iocoder.yudao.framework.file.core.enums.FileStorageEnum;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigCreateReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigPageReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.config.FileConfigUpdateReqVO;
+import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileConfigDO;
+import cn.iocoder.yudao.module.infra.dal.mysql.file.FileConfigMapper;
+import cn.iocoder.yudao.module.infra.mq.producer.file.FileConfigProducer;
+import cn.iocoder.yudao.module.infra.test.BaseDbUnitTest;
+import lombok.Data;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+
+import javax.annotation.Resource;
+import javax.validation.Validator;
+import java.io.Serializable;
+import java.util.Map;
+
+import static cn.hutool.core.util.RandomUtil.randomEle;
+import static cn.iocoder.yudao.framework.common.util.date.DateUtils.buildTime;
+import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.cloneIgnoreId;
+import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.max;
+import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals;
+import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
+import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
+import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
+import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_CONFIG_DELETE_FAIL_MASTER;
+import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_CONFIG_NOT_EXISTS;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+* {@link FileConfigServiceImpl} 的单元测试类
+*
+* @author 芋道源码
+*/
+@Import(FileConfigServiceImpl.class)
+public class FileConfigServiceImplTest extends BaseDbUnitTest {
+
+    @Resource
+    private FileConfigServiceImpl fileConfigService;
+
+    @Resource
+    private FileConfigMapper fileConfigMapper;
+
+    @MockBean
+    private FileConfigProducer fileConfigProducer;
+    @MockBean
+    private Validator validator;
+    @MockBean
+    private FileClientFactory fileClientFactory;
+
+    @Test
+    public void testInitLocalCache() {
+        // mock 数据
+        FileConfigDO configDO1 = randomFileConfigDO().setId(1L).setMaster(true);
+        fileConfigMapper.insert(configDO1);
+        FileConfigDO configDO2 = randomFileConfigDO().setId(2L).setMaster(false);
+        fileConfigMapper.insert(configDO2);
+        // mock fileClientFactory 获得 master
+        FileClient masterFileClient = mock(FileClient.class);
+        when(fileClientFactory.getFileClient(eq(1L))).thenReturn(masterFileClient);
+
+        // 调用
+        fileConfigService.initFileClients();
+        // 断言 fileClientFactory 调用
+        verify(fileClientFactory).createOrUpdateFileClient(eq(1L),
+                eq(configDO1.getStorage()), eq(configDO1.getConfig()));
+        verify(fileClientFactory).createOrUpdateFileClient(eq(2L),
+                eq(configDO2.getStorage()), eq(configDO2.getConfig()));
+        assertSame(masterFileClient, fileConfigService.getMasterFileClient());
+        // 断言 maxUpdateTime 缓存
+        assertEquals(max(configDO1.getUpdateTime(), configDO2.getUpdateTime()),
+                fileConfigService.getMaxUpdateTime());
+    }
+
+    @Test
+    public void testCreateFileConfig_success() {
+        // 准备参数
+        Map<String, Object> config = MapUtil.<String, Object>builder().put("basePath", "/yunai")
+                .put("domain", "https://www.iocoder.cn").build();
+        FileConfigCreateReqVO reqVO = randomPojo(FileConfigCreateReqVO.class,
+                o -> o.setStorage(FileStorageEnum.LOCAL.getStorage()).setConfig(config));
+
+        // 调用
+        Long fileConfigId = fileConfigService.createFileConfig(reqVO);
+        // 断言
+        assertNotNull(fileConfigId);
+        // 校验记录的属性是否正确
+        FileConfigDO fileConfig = fileConfigMapper.selectById(fileConfigId);
+        assertPojoEquals(reqVO, fileConfig, "config");
+        assertFalse(fileConfig.getMaster());
+        assertEquals("/yunai", ((LocalFileClientConfig) fileConfig.getConfig()).getBasePath());
+        assertEquals("https://www.iocoder.cn", ((LocalFileClientConfig) fileConfig.getConfig()).getDomain());
+        // verify 调用
+        verify(fileConfigProducer).sendFileConfigRefreshMessage();
+    }
+
+    @Test
+    public void testUpdateFileConfig_success() {
+        // mock 数据
+        FileConfigDO dbFileConfig = randomPojo(FileConfigDO.class, o -> o.setStorage(FileStorageEnum.LOCAL.getStorage())
+                .setConfig(new LocalFileClientConfig().setBasePath("/yunai").setDomain("https://www.iocoder.cn")));
+        fileConfigMapper.insert(dbFileConfig);// @Sql: 先插入出一条存在的数据
+        // 准备参数
+        FileConfigUpdateReqVO reqVO = randomPojo(FileConfigUpdateReqVO.class, o -> {
+            o.setId(dbFileConfig.getId()); // 设置更新的 ID
+            Map<String, Object> config = MapUtil.<String, Object>builder().put("basePath", "/yunai2")
+                    .put("domain", "https://doc.iocoder.cn").build();
+            o.setConfig(config);
+        });
+
+        // 调用
+        fileConfigService.updateFileConfig(reqVO);
+        // 校验是否更新正确
+        FileConfigDO fileConfig = fileConfigMapper.selectById(reqVO.getId()); // 获取最新的
+        assertPojoEquals(reqVO, fileConfig, "config");
+        assertEquals("/yunai2", ((LocalFileClientConfig) fileConfig.getConfig()).getBasePath());
+        assertEquals("https://doc.iocoder.cn", ((LocalFileClientConfig) fileConfig.getConfig()).getDomain());
+        // verify 调用
+        verify(fileConfigProducer).sendFileConfigRefreshMessage();
+    }
+
+    @Test
+    public void testUpdateFileConfig_notExists() {
+        // 准备参数
+        FileConfigUpdateReqVO reqVO = randomPojo(FileConfigUpdateReqVO.class);
+
+        // 调用, 并断言异常
+        assertServiceException(() -> fileConfigService.updateFileConfig(reqVO), FILE_CONFIG_NOT_EXISTS);
+    }
+
+    @Test
+    public void testUpdateFileConfigMaster_success() {
+        // mock 数据
+        FileConfigDO dbFileConfig = randomFileConfigDO().setMaster(false);
+        fileConfigMapper.insert(dbFileConfig);// @Sql: 先插入出一条存在的数据
+        FileConfigDO masterFileConfig = randomFileConfigDO().setMaster(true);
+        fileConfigMapper.insert(masterFileConfig);// @Sql: 先插入出一条存在的数据
+
+        // 调用
+        fileConfigService.updateFileConfigMaster(dbFileConfig.getId());
+        // 断言数据
+        assertTrue(fileConfigMapper.selectById(dbFileConfig.getId()).getMaster());
+        assertFalse(fileConfigMapper.selectById(masterFileConfig.getId()).getMaster());
+        // verify 调用
+        verify(fileConfigProducer).sendFileConfigRefreshMessage();
+    }
+
+    @Test
+    public void testUpdateFileConfigMaster_notExists() {
+        // 调用, 并断言异常
+        assertServiceException(() -> fileConfigService.updateFileConfigMaster(randomLongId()), FILE_CONFIG_NOT_EXISTS);
+    }
+
+    @Test
+    public void testDeleteFileConfig_success() {
+        // mock 数据
+        FileConfigDO dbFileConfig = randomFileConfigDO().setMaster(false);
+        fileConfigMapper.insert(dbFileConfig);// @Sql: 先插入出一条存在的数据
+        // 准备参数
+        Long id = dbFileConfig.getId();
+
+        // 调用
+        fileConfigService.deleteFileConfig(id);
+       // 校验数据不存在了
+       assertNull(fileConfigMapper.selectById(id));
+        // verify 调用
+        verify(fileConfigProducer).sendFileConfigRefreshMessage();
+    }
+
+    @Test
+    public void testDeleteFileConfig_notExists() {
+        // 准备参数
+        Long id = randomLongId();
+
+        // 调用, 并断言异常
+        assertServiceException(() -> fileConfigService.deleteFileConfig(id), FILE_CONFIG_NOT_EXISTS);
+    }
+
+    @Test
+    public void testDeleteFileConfig_master() {
+        // mock 数据
+        FileConfigDO dbFileConfig = randomFileConfigDO().setMaster(true);
+        fileConfigMapper.insert(dbFileConfig);// @Sql: 先插入出一条存在的数据
+        // 准备参数
+        Long id = dbFileConfig.getId();
+
+        // 调用, 并断言异常
+        assertServiceException(() -> fileConfigService.deleteFileConfig(id), FILE_CONFIG_DELETE_FAIL_MASTER);
+    }
+
+    @Test
+    public void testGetFileConfigPage() {
+       // mock 数据
+       FileConfigDO dbFileConfig = randomFileConfigDO().setName("芋道源码")
+               .setStorage(FileStorageEnum.LOCAL.getStorage());
+       dbFileConfig.setCreateTime(buildTime(2022, 11, 11));// 等会查询到
+       fileConfigMapper.insert(dbFileConfig);
+       // 测试 name 不匹配
+       fileConfigMapper.insert(cloneIgnoreId(dbFileConfig, o -> o.setName("源码")));
+       // 测试 storage 不匹配
+       fileConfigMapper.insert(cloneIgnoreId(dbFileConfig, o -> o.setStorage(FileStorageEnum.DB.getStorage())));
+       // 测试 createTime 不匹配
+       fileConfigMapper.insert(cloneIgnoreId(dbFileConfig, o -> o.setCreateTime(buildTime(2022, 12, 12))));
+       // 准备参数
+       FileConfigPageReqVO reqVO = new FileConfigPageReqVO();
+       reqVO.setName("芋道");
+       reqVO.setStorage(FileStorageEnum.LOCAL.getStorage());
+       reqVO.setBeginCreateTime(buildTime(2022, 11, 10));
+       reqVO.setEndCreateTime(buildTime(2022, 11, 12));
+
+       // 调用
+       PageResult<FileConfigDO> pageResult = fileConfigService.getFileConfigPage(reqVO);
+       // 断言
+       assertEquals(1, pageResult.getTotal());
+       assertEquals(1, pageResult.getList().size());
+       assertPojoEquals(dbFileConfig, pageResult.getList().get(0));
+    }
+
+    @Test
+    public void testFileConfig() {
+        // mock 数据
+        FileConfigDO dbFileConfig = randomFileConfigDO().setMaster(false);
+        fileConfigMapper.insert(dbFileConfig);// @Sql: 先插入出一条存在的数据
+        // 准备参数
+        Long id = dbFileConfig.getId();
+        // mock 获得 Client
+        FileClient fileClient = mock(FileClient.class);
+        when(fileClientFactory.getFileClient(eq(id))).thenReturn(fileClient);
+        when(fileClient.upload(any(), any())).thenReturn("https://www.iocoder.cn");
+
+        // 调用,并断言
+        assertEquals("https://www.iocoder.cn", fileConfigService.testFileConfig(id));
+    }
+
+    private FileConfigDO randomFileConfigDO() {
+        return randomPojo(FileConfigDO.class).setStorage(randomEle(FileStorageEnum.values()).getStorage())
+                .setConfig(new EmptyFileClientConfig());
+    }
+
+    @Data
+    public static class EmptyFileClientConfig implements FileClientConfig, Serializable {
+
+    }
+
+}

+ 51 - 36
yudao-module-infra/yudao-module-infra-impl/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceTest.java

@@ -3,11 +3,11 @@ package cn.iocoder.yudao.module.infra.service.file;
 import cn.hutool.core.io.resource.ResourceUtil;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
+import cn.iocoder.yudao.framework.file.core.client.FileClient;
 import cn.iocoder.yudao.framework.test.core.util.AssertUtils;
-import cn.iocoder.yudao.module.infra.controller.admin.file.vo.FilePageReqVO;
+import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
 import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
 import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
-import cn.iocoder.yudao.module.infra.framework.file.config.FileProperties;
 import cn.iocoder.yudao.module.infra.test.BaseDbUnitTest;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.mock.mockito.MockBean;
@@ -17,47 +17,46 @@ import javax.annotation.Resource;
 
 import static cn.iocoder.yudao.framework.common.util.date.DateUtils.buildTime;
 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
-import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
-import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
+import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
 import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.*;
 import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.*;
 
-@Import({FileServiceImpl.class, FileProperties.class})
+@Import({FileServiceImpl.class})
 public class FileServiceTest extends BaseDbUnitTest {
 
     @Resource
     private FileService fileService;
 
-    @MockBean
-    private FileProperties fileProperties;
-
     @Resource
     private FileMapper fileMapper;
 
+    @MockBean
+    private FileConfigService fileConfigService;
+
     @Test
     public void testGetFilePage() {
         // mock 数据
         FileDO dbFile = randomPojo(FileDO.class, o -> { // 等会查询到
-            o.setId("yunai");
+            o.setPath("yunai");
             o.setType("jpg");
             o.setCreateTime(buildTime(2021, 1, 15));
         });
         fileMapper.insert(dbFile);
-        // 测试 id 不匹配
-        fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> o.setId("tudou")));
+        // 测试 path 不匹配
+        fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> o.setPath("tudou")));
         // 测试 type 不匹配
         fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> {
-            o.setId("yunai02");
             o.setType("png");
         }));
         // 测试 createTime 不匹配
         fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> {
-            o.setId("yunai03");
             o.setCreateTime(buildTime(2020, 1, 15));
         }));
         // 准备参数
         FilePageReqVO reqVO = new FilePageReqVO();
-        reqVO.setId("yunai");
+        reqVO.setPath("yunai");
         reqVO.setType("jp");
         reqVO.setBeginCreateTime(buildTime(2021, 1, 10));
         reqVO.setEndCreateTime(buildTime(2021, 1, 20));
@@ -67,7 +66,7 @@ public class FileServiceTest extends BaseDbUnitTest {
         // 断言
         assertEquals(1, pageResult.getTotal());
         assertEquals(1, pageResult.getList().size());
-        AssertUtils.assertPojoEquals(dbFile, pageResult.getList().get(0), "content");
+        AssertUtils.assertPojoEquals(dbFile, pageResult.getList().get(0));
     }
 
     @Test
@@ -75,52 +74,68 @@ public class FileServiceTest extends BaseDbUnitTest {
         // 准备参数
         String path = randomString();
         byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
+        // mock Master 文件客户端
+        FileClient client = mock(FileClient.class);
+        when(fileConfigService.getMasterFileClient()).thenReturn(client);
+        String url = randomString();
+        when(client.upload(same(content), same(path))).thenReturn(url);
+        when(client.getId()).thenReturn(10L);
 
         // 调用
-        String url = fileService.createFile(path, content);
+        String result = fileService.createFile(path, content);
         // 断言
-        assertEquals(fileProperties.getBasePath() + path, url);
+        assertEquals(result, url);
         // 校验数据
-        FileDO file = fileMapper.selectById(path);
-        assertEquals(path, file.getId());
+        FileDO file = fileMapper.selectOne(FileDO::getPath, path);
+        assertEquals(10L, file.getConfigId());
+        assertEquals(path, file.getPath());
+        assertEquals(url, file.getUrl());
         assertEquals("jpg", file.getType());
-        assertArrayEquals(content, file.getContent());
-    }
-
-    @Test
-    public void testCreateFile_exists() {
-        // mock 数据
-        FileDO dbFile = randomPojo(FileDO.class);
-        fileMapper.insert(dbFile);
-        // 准备参数
-        String path = dbFile.getId(); // 模拟已存在
-        byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
-
-        // 调用,并断言异常
-        assertServiceException(() -> fileService.createFile(path, content), FILE_PATH_EXISTS);
+        assertEquals(content.length, file.getSize());
     }
 
     @Test
     public void testDeleteFile_success() {
         // mock 数据
-        FileDO dbFile = randomPojo(FileDO.class);
+        FileDO dbFile = randomPojo(FileDO.class, o -> o.setConfigId(10L).setPath("tudou.jpg"));
         fileMapper.insert(dbFile);// @Sql: 先插入出一条存在的数据
+        // mock Master 文件客户端
+        FileClient client = mock(FileClient.class);
+        when(fileConfigService.getFileClient(eq(10L))).thenReturn(client);
         // 准备参数
-        String id = dbFile.getId();
+        Long id = dbFile.getId();
 
         // 调用
         fileService.deleteFile(id);
         // 校验数据不存在了
         assertNull(fileMapper.selectById(id));
+        // 校验调用
+        verify(client).delete(eq("tudou.jpg"));
     }
 
     @Test
     public void testDeleteFile_notExists() {
         // 准备参数
-        String id = randomString();
+        Long id = randomLongId();
 
         // 调用, 并断言异常
         assertServiceException(() -> fileService.deleteFile(id), FILE_NOT_EXISTS);
     }
 
+    @Test
+    public void testGetFileContent() {
+        // 准备参数
+        Long configId = 10L;
+        String path = "tudou.jpg";
+        // mock 方法
+        FileClient client = mock(FileClient.class);
+        when(fileConfigService.getFileClient(eq(10L))).thenReturn(client);
+        byte[] content = new byte[]{};
+        when(client.getContent(eq("tudou.jpg"))).thenReturn(content);
+
+        // 调用
+        byte[] result = fileService.getFileContent(configId, path);
+        // 断言
+        assertSame(result, content);
+    }
 }

+ 3 - 2
yudao-module-infra/yudao-module-infra-impl/src/test/resources/application-unit-test.yaml

@@ -26,8 +26,9 @@ spring:
     port: 16379 # 端口(单元测试,使用 16379 端口)
     database: 0 # 数据库索引
 
-mybatis:
+mybatis-plus:
   lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试
+  type-aliases-package: ${yudao.info.base-package}.module.*.dal.dataobject
 
 --- #################### 定时任务相关配置 ####################
 
@@ -46,4 +47,4 @@ mybatis:
 # 芋道配置项,设置当前项目所有自定义的配置
 yudao:
   info:
-    base-package: cn.iocoder.yudao.module
+    base-package: cn.iocoder.yudao

+ 1 - 0
yudao-module-infra/yudao-module-infra-impl/src/test/resources/sql/clean.sql

@@ -8,3 +8,4 @@ DELETE FROM "infra_api_access_log";
 DELETE FROM "infra_file";
 DELETE FROM "infra_api_error_log";
 DELETE FROM "infra_test_demo";
+DELETE FROM "infra_file_config";

+ 20 - 2
yudao-module-infra/yudao-module-infra-impl/src/test/resources/sql/create_tables.sql

@@ -16,10 +16,28 @@ CREATE TABLE IF NOT EXISTS "infra_config" (
     PRIMARY KEY ("id")
 ) COMMENT '参数配置表';
 
+CREATE TABLE IF NOT EXISTS "infra_file_config" (
+    "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
+    "name" varchar(63) NOT NULL,
+    "storage" tinyint NOT NULL,
+    "remark" varchar(255),
+    "master" bit(1) NOT NULL,
+    "config" varchar(4096) NOT NULL,
+    "creator" varchar(64) DEFAULT '',
+    "create_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updater" varchar(64) DEFAULT '',
+    "update_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    "deleted" bit NOT NULL DEFAULT FALSE,
+    PRIMARY KEY ("id")
+) COMMENT '文件配置表';
+
 CREATE TABLE IF NOT EXISTS "infra_file" (
-    "id" varchar(188) NOT NULL,
+    "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
+    "config_id" bigint NOT NULL,
+    "path" varchar(512),
+    "url" varchar(1024),
     "type" varchar(63) DEFAULT NULL,
-    "content" blob NOT NULL,
+    "size" bigint NOT NULL,
     "creator" varchar(64) DEFAULT '',
     "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
     "updater" varchar(64) DEFAULT '',

+ 4 - 0
yudao-module-pay/yudao-module-pay-impl/src/main/java/cn/iocoder/yudao/module/pay/service/merchant/PayChannelServiceImpl.java

@@ -127,6 +127,7 @@ public class PayChannelServiceImpl implements PayChannelService {
         PayChannelDO channel = PayChannelConvert.INSTANCE.convert(reqVO);
         settingConfigAndCheckParam(channel, reqVO.getConfig());
         channelMapper.insert(channel);
+        // TODO 芋艿:缺少刷新本地缓存的机制
         return channel.getId();
     }
 
@@ -138,6 +139,7 @@ public class PayChannelServiceImpl implements PayChannelService {
         PayChannelDO channel = PayChannelConvert.INSTANCE.convert(updateReqVO);
         settingConfigAndCheckParam(channel, updateReqVO.getConfig());
         channelMapper.updateById(channel);
+        // TODO 芋艿:缺少刷新本地缓存的机制
     }
 
     @Override
@@ -146,6 +148,7 @@ public class PayChannelServiceImpl implements PayChannelService {
         this.validateChannelExists(id);
         // 删除
         channelMapper.deleteById(id);
+        // TODO 芋艿:缺少刷新本地缓存的机制
     }
 
     private void validateChannelExists(Long id) {
@@ -224,6 +227,7 @@ public class PayChannelServiceImpl implements PayChannelService {
         if (ObjectUtil.isNull(payClass)) {
             throw exception(CHANNEL_NOT_EXISTS);
         }
+        // TODO @芋艿:不要使用 hutool 的 json 工具,用项目的
         PayClientConfig config = JSONUtil.toBean(configStr, payClass);
 
         // 验证参数

+ 1 - 2
yudao-module-system/yudao-module-system-impl/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/permission/RoleMenuMapper.java

@@ -25,8 +25,7 @@ public interface RoleMenuMapper extends BaseMapperX<RoleMenuDO> {
             entity.setMenuId(menuId);
             return entity;
         }).collect(Collectors.toList());
-        // TODO 芋艿,mybatis plus 增加批量插入的功能
-        list.forEach(this::insert);
+        insertBatch(list);
     }
 
     default void deleteListByRoleIdAndMenuIds(Long roleId, Collection<Long> menuIds) {

+ 0 - 2
yudao-server/src/main/resources/application-dev.yaml

@@ -172,8 +172,6 @@ yudao:
     session-timeout: 30m
     mock-enable: true
     mock-secret: test
-  file:
-    base-path: http://api-dashboard.yudao.iocoder.cn${yudao.web.admin-api.prefix}/infra/file/get/
   xss:
     enable: false
     exclude-urls: # 如下两个 url,仅仅是为了演示,去掉配置也没关系

+ 0 - 2
yudao-server/src/main/resources/application-local.yaml

@@ -184,8 +184,6 @@ yudao:
     session-timeout: 1d
     mock-enable: true
     mock-secret: test
-  file:
-    base-path: http://127.0.0.1:${server.port}${yudao.web.admin-api.prefix}/infra/file/get/
   xss:
     enable: false
     exclude-urls: # 如下两个 url,仅仅是为了演示,去掉配置也没关系

+ 3 - 1
yudao-server/src/main/resources/application.yaml

@@ -92,7 +92,7 @@ yudao:
     ignore-urls:
       - /admin-api/system/tenant/get-id-by-name # 基于名字获取租户,不许带租户编号
       - /admin-api/system/captcha/get-image # 获取图片验证码,和租户无关
-      - /admin-api/infra/file/get/* # 获取图片,和租户无关
+      - /admin-api/infra/file/*/get/** # 获取图片,和租户无关
       - /admin-api/system/sms/callback/* # 短信回调接口,无法带上租户编号
     ignore-tables:
       - system_tenant
@@ -110,7 +110,9 @@ yudao:
       - tables
       - columns
       - infra_config
+      - infra_file_config
       - infra_file
+      - infra_file_content
       - infra_job
       - infra_job_log
       - infra_job_log

+ 59 - 0
yudao-ui-admin/src/api/infra/fileConfig.js

@@ -0,0 +1,59 @@
+import request from '@/utils/request'
+
+// 创建文件配置
+export function createFileConfig(data) {
+  return request({
+    url: '/infra/file-config/create',
+    method: 'post',
+    data: data
+  })
+}
+
+// 更新文件配置
+export function updateFileConfig(data) {
+  return request({
+    url: '/infra/file-config/update',
+    method: 'put',
+    data: data
+  })
+}
+
+// 更新文件配置为主配置
+export function updateFileConfigMaster(id) {
+  return request({
+    url: '/infra/file-config/update-master?id=' + id,
+    method: 'put'
+  })
+}
+
+// 删除文件配置
+export function deleteFileConfig(id) {
+  return request({
+    url: '/infra/file-config/delete?id=' + id,
+    method: 'delete'
+  })
+}
+
+// 获得文件配置
+export function getFileConfig(id) {
+  return request({
+    url: '/infra/file-config/get?id=' + id,
+    method: 'get'
+  })
+}
+
+// 获得文件配置分页
+export function getFileConfigPage(query) {
+  return request({
+    url: '/infra/file-config/page',
+    method: 'get',
+    params: query
+  })
+}
+
+export function testFileConfig(id) {
+  return request({
+    url: '/infra/file-config/test?id=' + id,
+    method: 'get'
+  })
+}

+ 1 - 1
yudao-ui-admin/src/components/DictTag/index.vue

@@ -17,7 +17,7 @@ export default {
   name: "DictTag",
   props: {
     type: String,
-    value: [Number, String, Array],
+    value: [Number, String, Boolean, Array],
   },
 };
 </script>

+ 2 - 0
yudao-ui-admin/src/utils/dict.js

@@ -25,6 +25,7 @@ export const DICT_TYPE = {
   SYSTEM_ERROR_CODE_TYPE: 'system_error_code_type',
 
   // ========== INFRA 模块 ==========
+  INFRA_BOOLEAN_STRING: 'infra_boolean_string',
   INFRA_REDIS_TIMEOUT_TYPE: 'infra_redis_timeout_type',
   INFRA_JOB_STATUS: 'infra_job_status',
   INFRA_JOB_LOG_STATUS: 'infra_job_log_status',
@@ -32,6 +33,7 @@ export const DICT_TYPE = {
   INFRA_CONFIG_TYPE: 'infra_config_type',
   INFRA_CODEGEN_TEMPLATE_TYPE: 'infra_codegen_template_type',
   INFRA_CODEGEN_SCENE: 'infra_codegen_scene',
+  INFRA_FILE_STORAGE: 'infra_file_storage',
 
   // ========== BPM 模块 ==========
   BPM_MODEL_CATEGORY: 'bpm_model_category',

+ 24 - 13
yudao-ui-admin/src/views/infra/file/index.vue

@@ -3,8 +3,8 @@
 
     <!-- 搜索工作栏 -->
     <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
-      <el-form-item label="文件路径" prop="id">
-        <el-input v-model="queryParams.id" placeholder="请输入文件路径" clearable size="small" @keyup.enter.native="handleQuery"/>
+      <el-form-item label="文件路径" prop="path">
+        <el-input v-model="queryParams.path" placeholder="请输入文件路径" clearable size="small" @keyup.enter.native="handleQuery"/>
       </el-form-item>
       <el-form-item label="文件类型" prop="type">
         <el-select v-model="queryParams.type" placeholder="请选择文件类型" clearable size="small">
@@ -31,21 +31,23 @@
 
     <!-- 列表 -->
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="文件路径" align="center" prop="id" width="300" />
+      <el-table-column label="文件名" align="center" prop="path" />
+      <el-table-column label="URL" align="center" prop="url" />
+      <el-table-column label="文件大小" align="center" prop="size" width="120" :formatter="sizeFormat" />
       <el-table-column label="文件类型" align="center" prop="type" width="80" />
-      <el-table-column label="文件内容" align="center" prop="content">
-        <template slot-scope="scope">
-          <img v-if="scope.row.type === 'jpg' || scope.row.type === 'png' || scope.row.type === 'gif'"
-               width="200px" :src="getFileUrl + scope.row.id">
-          <i v-else>非图片,无法预览</i>
-        </template>
-      </el-table-column>
-      <el-table-column label="创建时间" align="center" prop="createTime" width="180">
+<!--      <el-table-column label="文件内容" align="center" prop="content">-->
+<!--        <template slot-scope="scope">-->
+<!--          <img v-if="scope.row.type === 'jpg' || scope.row.type === 'png' || scope.row.type === 'gif'"-->
+<!--               width="200px" :src="getFileUrl + scope.row.id">-->
+<!--          <i v-else>非图片,无法预览</i>-->
+<!--        </template>-->
+<!--      </el-table-column>-->
+      <el-table-column label="上传时间" align="center" prop="createTime" width="180">
         <template slot-scope="scope">
           <span>{{ parseTime(scope.row.createTime) }}</span>
         </template>
       </el-table-column>
-      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100">
         <template slot-scope="scope">
           <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)"
                      v-hasPermi="['infra:file:delete']">删除</el-button>
@@ -102,7 +104,7 @@ export default {
       queryParams: {
         pageNo: 1,
         pageSize: 10,
-        id: null,
+        path: null,
         type: null,
       },
       // 用户导入参数
@@ -193,6 +195,15 @@ export default {
         this.$modal.msgSuccess("删除成功");
       }).catch(() => {});
     },
+    // 用户昵称展示
+    sizeFormat(row, column) {
+      const unitArr = ["Bytes","KB","MB","GB","TB","PB","EB","ZB","YB"];
+      const srcSize = parseFloat(row.size);
+      const index = Math.floor(Math.log(srcSize) / Math.log(1024));
+      let size =srcSize/Math.pow(1024,index);
+      size = size.toFixed(2);//保留的小数位数
+      return size + ' ' + unitArr[index];
+    },
   }
 };
 </script>

+ 313 - 0
yudao-ui-admin/src/views/infra/fileConfig/index.vue

@@ -0,0 +1,313 @@
+<template>
+  <div class="app-container">
+
+    <!-- 搜索工作栏 -->
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
+      <el-form-item label="配置名" prop="name">
+        <el-input v-model="queryParams.name" placeholder="请输入配置名" clearable size="small" @keyup.enter.native="handleQuery"/>
+      </el-form-item>
+      <el-form-item label="存储器" prop="storage">
+        <el-select v-model="queryParams.storage" placeholder="请选择存储器" clearable size="small">
+          <el-option v-for="dict in this.getDictDatas(DICT_TYPE.INFRA_FILE_STORAGE)"
+                     :key="dict.value" :label="dict.label" :value="dict.value"/>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间">
+        <el-date-picker v-model="dateRangeCreateTime" size="small" style="width: 240px" value-format="yyyy-MM-dd"
+                        type="daterange" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 操作工具栏 -->
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd"
+                   v-hasPermi="['infra:file-config:create']">新增</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <!-- 列表 -->
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="配置名" align="center" prop="name" />
+      <el-table-column label="存储器" align="center" prop="storage">
+        <template slot-scope="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_FILE_STORAGE" :value="scope.row.storage" />
+        </template>
+      </el-table-column>
+      <el-table-column label="备注" align="center" prop="remark" />
+      <el-table-column label="主配置" align="center" prop="primary">
+        <template slot-scope="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.master" />
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" align="center" prop="createTime" width="180">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)"
+                     v-hasPermi="['infra:file-config:update']">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-attract" @click="handleMaster(scope.row)"
+                     :disabled="scope.row.master" v-hasPermi="['infra:file-config:update']">主配置</el-button>
+          <el-button size="mini" type="text" icon="el-icon-share" @click="handleTest(scope.row)">测试</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)"
+                     v-hasPermi="['infra:file-config:delete']">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页组件 -->
+    <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNo" :limit.sync="queryParams.pageSize"
+                @pagination="getList"/>
+
+    <!-- 对话框(添加 / 修改) -->
+    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="120px">
+        <el-form-item label="配置名" prop="name">
+          <el-input v-model="form.name" placeholder="请输入配置名" />
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="form.remark" placeholder="请输入备注" />
+        </el-form-item>
+        <el-form-item label="存储器" prop="storage">
+          <el-select v-model="form.storage" placeholder="请选择存储器" :disabled="form.id">
+            <el-option v-for="dict in this.getDictDatas(DICT_TYPE.INFRA_FILE_STORAGE)"
+                       :key="dict.value" :label="dict.label" :value="parseInt(dict.value)" />
+          </el-select>
+        </el-form-item>
+        <!-- DB -->
+        <!-- Local / FTP / SFTP -->
+        <el-form-item v-if="form.storage >= 10 && form.storage <= 12" label="基础路径" prop="config.basePath">
+          <el-input v-model="form.config.basePath" placeholder="请输入基础路径" />
+        </el-form-item>
+        <el-form-item v-if="form.storage >= 11 && form.storage <= 12" label="主机地址" prop="config.host">
+          <el-input v-model="form.config.host" placeholder="请输入主机地址" />
+        </el-form-item>
+        <el-form-item v-if="form.storage >= 11 && form.storage <= 12" label="主机端口" prop="config.port">
+          <el-input-number min="0" v-model="form.config.port" placeholder="请输入主机端口" />
+        </el-form-item>
+        <el-form-item v-if="form.storage >= 11 && form.storage <= 12" label="用户名" prop="config.username">
+          <el-input v-model="form.config.username" placeholder="请输入密码" />
+        </el-form-item>
+        <el-form-item v-if="form.storage >= 11 && form.storage <= 12" label="密码" prop="config.password">
+          <el-input v-model="form.config.password" placeholder="请输入密码" />
+        </el-form-item>
+        <el-form-item v-if="form.storage === 11" label="连接模式" prop="config.mode">
+          <el-radio-group v-model="form.config.mode">
+            <el-radio key="Active" label="Active">主动模式</el-radio>
+            <el-radio key="Passive" label="Passive">主动模式</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <!-- S3 -->
+        <el-form-item v-if="form.storage === 20" label="节点地址" prop="config.endpoint">
+          <el-input v-model="form.config.endpoint" placeholder="请输入节点地址" />
+        </el-form-item>
+        <el-form-item v-if="form.storage === 20" label="区域" prop="config.region">
+          <el-input v-model="form.config.region" placeholder="请输入区域" />
+        </el-form-item>
+        <el-form-item v-if="form.storage === 20" label="存储 bucket" prop="config.bucket">
+          <el-input v-model="form.config.bucket" placeholder="请输入 bucket" />
+        </el-form-item>
+        <el-form-item v-if="form.storage === 20" label="accessKey" prop="config.accessKey">
+          <el-input v-model="form.config.accessKey" placeholder="请输入 accessKey" />
+        </el-form-item>
+        <el-form-item v-if="form.storage === 20" label="accessSecret" prop="config.accessSecret">
+          <el-input v-model="form.config.accessSecret" placeholder="请输入 accessSecret" />
+        </el-form-item>
+        <!-- 通用 -->
+        <el-form-item v-if="form.storage === 20" label="自定义域名"> <!-- 无需参数校验,所以去掉 prop -->
+          <el-input v-model="form.config.domain" placeholder="请输入自定义域名" />
+        </el-form-item>
+        <el-form-item v-else-if="form.storage" label="自定义域名" prop="config.domain">
+          <el-input v-model="form.config.domain" placeholder="请输入自定义域名" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  createFileConfig,
+  updateFileConfig,
+  deleteFileConfig,
+  getFileConfig,
+  getFileConfigPage,
+  testFileConfig, updateFileConfigMaster
+} from "@/api/infra/fileConfig";
+
+export default {
+  name: "FileConfig",
+  components: {
+  },
+  data() {
+    return {
+      // 遮罩层
+      loading: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 总条数
+      total: 0,
+      // 文件配置列表
+      list: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      dateRangeCreateTime: [],
+      // 查询参数
+      queryParams: {
+        pageNo: 1,
+        pageSize: 10,
+        name: null,
+        storage: null,
+      },
+      // 表单参数
+      form: {
+        storage: undefined,
+        config: {}
+      },
+      // 表单校验
+      rules: {
+        name: [{ required: true, message: "配置名不能为空", trigger: "blur" }],
+        storage: [{ required: true, message: "存储器不能为空", trigger: "change" }],
+        config: {
+          basePath: [{ required: true, message: "基础路径不能为空", trigger: "blur" }],
+          host: [{ required: true, message: "主机地址不能为空", trigger: "blur" }],
+          port: [{ required: true, message: "主机端口不能为空", trigger: "blur" }],
+          username: [{ required: true, message: "用户名不能为空", trigger: "blur" }],
+          password: [{ required: true, message: "密码不能为空", trigger: "blur" }],
+          mode: [{ required: true, message: "连接模式不能为空", trigger: "change" }],
+          endpoint: [{ required: true, message: "节点地址不能为空", trigger: "blur" }],
+          region: [{ required: true, message: "区域名不能为空", trigger: "blur" }],
+          bucket: [{ required: true, message: "存储 bucket 不能为空", trigger: "blur" }],
+          accessKey: [{ required: true, message: "accessKey 不能为空", trigger: "blur" }],
+          accessSecret: [{ required: true, message: "accessSecret 不能为空", trigger: "blur" }],
+          domain: [{ required: true, message: "自定义域名不能为空", trigger: "blur" }],
+        },
+      }
+    };
+  },
+  created() {
+    this.getList();
+  },
+  methods: {
+    /** 查询列表 */
+    getList() {
+      this.loading = true;
+      // 处理查询参数
+      let params = {...this.queryParams};
+      this.addBeginAndEndTime(params, this.dateRangeCreateTime, 'createTime');
+      // 执行查询
+      getFileConfigPage(params).then(response => {
+        this.list = response.data.list;
+        this.total = response.data.total;
+        this.loading = false;
+      });
+    },
+    /** 取消按钮 */
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    /** 表单重置 */
+    reset() {
+      this.form = {
+        id: undefined,
+        name: undefined,
+        storage: undefined,
+        remark: undefined,
+        config: {},
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.queryParams.pageNo = 1;
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.dateRangeCreateTime = [];
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    /** 新增按钮操作 */
+    handleAdd() {
+      this.reset();
+      this.open = true;
+      this.title = "添加文件配置";
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      const id = row.id;
+      getFileConfig(id).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改文件配置";
+      });
+    },
+    /** 提交按钮 */
+    submitForm() {
+      this.$refs["form"].validate(valid => {
+        if (!valid) {
+          return;
+        }
+        // 修改的提交
+        if (this.form.id != null) {
+          updateFileConfig(this.form).then(response => {
+            this.$modal.msgSuccess("修改成功");
+            this.open = false;
+            this.getList();
+          });
+          return;
+        }
+        // 添加的提交
+        createFileConfig(this.form).then(response => {
+          this.$modal.msgSuccess("新增成功");
+          this.open = false;
+          this.getList();
+        });
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      const id = row.id;
+      this.$modal.confirm('是否确认删除文件配置编号为"' + id + '"的数据项?').then(function() {
+        return deleteFileConfig(id);
+      }).then(() => {
+        this.getList();
+        this.$modal.msgSuccess("删除成功");
+      }).catch(() => {});
+    },
+    /** 主配置按钮操作 */
+    handleMaster(row) {
+      const id = row.id;
+      this.$modal.confirm('是否确认修改配置编号为"' + id + '"的数据项为主配置?').then(function() {
+        return updateFileConfigMaster(id);
+      }).then(() => {
+        this.getList();
+        this.$modal.msgSuccess("修改成功");
+      }).catch(() => {});
+    },
+    /** 测试按钮操作 */
+    handleTest(row) {
+      testFileConfig(row.id).then((response) => {
+        this.$modal.alert("测试通过,上传文件成功!访问地址:" + response.data);
+      }).catch(() => {});
+    },
+  }
+};
+</script>

Vissa filer visades inte eftersom för många filer har ändrats