目录
-
- 一、问题背景
- 二、排查文件名的完整传递流程
-
- [1. 后端设置响应头](#1. 后端设置响应头)
- [2. 前端获取响应头并触发下载](#2. 前端获取响应头并触发下载)
- [三、问题根源:URLEncoder 与 decodeURI 的处理规则不同](#三、问题根源:URLEncoder 与 decodeURI 的处理规则不同)
-
- [1. 后端 URLEncoder 的编码规则](#1. 后端 URLEncoder 的编码规则)
- [2. 前端 decodeURI 的解码规则](#2. 前端 decodeURI 的解码规则)
- [3. 空格也存在类似的问题](#3. 空格也存在类似的问题)
- 四、解决方案:调整后端编码方式
-
- [1. 修改后的编码方法](#1. 修改后的编码方法)
- [2. 修改响应头设置逻辑](#2. 修改响应头设置逻辑)
- 五、修改前后的效果对比
- 六、总结
一、问题背景
最近在开发催缴打印 PDF 导出功能时,遇到了一个比较奇怪的问题:接口正常返回文件流,PDF 也能够正常下载,但是下载后的文件名却与传入的文件名不一致。
例如,接口传入的文件名为:
text
1#1212催缴合同测试-2026年11月02日-收费通知单
实际下载保存后的文件名却变成了:
text
1%231212催缴合同测试-2026年11月02日-收费通知单.pdf
仔细观察发现,中文、日期、横线等内容都正常,只有特殊字符 # 被转换成了 %23。
一开始怀疑是浏览器下载时对特殊字符进行了处理,但进一步排查后发现,问题实际上出在后端 URL 编码与前端 URL 解码规则不一致。
二、排查文件名的完整传递流程
要解决这个问题,首先需要明确文件名是如何从接口参数传递到最终下载文件的。
1. 后端设置响应头
在 PDF 导出接口中,后端通过 Content-Disposition 响应头设置下载文件名:
java
response.setHeader("Content-Disposition",
"attachment; filename=" +
URLEncoder.encode(fileName, StandardCharsets.UTF_8) +
".pdf");
这里使用 URLEncoder.encode() 对文件名进行 UTF-8 编码,主要是为了避免中文字符直接放入 HTTP 响应头时出现乱码或兼容性问题。
2. 前端获取响应头并触发下载
前端并没有直接使用请求时传入的文件名,而是从响应头中读取文件名:
javascript
let disposition = decodeURI(
res.headers["content-disposition"]
);
let fileName = disposition.split("=")[1];
elink.download = fileName;
整个流程可以概括为:
text
前端传入 fileName
|
v
后端接收文件名
|
v
URLEncoder.encode() 编码
|
v
设置 Content-Disposition 响应头
|
v
前端获取响应头
|
v
decodeURI() 解码
|
v
elink.download 设置文件名
|
v
浏览器保存 PDF
从这里可以看出,最终文件名取决于后端的编码方式和前端的解码方式是否匹配。
三、问题根源:URLEncoder 与 decodeURI 的处理规则不同
1. 后端 URLEncoder 的编码规则
Java 中的 URLEncoder.encode() 使用的是表单编码规则,即 application/x-www-form-urlencoded。
对于不同字符,其编码结果如下:
| 原始字符 | 编码结果 |
|---|---|
# |
%23 |
| 空格 | + |
催 |
%E5%82%AC |
& |
%26 |
+ |
%2B |
= |
%3D |
例如:
java
String fileName = "1#1212催缴合同测试";
String encoded = URLEncoder.encode(
fileName,
StandardCharsets.UTF_8
);
System.out.println(encoded);
输出结果类似:
text
1%231212%E5%82%AC%E7%BC%B4%E5%90%88%E5%90%8C...
可以看到,中文和特殊字符都被转换成了百分号编码。
这一步本身没有问题,文件名中的字符并没有丢失。
2. 前端 decodeURI 的解码规则
问题出在前端使用的 decodeURI()。
JavaScript 提供了两个容易混淆的解码方法:
decodeURI():用于解码完整 URI,会保留 URI 中具有特殊含义的保留字符。decodeURIComponent():用于解码 URI 的组成部分,会进一步解码这些保留字符。
decodeURI() 不会解码以下保留字符对应的百分号编码:
text
; / ? : @ & = + $ , #
因此:
javascript
console.log(decodeURI("%23"));
输出:
text
%23
而:
javascript
console.log(decodeURIComponent("%23"));
输出:
text
#
这就解释了为什么中文可以正常显示,而 # 却依然保留为 %23。
3. 空格也存在类似的问题
除了 #,空格也会出现编码不一致的问题。
URLEncoder.encode() 会将空格转换成 +:
text
原始内容:催缴 合同
编码结果:%E5%82%AC%E7%BC%B4+%E5%90%88%E5%90%8C
但是 decodeURI() 不会将 + 还原为空格。
因此,如果文件名中存在空格,最终下载名称也可能与原始名称不一致。
四、解决方案:调整后端编码方式
既然问题已经明确,那么解决思路也就比较清晰了。
目前项目中前端下载逻辑已经在多个地方使用,直接修改前端解码方式可能会影响其他下载功能。
因此,本次选择保持前端 decodeURI() 不变,只调整后端文件名的编码结果,使其能够兼容现有的解码逻辑。
具体处理步骤如下:
- 使用
URLEncoder.encode()对文件名进行 UTF-8 编码。 - 将空格对应的
+替换成%20。 - 将
decodeURI()不会主动解码的保留字符还原为原始字符。 - 对
=特殊处理,继续保留%3D,避免前端通过split("=")截取文件名时发生截断。
1. 修改后的编码方法
java
public static String encodeFileName(String fileName) {
// 第一步:UTF-8 编码
String encoded = URLEncoder.encode(
fileName,
StandardCharsets.UTF_8
);
// 第二步:将空格转换为 %20
encoded = encoded.replace("+", "%20");
// 第三步:还原 decodeURI 不会解码的保留字符
String[] reservedChars = {
"%3B", "%2F", "%3F", "%3A",
"%40", "%26", "%2B", "%24",
"%2C", "%23"
};
String[] originalChars = {
";", "/", "?", ":", "@", "&",
"+", "$", ",", "#"
};
for (int i = 0; i < reservedChars.length; i++) {
encoded = encoded.replace(
reservedChars[i],
originalChars[i]
);
}
return encoded;
}
这里需要注意:= 没有加入还原列表,因此仍然保持 %3D。
另外,这种实现依赖 URLEncoder 输出的大写十六进制形式,适用于当前这套编码逻辑。
2. 修改响应头设置逻辑
将原来的代码:
java
response.setHeader("Content-Disposition",
"attachment; filename=" +
URLEncoder.encode(fileName, StandardCharsets.UTF_8) +
".pdf");
修改为:
java
String encodedFileName = encodeFileName(fileName);
response.setHeader(
"Content-Disposition",
"attachment; filename=" + encodedFileName + ".pdf"
);
这样就不需要修改前端原有下载逻辑。
五、修改前后的效果对比
以本次催缴打印 PDF 导出为例。
原始文件名:
text
1#1212催缴合同测试-2026年11月02日-收费通知单
修改前
后端编码:
text
1%231212%E5%82%AC%E7%BC%B4%E5%90%88%E5%90%8C...
前端解码:
javascript
decodeURI(encodedFileName);
最终下载文件名:
text
1%231212催缴合同测试-2026年11月02日-收费通知单.pdf
修改后
后端编码:
text
1#1212%E5%82%AC%E7%BC%B4%E5%90%88%E5%90%8C...
前端解码:
javascript
decodeURI(encodedFileName);
最终下载文件名:
text
1#1212催缴合同测试-2026年11月02日-收费通知单.pdf
文件名中的特殊字符和中文均能够按照预期保留。
六、总结
这次问题并不是 PDF 导出异常,也不是浏览器对文件名进行了错误处理,而是后端编码和前端解码使用了不同的处理规则。
核心原因可以总结为:
- 后端使用
URLEncoder.encode(),将文件名转换成 UTF-8 百分号编码。 - 前端使用
decodeURI(),不会还原部分保留字符。 %23对应#,但decodeURI()会保留%23,因此最终下载文件名出现编码残留。- 空格对应的
+也不会被decodeURI()自动还原。
开发中需要特别注意:URL 编码和解码必须使用匹配的规则,不能简单地认为所有百分号编码都能通过 decodeURI() 还原。
另外,从 HTTP 文件下载规范来看,更推荐使用 Content-Disposition 的 filename* 参数,通过 RFC 5987 编码方式传递 UTF-8 文件名,例如:
http
Content-Disposition: attachment; filename*=UTF-8''1%231212%E5%82%AC...
前端则应配套解析 filename*,或者使用 decodeURIComponent() 对提取出的文件名进行完整解码。
这样可以从协议层面更规范地解决中文、空格和特殊字符的文件名兼容问题。
一次看似简单的文件名乱码问题,实际上涉及 Java URL 编码、HTTP 响应头规范以及 JavaScript URI 解码规则。排查类似问题时,建议从数据传递的完整链路入手,而不是只关注最终的下载结果。