封装 yudao-spring-boot-starter-file 组件,初步实现 S3 对接云存储的能力

This commit is contained in:
YunaiV 2022-03-13 21:23:03 +08:00
parent 446f601c8a
commit ed53ca3de9
15 changed files with 498 additions and 13 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,69 @@
<?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 服务器
3. db数据库
4. 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>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.17.147</version>
</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,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,58 @@
package cn.iocoder.yudao.framework.file.core.client.impl;
import cn.iocoder.yudao.framework.file.core.client.FileClient;
import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
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;
}
}

View File

@ -0,0 +1,95 @@
package cn.iocoder.yudao.framework.file.core.client.impl.s3;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.file.core.client.impl.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.PutObjectRequest;
import java.net.URI;
import static cn.iocoder.yudao.framework.file.core.client.impl.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) {
}
@Override
public byte[] getContent(String path) {
return new byte[0];
}
}

View File

@ -0,0 +1,83 @@
package cn.iocoder.yudao.framework.file.core.client.impl.s3;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
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;
@AssertTrue(message = "domain 不能为空")
@SuppressWarnings("RedundantIfStatement")
public boolean isDomainValid() {
// 如果是七牛必须带有 domain
if (endpoint.contains(ENDPOINT_QINIU) && StrUtil.isEmpty(domain)) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,36 @@
package cn.iocoder.yudao.framework.file.core.client.impl.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,4 @@
/**
* 占位避免 package 无法提交到 Git 仓库
*/
package cn.iocoder.yudao.framework.file.config;

View File

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

View File

@ -0,0 +1,81 @@
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 cn.iocoder.yudao.framework.file.core.client.impl.s3.S3FileClient;
import cn.iocoder.yudao.framework.file.core.client.impl.s3.S3FileClientConfig;
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.setBucket("s3-test-yudao");
config.setDomain("http://r8oo8po1q.hn-bkt.clouddn.com"); // 如果有自定义域名则可以设置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);
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB