前几天有个同事找我,说想做个内部页面,让用户点一下按钮就能把资料包下载下来。我第一反应是:这还不简单?一个<a>标签加一个download属性就完事了。结果真正做下去才发现,需求远不止"能点能下"这么简单——文件名是中文、下载目录不能被外网扫穿、还要支持"点击后实时导出一份报表"这类动态下载。这些小需求叠加在一起,把一个看似简单的功能变成了一次非常典型的 Flask 实战。如果你也是 Python 入门之后想做点能用的工具,或者需要在内网系统里加一个文件下载入口,这篇文章应该能帮你少走不少弯路。
1. 先掂量一下:这个"点击下载"到底要不要后端参与
1.1 纯 HTML 能下载,但解决不了真实需求
第一次遇到"网页点击下载"需求时,大多数人想到的就是纯前端方案:
html复制<a href="files/资料.zip" download>点击下载</a>
这个写法在浏览器里确实能触发下载,前提是 files/资料.zip 这个文件已经放在 Web 服务器能访问到的静态目录下。但实战里你会发现,纯静态方案有几个绕不过去的坎:
- 文件必须预先生成好。用户点"导出报表"时,报表内容是实时算出来的,不可能提前放到服务器目录里等你下载。
- 目录结构会直接暴露给前端。静态目录一旦写成
/files/...,只要知道路径,谁都可以猜文件列表、遍历文件名。 - 没法做权限控制。任何拿到 URL 的人都能下载,完全不设防。
- 没法统计、没法打日志。你不知道谁在什么时候下载了什么文件,出了问题很难追溯。
所以,只要需求稍微复杂一点,后端就必须参与进来。Python 在这里的优势是:写接口简单,生态成熟,哪怕你只学了基础语法,也能快速看懂。
1.2 对比下来,Flask 最合适
做这类小工具,我一般会在 Flask、Django、FastAPI 之间挑。很多初学者一上来就想用 Django,其实有点重了。我整理了一个简单的对比:
| 方案 | 路由写法 | 适合场景 | 上手成本 |
|---|---|---|---|
| Flask | 装饰器声明路由,直观 | 小工具、内部系统、单机演示 | 低 |
| FastAPI | 类型注解 + 异步,现代感强 | 需要接口文档的团队项目 | 中 |
| Django | 全功能框架,自带 Admin | 大型 Web 系统 | 高 |
| 纯静态服务器 | 无代码,配置即用 | 只下载固定文件、无权限需求 | 最低 |
我的建议是:如果你的核心诉求是"用最少代码把功能跑通",Flask 几乎是最快的路线。它路由明确,send_file / send_from_directory 这两个内置函数就是为下载场景设计的,不用自己拼 HTTP 响应头。
如果你还没装 Python 环境,先按常规流程装好 Python,配置好 pip,然后建议用虚拟环境隔离依赖:
bash复制python -m venv venv
# Windows
venv\Scripts\activate
# macOS / Linux
source venv/bin/activate
pip install flask
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最简实现:Flask 路由 + send_file 把第一个文件送到浏览器
2.1 项目目录结构
我先搭一个最小可运行的项目:
text复制download-demo/
├── app.py
├── downloads/
│ ├── 测试文件.zip
│ └── 项目资料.pdf
└── templates/
└── index.html
downloads/ 目录放实际要下载的文件,templates/index.html 是下载页面。第一次测试,建议放一个文件名带中文的文件,比如"测试文件.zip",后面你会知道为什么我特别强调这一点。
2.2 后端代码逐行拆解
新建 app.py,写入以下代码:
python复制import os
from flask import Flask, send_from_directory, abort
app = Flask(__name__)
# 下载目录:始终用绝对路径,避免运行时目录不同导致找不到文件
DOWNLOAD_FOLDER = os.path.join(os.getcwd(), "downloads")
@app.route("/download/<path:filename>")
def download_file(filename):
return send_from_directory(DOWNLOAD_FOLDER, filename)
if __name__ == "__main__":
os.makedirs(DOWNLOAD_FOLDER, exist_ok=True)
app.run(host="0.0.0.0", port=5000, debug=True)
这里有几个容易忽略的点:
- 路由里的
<path:filename>,path转换器允许匹配/,也就是说文件可以放在子目录里,比如/download/reports/2024/01.csv。如果只用<filename>,就只能匹配一层文件。 send_from_directory(DOWNLOAD_FOLDER, filename)的作用是"安全地"把DOWNLOAD_FOLDER下的文件返回给浏览器。它内部会拼接路径、设置Content-Disposition响应头,浏览器收到这个响应头后就会触发下载。debug=True只适合开发调试,上线前记得关掉。
2.3 前端页面
templates/index.html 可以写得非常简单:
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>下载中心</title>
</head>
<body>
<h1>下载中心</h1>
<ul>
<li><a href="/download/测试文件.zip" download>点击下载:测试文件.zip</a></li>
<li><a href="/download/项目资料.pdf" download>点击下载:项目资料.pdf</a></li>
</ul>
</body>
</html>
很多人会问:都用了 Flask,前端还需要写 download 属性吗?我的结论是:写了更好,不写也能下载。download 属性是浏览器层面"强制下载"的提示,而后端返回的 Content-Disposition: attachment 才是最终决定是否下载的关键。当后端已经明确返回附件类型时,浏览器都会乖乖弹出下载。所以,这个属性可以理解为"给纯静态页面用的保险",在 Flask 场景下属于锦上添花。
2.4 跑起来看效果
终端执行:
bash复制python app.py
浏览器打开 http://127.0.0.1:5000/,点击链接,文件应该就下载了。如果 5000 端口被占用,可以在 app.run 里改端口,比如 port=5001。
到这里,一个最基础的"网页点击下载"已经成立。但别高兴太早,我接下来要说的中文文件名问题,十个人里有八个会踩。
3. 中文文件名乱码:一个响应头引发的血案
3.1 你可能会看到的奇怪现象
用上面的代码下载"测试文件.zip"时,新版浏览器可能一切正常。但如果你把文件改名成"测试报告_V1.0(最终版).pdf",或者用某些老版本浏览器、某些下载工具去下载,就有概率出现这些情况:
- 下载下来的文件名变成一串
%E6%B5%8B%E8%AF%95...; - 文件名变成乱码,比如
æµè¯å 楤.zip; - 服务器日志里看着一切正常,浏览器却一直弹"无法下载"。
3.2 为什么会这样:Content-Disposition 的历史包袱
HTTP 响应头的历史包袱很重。早期协议规定 Content-Disposition 的 filename 参数只支持 ASCII 字符,所以:
text复制Content-Disposition: attachment; filename="report.pdf"
是完全没问题的。但文件名一旦变成中文,老标准就无能为力了。后来 RFC 5987 规定了新格式:
text复制Content-Disposition: attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip
filename* 里的值必须用 URL 编码后的 UTF-8 字符。问题是,不同浏览器对新旧格式的兼容程度不一样,很容易出现"一个管用、另一个乱码"的情况。
Flask 底层已经尽量处理了这个问题,但当你手动设置了 Content-Disposition,或者文件名里带着 ( ) [ ] 这类特殊字符时,还是可能翻车。
3.3 修复方案:自己写一个稳定的响应头
我的做法是,在返回响应后主动覆盖 Content-Disposition,让它同时兼容新旧格式:
python复制import os
from urllib.parse import quote
from flask import Flask, send_from_directory, abort, make_response
app = Flask(__name__)
DOWNLOAD_FOLDER = os.path.join(os.getcwd(), "downloads")
@app.route("/download/<path:filename>")
def download_file(filename):
# 先交给 Flask 生成响应,再修正响应头
response = make_response(send_from_directory(DOWNLOAD_FOLDER, filename))
# 对中文文件名做 URL 编码
quoted = quote(filename)
# 同时提供 filename 和 filename*,最大化浏览器兼容性
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{quoted}"
return response
这里只保留了 filename*,没有保留普通 filename。实际测试下来,现代浏览器都支持 filename*,所以这样写最不容易乱码。如果你还想兼容特别老的浏览器,可以再补一个 fallback:
python复制response.headers["Content-Disposition"] = f"attachment; filename=\"download\"; filename*=UTF-8''{quoted}"
filename="download" 是给不认 filename* 的老浏览器一个兜底名字,至少不会乱码。
3.4 另一个容易忽略的坑:URL 里的中文目录
即使文件名处理好了,路由参数里如果有中文目录,也容易踩坑。比如访问 /download/2024年度/测试.zip,浏览器地址栏会先把中文转成百分号编码,Flask 能正确解码,但如果你在 Nginx 这类反向代理层没有配置好编码规则,就可能出现 404。
我的建议是:能不用中文路径就不用中文路径。目录和文件尽量用英文命名,如果需要把中文名展示给用户,用响应头里的 filename* 解决,不要赌路径编码的兼容性。
4. 守住下载目录的边界:路径穿越与安全校验
4.1 一个能读任意文件的漏洞
很多初学 Flask 的人写完 send_from_directory 就收工了,完全没想到一个问题:
text复制/download/..%2F..%2Fetc%2Fpasswd
浏览器在请求前会把 %2F 解码成 /,Flask 会尝试将路径拼接到 DOWNLOAD_FOLDER 后。如果没有防护,最终可能读取到 downloads/../../etc/passwd,也就是服务器系统文件。一旦这种路径穿越漏洞被利用,整个服务器敏感文件都可能被下载走,后果非常严重。
在 Linux 下是 /etc/passwd,在 Windows 下可能是 C:/Windows/win.ini 或者各种带盘符的路径。路径穿越是 Web 安全里最常见也最低级的漏洞之一,但它真的反复出现。
4.2 新版 Flask 会自己防吗?
Flask 底层的 send_from_directory 依赖 Werkzeug 的 safe_join 函数,在新版本里,.. 这类越界路径会直接抛 404,所以单纯用这个函数时,默认安全系数已经比较高。
但这不意味着你可以完全不管。因为实际项目中,你可能会在路由层先对文件名做拼接、改名、落盘,然后再传给下载函数;也可能使用了自定义的 send_file;还可能在前面接了 CDN 或网关。任何一处漏掉规范化校验,都可能成为突破口。防御式编程的原则是:不依赖框架的默认行为,自己把边界守住。
4.3 推荐的安全校验模板
我习惯在下载路由里做三层校验:
python复制import os
from flask import Flask, send_file, abort, request
from werkzeug.utils import safe_join
app = Flask(__name__)
DOWNLOAD_FOLDER = os.path.abspath("downloads")
@app.route("/download/<path:filename>")
def download_file(filename):
# 第一层:拒绝路径穿越特征
if ".." in filename or filename.startswith("/"):
abort(403)
# 第二层:用 safe_join 拼接规范化路径,越界返回 None
safe_path = safe_join(DOWNLOAD_FOLDER, filename)
if safe_path is None:
abort(403)
# 第三层:确认文件存在且确实是一个文件(不是目录)
if not os.path.isfile(safe_path):
abort(404)
return send_file(safe_path, as_attachment=True)
safe_join 会做两件事:一是防止路径穿越,二是把路径规范化。只要拼接结果不落在 DOWNLOAD_FOLDER 之内,它就会返回 None。加上 os.path.isfile 之后,目录访问也会被拦住。
如果你不想引入 safe_join,也可以自己判断:
python复制real_path = os.path.realpath(os.path.join(DOWNLOAD_FOLDER, filename))
if not real_path.startswith(DOWNLOAD_FOLDER + os.sep):
abort(403)
但 safe_join 是现成的,没必要重复造轮子。
4.4 可选的访问控制和文件类型白名单
内部工具经常还要加一层"不能随便谁都来下"的约束。最简单的方式是加一个 token 参数,下载链接必须带上正确 token 才能下载:
python复制TOKEN = "my-secret-token"
@app.route("/download/<path:filename>")
def download_file(filename):
# 校验 token
if request.args.get("token") != TOKEN:
abort(403)
# 继续上面的安全校验...
这种方案不适合公网生产环境,因为它本质上是"一次性的固定钥匙",但在内网工具、演示项目里非常实用。更严格的做法是把 token 和文件路径一起做签名,比如 ?token=md5(filename + secret),防止链接被篡改。这里不展开,你知道这个方向就行。
5. 动态生成再下载:点击那一刻才产生的文件
5.1 典型场景:导出 CSV / 报表
很多"点击下载"不是下载现成文件,而是"点击后生成再下载"。比如:
- 用户选择日期范围,点击"导出交易记录",后端查数据库生成 CSV;
- 用户点击"备份",后端把所有配置打包成 ZIP 再返回;
- 用户点击"下载报告",后端渲染一份 PDF。
这里的关键是:文件在点击的那一刻才存在于内存或临时目录中,不是为了下载而提前放到服务器上的。
正好,很多玩量化交易的朋友会导出交易记录做复盘,我下面用一个"导出 CSV"的例子来说明动态生成的核心套路。
5.2 用内存生成 CSV 并下载
python复制import csv
import io
from flask import Flask, Response
from urllib.parse import quote
app = Flask(__name__)
@app.route("/export")
def export_csv():
# 模拟查询结果
rows = [
["日期", "代码", "收盘价", "成交量"],
["2024-01-02", "600000", 10.52, 120000],
["2024-01-03", "600000", 10.68, 135000],
["2024-01-04", "600000", 10.71, 98000],
]
# 用 StringIO 在内存中生成 CSV 文本
output = io.StringIO()
writer = csv.writer(output, lineterminator="\r\n")
writer.writerows(rows)
# 关键一步:指针回到开头,否则读取不到内容
output.seek(0)
filename = "trade_records.csv"
quoted = quote(filename)
return Response(
output.getvalue(),
mimetype="text/csv; charset=utf-8",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{quoted}"
}
)
浏览器访问 /export 后,会直接下载一个 trade_records.csv 文件。这是一个非常标准的内存下载方案,不产生临时文件,不占用磁盘空间。
5.3 新手最容易踩的三个坑
这个方案我有一次连续踩了三个坑,都值得单独说:
第一个坑是忘记 output.seek(0)。StringIO 写入后,指针停在末尾,如果不把指针搬回开头,getvalue() 读到的内容是空的,下载下来的文件就是 0 字节。这个 bug 极其隐蔽,因为代码逻辑看起来完全没问题,就是少了一行。
第二个坑是 Excel 打开 CSV 中文乱码。CSV 本质是文本文件,用 UTF-8 编码没问题,但 Excel 默认用本地编码打开 CSV,于是中文变成乱码。解决方案是给 CSV 加 UTF-8 BOM 头。把 output.getvalue() 改成:
python复制from flask import Response
content = "\ufeff" + output.getvalue() # \ufeff 就是 UTF-8 BOM
return Response(
content,
mimetype="text/csv; charset=utf-8",
...
)
记住这个细节,凡是给 Windows 用户下载 CSV,都要考虑加 BOM。
第三个坑是 CSV 的换行符。csv.writer 默认会把换行符写成 \r\n,但在浏览器和 Linux 服务器环境中容易混入 \n,导致 Excel 里的表格变成一行。明确指定 lineterminator="\r\n" 可以避免这个坑。
5.4 大文件怎么办:临时文件 + 用后即删
如果动态生成的是几十 MB 的压缩包,继续在内存里生成就不合适了,会占大量内存,还可能触发 WSGI 服务器的超时限制。更稳妥的做法是:先写临时文件,发送完成后再删除。
python复制import os
import tempfile
from flask import send_file, after_this_request
@app.route("/export_big")
def export_big():
fd, tmp_path = tempfile.mkstemp(suffix=".zip")
# 往 tmp_path 写入压缩包内容
os.write(fd, b"...") # 这里替换为真实生成逻辑
os.close(fd)
@after_this_request
def cleanup(response):
try:
os.remove(tmp_path)
except OSError:
pass
return response
return send_file(tmp_path, as_attachment=True, download_name="archive.zip")
after_this_request 是 Flask 的钩子,响应发送结束后会执行清理函数,这样临时文件不会堆积在服务器上。
6. 用 Selenium 模拟真实点击,把下载链路跑一遍
6.1 为什么要自动化验证
下载功能改过几次之后,我明显感觉到手动测试太累了:打开浏览器、点击链接、切到下载目录、确认文件名字和大小,每改一次都要重复一遍。于是干脆写了个 Selenium 脚本,自动打开页面、点击下载、验证文件落地。
这个脚本还有一个好处:可以在不同浏览器上跑,确认兼容性问题有没有复发。比如中文文件名乱码,改完响应头后,用自动化脚本在 Chrome 和 Edge 上各跑一遍,比肉眼确认可靠得多。
6.2 准备浏览器驱动
先安装依赖:
bash复制pip install selenium webdriver-manager
webdriver-manager 会自动下载匹配浏览器版本的驱动,省去手动下载 chromedriver 的步骤。
6.3 自动化下载验证脚本
python复制import os
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
TARGET_DIR = os.path.abspath("test_downloads")
os.makedirs(TARGET_DIR, exist_ok=True)
# 清空测试下载目录,避免上次文件干扰判断
for f in os.listdir(TARGET_DIR):
os.remove(os.path.join(TARGET_DIR, f))
opts = Options()
# 关键:把 Chrome 的下载目录指到我们自己的目录
opts.add_experimental_option("prefs", {
"download.default_directory": TARGET_DIR,
"download.prompt_for_download": False,
"safebrowsing.enabled": True,
})
driver = webdriver.Chrome(options=opts)
try:
driver.get("http://127.0.0.1:5000/")
link = driver.find_element(By.CSS_SELECTOR, "a[download]")
link.click()
# 轮询等待文件出现,最多等 10 秒
deadline = time.time() + 10
while time.time() < deadline:
files = os.listdir(TARGET_DIR)
if files and not any(f.endswith(".crdownload") for f in files):
break
time.sleep(0.5)
files = os.listdir(TARGET_DIR)
assert len(files) == 1, f"期望下载 1 个文件,实际 {len(files)}"
assert files[0] == "测试文件.zip", f"文件名不对:{files[0]}"
print("下载验证通过:", files[0])
finally:
driver.quit()
这里有两个细节值得说明:
- Chrome 下载过程中会生成
.crdownload临时文件,下载完成后才重命名为正式文件名。判断"下载是否结束"时,只看到.crdownload不代表完成,必须等它消失。 assert len(files) == 1之前要先清空目录,否则旧文件会导致误判。
6.4 自动化测试常见的两个坑
第一个坑是 headless 模式。Chrome 的无头模式(headless)在某些版本下不会触发下载,或者下载目录设置不生效。如果必须用无头模式,可以把 opts.add_argument("--headless=new") 加上,但建议先在有头模式下跑通,再切无头。
第二个坑是点击后页面跳转而不是下载。这种情况多半是 a 标签没有 download 属性,且后端返回的响应头不是 attachment。脚本点击后,Chrome 直接在标签页里打开了 /download/xxx,文件内容变成了纯文本展示。排查时,先用浏览器开发者工具看网络请求的 Content-Disposition,确认后端真的返回了附件类型。
写在最后的几个实战心得
这个功能看起来很小,但真正做完之后,我最大的感受是:一个"点击下载"背后其实是 HTTP 协议、字符编码、路径安全、自动化测试的综合工程。很多初学者只盯着"能下载就行",忽略了文件名乱码和路径穿越,直到上线被用户或安全测试打脸才回头补课。
最后分享两个我实际项目里的习惯:
一是调试响应头比猜原因快得多。遇到下载行为异常,先在浏览器开发者工具的 Network 面板里点开对应请求,看 Response Headers 里的 Content-Disposition。这个头写对了,90% 的问题都解决了。
二是不要把下载目录和代码目录混在一起。我习惯把所有可下载文件放在一个独立目录,并定期清理临时文件。这样即使未来代码逻辑写错,泄露范围也仅限于这个目录,而不是整个项目。
如果你只是临时做个内部小工具,把文中的第二、三章代码拼起来就够用了。如果你要长期维护,建议把安全校验和自动化验证一起跟上。后面我还打算在这套代码基础上接一个简单的下载次数统计,用 SQLite 记录每个文件的下载时间和 IP,给运营做参考。这个功能下次有机会再单独写一篇。
