!98 文件存储的功能,支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、FTP、数据库等

Merge pull request !98 from 芋道源码/feature/1.6.1
This commit is contained in:
芋道源码 2022-03-16 16:03:50 +00:00 committed by Gitee
commit d2075d5c18
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
93 changed files with 3474 additions and 276 deletions

View File

@ -116,7 +116,7 @@ ps核心功能已经实现正在对接微信小程序中...
| | 表单构建 | 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 |
| 🚀 | 配置管理 | 对系统动态配置常用参数,支持 SpringBoot 加载 |
| ⭐️ | 定时任务 | 在线(添加、修改、删除)任务调度包含执行结果日志 |
| 🚀 | 文件服务 | 支持本地文件存储,同时支持兼容 Amazon S3 协议的云服务、开源组件 |
| 🚀 | 文件服务 | 支持将文件存储到 S3MinIO、阿里云、腾讯云、七牛云、本地、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) | - |

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -22,13 +22,40 @@ public class FileUtils {
*/
@SneakyThrows
public static File createTempFile(String data) {
// 创建文件通过 UUID 保证唯一
File file = File.createTempFile(IdUtil.simpleUUID(), null);
// 标记 JVM 退出时自动删除
file.deleteOnExit();
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();
return file;
}
}

View File

@ -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) {

View File

@ -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);
}

View File

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

View File

@ -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;
}

View File

@ -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));
}

View File

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

View File

@ -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. ftpFTP 服务器
2. sftpSFTP 服务器
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>

View File

@ -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();
}
}

View File

@ -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 访问地址
* 使用场景localftpdb通过 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);
}
}

View File

@ -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);
}

View File

@ -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 {
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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());
}
}

View File

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

View File

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

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

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

View File

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

View File

@ -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, "该文件配置不允许删除,原因:它是主配置,删除会导致无法上传文件");
}

View File

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

View File

@ -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}}

View File

@ -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);
}
}

View File

@ -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")

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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> {
}

View File

@ -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);
}
}

View File

@ -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 {
}

View File

@ -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 七牛等等
}

View File

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

View File

@ -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();
}
};

View File

@ -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();
}
}

View File

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

View File

@ -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";
}
}

View File

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

View File

@ -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);
}
}

View File

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

View File

@ -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);

View File

@ -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();
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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 {
}
}

View File

@ -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);
}
}

View File

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

View File

@ -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";

View File

@ -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 '',

View File

@ -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);
// 验证参数

View File

@ -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) {

View File

@ -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仅仅是为了演示去掉配置也没关系

View File

@ -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仅仅是为了演示去掉配置也没关系

View File

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

View File

@ -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'
})
}

View File

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

View File

@ -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',

View File

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

View File

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