diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java index e1e0ac08e7..85a644f1f1 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java @@ -47,8 +47,15 @@ public class HttpUtils { return builder.build(); } - private String append(String base, Map query, boolean fragment) { - return append(base, query, null, fragment); + public static String removeUrlQuery(String url) { + if (!StrUtil.contains(url, '?')) { + return url; + } + UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset()); + // 移除 query、fragment + builder.setQuery(null); + builder.setFragment(null); + return builder.build(); } /** diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java index e97d11bc4b..db914cc105 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApi.java @@ -42,4 +42,14 @@ public interface FileApi { String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, String name, String directory, String type); + /** + * 生成文件预签名地址,用于读取 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @return 文件预签名地址 + */ + String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url, + Integer expirationSeconds); + } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java index 72c351129d..98bdba2a53 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/api/file/FileApiImpl.java @@ -22,4 +22,9 @@ public class FileApiImpl implements FileApi { return fileService.createFile(content, name, directory, type); } + @Override + public String presignGetUrl(String url, Integer expirationSeconds) { + return fileService.presignGetUrl(url, expirationSeconds); + } + } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java index afcf716237..f21e79a188 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java @@ -51,7 +51,7 @@ public class FileController { } @GetMapping("/presigned-url") - @Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") + @Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") @Parameters({ @Parameter(name = "name", description = "文件名称", required = true), @Parameter(name = "directory", description = "文件目录") @@ -59,7 +59,7 @@ public class FileController { public CommonResult getFilePresignedUrl( @RequestParam("name") String name, @RequestParam(value = "directory", required = false) String directory) { - return success(fileService.getFilePresignedUrl(name, directory)); + return success(fileService.presignPutUrl(name, directory)); } @PostMapping("/create") diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java index 7f85e996d7..a4c1d202e8 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java @@ -41,7 +41,7 @@ public class AppFileController { } @GetMapping("/presigned-url") - @Operation(summary = "获取文件预签名地址", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") + @Operation(summary = "获取文件预签名地址(上传)", description = "模式二:前端上传文件:用于前端直接上传七牛、阿里云 OSS 等文件存储器") @Parameters({ @Parameter(name = "name", description = "文件名称", required = true), @Parameter(name = "directory", description = "文件目录") @@ -49,7 +49,7 @@ public class AppFileController { public CommonResult getFilePresignedUrl( @RequestParam("name") String name, @RequestParam(value = "directory", required = false) String directory) { - return success(fileService.getFilePresignedUrl(name, directory)); + return success(fileService.presignPutUrl(name, directory)); } @PostMapping("/create") diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java index 053b3c5101..cf1cd620ae 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java @@ -1,7 +1,5 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client; -import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO; - /** * 文件客户端 * @@ -42,13 +40,26 @@ public interface FileClient { */ byte[] getContent(String path) throws Exception; + // ========== 文件签名,目前仅 S3 支持 ========== + /** - * 获得文件预签名地址 + * 获得文件预签名地址,用于上传 * * @param path 相对路径 * @return 文件预签名地址 */ - default FilePresignedUrlRespDTO getPresignedObjectUrl(String path) throws Exception { + default String presignPutUrl(String path) { + throw new UnsupportedOperationException("不支持的操作"); + } + + /** + * 生成文件预签名地址,用于读取 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @return 文件预签名地址 + */ + default String presignGetUrl(String url, Integer expirationSeconds) { throw new UnsupportedOperationException("不支持的操作"); } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/FilePresignedUrlRespDTO.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/FilePresignedUrlRespDTO.java deleted file mode 100644 index 6a1258e9e0..0000000000 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/FilePresignedUrlRespDTO.java +++ /dev/null @@ -1,29 +0,0 @@ -package cn.iocoder.yudao.module.infra.framework.file.core.client.s3; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 文件预签名地址 Response DTO - * - * @author owen - */ -@Data -@AllArgsConstructor -@NoArgsConstructor -public class FilePresignedUrlRespDTO { - - /** - * 文件上传 URL(用于上传) - * - * 例如说: - */ - private String uploadUrl; - - /** - * 文件 URL(用于读取、下载等) - */ - private String url; - -} diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java index a33f0d738c..8e21c76f42 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java @@ -1,8 +1,10 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client.s3; import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpUtil; +import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; @@ -15,9 +17,11 @@ 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 software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import java.net.URI; +import java.net.URL; import java.time.Duration; /** @@ -27,6 +31,8 @@ import java.time.Duration; */ public class S3FileClient extends AbstractFileClient { + private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); + private S3Client client; private S3Presigner presigner; @@ -75,7 +81,7 @@ public class S3FileClient extends AbstractFileClient { // 上传文件 client.putObject(putRequest, RequestBody.fromBytes(content)); // 拼接返回路径 - return config.getDomain() + "/" + path; + return presignGetUrl(path, null); } @Override @@ -97,23 +103,38 @@ public class S3FileClient extends AbstractFileClient { } @Override - public FilePresignedUrlRespDTO getPresignedObjectUrl(String path) { - Duration expiration = Duration.ofHours(24); - return new FilePresignedUrlRespDTO(getPresignedUrl(path, expiration), config.getDomain() + "/" + path); + public String presignPutUrl(String path) { + return presigner.presignPutObject(PutObjectPresignRequest.builder() + .signatureDuration(EXPIRATION_DEFAULT) + .putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build()) + .url().toString(); } - /** - * 生成动态的预签名上传 URL - * - * @param path 相对路径 - * @param expiration 过期时间 - * @return 生成的上传 URL - */ - private String getPresignedUrl(String path, Duration expiration) { - return presigner.presignPutObject(PutObjectPresignRequest.builder() + @Override + public String presignGetUrl(String url, Integer expirationSeconds) { + // 1. 将 url 转换为 path + String path = StrUtil.removePrefix(url, config.getDomain() + "/"); + path = HttpUtils.removeUrlQuery(path); + + // 2.1 情况一:公开访问:无需签名 + // 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名 + if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) { + return config.getDomain() + "/" + path; + } + + // 2.2 情况二:私有访问:生成 GET 预签名 URL + String finalPath = path; + Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT; + URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder() .signatureDuration(expiration) - .putObjectRequest(b -> b.bucket(config.getBucket()).key(path)) - .build()).url().toString(); + .getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build()) + .url(); + // 特殊:适配未使用 domain 返回的情况!!! + String signedUrlStr = signedUrl.toString(); + if (!signedUrlStr.startsWith(config.getDomain())) { + signedUrlStr = signedUrlStr.replaceFirst(signedUrl.getProtocol() + "://" + signedUrl.getHost(), config.getDomain()); + } + return signedUrlStr; } /** diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java index fb19317e02..216197964a 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClientConfig.java +++ b/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 { @NotNull(message = "enablePathStyleAccess 不能为空") private Boolean enablePathStyleAccess; + /** + * 是否公开访问 + * + * true:公开访问,所有人都可以访问 + * false:私有访问,只有配置的 accessKey 才可以访问 + */ + @NotNull(message = "是否公开访问不能为空") + private Boolean enablePublicAccess; + @SuppressWarnings("RedundantIfStatement") @AssertTrue(message = "domain 不能为空") @JsonIgnore diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java index 5b15ad8739..5e3448b0fe 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java @@ -37,14 +37,22 @@ public interface FileService { String name, String directory, String type); /** - * 生成文件预签名地址信息 + * 生成文件预签名地址信息,用于上传 * * @param name 文件名 * @param directory 目录 * @return 预签名地址信息 */ - FilePresignedUrlRespVO getFilePresignedUrl(@NotEmpty(message = "文件名不能为空") String name, - String directory); + FilePresignedUrlRespVO presignPutUrl(@NotEmpty(message = "文件名不能为空") String name, + String directory); + /** + * 生成文件预签名地址信息,用于读取 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @return 文件预签名地址 + */ + String presignGetUrl(String url, Integer expirationSeconds); /** * 创建文件 diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java index 98447fb370..f47275d33c 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java +++ b/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; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO; 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 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.core.client.FileClient; -import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO; import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; import com.google.common.annotations.VisibleForTesting; import jakarta.annotation.Resource; @@ -126,19 +126,27 @@ public class FileServiceImpl implements FileService { @Override @SneakyThrows - public FilePresignedUrlRespVO getFilePresignedUrl(String name, String directory) { + public FilePresignedUrlRespVO presignPutUrl(String name, String directory) { // 1. 生成上传的 path,需要保证唯一 String path = generateUploadPath(name, directory); // 2. 获取文件预签名地址 FileClient fileClient = fileConfigService.getMasterFileClient(); - FilePresignedUrlRespDTO presignedObjectUrl = fileClient.getPresignedObjectUrl(path); - return BeanUtils.toBean(presignedObjectUrl, FilePresignedUrlRespVO.class, - object -> object.setConfigId(fileClient.getId()).setPath(path)); + String uploadUrl = fileClient.presignPutUrl(path); + String visitUrl = fileClient.presignGetUrl(path, null); + return new FilePresignedUrlRespVO().setConfigId(fileClient.getId()) + .setPath(path).setUploadUrl(uploadUrl).setUrl(visitUrl); + } + + @Override + public String presignGetUrl(String url, Integer expirationSeconds) { + FileClient fileClient = fileConfigService.getMasterFileClient(); + return fileClient.presignGetUrl(url, expirationSeconds); } @Override public Long createFile(FileCreateReqVO createReqVO) { + createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数 FileDO file = BeanUtils.toBean(createReqVO, FileDO.class); fileMapper.insert(file); return file.getId(); diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java index 3c40ce23e7..981971b9f9 100644 --- a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java +++ b/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; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +@SuppressWarnings("resource") public class S3FileClientTest { @Test @@ -70,6 +71,7 @@ public class S3FileClientTest { config.setAccessSecret("kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP"); config.setBucket("ruoyi-vue-pro"); config.setDomain("http://test.yudao.iocoder.cn"); // 如果有自定义域名,则可以设置。http://static.yudao.iocoder.cn + config.setEnablePathStyleAccess(false); // 默认上海的 endpoint config.setEndpoint("s3-cn-south-1.qiniucs.com"); @@ -77,6 +79,32 @@ public class S3FileClientTest { testExecuteUpload(config); } + @Test + @Disabled // 七牛云存储(读私有桶),如果要集成测试,可以注释本行 + public void testQiniu_privateGet() { + 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-private"); + config.setDomain("http://t151glocd.hn-bkt.clouddn.com"); // 如果有自定义域名,则可以设置。http://static.yudao.iocoder.cn + config.setEnablePathStyleAccess(false); + // 默认上海的 endpoint + config.setEndpoint("s3-cn-south-1.qiniucs.com"); + + // 校验配置 + ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config); + // 创建 Client + S3FileClient client = new S3FileClient(0L, config); + client.init(); + // 执行生成 URL 签名 + String path = "output.png"; + String presignedUrl = client.presignGetUrl(path, 300); + System.out.println(presignedUrl); + } + @Test @Disabled // 华为云存储,如果要集成测试,可以注释本行 public void testHuaweiCloud() throws Exception { @@ -93,7 +121,7 @@ public class S3FileClientTest { testExecuteUpload(config); } - private void testExecuteUpload(S3FileClientConfig config) throws Exception { + private void testExecuteUpload(S3FileClientConfig config) { // 校验配置 ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config); // 创建 Client