文件下载中文文件名乱码终极方案

文件下载接口中文文件名乱码终极解决方案: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"

问题出在三个地方:

  1. HTTP 头是 ASCII 的世界。 RFC 7230 规定消息头只能含可见 ASCII(33--126)。中文直接塞进去,按规范就是非法字节,传输链路里任何一环(代理、网关、框架)都可能给你重新解释一次,于是乱码。

  2. filename 参数从未规定编码。 最初的 RFC 1806 / 2183 只说这是个「建议的文件名」,没说用什么字符集。浏览器只能按自己的默认编码去猜:Chrome/Firefox 早期当 UTF-8,IE 当系统代码页(中文 Windows 上是 GBK),老 Safari 当 Latin-1。猜不一致,乱码就来了。

  3. 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-8
  • language:语言标签,可以为空 ,所以常见写法是 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 还规定:当 filenamefilename* 同时出现时,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
  • DjangoFileResponse(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=noneset 配合预编码的 UTF-8 值),别把原始中文丢进 add_header

避坑速查清单

  1. 下载用 attachment,别用 inline 排查时混淆。
  2. 真名走 filename*=UTF-8''<编码>,编码函数用 quote(..., safe="")
  3. filename* 写在 filename 之后filename 必须是纯 ASCII 兜底名。
  4. IE / 旧 Edge 不认 filename*,按 UA 分流给百分号编码的 filename
  5. 框架(Flask/Werkzeug、Django)已内置 RFC 8187,优先用 download_name/filename 参数。
  6. nginx 不自动编码,别把原始中文写进 add_header,交给上游应用设头最稳。

小结

中文文件名乱码不是「编码没配对」这么简单,而是 HTTP 头 ASCII 限制 + filename 无编码声明 + 浏览器各凭本事的兼容史叠加出来的老坑。解法链条是:

根因(Content-Disposition 编码盲区)→ 标准(RFC 5987/8187 的 filename* + 正确百分号编码 + 顺序与优先级)→ 兼容(现代浏览器吃 filename*、IE 走 UA 分流、兜底 filename 保 ASCII)。

把上面那两个 Python 函数收进工具库,以后下载接口的中文名,基本可以一次写对、不再返工。

相关推荐
枫叶V1 小时前
请求可以重试,业务只能生效一次:我们的幂等架构实践
后端
Ai拆代码的曹操1 小时前
K8s 调度器 predicate 阶段揭秘:为什么你的 Pod 总是堆在同一台机器
后端·容器
Ai拆代码的曹操1 小时前
手摸手排查:Pod CrashLoopBackOff 日志丢失问题,3 层兜底方案
后端·容器
程序员包打听1 小时前
从 npx 到 moonx,moonbit 的野心与展望
前端·后端
Conan在掘金2 小时前
ArkTS 进阶之道(8):@Prop/@Link 父子传值——单向 vs 双向数据流根因
后端
JoyT2 小时前
面向Agent系统的Java后端知识总览(上)
后端
AI编程实验室2 小时前
用 npm + Three.js 做一颗西瓜:把夏天的清凉感放进浏览器
前端·后端·ai编程
SimonKing2 小时前
别再盲目跑测试了,用 JaCoCo 告诉你哪些代码根本没被覆盖
java·后端·程序员