From ed53ca3de9283dc5ab2a31140b04e40ec4acf5ad Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 13 Mar 2022 21:23:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=81=E8=A3=85=20yudao-spring-boot-starter-?= =?UTF-8?q?file=20=E7=BB=84=E4=BB=B6=EF=BC=8C=E5=88=9D=E6=AD=A5=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=20S3=20=E5=AF=B9=E6=8E=A5=E4=BA=91=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E7=9A=84=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yudao-framework/pom.xml | 1 + .../util/validation/ValidationUtils.java | 4 +- .../core/client/impl/AbstractPayClient.java | 15 +-- .../yudao-spring-boot-starter-file/pom.xml | 69 +++++++++++++ .../file/core/client/FileClient.java | 41 ++++++++ .../file/core/client/FileClientConfig.java | 16 +++ .../core/client/impl/AbstractFileClient.java | 58 +++++++++++ .../core/client/impl/s3/S3FileClient.java | 95 ++++++++++++++++++ .../client/impl/s3/S3FileClientConfig.java | 83 +++++++++++++++ .../impl/s3/S3ModifyPathInterceptor.java | 36 +++++++ .../framework/file/config/package-info.java | 4 + .../file/core/client/package-info.java | 4 + .../file/core/client/s3/S3FileClientTest.java | 81 +++++++++++++++ .../file/core/enums/package-info.java | 4 + .../src/test/resources/file/erweima.jpg | Bin 0 -> 18385 bytes 15 files changed, 498 insertions(+), 13 deletions(-) create mode 100644 yudao-framework/yudao-spring-boot-starter-file/pom.xml create mode 100644 yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClient.java create mode 100644 yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientConfig.java create mode 100644 yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/AbstractFileClient.java create mode 100644 yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3FileClient.java create mode 100644 yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3FileClientConfig.java create mode 100644 yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3ModifyPathInterceptor.java create mode 100644 yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/config/package-info.java create mode 100644 yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/package-info.java create mode 100644 yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClientTest.java create mode 100644 yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/enums/package-info.java create mode 100644 yudao-framework/yudao-spring-boot-starter-file/src/test/resources/file/erweima.jpg diff --git a/yudao-framework/pom.xml b/yudao-framework/pom.xml index 8c4a9b586..73bb614cd 100644 --- a/yudao-framework/pom.xml +++ b/yudao-framework/pom.xml @@ -16,6 +16,7 @@ yudao-spring-boot-starter-web yudao-spring-boot-starter-security + yudao-spring-boot-starter-file yudao-spring-boot-starter-monitor yudao-spring-boot-starter-protection yudao-spring-boot-starter-config diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/validation/ValidationUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/validation/ValidationUtils.java index 9ff5b0583..d9a01747d 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/validation/ValidationUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/validation/ValidationUtils.java @@ -39,8 +39,8 @@ public class ValidationUtils { && PATTERN_XML_NCNAME.matcher(str).matches(); } - public static void validate(Validator validator, Object reqVO, Class... groups) { - Set> constraintViolations = validator.validate(reqVO, groups); + public static void validate(Validator validator, Object object, Class... groups) { + Set> constraintViolations = validator.validate(object, groups); if (CollUtil.isNotEmpty(constraintViolations)) { throw new ConstraintViolationException(constraintViolations); } diff --git a/yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/AbstractPayClient.java b/yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/AbstractPayClient.java index 38b8875a1..292b6cf01 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/AbstractPayClient.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-pay/src/main/java/cn/iocoder/yudao/framework/pay/core/client/impl/AbstractPayClient.java @@ -1,7 +1,6 @@ package cn.iocoder.yudao.framework.pay.core.client.impl; import cn.hutool.extra.validation.ValidationUtil; -import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; import cn.iocoder.yudao.framework.pay.core.client.AbstractPayCodeMapping; import cn.iocoder.yudao.framework.pay.core.client.PayClient; import cn.iocoder.yudao.framework.pay.core.client.PayClientConfig; @@ -11,7 +10,6 @@ import cn.iocoder.yudao.framework.pay.core.client.dto.PayRefundUnifiedReqDTO; import cn.iocoder.yudao.framework.pay.core.client.dto.PayRefundUnifiedRespDTO; import lombok.extern.slf4j.Slf4j; -import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; /** @@ -26,7 +24,6 @@ public abstract class AbstractPayClient implemen * 渠道编号 */ private final Long channelId; - /** * 渠道编码 */ @@ -40,10 +37,6 @@ public abstract class AbstractPayClient 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 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 implemen return result; } - - protected abstract PayCommonResult doUnifiedOrder(PayOrderUnifiedReqDTO reqDTO) throws Throwable; - @Override public PayCommonResult unifiedRefund(PayRefundUnifiedReqDTO reqDTO) { PayCommonResult resp; @@ -115,7 +109,6 @@ public abstract class AbstractPayClient implemen return resp; } - protected abstract PayCommonResult doUnifiedRefund(PayRefundUnifiedReqDTO reqDTO) throws Throwable; } diff --git a/yudao-framework/yudao-spring-boot-starter-file/pom.xml b/yudao-framework/yudao-spring-boot-starter-file/pom.xml new file mode 100644 index 000000000..126bf9b77 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-file/pom.xml @@ -0,0 +1,69 @@ + + + + yudao-framework + cn.iocoder.boot + ${revision} + + 4.0.0 + yudao-spring-boot-starter-file + + ${project.artifactId} + 文件客户端,支持多种存储器 + 1. file:本地磁盘 + 2. ftp:FTP 服务器 + 3. db:数据库 + 4. s3:支持 S3 协议的云存储服务,例如说 MinIO、阿里云、华为云、腾讯云、七牛云等等 + + https://github.com/YunaiV/ruoyi-vue-pro + + + + cn.iocoder.boot + yudao-common + + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.slf4j + slf4j-api + + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + + + + software.amazon.awssdk + s3 + 2.17.147 + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + test + + + + diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClient.java b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClient.java new file mode 100644 index 000000000..60e0fc51b --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClient.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.framework.file.core.client; + +/** + * 文件客户端 + * + * @author 芋道源码 + */ +public interface FileClient { + + /** + * 获得客户端编号 + * + * @return 客户端编号 + */ + Long getId(); + + /** + * 上传文件 + * + * @param content 文件流 + * @param path 相对路径 + * @return 完整路径,即 HTTP 访问地址 + */ + String upload(byte[] content, String path); + + /** + * 删除文件 + * + * @param path 相对路径 + */ + void delete(String path); + + /** + * 获得文件的内容 + * + * @param path 相对路径 + * @return 文件的内容 + */ + byte[] getContent(String path); + +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientConfig.java b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientConfig.java new file mode 100644 index 000000000..9461c05d8 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/FileClientConfig.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.framework.file.core.client; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * 文件客户端的配置 + * 不同实现的客户端,需要不同的配置,通过子类来定义 + * + * @author 芋道源码 + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +// @JsonTypeInfo 注解的作用,Jackson 多态 +// 1. 序列化到时数据库时,增加 @class 属性。 +// 2. 反序列化到内存对象时,通过 @class 属性,可以创建出正确的类型 +public interface FileClientConfig { +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/AbstractFileClient.java b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/AbstractFileClient.java new file mode 100644 index 000000000..13f104118 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/AbstractFileClient.java @@ -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 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; + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3FileClient.java b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3FileClient.java new file mode 100644 index 000000000..09d98398e --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3FileClient.java @@ -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 { + + 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]; + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3FileClientConfig.java b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3FileClientConfig.java new file mode 100644 index 000000000..858759c26 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3FileClientConfig.java @@ -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; + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3ModifyPathInterceptor.java b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3ModifyPathInterceptor.java new file mode 100644 index 000000000..2f4f19f84 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-file/src/main/java/cn/iocoder/yudao/framework/file/core/client/impl/s3/S3ModifyPathInterceptor.java @@ -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(); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/config/package-info.java b/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/config/package-info.java new file mode 100644 index 000000000..113f3e5e5 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/config/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位,避免 package 无法提交到 Git 仓库 + */ +package cn.iocoder.yudao.framework.file.config; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/package-info.java b/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/package-info.java new file mode 100644 index 000000000..b6c1cd4a9 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位,避免 package 无法提交到 Git 仓库 + */ +package cn.iocoder.yudao.framework.file.core.client; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClientTest.java b/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClientTest.java new file mode 100644 index 000000000..5db3ac699 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/client/s3/S3FileClientTest.java @@ -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); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/enums/package-info.java b/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/enums/package-info.java new file mode 100644 index 000000000..e1da5db23 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-file/src/test/java/cn/iocoder/yudao/framework/file/core/enums/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位,避免 package 无法提交到 Git 仓库 + */ +package cn.iocoder.yudao.framework.file.core.enums; diff --git a/yudao-framework/yudao-spring-boot-starter-file/src/test/resources/file/erweima.jpg b/yudao-framework/yudao-spring-boot-starter-file/src/test/resources/file/erweima.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1447283cdf1b49b51c1204a160e01cb789e957a8 GIT binary patch literal 18385 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 pEwzL`>i*8R__Kv1*C}gmHZq>QI%40h(qzO