From 472993e25ac2c837f22a0578740775d5adb1e69f Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 7 Jun 2026 12:21:03 +0800 Subject: [PATCH] fix(infra): 修复文件 URL 特殊字符编码问题 --- yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/http/HttpUtils.java | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/http/HttpUtilsTest.java | 30 ++++++++++++++++++++++++++++++ yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java | 12 +++++++----- yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileMapper.java | 7 +++++++ yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java | 3 ++- yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java | 11 ++++++++--- yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java | 27 +++++++++++++++++++++++++-- yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java | 9 +++++++++ yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java | 5 +++++ yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java | 15 +++++++++++++++ yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java | 17 +++++++++++++++++ 12 files changed, 233 insertions(+), 11 deletions(-) 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 fe348b6..95220a9 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 @@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletRequest; import org.springframework.util.StringUtils; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; import java.net.URI; import java.net.URLDecoder; @@ -55,11 +56,61 @@ public class HttpUtils { * @return 解码后的路径 */ public static String decodeUrlPath(String path) { + if (StrUtil.isEmpty(path)) { + return path; + } // 先将 + 替换为 %2B,避免被 URLDecoder 解码为空格 String encoded = path.replace("+", "%2B"); return URLDecoder.decode(encoded, StandardCharsets.UTF_8); } + /** + * 编码 URL 路径,按路径段编码,保留 / 分隔符 + * + * @param path URL 路径,例如 20250602/xxx.pdf + * @return 编码后的路径 + */ + public static String encodeUrlPath(String path) { + if (StrUtil.isEmpty(path)) { + return path; + } + String[] segments = path.split(StrUtil.SLASH, -1); + StringBuilder result = new StringBuilder(path.length()); + for (int i = 0; i < segments.length; i++) { + if (i > 0) { + result.append(StrUtil.SLASH); + } + result.append(encodeUrlPathSegment(segments[i])); + } + return result.toString(); + } + + /** + * 编码 URL 路径段 + * + * @param segment URL 路径段 + * @return 编码后的路径段 + */ + public static String encodeUrlPathSegment(String segment) { + return UriUtils.encodePathSegment(segment, StandardCharsets.UTF_8); + } + + public static String removeUrlPathQueryAndFragment(String path) { + if (StrUtil.isEmpty(path)) { + return path; + } + int endIndex = path.length(); + int queryIndex = path.indexOf('?'); + if (queryIndex >= 0) { + endIndex = queryIndex; + } + int fragmentIndex = path.indexOf('#'); + if (fragmentIndex >= 0 && fragmentIndex < endIndex) { + endIndex = fragmentIndex; + } + return path.substring(0, endIndex); + } + public static String replaceUrlQuery(String url, String key, String value) { UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset()); // 先移除;再添加 diff --git a/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/http/HttpUtilsTest.java b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/http/HttpUtilsTest.java index a42874f..e2b6120 100644 --- a/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/http/HttpUtilsTest.java +++ b/yudao-framework/yudao-common/src/test/java/cn/iocoder/yudao/framework/common/util/http/HttpUtilsTest.java @@ -10,6 +10,36 @@ import static org.junit.jupiter.api.Assertions.assertEquals; public class HttpUtilsTest { @Test + public void testEncodeUrlPath() { + // 准备参数 + String path = "avatar/中文 100%+文件.jpg"; + + // 调用 + String result = HttpUtils.encodeUrlPath(path); + + // 断言 + assertEquals("avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg", result); + } + + @Test + public void testDecodeUrlPath() { + // 准备参数:+ 是路径字符,不应该按 query parameter 语义解码为空格 + String path = "avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg"; + + // 调用 + String result = HttpUtils.decodeUrlPath(path); + + // 断言 + assertEquals("avatar/中文 100%+文件.jpg", result); + } + + @Test + public void testRemoveUrlPathQueryAndFragment() { + assertEquals("avatar/test.jpg", HttpUtils.removeUrlPathQueryAndFragment("avatar/test.jpg?token=1#preview")); + assertEquals("avatar/test.jpg", HttpUtils.removeUrlPathQueryAndFragment("avatar/test.jpg#preview?token=1")); + } + + @Test public void testReplaceUrlQuery_replace() { // 准备参数 String url = "https://www.iocoder.cn/path?a=1&b=2"; 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 ca7db64..12bab8a 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 @@ -1,10 +1,11 @@ package cn.iocoder.yudao.module.infra.controller.admin.file; +import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.StrUtil; -import cn.hutool.core.util.URLUtil; import cn.iocoder.yudao.framework.common.pojo.CommonResult; 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.framework.tenant.core.aop.TenantIgnore; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.*; @@ -27,7 +28,6 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.nio.charset.StandardCharsets; import java.util.List; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; @@ -111,10 +111,10 @@ public class FileController { if (StrUtil.isEmpty(path)) { throw new IllegalArgumentException("结尾的 path 路径必须传递"); } - // 解码,解决中文路径的问题 + // 解码,解决中文、%、+ 等特殊字符路径的问题 // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/807/ // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/ - path = URLUtil.decode(path, StandardCharsets.UTF_8, false); + path = HttpUtils.decodeUrlPath(path); // 读取内容 byte[] content = fileService.getFileContent(configId, path); @@ -123,7 +123,9 @@ public class FileController { response.setStatus(HttpStatus.NOT_FOUND.value()); return; } - writeAttachment(response, path, content); + FileDO file = fileService.getFileByConfigIdAndPath(configId, path); + String filename = file != null && StrUtil.isNotEmpty(file.getName()) ? file.getName() : FileUtil.getName(path); + writeAttachment(response, filename, content); } @GetMapping("/page") diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileMapper.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileMapper.java index 11233a4..a5b17c6 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileMapper.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/file/FileMapper.java @@ -23,4 +23,11 @@ public interface FileMapper extends BaseMapperX { .orderByDesc(FileDO::getId)); } + default FileDO selectLatestByConfigIdAndPath(Long configId, String path) { + return selectLastOne(new LambdaQueryWrapperX() + .eq(FileDO::getConfigId, configId) + .eq(FileDO::getPath, path) + .orderByAsc(FileDO::getId)); + } + } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java index 7ddade3..d03caca 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import lombok.extern.slf4j.Slf4j; /** @@ -72,7 +73,7 @@ public abstract class AbstractFileClient implem * @return URL 访问地址 */ protected String formatFileUrl(String domain, String path) { - return StrUtil.format("{}/admin-api/infra/file/{}/get/{}", domain, getId(), path); + return StrUtil.format("{}/admin-api/infra/file/{}/get/{}", domain, getId(), HttpUtils.encodeUrlPath(path)); } } 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 aad9007..98d37d8 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 @@ -116,13 +116,18 @@ public class S3FileClient extends AbstractFileClient { @Override public String presignGetUrl(String url, Integer expirationSeconds) { // 1. 将 url 转换为 path - String path = StrUtil.removePrefix(url, config.getDomain() + "/"); - path = HttpUtils.decodeUrlPath(HttpUtils.removeUrlQuery(path)); + // 完整 Domain URL 会解码还原为原始对象名;裸 path 约定已经是未编码的对象名 + boolean domainUrl = StrUtil.startWith(url, config.getDomain() + "/"); + String path = domainUrl ? StrUtil.removePrefix(url, config.getDomain() + "/") : url; + if (domainUrl) { + path = HttpUtils.removeUrlPathQueryAndFragment(path); + path = HttpUtils.decodeUrlPath(path); + } // 2.1 情况一:公开访问:无需签名 // 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名 if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) { - return config.getDomain() + "/" + path; + return config.getDomain() + "/" + HttpUtils.encodeUrlPath(path); } // 2.2 情况二:私有访问:生成 GET 预签名 URL diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java index f8cd1c4..3b0b533 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java @@ -85,9 +85,9 @@ public class FileTypeUtils { // 设置内容显示、下载文件名:https://www.cnblogs.com/wq-9/articles/12165056.html if (isImage(mineType)) { // 参见 https://github.com/YunaiV/ruoyi-vue-pro/issues/692 讨论 - response.setHeader("Content-Disposition", "inline;filename=" + HttpUtils.encodeUtf8(filename)); + response.setHeader("Content-Disposition", buildContentDisposition("inline", filename)); } else { - response.setHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename)); + response.setHeader("Content-Disposition", buildContentDisposition("attachment", filename)); } // 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题 if (StrUtil.containsIgnoreCase(mineType, "video")) { @@ -98,6 +98,29 @@ public class FileTypeUtils { IoUtil.write(response.getOutputStream(), false, content); } + private static String buildContentDisposition(String dispositionType, String filename) { + return StrUtil.format("{};filename=\"{}\";filename*=UTF-8''{}", + dispositionType, buildFallbackFilename(filename), HttpUtils.encodeUrlPathSegment(filename)); + } + + private static String buildFallbackFilename(String filename) { + if (StrUtil.isEmpty(filename)) { + return "download"; + } + StringBuilder result = new StringBuilder(filename.length()); + for (int i = 0; i < filename.length(); i++) { + char ch = filename.charAt(i); + if (ch == '"' || ch == '\\') { + result.append('\\').append(ch); + } else if (ch >= 0x20 && ch <= 0x7E) { + result.append(ch); + } else { + result.append('_'); + } + } + return result.toString(); + } + /** * 判断是否是图片 * 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 421bbe8..a561b4e 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 @@ -86,4 +86,13 @@ public interface FileService { */ byte[] getFileContent(Long configId, String path) throws Exception; + /** + * 获得文件 + * + * @param configId 配置编号 + * @param path 文件路径 + * @return 文件 + */ + FileDO getFileByConfigIdAndPath(Long configId, String path); + } 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 a93c07d..7fe36cd 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 @@ -238,4 +238,9 @@ public class FileServiceImpl implements FileService { return client.getContent(path); } + @Override + public FileDO getFileByConfigIdAndPath(Long configId, String path) { + return fileMapper.selectLatestByConfigIdAndPath(configId, path); + } + } diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java index 1164afd..f7681f7 100644 --- a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java @@ -42,6 +42,21 @@ public class LocalFileClientTest { } @Test + public void testUpload_encodeUrlPath() { + // 准备参数 + LocalFileClient client = createClient(); + byte[] content = "test".getBytes(StandardCharsets.UTF_8); + String path = "avatar/中文 100%+文件.txt"; + + // 调用 + String url = client.upload(content, path, "text/plain"); + + // 断言 + assertEquals("http://127.0.0.1:48080/admin-api/infra/file/0/get/avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.txt", url); + assertArrayEquals(content, FileUtil.readBytes(new File(tempDir, path))); + } + + @Test public void testUpload_pathInvalid() { // 准备参数 LocalFileClient client = createClient(); 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 981971b..8a3e2cd 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,10 +9,67 @@ import jakarta.validation.Validation; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + @SuppressWarnings("resource") public class S3FileClientTest { @Test + public void testPresignGetUrl_publicAccess_encodeUrlPath() { + // 准备参数 + S3FileClientConfig config = new S3FileClientConfig(); + config.setDomain("https://static.iocoder.cn"); + config.setEnablePublicAccess(true); + S3FileClient client = new S3FileClient(0L, config); + + // 调用 + String result = client.presignGetUrl("avatar/中文 100%+文件.jpg", 300); + + // 断言 + assertEquals("https://static.iocoder.cn/avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg", result); + } + + @Test + public void testPresignGetUrl_publicAccess_decodeDomainUrl() { + // 准备参数 + S3FileClientConfig config = new S3FileClientConfig(); + config.setDomain("https://static.iocoder.cn"); + config.setEnablePublicAccess(true); + S3FileClient client = new S3FileClient(0L, config); + + // 调用 + String result = client.presignGetUrl("https://static.iocoder.cn/avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg?token=1", 300); + + // 断言 + assertEquals("https://static.iocoder.cn/avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg", result); + } + + @Test + public void testPresignGetUrl_privateAccess_rawPath() { + // 准备参数 + S3FileClientConfig config = new S3FileClientConfig(); + config.setAccessKey("admin"); + config.setAccessSecret("password"); + config.setBucket("yudao"); + config.setDomain("http://127.0.0.1:9000/yudao"); + config.setEndpoint("http://127.0.0.1:9000"); + config.setEnablePathStyleAccess(true); + config.setEnablePublicAccess(false); + S3FileClient client = new S3FileClient(0L, config); + client.init(); + + // 调用 + String result = client.presignGetUrl("avatar/中文 100%+文件.jpg", 300); + + // 断言 + assertTrue(result.contains("/yudao/avatar/%E4%B8%AD%E6%96%87%20100%25")); + assertTrue(result.contains("%E6%96%87%E4%BB%B6.jpg")); + assertFalse(result.contains("%25E4%25B8%25AD")); + } + + @Test @Disabled // MinIO,如果要集成测试,可以注释本行 public void testMinIO() throws Exception { S3FileClientConfig config = new S3FileClientConfig(); diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java index 60c0b0c..4b14aec 100644 --- a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java @@ -212,6 +212,23 @@ public class FileServiceImplTest extends BaseDbUnitTest { } @Test + public void testGetFileByConfigIdAndPath() { + // mock 数据 + FileDO dbFile = randomPojo(FileDO.class, o -> o.setConfigId(10L).setPath("avatar/中文 100%+文件.jpg")); + fileMapper.insert(dbFile); + FileDO latestFile = ObjectUtils.cloneIgnoreId(dbFile, o -> o.setName("最新文件名.jpg")); + fileMapper.insert(latestFile); + fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> o.setPath("avatar/other.jpg"))); + fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> o.setConfigId(20L))); + + // 调用 + FileDO result = fileService.getFileByConfigIdAndPath(10L, "avatar/中文 100%+文件.jpg"); + + // 断言 + AssertUtils.assertPojoEquals(latestFile, result); + } + + @Test public void testCreateFileByPresignedPath_success() { // 准备参数 FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { -- libgit2 0.21.4