文件下载接口中文文件名乱码终极解决方案:Content-Disposition 编码、RFC 5987 与多浏览器兼容处理(含 Python 实现)
太长不看版:乱码的根因是 HTTP 头本来就是 ASCII 世界,
filename参数从来没规定过编码,于是浏览器各猜各的。现代浏览器用filename*=UTF-8''百分号编码走 RFC 8187 才是正解,但只写这一句不够------老浏览器不认、顺序错会失效、编码函数用错照样乱码。文末给了一份可直接抄走的 Python 工具函数,覆盖 FastAPI / Flask / Django 与 nginx 反代场景。
前言:这个坑我翻过三次车
做下载接口的同学基本都踩过:后端明明传了「报表_2026.xlsx」,用户点下载,Chrome 弹出来叫 %E6%8A%A5...xlsx,IE 直接变「涓枃鏂.xlsx」(典型的 UTF-8 被当成 GBK 解),Safari 更绝,干脆给你一个 download。
我前两次都是「搜一下,加个 filename*=UTF-8'' 完事」,第三次在政务客户的 IE 兼容模式上又翻了------因为 IE 根本不认 filename*。所以这篇文章不想复述别人博客里那半句结论,而是把根因、标准、兼容三件事讲透,再给你一套能直接上生产的代码。
第一大重点:乱码从哪来 ------ Content-Disposition 的编码盲区
Content-Disposition 长这样:
css
Content-Disposition: attachment; filename="report.xlsx"
问题出在三个地方:
-
HTTP 头是 ASCII 的世界。 RFC 7230 规定消息头只能含可见 ASCII(33--126)。中文直接塞进去,按规范就是非法字节,传输链路里任何一环(代理、网关、框架)都可能给你重新解释一次,于是乱码。
-
filename参数从未规定编码。 最初的 RFC 1806 / 2183 只说这是个「建议的文件名」,没说用什么字符集。浏览器只能按自己的默认编码去猜:Chrome/Firefox 早期当 UTF-8,IE 当系统代码页(中文 Windows 上是 GBK),老 Safari 当 Latin-1。猜不一致,乱码就来了。 -
inline还是attachment也影响表现。 同样一个乱码文件名,inline(预览)时浏览器可能再从 URL 末段补一个名字,attachment(下载)时才是你设置的为准。所以排查时先确认你用的是attachment。
一句话总结盲区:老的 filename 是个「无编码声明的字符串」,在跨浏览器下载场景里天生不可靠。 这就是为什么需要标准来补一刀。
第二大重点:RFC 5987 / 8187 才是正解,但写法有 3 个坑
RFC 5987(后来被 RFC 8187 修订)给 header 里的非 ASCII 值定了格式:
rust
filename*=charset'language'percent-encoded-value
charset:字符集,下载场景固定UTF-8language:语言标签,可以为空 ,所以常见写法是UTF-8''percent-encoded-value:把文件名按 UTF-8 编码后再做百分号编码
完整示例:
perl
Content-Disposition: attachment; filename*=UTF-8''%E6%8A%A5%E8%A1%A8_2026.xlsx
浏览器拿到 filename*,知道「哦,这是 UTF-8 百分号编码」,就能正确还原成「报表_2026.xlsx」。RFC 8187 还规定:当 filename 和 filename* 同时出现时,filename* 优先。 这是后面兼容方案的地基。
但别人帖子很少讲透的三个坑,正是线上翻车的重灾区:
坑 1:百分号编码函数用错
很多代码用 urllib.parse.quote(filename),但 quote 默认 safe='/'------它会把 / 原样保留。而 RFC 8187 的 value 里不应该出现字面量 /,否则某些解析器会把文件名当路径拆开。正确做法是 safe="",让除 字母/数字/-._~ 之外的一切都编码:
python
import urllib.parse
# 错误:留了 '/',还可能在含斜杠的文件名上出问题
bad = urllib.parse.quote("a/b报表.xlsx") # a/b%E6%8A%A5...
# 正确:全部按 RFC 8187 unreserved 之外编码
good = urllib.parse.quote("a/b报表.xlsx", safe="") # a%2Fb%E6%8A%A5...
坑 2:filename* 必须放在 filename 之后
虽然 RFC 说 filename* 优先,但部分老旧解析器是按出现顺序取最后一个能识别的参数 。把 filename* 写在后面,能同时讨好「按优先级」和「按出现顺序」两类实现。顺序反了,个别浏览器会取错。
坑 3:别把中文直接塞进 filename 当「兼容兜底」
有人图省事写成 filename="报表.xlsx",想着「老浏览器至少能显示」。错了------这本身就违反 RFC 7230(头里出现非 ASCII 字节),nginx、Werkzeug、部分网关会直接拒绝或转义,反而更糟。兜底 filename 必须是纯 ASCII,比如一个英文默认名。
第三大重点:光有 RFC 不够,浏览器兼容要分流
filename* 的支持情况是分水岭,直接决定你要不要做 UA 嗅探:
| 浏览器 | 认 filename*(RFC 8187) |
实际表现 |
|---|---|---|
| Chrome / Edge(Chromium) / Firefox / Opera | 认 | 取 filename*,显示中文 ✓ |
| Safari 10.1+ | 认 | 取 filename*,显示中文 ✓ |
| Safari < 10.1 | 不认 | 只看 filename,但能接受其中的 UTF-8 原始字节 |
| IE(Trident) / 旧 Edge(EdgeHTML) | 不认 | 只看 filename,需把 UTF-8 字节百分号编码塞进去 |
结论很清楚:现代浏览器靠 filename* 就能解决 99% 的 case;只有 IE 和极老的 Safari 需要特殊照顾。 而 IE 在政企、银行内网里还活着,所以不能装看不见。
兼容策略
- 现代浏览器 :
attachment; filename="fallback.pdf"; filename*=UTF-8''<编码>filename*给中文真名,filename给一个 ASCII 兜底(老浏览器至少不崩)。
- IE / Trident :不认
filename*,但会把filename里的百分号编码按 UTF-8 解。所以给它attachment; filename="<UTF-8字节的百分号编码>"。 - 老 Safari :不认
filename*,但接受filename里的 UTF-8 原始字节(畸形但能用)。线上若还要兼容,可针对 UA 单独返回带原始 UTF-8 字节的filename(注意这违反 RFC 7230,仅作兜底)。
可复用的 Python 终极方案
下面这套函数直接能抄。核心是「双参数头 + UA 分流」。
1. 基础版:双参数头(覆盖现代浏览器 + ASCII 兜底)
python
import urllib.parse
from typing import Tuple
def encode_rfc8187(filename: str) -> str:
"""按 RFC 8187 对文件名做百分号编码。
只保留 unreserved 字符(字母、数字、- . _ ~),其余一律编码,
避免把 '/' ':' 等当成路径分隔符。
"""
return urllib.parse.quote(filename, safe="")
def build_disposition(filename: str, ascii_fallback: str = "download.pdf") -> str:
"""生成现代浏览器兼容的 Content-Disposition 头。
形如:attachment; filename="download.pdf"; filename*=UTF-8''%E6%8A%A5...
"""
encoded = encode_rfc8187(filename)
# filename* 必须写在 filename 之后;filename* 优先于 filename
return f'attachment; filename="{ascii_fallback}"; filename*=UTF-8\'\'{encoded}'
2. 进阶版:UA 嗅探,IE 特供
python
def build_disposition_smart(filename: str, user_agent: str = "",
ascii_fallback: str = "download.pdf") -> str:
"""同时兼容现代浏览器与老旧 IE/Edge(Trident)。"""
encoded = encode_rfc8187(filename)
ua = (user_agent or "").lower()
is_ie = "msie" in ua or ("trident" in ua and "edge" not in ua)
if is_ie:
# IE 不认 filename*,但会把 filename 里的百分号编码按 UTF-8 解码
ie_value = urllib.parse.quote(filename.encode("utf-8"))
return f'attachment; filename="{ie_value}"'
# 现代浏览器:filename* 给真名,filename 给 ASCII 兜底
return f'attachment; filename="{ascii_fallback}"; filename*=UTF-8\'\'{encoded}'
3. FastAPI 落地(大文件用流式,别把内存撑爆)
python
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.get("/download")
def download(req: Request):
filename = "报表_2026.xlsx"
encoded = urllib.parse.quote(filename, safe="")
headers = {
# filename* 写在后面;filename 给 ASCII 兜底
"Content-Disposition": f'attachment; filename="report.xlsx"; filename*=UTF-8\'\'{encoded}',
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
def iter_file(path: str):
with open(path, "rb") as f:
# 8KB 分块,避免大文件占满内存
while chunk := f.read(8192):
yield chunk
return StreamingResponse(iter_file("report.xlsx"), headers=headers)
4. Flask / Django 落地点
- Flask :新版本 Werkzeug 的
send_file(..., download_name="报表.xlsx", as_attachment=True)会自动 帮你按 RFC 8187 处理中文名,不用手搓。但如果你是自己拼Response,就调用上面的build_disposition。 - Django :
FileResponse(open(path,'rb'), filename="报表.xlsx", as_attachment=True)同样内置了 RFC 8187 编码;要手动控制就response["Content-Disposition"] = build_disposition("报表.xlsx")。
框架已经帮你做对的事,别重复造轮子;只有当你在反代层(nginx)或裸 Response 里拼头时,才需要上面的函数。
5. nginx 反代坑(很多人栽在这)
nginx 的 add_header 不会替你做百分号编码。如果你在 nginx 里写:
bash
add_header Content-Disposition "attachment; filename*=UTF-8''$filename";
而 $filename 是原始中文,出来的头就是非法字节,照样乱码。两个选择:
- 首选 :让上游 Python 应用把编码好的头设好,nginx 用
proxy_pass_header/ 不覆盖即可,别在 nginx 重设。 - 硬要在 nginx 做 :必须先把变量百分号编码好(如用
map+escape=none或set配合预编码的 UTF-8 值),别把原始中文丢进add_header。
避坑速查清单
- 下载用
attachment,别用inline排查时混淆。 - 真名走
filename*=UTF-8''<编码>,编码函数用quote(..., safe="")。 filename*写在filename之后 ;filename必须是纯 ASCII 兜底名。- IE / 旧 Edge 不认
filename*,按 UA 分流给百分号编码的filename。 - 框架(Flask/Werkzeug、Django)已内置 RFC 8187,优先用
download_name/filename参数。 - nginx 不自动编码,别把原始中文写进
add_header,交给上游应用设头最稳。
小结
中文文件名乱码不是「编码没配对」这么简单,而是 HTTP 头 ASCII 限制 + filename 无编码声明 + 浏览器各凭本事的兼容史叠加出来的老坑。解法链条是:
根因(Content-Disposition 编码盲区)→ 标准(RFC 5987/8187 的
filename*+ 正确百分号编码 + 顺序与优先级)→ 兼容(现代浏览器吃filename*、IE 走 UA 分流、兜底filename保 ASCII)。
把上面那两个 Python 函数收进工具库,以后下载接口的中文名,基本可以一次写对、不再返工。