当前位置: 首页 > ai >正文

Spring 转发 form-data 文件上传请求时中文文件名乱码

Spring 转发 form-data 文件上传请求时中文文件名乱码

    • 复现问题
    • 找原因
    • 解决问题
    • 参考

复现问题

后端有两个接口:

/upload 是文件上传的接口。

/forward 是转发文件上传请求的接口。

@RequestMapping
@RestController
public class FileUploadController {/*** 直接调用-文件上传*/@PostMapping("/upload")public String upload(HttpServletRequest request, @RequestParam("title") String title,@RequestParam("file") MultipartFile file) {return """title: %s <br/>filename: %s <br/>contentType: %s""".formatted(title, file.getOriginalFilename(), file.getContentType());}/*** 转发请求*/@PostMapping("/forward")public String forward(HttpServletRequest request) throws ServletException, IOException {try (CloseableHttpClient httpclient = HttpClients.createDefault()) {ClassicRequestBuilder requestBuilder = ClassicRequestBuilder.post("http://localhost:8080/upload");MultipartEntityBuilder entityBuilder = MultipartEntityBuilder.create();for (Part part : request.getParts()) {MultipartPartBuilder partBuilder = MultipartPartBuilder.create();for (String headerName : part.getHeaderNames()) {partBuilder.addHeader(headerName, part.getHeader(headerName));}InputStreamBody body = new InputStreamBody(part.getInputStream(), part.getSubmittedFileName());partBuilder.setBody(body);entityBuilder.addPart(partBuilder.build());}ClassicHttpRequest forwardRequest = requestBuilder.setEntity(entityBuilder.build()).build();return httpclient.execute(forwardRequest, response -> {final HttpEntity responseEntity = response.getEntity();String res = EntityUtils.toString(responseEntity);EntityUtils.consume(responseEntity);return res;});}}
}

前端使用 form 进行文件上传

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>test</title>
</head>
<body><h2>直接调用</h2><form action="/upload" method="post" enctype="multipart/form-data"><label>标题:</label><input type="text" name="title" value="直接调用" required/><label>文件:</label><input type="file" name="file" required/><input type="submit" value="提交"></form><h2>转发请求</h2><form action="/forward" method="post" enctype="multipart/form-data"><label>标题:</label><input type="text" name="title" value="转发请求" required/><label>文件:</label><input type="file" name="file" required/><input type="submit" value="转发"></form>
</body>
</html>

image-20250430104419317

测试发现直接调用时,文件名正常显示,但转发请求时却乱码了。

image-20250430104908735

找原因

通过 Apifox 调用 /forward 发现不会乱码。

image-20250430105221852

使用 fiddler 抓包,浏览器和 Apifox 请求 /forward 的数据包。发现 Apifox 在 multipart body 的 Content-Disposition header 中多了一个 filename* 的属性。

image-20250430105608060

Content-Disposition 的文档中这样写到:

  • filename

    后面是要传送的文件的初始名称的字符串。这个参数总是可选的,而且不能盲目使用:路径信息必须舍掉,同时要进行一定的转换以符合服务器文件系统规则。这个参数主要用来提供展示性信息。当与 Content-Disposition: attachment 一同使用的时候,它被用作"保存为"对话框中呈现给用户的默认文件名。

  • filename\*

    filenamefilename* 两个参数的唯一区别在于,filename* 采用了 RFC 5987 中规定的编码方式。当 filenamefilename* 同时出现的时候,应该优先采用 filename*,假如二者都支持的话。

filename* 的优先级高于 filename

当使用 multipart/form-data 格式提交表单数据时,每个子部分(例如每个表单字段和任何与字段数据相关的文件)都需要提供一个 Content-Disposition 标头,以提供相关信息。标头的第一个指令始终为 form-data,并且还必须包含一个 name 参数来标识相关字段。额外的指令不区分大小写,并使用带引号的字符串语法在 = 号后面指定参数。多个参数之间使用分号(;)分隔。

Content-Disposition: form-data; name="fieldName"
Content-Disposition: form-data; name="fieldName"; filename="filename.jpg"

上述 Content-Disposition 的文档中也指出了,要添加多个参数,需要用 ; 分隔。

下一步就是要弄明白 filename* 的值应该怎么构造。以下是 RFC 5987 中关于参数名、参数值的语法,其中又提到了 RFC 2231、RFC 3986, 比较晦涩难懂:

  parameter     = reg-parameter / ext-parameterreg-parameter = parmname LWSP "=" LWSP valueext-parameter = parmname "*" LWSP "=" LWSP ext-valueparmname      = 1*attr-charext-value     = charset  "'" [ language ] "'" value-chars; like RFC 2231's <extended-initial-value>; (see [RFC2231], Section 7)charset       = "UTF-8" / "ISO-8859-1" / mime-charsetmime-charset  = 1*mime-charsetcmime-charsetc = ALPHA / DIGIT/ "!" / "#" / "$" / "%" / "&"/ "+" / "-" / "^" / "_" / "`"/ "{" / "}" / "~"; as <mime-charset> in Section 2.3 of [RFC2978]; except that the single quote is not included; SHOULD be registered in the IANA charset registrylanguage      = <Language-Tag, defined in [RFC5646], Section 2.1>value-chars   = *( pct-encoded / attr-char )pct-encoded   = "%" HEXDIG HEXDIG; see [RFC3986], Section 2.1attr-char     = ALPHA / DIGIT/ "!" / "#" / "$" / "&" / "+" / "-" / "."/ "^" / "_" / "`" / "|" / "~"; token except ( "*" / "'" / "%" )

总结后就是:

// charset 字符集,比如 UTF-8、ISO-8859-1
// language 语言,比如 en,可以省略,language 的前后使用单引号分隔
// percentEncoding 百分号编码,% 加上两个 16 进制,比如中的编码为 %E4%B8%AD,我们常见的空格的编码为 %20
// rawStr 原始的字符串,比如 中文 abc 123.png
parmname + "*" + "=" + charset + "'" + language + "'" + percentEncoding(rawStr)

js 相关

注意 encodeURIComponent 并未将 百分号编码 | MDN 中描述的所有特殊字符进行编码,在 encodeURIComponent() - JavaScript | MDN 的描述中提到它不会编码 ! * ' ( ) 这五个字符,所以需要特殊处理。

百分号编码 | MDN 还提到了以下内容,这对后续 java 后端添加 filename* 很重要。

根据上下文,空白符 ' ' 将会转换为 '+' (如使用百分号编码的 application/x-www-form-urlencoded 消息),或者将会转换为 '%20'(如 URL 中)。

翻译成 js 代码是:

// 例如 filename*=UTF-8''%E4%B8%AD%E6%96%87%20abc%20123.png
parmname = "filename"
charset = "UTF-8"
language = ""
rawStr = "中文 abc 123.png"function percentEncoding(rawStr) {var res = encodeURIComponent(rawStr)// 特殊处理 ! * ' ( )res = res.replace(/\*/g, '%2A')res = res.replace(/!/g, '%21')res = res.replace(/\(/g, '%28')res = res.replace(/\)/g, '%29')res = res.replace(/'/g, '%27')return res
}
// 重点
kv = `${parmname}*=${charset}'${language}'${percentEncoding(rawStr)}`

image-20250430144427345

解决问题

解决问题最直接的解决办法就是前端上传时在 Content-Disposition 中添加 ; filename*=UTF-8''%E4%B8%AD%E6%96%87%20abc%20123.png 这一段。但查阅资料后发现 <form> 标签不支持直接控制 Content-Disposition 的值。

最后采用后端转发时添加 filename* 的方式。

/*** 转发请求*/
@PostMapping("/forward")
public String forward(HttpServletRequest request) throws ServletException, IOException {try (CloseableHttpClient httpclient = HttpClients.createDefault()) {ClassicRequestBuilder requestBuilder = ClassicRequestBuilder.post("http://localhost:8080/upload");MultipartEntityBuilder entityBuilder = MultipartEntityBuilder.create();for (Part part : request.getParts()) {MultipartPartBuilder partBuilder = MultipartPartBuilder.create();for (String headerName : part.getHeaderNames()) {String headerValue = part.getHeader(headerName);// 如果是文件上传,则为 Content-Disposition 添加 filename*if ("Content-Disposition".equalsIgnoreCase(headerName)&& headerValue.contains("filename")&& !headerValue.contains("filename*")) {headerValue += "; filename*=UTF-8''%s".formatted(percentEncoding(part.getSubmittedFileName()));}partBuilder.addHeader(headerName, headerValue);}InputStreamBody body = new InputStreamBody(part.getInputStream(), part.getSubmittedFileName());partBuilder.setBody(body);entityBuilder.addPart(partBuilder.build());}ClassicHttpRequest forwardRequest = requestBuilder.setEntity(entityBuilder.build()).build();return httpclient.execute(forwardRequest, response -> {final HttpEntity responseEntity = response.getEntity();String res = EntityUtils.toString(responseEntity);EntityUtils.consume(responseEntity);return res;});}
}private static String percentEncoding(String raw) {// 在 encode 方法的注释中能看出,encode 方法遵循 application/x-www-form-urlencoded 的规范,// 会将空格替换为 +,所以需要再次将 + 替换为 %20return URLEncoder.encode(raw, StandardCharsets.UTF_8).replace("+", "%20");
}

乱码问题完美解决:

image-20250430150335568

参考

  • Content-Disposition - HTTP | MDN | en-US
  • Content-Disposition - HTTP | MDN | zh-CN
  • RFC 5987 - Character Set and Language Encoding for Hypertext Transfer Protocol (HTTP) Header Field Parameters
  • RFC 2231 - MIME Parameter Value and Encoded Word Extensions: Character Sets, Languages, and Continuations
  • 百分号编码 - MDN Web 文档术语表:Web 相关术语的定义 | MDN
  • encodeURIComponent() - JavaScript | MDN
  • RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax
  • 【编码篇】看破字符 %20 之谜,百分号编码以及其背后前言 提到这个 %20,想必大家都见过,熟悉一点编码的人,还会知道 - 掘金
  • 下载的附件名总乱码?你该去读一下 RFC 文档了! - 郑晓龙 - 博客园
http://www.xdnf.cn/news/3101.html

相关文章:

  • 【大模型面试每日一题】Day 4:低资源语言建模方案
  • vue3 打字机效果
  • 【CUDA pytorch】
  • DAPO:对GRPO的几点改进
  • 模式识别的基本概念与理论体系
  • 智能机器人在物流行业的应用:效率提升与未来展望
  • pycharm导入同目录下文件未标红但报错ModuleNotFoundError
  • iVX 开源战略:多维突破下的产业生态革新与未来图景
  • MCP的基础知识
  • C++从入门到实战(十一)详细讲解C/C++语言中内存分布与C与C++内存管理对比
  • 一种动态分配内存错误的解决办法
  • Chrome插件备忘
  • Godot笔记:入门索引
  • 卷积神经网络
  • 解析2.4G射频芯片采用DFN封装的技术原因
  • 32单片机——串口
  • 精选10个好用的WordPress免费主题
  • Day106 | 灵神 | 二叉树 二叉树中的最长交错路径
  • OpenAI 2025 4月最新动态综述
  • DINOv2 - 无监督学习鲁棒视觉特征
  • Webpack 和 Vite 中静态资源动态加载的实现原理与方法详解
  • kotlin中Triple的作用
  • C#基础简述
  • Elasticsearch入门速通01:核心概念与选型指南
  • Unity URPShader:实现和PS一样的色相/饱和度调整参数效果(修复)
  • Springboot使用ThreadLocal提供线程局部变量,传递登录用户名
  • 计算机考研精炼 操作系统
  • Smart Link+Monitor Link组网
  • 【solidity基础】一文说清楚合约函数的大小事
  • HFI笔记