Commit 97a981c2949027127934082bfb497e889c66438f

Authored by YunaiV
1 parent 4c5134ae

feat:【infra 基础设施】支持私有桶

yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java
... ... @@ -47,8 +47,15 @@ public class HttpUtils {
47 47 return builder.build();
48 48 }
49 49  
50   - private String append(String base, Map<String, ?> query, boolean fragment) {
51   - return append(base, query, null, fragment);
  50 + public static String removeUrlQuery(String url) {
  51 + if (!StrUtil.contains(url, '?')) {
  52 + return url;
  53 + }
  54 + UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset());
  55 + // 移除 query、fragment
  56 + builder.setQuery(null);
  57 + builder.setFragment(null);
  58 + return builder.build();
52 59 }
53 60  
54 61 /**
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java
... ... @@ -42,4 +42,14 @@ public interface FileApi {
42 42 String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
43 43 String name, String directory, String type);
44 44  
  45 + /**
  46 + * 生成文件预签名地址,用于读取
  47 + *
  48 + * @param url 完整的文件访问地址
  49 + * @param expirationSeconds 访问有效期,单位秒
  50 + * @return 文件预签名地址
  51 + */
  52 + String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
  53 + Integer expirationSeconds);
  54 +
45 55 }
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java
... ... @@ -22,4 +22,9 @@ public class FileApiImpl implements FileApi {
22 22 return fileService.createFile(content, name, directory, type);
23 23 }
24 24  
  25 + @Override
  26 + public String presignGetUrl(String url, Integer expirationSeconds) {
  27 + return fileService.presignGetUrl(url, expirationSeconds);
  28 + }
  29 +
25 30 }
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java
... ... @@ -51,7 +51,7 @@ public class FileController {
51 51 }
52 52  
53 53 @GetMapping("/presigned-url")
54   - @Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
  54 + @Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
55 55 @Parameters({
56 56 @Parameter(name = "name", description = "文件名称", required = true),
57 57 @Parameter(name = "directory", description = "文件目录")
... ... @@ -59,7 +59,7 @@ public class FileController {
59 59 public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl(
60 60 @RequestParam("name") String name,
61 61 @RequestParam(value = "directory", required = false) String directory) {
62   - return success(fileService.getFilePresignedUrl(name, directory));
  62 + return success(fileService.presignPutUrl(name, directory));
63 63 }
64 64  
65 65 @PostMapping("/create")
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java
... ... @@ -41,7 +41,7 @@ public class AppFileController {
41 41 }
42 42  
43 43 @GetMapping("/presigned-url")
44   - @Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
  44 + @Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器")
45 45 @Parameters({
46 46 @Parameter(name = "name", description = "文件名称", required = true),
47 47 @Parameter(name = "directory", description = "文件目录")
... ... @@ -49,7 +49,7 @@ public class AppFileController {
49 49 public CommonResult<FilePresignedUrlRespVO> getFilePresignedUrl(
50 50 @RequestParam("name") String name,
51 51 @RequestParam(value = "directory", required = false) String directory) {
52   - return success(fileService.getFilePresignedUrl(name, directory));
  52 + return success(fileService.presignPutUrl(name, directory));
53 53 }
54 54  
55 55 @PostMapping("/create")
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java
1 1 package cn.iocoder.yudao.module.infra.framework.file.core.client;
2 2  
3   -import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO;
4   -
5 3 /**
6 4 * 文件客户端
7 5 *
... ... @@ -42,13 +40,26 @@ public interface FileClient {
42 40 */
43 41 byte[] getContent(String path) throws Exception;
44 42  
  43 + // ========== 文件签名,目前仅 S3 支持 ==========
  44 +
45 45 /**
46   - * 获得文件预签名地址
  46 + * 获得文件预签名地址,用于上传
47 47 *
48 48 * @param path 相对路径
49 49 * @return 文件预签名地址
50 50 */
51   - default FilePresignedUrlRespDTO getPresignedObjectUrl(String path) throws Exception {
  51 + default String presignPutUrl(String path) {
  52 + throw new UnsupportedOperationException("不支持的操作");
  53 + }
  54 +
  55 + /**
  56 + * 生成文件预签名地址,用于读取
  57 + *
  58 + * @param url 完整的文件访问地址
  59 + * @param expirationSeconds 访问有效期,单位秒
  60 + * @return 文件预签名地址
  61 + */
  62 + default String presignGetUrl(String url, Integer expirationSeconds) {
52 63 throw new UnsupportedOperationException("不支持的操作");
53 64 }
54 65  
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/FilePresignedUrlRespDTO.java deleted
1   -package cn.iocoder.yudao.module.infra.framework.file.core.client.s3;
2   -
3   -import lombok.AllArgsConstructor;
4   -import lombok.Data;
5   -import lombok.NoArgsConstructor;
6   -
7   -/**
8   - * 文件预签名地址 Response DTO
9   - *
10   - * @author owen
11   - */
12   -@Data
13   -@AllArgsConstructor
14   -@NoArgsConstructor
15   -public class FilePresignedUrlRespDTO {
16   -
17   - /**
18   - * 文件上传 URL(用于上传)
19   - *
20   - * 例如说:
21   - */
22   - private String uploadUrl;
23   -
24   - /**
25   - * 文件 URL(用于读取、下载等)
26   - */
27   - private String url;
28   -
29   -}
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java
1 1 package cn.iocoder.yudao.module.infra.framework.file.core.client.s3;
2 2  
3 3 import cn.hutool.core.io.IoUtil;
  4 +import cn.hutool.core.util.BooleanUtil;
4 5 import cn.hutool.core.util.StrUtil;
5 6 import cn.hutool.http.HttpUtil;
  7 +import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
6 8 import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
7 9 import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
8 10 import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
... ... @@ -15,9 +17,11 @@ import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
15 17 import software.amazon.awssdk.services.s3.model.GetObjectRequest;
16 18 import software.amazon.awssdk.services.s3.model.PutObjectRequest;
17 19 import software.amazon.awssdk.services.s3.presigner.S3Presigner;
  20 +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
18 21 import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
19 22  
20 23 import java.net.URI;
  24 +import java.net.URL;
21 25 import java.time.Duration;
22 26  
23 27 /**
... ... @@ -27,6 +31,8 @@ import java.time.Duration;
27 31 */
28 32 public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
29 33  
  34 + private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24);
  35 +
30 36 private S3Client client;
31 37 private S3Presigner presigner;
32 38  
... ... @@ -75,7 +81,7 @@ public class S3FileClient extends AbstractFileClient&lt;S3FileClientConfig&gt; {
75 81 // 上传文件
76 82 client.putObject(putRequest, RequestBody.fromBytes(content));
77 83 // 拼接返回路径
78   - return config.getDomain() + "/" + path;
  84 + return presignGetUrl(path, null);
79 85 }
80 86  
81 87 @Override
... ... @@ -97,23 +103,38 @@ public class S3FileClient extends AbstractFileClient&lt;S3FileClientConfig&gt; {
97 103 }
98 104  
99 105 @Override
100   - public FilePresignedUrlRespDTO getPresignedObjectUrl(String path) {
101   - Duration expiration = Duration.ofHours(24);
102   - return new FilePresignedUrlRespDTO(getPresignedUrl(path, expiration), config.getDomain() + "/" + path);
  106 + public String presignPutUrl(String path) {
  107 + return presigner.presignPutObject(PutObjectPresignRequest.builder()
  108 + .signatureDuration(EXPIRATION_DEFAULT)
  109 + .putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build())
  110 + .url().toString();
103 111 }
104 112  
105   - /**
106   - * 生成动态的预签名上传 URL
107   - *
108   - * @param path 相对路径
109   - * @param expiration 过期时间
110   - * @return 生成的上传 URL
111   - */
112   - private String getPresignedUrl(String path, Duration expiration) {
113   - return presigner.presignPutObject(PutObjectPresignRequest.builder()
  113 + @Override
  114 + public String presignGetUrl(String url, Integer expirationSeconds) {
  115 + // 1. 将 url 转换为 path
  116 + String path = StrUtil.removePrefix(url, config.getDomain() + "/");
  117 + path = HttpUtils.removeUrlQuery(path);
  118 +
  119 + // 2.1 情况一:公开访问:无需签名
  120 + // 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
  121 + if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
  122 + return config.getDomain() + "/" + path;
  123 + }
  124 +
  125 + // 2.2 情况二:私有访问:生成 GET 预签名 URL
  126 + String finalPath = path;
  127 + Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT;
  128 + URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder()
114 129 .signatureDuration(expiration)
115   - .putObjectRequest(b -> b.bucket(config.getBucket()).key(path))
116   - .build()).url().toString();
  130 + .getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build())
  131 + .url();
  132 + // 特殊:适配未使用 domain 返回的情况!!!
  133 + String signedUrlStr = signedUrl.toString();
  134 + if (!signedUrlStr.startsWith(config.getDomain())) {
  135 + signedUrlStr = signedUrlStr.replaceFirst(signedUrl.getProtocol() + "://" + signedUrl.getHost(), config.getDomain());
  136 + }
  137 + return signedUrlStr;
117 138 }
118 139  
119 140 /**
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java
... ... @@ -73,6 +73,15 @@ public class S3FileClientConfig implements FileClientConfig {
73 73 @NotNull(message = "enablePathStyleAccess 不能为空")
74 74 private Boolean enablePathStyleAccess;
75 75  
  76 + /**
  77 + * 是否公开访问
  78 + *
  79 + * true:公开访问,所有人都可以访问
  80 + * false:私有访问,只有配置的 accessKey 才可以访问
  81 + */
  82 + @NotNull(message = "是否公开访问不能为空")
  83 + private Boolean enablePublicAccess;
  84 +
76 85 @SuppressWarnings("RedundantIfStatement")
77 86 @AssertTrue(message = "domain 不能为空")
78 87 @JsonIgnore
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java
... ... @@ -37,14 +37,22 @@ public interface FileService {
37 37 String name, String directory, String type);
38 38  
39 39 /**
40   - * 生成文件预签名地址信息
  40 + * 生成文件预签名地址信息,用于上传
41 41 *
42 42 * @param name 文件名
43 43 * @param directory 目录
44 44 * @return 预签名地址信息
45 45 */
46   - FilePresignedUrlRespVO getFilePresignedUrl(@NotEmpty(message = "文件名不能为空") String name,
47   - String directory);
  46 + FilePresignedUrlRespVO presignPutUrl(@NotEmpty(message = "文件名不能为空") String name,
  47 + String directory);
  48 + /**
  49 + * 生成文件预签名地址信息,用于读取
  50 + *
  51 + * @param url 完整的文件访问地址
  52 + * @param expirationSeconds 访问有效期,单位秒
  53 + * @return 文件预签名地址
  54 + */
  55 + String presignGetUrl(String url, Integer expirationSeconds);
48 56  
49 57 /**
50 58 * 创建文件
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java
... ... @@ -6,6 +6,7 @@ import cn.hutool.core.lang.Assert;
6 6 import cn.hutool.core.util.StrUtil;
7 7 import cn.hutool.crypto.digest.DigestUtil;
8 8 import cn.iocoder.yudao.framework.common.pojo.PageResult;
  9 +import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
9 10 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
10 11 import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
11 12 import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
... ... @@ -13,7 +14,6 @@ import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresigned
13 14 import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
14 15 import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
15 16 import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient;
16   -import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO;
17 17 import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils;
18 18 import com.google.common.annotations.VisibleForTesting;
19 19 import jakarta.annotation.Resource;
... ... @@ -126,19 +126,27 @@ public class FileServiceImpl implements FileService {
126 126  
127 127 @Override
128 128 @SneakyThrows
129   - public FilePresignedUrlRespVO getFilePresignedUrl(String name, String directory) {
  129 + public FilePresignedUrlRespVO presignPutUrl(String name, String directory) {
130 130 // 1. 生成上传的 path,需要保证唯一
131 131 String path = generateUploadPath(name, directory);
132 132  
133 133 // 2. 获取文件预签名地址
134 134 FileClient fileClient = fileConfigService.getMasterFileClient();
135   - FilePresignedUrlRespDTO presignedObjectUrl = fileClient.getPresignedObjectUrl(path);
136   - return BeanUtils.toBean(presignedObjectUrl, FilePresignedUrlRespVO.class,
137   - object -> object.setConfigId(fileClient.getId()).setPath(path));
  135 + String uploadUrl = fileClient.presignPutUrl(path);
  136 + String visitUrl = fileClient.presignGetUrl(path, null);
  137 + return new FilePresignedUrlRespVO().setConfigId(fileClient.getId())
  138 + .setPath(path).setUploadUrl(uploadUrl).setUrl(visitUrl);
  139 + }
  140 +
  141 + @Override
  142 + public String presignGetUrl(String url, Integer expirationSeconds) {
  143 + FileClient fileClient = fileConfigService.getMasterFileClient();
  144 + return fileClient.presignGetUrl(url, expirationSeconds);
138 145 }
139 146  
140 147 @Override
141 148 public Long createFile(FileCreateReqVO createReqVO) {
  149 + createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数
142 150 FileDO file = BeanUtils.toBean(createReqVO, FileDO.class);
143 151 fileMapper.insert(file);
144 152 return file.getId();
... ...
yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java
... ... @@ -9,6 +9,7 @@ import jakarta.validation.Validation;
9 9 import org.junit.jupiter.api.Disabled;
10 10 import org.junit.jupiter.api.Test;
11 11  
  12 +@SuppressWarnings("resource")
12 13 public class S3FileClientTest {
13 14  
14 15 @Test
... ... @@ -70,6 +71,7 @@ public class S3FileClientTest {
70 71 config.setAccessSecret("kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP");
71 72 config.setBucket("ruoyi-vue-pro");
72 73 config.setDomain("http://test.yudao.iocoder.cn"); // 如果有自定义域名,则可以设置。http://static.yudao.iocoder.cn
  74 + config.setEnablePathStyleAccess(false);
73 75 // 默认上海的 endpoint
74 76 config.setEndpoint("s3-cn-south-1.qiniucs.com");
75 77  
... ... @@ -78,6 +80,32 @@ public class S3FileClientTest {
78 80 }
79 81  
80 82 @Test
  83 + @Disabled // 七牛云存储(读私有桶),如果要集成测试,可以注释本行
  84 + public void testQiniu_privateGet() {
  85 + S3FileClientConfig config = new S3FileClientConfig();
  86 + // 配置成你自己的
  87 +// config.setAccessKey(System.getenv("QINIU_ACCESS_KEY"));
  88 +// config.setAccessSecret(System.getenv("QINIU_SECRET_KEY"));
  89 + config.setAccessKey("b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8");
  90 + config.setAccessSecret("kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP");
  91 + config.setBucket("ruoyi-vue-pro-private");
  92 + config.setDomain("http://t151glocd.hn-bkt.clouddn.com"); // 如果有自定义域名,则可以设置。http://static.yudao.iocoder.cn
  93 + config.setEnablePathStyleAccess(false);
  94 + // 默认上海的 endpoint
  95 + config.setEndpoint("s3-cn-south-1.qiniucs.com");
  96 +
  97 + // 校验配置
  98 + ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config);
  99 + // 创建 Client
  100 + S3FileClient client = new S3FileClient(0L, config);
  101 + client.init();
  102 + // 执行生成 URL 签名
  103 + String path = "output.png";
  104 + String presignedUrl = client.presignGetUrl(path, 300);
  105 + System.out.println(presignedUrl);
  106 + }
  107 +
  108 + @Test
81 109 @Disabled // 华为云存储,如果要集成测试,可以注释本行
82 110 public void testHuaweiCloud() throws Exception {
83 111 S3FileClientConfig config = new S3FileClientConfig();
... ... @@ -93,7 +121,7 @@ public class S3FileClientTest {
93 121 testExecuteUpload(config);
94 122 }
95 123  
96   - private void testExecuteUpload(S3FileClientConfig config) throws Exception {
  124 + private void testExecuteUpload(S3FileClientConfig config) {
97 125 // 校验配置
98 126 ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config);
99 127 // 创建 Client
... ...