Commit 472993e25ac2c837f22a0578740775d5adb1e69f

Authored by YunaiV
1 parent 858a351c

fix(infra): 修复文件 URL 特殊字符编码问题

- 新增 URL Path 编解码工具,保留 + 字符语义
- 文件访问基于原始 URI 解码 path,避免中文、空格、% 等字符下载异常
- 文件客户端生成 URL 时按路径段编码,S3 兼容完整 URL 和裸 path
- 优化 Content-Disposition 文件名,支持 filename 和 filename*
- 下载文件名优先使用文件记录的原始名称
- 补充 HTTP、Local、S3、文件服务相关单测
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;
9 9 import org.springframework.util.StringUtils;
10 10 import org.springframework.web.util.UriComponents;
11 11 import org.springframework.web.util.UriComponentsBuilder;
  12 +import org.springframework.web.util.UriUtils;
12 13  
13 14 import java.net.URI;
14 15 import java.net.URLDecoder;
... ... @@ -55,11 +56,61 @@ public class HttpUtils {
55 56 * @return 解码后的路径
56 57 */
57 58 public static String decodeUrlPath(String path) {
  59 + if (StrUtil.isEmpty(path)) {
  60 + return path;
  61 + }
58 62 // 先将 + 替换为 %2B,避免被 URLDecoder 解码为空格
59 63 String encoded = path.replace("+", "%2B");
60 64 return URLDecoder.decode(encoded, StandardCharsets.UTF_8);
61 65 }
62 66  
  67 + /**
  68 + * 编码 URL 路径,按路径段编码,保留 / 分隔符
  69 + *
  70 + * @param path URL 路径,例如 20250602/xxx.pdf
  71 + * @return 编码后的路径
  72 + */
  73 + public static String encodeUrlPath(String path) {
  74 + if (StrUtil.isEmpty(path)) {
  75 + return path;
  76 + }
  77 + String[] segments = path.split(StrUtil.SLASH, -1);
  78 + StringBuilder result = new StringBuilder(path.length());
  79 + for (int i = 0; i < segments.length; i++) {
  80 + if (i > 0) {
  81 + result.append(StrUtil.SLASH);
  82 + }
  83 + result.append(encodeUrlPathSegment(segments[i]));
  84 + }
  85 + return result.toString();
  86 + }
  87 +
  88 + /**
  89 + * 编码 URL 路径段
  90 + *
  91 + * @param segment URL 路径段
  92 + * @return 编码后的路径段
  93 + */
  94 + public static String encodeUrlPathSegment(String segment) {
  95 + return UriUtils.encodePathSegment(segment, StandardCharsets.UTF_8);
  96 + }
  97 +
  98 + public static String removeUrlPathQueryAndFragment(String path) {
  99 + if (StrUtil.isEmpty(path)) {
  100 + return path;
  101 + }
  102 + int endIndex = path.length();
  103 + int queryIndex = path.indexOf('?');
  104 + if (queryIndex >= 0) {
  105 + endIndex = queryIndex;
  106 + }
  107 + int fragmentIndex = path.indexOf('#');
  108 + if (fragmentIndex >= 0 && fragmentIndex < endIndex) {
  109 + endIndex = fragmentIndex;
  110 + }
  111 + return path.substring(0, endIndex);
  112 + }
  113 +
63 114 public static String replaceUrlQuery(String url, String key, String value) {
64 115 UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset());
65 116 // 先移除;再添加
... ...
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;
10 10 public class HttpUtilsTest {
11 11  
12 12 @Test
  13 + public void testEncodeUrlPath() {
  14 + // 准备参数
  15 + String path = "avatar/中文 100%+文件.jpg";
  16 +
  17 + // 调用
  18 + String result = HttpUtils.encodeUrlPath(path);
  19 +
  20 + // 断言
  21 + assertEquals("avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg", result);
  22 + }
  23 +
  24 + @Test
  25 + public void testDecodeUrlPath() {
  26 + // 准备参数:+ 是路径字符,不应该按 query parameter 语义解码为空格
  27 + String path = "avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg";
  28 +
  29 + // 调用
  30 + String result = HttpUtils.decodeUrlPath(path);
  31 +
  32 + // 断言
  33 + assertEquals("avatar/中文 100%+文件.jpg", result);
  34 + }
  35 +
  36 + @Test
  37 + public void testRemoveUrlPathQueryAndFragment() {
  38 + assertEquals("avatar/test.jpg", HttpUtils.removeUrlPathQueryAndFragment("avatar/test.jpg?token=1#preview"));
  39 + assertEquals("avatar/test.jpg", HttpUtils.removeUrlPathQueryAndFragment("avatar/test.jpg#preview?token=1"));
  40 + }
  41 +
  42 + @Test
13 43 public void testReplaceUrlQuery_replace() {
14 44 // 准备参数
15 45 String url = "https://www.iocoder.cn/path?a=1&b=2";
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java
1 1 package cn.iocoder.yudao.module.infra.controller.admin.file;
2 2  
  3 +import cn.hutool.core.io.FileUtil;
3 4 import cn.hutool.core.io.IoUtil;
4 5 import cn.hutool.core.util.StrUtil;
5   -import cn.hutool.core.util.URLUtil;
6 6 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
7 7 import cn.iocoder.yudao.framework.common.pojo.PageResult;
  8 +import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
8 9 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
9 10 import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
10 11 import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.*;
... ... @@ -27,7 +28,6 @@ import org.springframework.validation.annotation.Validated;
27 28 import org.springframework.web.bind.annotation.*;
28 29 import org.springframework.web.multipart.MultipartFile;
29 30  
30   -import java.nio.charset.StandardCharsets;
31 31 import java.util.List;
32 32  
33 33 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
... ... @@ -111,10 +111,10 @@ public class FileController {
111 111 if (StrUtil.isEmpty(path)) {
112 112 throw new IllegalArgumentException("结尾的 path 路径必须传递");
113 113 }
114   - // 解码,解决中文路径的问题
  114 + // 解码,解决中文、%、+ 等特殊字符路径的问题
115 115 // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/807/
116 116 // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/
117   - path = URLUtil.decode(path, StandardCharsets.UTF_8, false);
  117 + path = HttpUtils.decodeUrlPath(path);
118 118  
119 119 // 读取内容
120 120 byte[] content = fileService.getFileContent(configId, path);
... ... @@ -123,7 +123,9 @@ public class FileController {
123 123 response.setStatus(HttpStatus.NOT_FOUND.value());
124 124 return;
125 125 }
126   - writeAttachment(response, path, content);
  126 + FileDO file = fileService.getFileByConfigIdAndPath(configId, path);
  127 + String filename = file != null && StrUtil.isNotEmpty(file.getName()) ? file.getName() : FileUtil.getName(path);
  128 + writeAttachment(response, filename, content);
127 129 }
128 130  
129 131 @GetMapping("/page")
... ...
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&lt;FileDO&gt; {
23 23 .orderByDesc(FileDO::getId));
24 24 }
25 25  
  26 + default FileDO selectLatestByConfigIdAndPath(Long configId, String path) {
  27 + return selectLastOne(new LambdaQueryWrapperX<FileDO>()
  28 + .eq(FileDO::getConfigId, configId)
  29 + .eq(FileDO::getPath, path)
  30 + .orderByAsc(FileDO::getId));
  31 + }
  32 +
26 33 }
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/AbstractFileClient.java
1 1 package cn.iocoder.yudao.module.infra.framework.file.core.client;
2 2  
3 3 import cn.hutool.core.util.StrUtil;
  4 +import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
4 5 import lombok.extern.slf4j.Slf4j;
5 6  
6 7 /**
... ... @@ -72,7 +73,7 @@ public abstract class AbstractFileClient&lt;Config extends FileClientConfig&gt; implem
72 73 * @return URL 访问地址
73 74 */
74 75 protected String formatFileUrl(String domain, String path) {
75   - return StrUtil.format("{}/admin-api/infra/file/{}/get/{}", domain, getId(), path);
  76 + return StrUtil.format("{}/admin-api/infra/file/{}/get/{}", domain, getId(), HttpUtils.encodeUrlPath(path));
76 77 }
77 78  
78 79 }
... ...
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&lt;S3FileClientConfig&gt; {
116 116 @Override
117 117 public String presignGetUrl(String url, Integer expirationSeconds) {
118 118 // 1. 将 url 转换为 path
119   - String path = StrUtil.removePrefix(url, config.getDomain() + "/");
120   - path = HttpUtils.decodeUrlPath(HttpUtils.removeUrlQuery(path));
  119 + // 完整 Domain URL 会解码还原为原始对象名;裸 path 约定已经是未编码的对象名
  120 + boolean domainUrl = StrUtil.startWith(url, config.getDomain() + "/");
  121 + String path = domainUrl ? StrUtil.removePrefix(url, config.getDomain() + "/") : url;
  122 + if (domainUrl) {
  123 + path = HttpUtils.removeUrlPathQueryAndFragment(path);
  124 + path = HttpUtils.decodeUrlPath(path);
  125 + }
121 126  
122 127 // 2.1 情况一:公开访问:无需签名
123 128 // 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
124 129 if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
125   - return config.getDomain() + "/" + path;
  130 + return config.getDomain() + "/" + HttpUtils.encodeUrlPath(path);
126 131 }
127 132  
128 133 // 2.2 情况二:私有访问:生成 GET 预签名 URL
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FileTypeUtils.java
... ... @@ -85,9 +85,9 @@ public class FileTypeUtils {
85 85 // 设置内容显示、下载文件名:https://www.cnblogs.com/wq-9/articles/12165056.html
86 86 if (isImage(mineType)) {
87 87 // 参见 https://github.com/YunaiV/ruoyi-vue-pro/issues/692 讨论
88   - response.setHeader("Content-Disposition", "inline;filename=" + HttpUtils.encodeUtf8(filename));
  88 + response.setHeader("Content-Disposition", buildContentDisposition("inline", filename));
89 89 } else {
90   - response.setHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename));
  90 + response.setHeader("Content-Disposition", buildContentDisposition("attachment", filename));
91 91 }
92 92 // 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题
93 93 if (StrUtil.containsIgnoreCase(mineType, "video")) {
... ... @@ -98,6 +98,29 @@ public class FileTypeUtils {
98 98 IoUtil.write(response.getOutputStream(), false, content);
99 99 }
100 100  
  101 + private static String buildContentDisposition(String dispositionType, String filename) {
  102 + return StrUtil.format("{};filename=\"{}\";filename*=UTF-8''{}",
  103 + dispositionType, buildFallbackFilename(filename), HttpUtils.encodeUrlPathSegment(filename));
  104 + }
  105 +
  106 + private static String buildFallbackFilename(String filename) {
  107 + if (StrUtil.isEmpty(filename)) {
  108 + return "download";
  109 + }
  110 + StringBuilder result = new StringBuilder(filename.length());
  111 + for (int i = 0; i < filename.length(); i++) {
  112 + char ch = filename.charAt(i);
  113 + if (ch == '"' || ch == '\\') {
  114 + result.append('\\').append(ch);
  115 + } else if (ch >= 0x20 && ch <= 0x7E) {
  116 + result.append(ch);
  117 + } else {
  118 + result.append('_');
  119 + }
  120 + }
  121 + return result.toString();
  122 + }
  123 +
101 124 /**
102 125 * 判断是否是图片
103 126 *
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileService.java
... ... @@ -86,4 +86,13 @@ public interface FileService {
86 86 */
87 87 byte[] getFileContent(Long configId, String path) throws Exception;
88 88  
  89 + /**
  90 + * 获得文件
  91 + *
  92 + * @param configId 配置编号
  93 + * @param path 文件路径
  94 + * @return 文件
  95 + */
  96 + FileDO getFileByConfigIdAndPath(Long configId, String path);
  97 +
89 98 }
... ...
yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java
... ... @@ -238,4 +238,9 @@ public class FileServiceImpl implements FileService {
238 238 return client.getContent(path);
239 239 }
240 240  
  241 + @Override
  242 + public FileDO getFileByConfigIdAndPath(Long configId, String path) {
  243 + return fileMapper.selectLatestByConfigIdAndPath(configId, path);
  244 + }
  245 +
241 246 }
... ...
yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java
... ... @@ -42,6 +42,21 @@ public class LocalFileClientTest {
42 42 }
43 43  
44 44 @Test
  45 + public void testUpload_encodeUrlPath() {
  46 + // 准备参数
  47 + LocalFileClient client = createClient();
  48 + byte[] content = "test".getBytes(StandardCharsets.UTF_8);
  49 + String path = "avatar/中文 100%+文件.txt";
  50 +
  51 + // 调用
  52 + String url = client.upload(content, path, "text/plain");
  53 +
  54 + // 断言
  55 + 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);
  56 + assertArrayEquals(content, FileUtil.readBytes(new File(tempDir, path)));
  57 + }
  58 +
  59 + @Test
45 60 public void testUpload_pathInvalid() {
46 61 // 准备参数
47 62 LocalFileClient client = createClient();
... ...
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;
9 9 import org.junit.jupiter.api.Disabled;
10 10 import org.junit.jupiter.api.Test;
11 11  
  12 +import static org.junit.jupiter.api.Assertions.assertEquals;
  13 +import static org.junit.jupiter.api.Assertions.assertFalse;
  14 +import static org.junit.jupiter.api.Assertions.assertTrue;
  15 +
12 16 @SuppressWarnings("resource")
13 17 public class S3FileClientTest {
14 18  
15 19 @Test
  20 + public void testPresignGetUrl_publicAccess_encodeUrlPath() {
  21 + // 准备参数
  22 + S3FileClientConfig config = new S3FileClientConfig();
  23 + config.setDomain("https://static.iocoder.cn");
  24 + config.setEnablePublicAccess(true);
  25 + S3FileClient client = new S3FileClient(0L, config);
  26 +
  27 + // 调用
  28 + String result = client.presignGetUrl("avatar/中文 100%+文件.jpg", 300);
  29 +
  30 + // 断言
  31 + assertEquals("https://static.iocoder.cn/avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg", result);
  32 + }
  33 +
  34 + @Test
  35 + public void testPresignGetUrl_publicAccess_decodeDomainUrl() {
  36 + // 准备参数
  37 + S3FileClientConfig config = new S3FileClientConfig();
  38 + config.setDomain("https://static.iocoder.cn");
  39 + config.setEnablePublicAccess(true);
  40 + S3FileClient client = new S3FileClient(0L, config);
  41 +
  42 + // 调用
  43 + 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);
  44 +
  45 + // 断言
  46 + assertEquals("https://static.iocoder.cn/avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg", result);
  47 + }
  48 +
  49 + @Test
  50 + public void testPresignGetUrl_privateAccess_rawPath() {
  51 + // 准备参数
  52 + S3FileClientConfig config = new S3FileClientConfig();
  53 + config.setAccessKey("admin");
  54 + config.setAccessSecret("password");
  55 + config.setBucket("yudao");
  56 + config.setDomain("http://127.0.0.1:9000/yudao");
  57 + config.setEndpoint("http://127.0.0.1:9000");
  58 + config.setEnablePathStyleAccess(true);
  59 + config.setEnablePublicAccess(false);
  60 + S3FileClient client = new S3FileClient(0L, config);
  61 + client.init();
  62 +
  63 + // 调用
  64 + String result = client.presignGetUrl("avatar/中文 100%+文件.jpg", 300);
  65 +
  66 + // 断言
  67 + assertTrue(result.contains("/yudao/avatar/%E4%B8%AD%E6%96%87%20100%25"));
  68 + assertTrue(result.contains("%E6%96%87%E4%BB%B6.jpg"));
  69 + assertFalse(result.contains("%25E4%25B8%25AD"));
  70 + }
  71 +
  72 + @Test
16 73 @Disabled // MinIO,如果要集成测试,可以注释本行
17 74 public void testMinIO() throws Exception {
18 75 S3FileClientConfig config = new S3FileClientConfig();
... ...
yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java
... ... @@ -212,6 +212,23 @@ public class FileServiceImplTest extends BaseDbUnitTest {
212 212 }
213 213  
214 214 @Test
  215 + public void testGetFileByConfigIdAndPath() {
  216 + // mock 数据
  217 + FileDO dbFile = randomPojo(FileDO.class, o -> o.setConfigId(10L).setPath("avatar/中文 100%+文件.jpg"));
  218 + fileMapper.insert(dbFile);
  219 + FileDO latestFile = ObjectUtils.cloneIgnoreId(dbFile, o -> o.setName("最新文件名.jpg"));
  220 + fileMapper.insert(latestFile);
  221 + fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> o.setPath("avatar/other.jpg")));
  222 + fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> o.setConfigId(20L)));
  223 +
  224 + // 调用
  225 + FileDO result = fileService.getFileByConfigIdAndPath(10L, "avatar/中文 100%+文件.jpg");
  226 +
  227 + // 断言
  228 + AssertUtils.assertPojoEquals(latestFile, result);
  229 + }
  230 +
  231 + @Test
215 232 public void testCreateFileByPresignedPath_success() {
216 233 // 准备参数
217 234 FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> {
... ...