首页 > 架构设计 > 数据架构 > 文件下载中文文件名乱码终极方案
太长不看版:乱码的根因是 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: 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(后来被 RFC 8187 修订)给 header 里的非 ASCII 值定了格式:
filename*=charset'language'percent-encoded-value
charset:字符集,下载场景固定
UTF-8
language:语言标签,
可以为空,所以常见写法是
UTF-8''
percent-encoded-value:把文件名按 UTF-8 编码后再做百分号编码
完整示例:
Content-Disposition: attachment; filename*=UTF-8''%E6%8A%A5%E8%A1%A8_2026.xlsx
浏览器拿到
filename*,知道「哦,这是 UTF-8 百分号编码」,就能正确还原成「报表_2026.xlsx」。
RFC 8187 还规定:当
filename
和
filename*
同时出现时,
filename*
优先。 这是后面兼容方案的地基。
但别人帖子很少讲透的三个坑,正是线上翻车的重灾区:
很多代码用
urllib.parse.quote(filename),但
quote 默认
safe='/'——它会把
/ 原样保留。而 RFC 8187 的 value 里不应该出现字面量
/,否则某些解析器会把文件名当路径拆开。正确做法是
safe="",让除
字母/数字/-._~ 之外的一切都编码:
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...
filename*
必须放在
filename
之后虽然 RFC 说
filename* 优先,但
部分老旧解析器是按出现顺序取最后一个能识别的参数。把
filename* 写在后面,能同时讨好「按优先级」和「按出现顺序」两类实现。顺序反了,个别浏览器会取错。
filename
当「兼容兜底」有人图省事写成
filename="报表.xlsx",想着「老浏览器至少能显示」。错了——这本身就违反 RFC 7230(头里出现非 ASCII 字节),nginx、Werkzeug、部分网关会直接拒绝或转义,反而更糟。
兜底
filename
必须是纯 ASCII,比如一个英文默认名。
filename* 的支持情况是分水岭,直接决定你要不要做 UA 嗅探:
|
浏览器 |
认
|
实际表现 |
|---|---|---|
|
Chrome / Edge(Chromium) / Firefox / Opera |
认 |
取
|
|
Safari 10.1+ |
认 |
取
|
|
Safari < 10.1 |
不认 |
只看
|
|
IE(Trident) / 旧 Edge(EdgeHTML) |
不认 |
只看
|
结论很清楚:
现代浏览器靠
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,仅作兜底)。
下面这套函数直接能抄。核心是「双参数头 + UA 分流」。
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}'
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}'
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)
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 里拼头时,才需要上面的函数。
nginx 的
add_header
不会替你做百分号编码。如果你在 nginx 里写:
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 函数收进工具库,以后下载接口的中文名,基本可以一次写对、不再返工。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70050389/viewspace-3176984/ ,如需转载,请注明出处,否则将追究法律责任。