Java 文件下载时文件名中的 `#` 被转换成 `%23`?一次 URL 编码与解码问题排查

目录

一、问题背景

最近在开发催缴打印 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() 不变,只调整后端文件名的编码结果,使其能够兼容现有的解码逻辑。

具体处理步骤如下:

  1. 使用 URLEncoder.encode() 对文件名进行 UTF-8 编码。
  2. 将空格对应的 + 替换成 %20。
  3. 将 decodeURI() 不会主动解码的保留字符还原为原始字符。
  4. 对 = 特殊处理,继续保留 %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 解码规则。排查类似问题时,建议从数据传递的完整链路入手,而不是只关注最终的下载结果。

相关推荐
FRing032 小时前
vue2干货总结2(v-bind、v-for、v-model、指令修饰符、v-bind操作class和style)
开发语言·前端·vue.js·vue2
by209992 小时前
C++ 智能指针的所有权与使用
开发语言·c++·经验分享
砚底藏山河2 小时前
python量化自定义周期 K 线合成
java·开发语言·数据库·python·金融
IT召唤狮2 小时前
开源企业级零信任项目Pangolin,零信任能挡住PHP核弹漏洞吗?
开发语言·开源·php
知已难寻2 小时前
03 · OpenStack的计算管理 Nova
运维·服务器·开发语言·云原生·云计算·openstack·开源软件
Rover Ramble3 小时前
C++ 用互斥锁实现读写锁
开发语言·c++
猿究院_夏燊3 小时前
MySQL 的四种隔离级别
java·数据库·mysql
码流子3 小时前
移动收费怎么做?手持终端+App+收费中心的架构实践
java·开发语言·架构
雪落漂泊4 小时前
C++算法 滑动窗口篇(下)
开发语言·c++