码上问学问代码之理,学编程之道

第 9 章 · 文件上传与下载

JSON 只能传文字,用户要换头像、要传封面图,就得让「文件」穿过那道 HTTP 请求。这一章教你把文件安全地收下来、存到对的地方、再用一个 URL 交还给前端——顺带把上传功能里最容易出事的三个口子(类型、文件名、体积)一个个堵上。

文件上传的 HTTP 原理

multipart/form-data
文件上传走 multipart/form-data 请求(Content-Type: multipart/form-data; boundary=ABC123) --ABC123 Content-Disposition: form-data; name="avatar"; filename="a.png" Content-Type: image/png [ 二进制图片数据 …… ] --ABC123 Content-Disposition: form-data; name="desc" 我的头像 --ABC123--

一个上传请求里,boundary 像「胶带」把多个 part 隔开:每个 part 可以是普通字段(如 desc),也可以是二进制文件(如头像图片),Flask 按 name 分别取出。

先讲为什么前面所有接口用的都是 application/json,那是「一段文本」的格式。可图片是一堆二进制字节,里面可能含有各种字节值,直接塞进 JSON 会破坏结构。于是 HTTP 提供了另一种消息格式:multipart/form-data——它把一次请求体切成若干「分段」,每段有自己的名字和内容,一段放文本字段、另一段放文件。
你可以把它想成一个快递箱里分格装了不同的东西,每格贴着标签,服务器按标签取件。
动手做浏览器里最原始的上传方式就是一个表单:method="post",编码方式设为 multipart/form-data,里面放一个 <input type="file">。注意:漏掉 enctype 这个属性,浏览器默认按 URL 编码发送,文件内容会变成一串乱码。
upload.htmlhtml
<form action="/api/user/avatar" method="post" enctype="multipart/form-data">
  <input type="file" name="file" accept="image/png,image/jpeg,image/gif">
  <button type="submit">上传头像</button>
</form>

<!-- 关键点:
     method="post"          文件上传必须是 POST
     enctype="multipart/form-data"  缺少它就传不了文件
     name="file"            这个名字要和后端 request.files["file"] 对上
     accept="..."           只是给浏览器的「选择文件」对话框加过滤,不是安全校验 -->
页面效果浏览器渲染结果
运行结果: 浏览器中选择一张 avatar.png 提交后, 请求体会被切成多个分段(抓包看到的原始样子): Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxk ------WebKitFormBoundary7MA4YWxk Content-Disposition: form-data; name="file"; filename="avatar.png" Content-Type: image/png (这里是 avatar.png 的二进制字节) ------WebKitFormBoundary7MA4YWxk--
讲透
  • boundary 是分隔符:请求头里的 boundary 告诉服务器「用什么字符串区分各段」。它由客户端随机生成,保证不会和文件内容撞车。
  • 每段一个 Content-Disposition:普通文本字段只有 name;文件字段会多一个 filename。后端就是靠这两个值来定位文件的。
  • 前端用 axios 时不用手写这些:构造一个 FormData 对象 append 文件即可,浏览器会自动设置正确的 Content-Type 和 boundary。
  • 前后端分离后,表单几乎用不到了:Vue 里多半是 <input type="file"> 隐藏 + 上传接口 POST /api/user/avatar(对照接口文档 2.5)。但原理不变。

Flask 里怎么接住一个文件

request.files / file.filename / file.save()
Flask 怎么接住一个文件 request.files 一个「文件字典」 {'avatar': FileStorage} 按 name 取: request.files['avatar'] FileStorage 对象 .filename 原始文件名 .save(path) 保存到磁盘 取到后别急着存:先判空(用户没选文件时 files 里没有这一项),再做安全校验 file = request.files.get('avatar'); if not file: return fail('未选择文件')

上传的文件在 request.files 里,按表单的 name 取出 FileStorage 对象,用 .filename 拿原始名、用 .save(path) 落盘。取到后要先判空再校验。

动手做Flask 把上传的文件放在 request.files 里——它是一个字典,键就是表单里那个 name。取值后用 file.filename 拿原始文件名,用 file.save(路径) 落盘。先写一个最小版本跑通它。
apps/user/views.py(最小版)python
import os
from flask import Blueprint, request, jsonify

user_bp = Blueprint("user", __name__, url_prefix="/api/user")


@user_bp.post("/upload")
def upload_demo():
    # ① 表单里没有 file 这个字段时,直接拒绝
    if "file" not in request.files:
        return jsonify(code=400, msg="请选择要上传的文件", data=None)

    file = request.files["file"]

    # ② 用户什么都没选就点了提交,filename 会是空字符串
    if file.filename == "":
        return jsonify(code=400, msg="文件名不能为空", data=None)

    # ③ 落盘(这里先直接存,安全问题下一节处理)
    save_path = os.path.join("static/upload", file.filename)
    file.save(save_path)

    return jsonify(code=200, msg="上传成功", data={"path": "/static/upload/" + file.filename})
运行输出Python 运行结果
运行结果: $ curl -X POST http://127.0.0.1:5000/api/user/upload \ -F "file=@/Users/me/avatar.png" { "code": 200, "msg": "上传成功", "data": { "path": "/static/upload/avatar.png" } } # 服务端目录里出现了 static/upload/avatar.png # 注意:curl 用 -F 参数就是构造 multipart/form-data,@ 表示「从本地文件读」
讲透
  • request.files 对应的就是表单里的文件字段;普通文本字段仍在 request.form 里。一次 multipart 请求可以两者并存。
  • file 是一个 FileStorage 对象:.filename 是客户端报上来的原始文件名,.stream 是可读的字节流。所以除了 .save(),你也可以 file.read() 读内容自己处理。
  • .save() 会自动建文件但不会建文件夹:如果 static/upload 目录不存在,会直接抛 FileNotFoundError,所以要先 os.makedirs(..., exist_ok=True)。
  • 上面这段代码能跑通,但绝不能上线:文件名由客户端说了算、类型没管、体积没管——三个大洞,下一节逐个堵。

安全校验三件套

类型 · 文件名 · 体积
安全校验三件套:类型 · 文件名 · 体积 上传的文件 关卡 ① 类型 扩展名白名单 png/jpg 关卡 ② 文件名 secure_filename 防路径穿越 关卡 ③ 体积 限制大小,别撑爆磁盘 三道关全过才允许保存;任何一道不过就返回明确错误 危险示例:../../etc/passwd 这类文件名会把文件写到目录外,必须过滤

上传文件要过三道关:类型走扩展名白名单、文件名用 secure_filename 过滤(防 ../ 路径穿越)、体积设上限。全部通过才保存,否则返回明确错误。

先讲为什么文件上传是 Web 应用里最危险的入口之一。攻击者能拿它做的事包括:传一个名叫 ../../etc/passwd 的文件把内容写到系统目录(路径穿越);传一个 shell.php 然后访问它执行代码(webshell);传一个 5GB 的文件把服务器磁盘撑爆(拒绝服务)。
三件套就是针对这三类攻击的最基本防线:后缀白名单、secure_filename 清洗文件名、限制请求体积。
动手做① 用后缀白名单判断类型(白名单,不是黑名单——只允许你知道安全的,其余一律拒绝);② 用 secure_filename() 把危险字符清掉;③ 用 MAX_CONTENT_LENGTH 限制整个请求体积。
apps/utils/upload.pypython
import os
from werkzeug.utils import secure_filename

# 接口文档 2.5:头像只允许 jpg / png / gif —— 用白名单
ALLOWED_EXT = {"jpg", "jpeg", "png", "gif"}


def allowed_file(filename: str) -> bool:
    """借助 rsplit 取最后一个点后面的后缀,再判断是否在白名单里。"""
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXT


def safe_name(filename: str) -> str:
    """把文件名清洗成安全形式,例如 '../../../etc/passwd' -> 'etc_passwd'。"""
    return secure_filename(filename)
运行输出Python 运行结果
运行结果: >>> allowed_file("avatar.png") True >>> allowed_file("shell.php") False >>> allowed_file("photo.JPG") True # 大小写不敏感,先 lower() 再比 >>> safe_name("../../../etc/passwd") 'etc_passwd' # 斜杠、点点都被替换掉,无法穿越目录 >>> safe_name("我的头像.png") 'png' # 注意!中文名会被清空,只剩下后缀
apps/__init__.py(体积限制)python
from flask import Flask, jsonify

app = Flask(__name__)

# 限制整个请求体最大 2MB(对齐接口文档 2.5:头像 ≤ 2MB)
app.config["MAX_CONTENT_LENGTH"] = 2 * 1024 * 1024


# 超限时 Flask 会抛 413 异常,捕获它并按统一格式返回
@app.errorhandler(413)
def too_large(e):
    return jsonify(code=400, msg="文件太大,头像不能超过 2MB", data=None)
运行输出Python 运行结果
运行结果: $ curl -X POST http://127.0.0.1:5000/api/user/avatar -F "file=@big_5mb.png" { "code": 400, "msg": "文件太大,头像不能超过 2MB", "data": null } # 注意:请求体一旦超过阈值,Flask 会在读完之前就中断, # 所以这是「防守在最前面」的一层,比在视图里判断 file.content_length 更可靠
避坑(一):只检查后缀是不够的。攻击者可以把 shell.php 改名为 shell.png 绕过白名单,文件内容其实还是 PHP(「伪装扩展名」)。更严的做法是再校验文件头魔数(PNG 开头是 \x89PNG、JPEG 是 \xff\xd8\xff),并把上传目录配置成不解析脚本(后面第 7 节讲)。
避坑(二):不要用黑名单。写 if ext in {"php","jsp","exe"} 永远列不全——总有你没见过的可执行后缀。白名单是唯一稳妥的思路。
避坑secure_filename() 会把所有非 ASCII 字符删掉,中文文件名「我的头像.png」会变成只剩 png,导致存盘后没有扩展名。正确姿势是「先取原始文件名的后缀做白名单校验,再用 uuid 重新生成文件名」——原始文件名只用来判断类型,绝不直接拿来当存盘路径。

存到哪里:目录规划与唯一文件名

UPLOAD_FOLDER + 分目录 + uuid
落盘:重命名 + 分目录,避免中文 / 重名 头像.png 中文名、易重名 处理 uuid 重命名 + 校验 类型 / 体积 uploads/2026/10/ f3a9c1...e7.png 按日期分目录 用 uuid 当文件名、按日期分目录,既避免中文 / 重名冲突,也方便做体积与类型校验。 上传目录别放代码目录里,否则部署时会被清掉(见生产环境一节)。

用户传来的文件名常带中文、还可能重名。正确做法是:用 uuid 生成唯一文件名、按日期分目录存放,并对类型与体积做校验;上传目录要放在代码目录之外。

先讲为什么如果所有人都把头像存成 avatar.png,张三传完李四再传,张三的头像就被覆盖了。而且几万张图平铺在一个目录里,文件系统查询会变慢,运维想按时间清理也无从下手。
所以规矩是:上传目录要能配置(不写死)、按日期或用户分目录存放、文件名用 uuid 保证全局唯一。
动手做把上传根目录放进配置项 UPLOAD_FOLDER,再写一个「生成存储路径」的小工具:按 avatar/2026/10/ 这类日期分目录,文件名用 uuid 重命名。
config.pypython
import os

BASE_DIR = os.path.abspath(os.path.dirname(__file__))


class Config:
    SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me")
    MAX_CONTENT_LENGTH = 2 * 1024 * 1024          # 单个请求最大 2MB

    # 本地上传根目录:放在 static 下,Flask 才能直接通过 /static/... 访问
    # 注意:生产环境要改成独立目录,见第 7 节
    UPLOAD_FOLDER = os.path.join(BASE_DIR, "static", "upload")

    ALLOWED_EXT = {"jpg", "jpeg", "png", "gif"}
运行输出Python 运行结果
运行结果: 配置加载后,UPLOAD_FOLDER 的值形如: /Users/me/hiflask/apps/static/upload 目录不要再往代码里写死路径,统一从 config["UPLOAD_FOLDER"] 取
apps/utils/upload.py(续)python
import os
import uuid
from datetime import datetime
from flask import current_app


def build_save_path(ext: str, sub_dir: str = "avatar"):
    """返回 (绝对路径, 相对 URL)。按 年/月 分目录,文件名用 uuid 保证唯一。"""
    root = current_app.config["UPLOAD_FOLDER"]      # 从配置取,不写死

    # ① 日期分目录:upload/avatar/2026/10/
    now = datetime.now()
    rel_dir = os.path.join(sub_dir, now.strftime("%Y"), now.strftime("%m"))

    # ② 物理目录如果不存在就建出来(.save() 不会自动建目录)
    abs_dir = os.path.join(root, rel_dir)
    os.makedirs(abs_dir, exist_ok=True)

    # ③ 文件名用 uuid4,彻底避免同名覆盖
    filename = uuid.uuid4().hex + "." + ext

    abs_path = os.path.join(abs_dir, filename)
    # ④ 对外可访问的 URL(/static 前缀来自 Flask 默认静态目录)
    url_path = "/static/upload/" + rel_dir.replace(os.sep, "/") + "/" + filename
    return abs_path, url_path
运行输出Python 运行结果
运行结果: >>> build_save_path("png") ('/Users/me/hiflask/apps/static/upload/avatar/2026/10/3f9a1c2d4e5b6a7c.png', '/static/upload/avatar/2026/10/3f9a1c2d4e5b6a7c.png') # 每次调用文件名都不同 —— 张三、李四的头像互不覆盖
讲透
  • 为什么用 uuid 而不是「用户名+时间戳」:uuid4 是随机生成的 32 位十六进制串,几乎不可能碰撞,也不会泄漏用户信息;而「张三_2026.png」会把用户名暴露在 URL 里。
  • 日期分目录的附加好处:单目录文件数可控(不会几十万张挤在一起),备份和清理都能按目录批处理。
  • 拼接路径一律用 os.path.join:Windows 用反斜杠、Linux 用正斜杠,硬拼字符串到了另一个系统就可能出错。但URL 必须用正斜杠,所以最后做了一次 replace(os.sep, "/")。
  • current_app 又出现了(第 4 章的老朋友):工具函数不在视图里,拿不到请求上下文里的 app,就用 current_app.config 读配置——这样配置改了不用动代码。

头像上传接口

POST /api/user/avatar | 需登录
动手做把前面几块拼起来,实现接口文档 2.5:需要登录(用上一章的 @login_required)、只收 jpg/png/gif、≤ 2MB、存盘后把新头像路径写进 user.avatar,最后返回一个可访问的 URL。
apps/user/views.py(续)python
from flask import current_app, g, jsonify, request
from ..utils.auth import login_required
from ..utils.upload import allowed_file, build_save_path
from ..models import db


@user_bp.post("/avatar")
@login_required                                    # ① 必须登录
def upload_avatar():
    if "file" not in request.files:
        return jsonify(code=400, msg="请选择要上传的图片", data=None)

    file = request.files["file"]
    if file.filename == "":
        return jsonify(code=400, msg="文件名不能为空", data=None)

    # ② 类型白名单(体积已由 MAX_CONTENT_LENGTH 在更前面拦掉)
    if not allowed_file(file.filename):
        return jsonify(code=400, msg="只允许上传 jpg/png/gif 图片", data=None)

    # ③ 用 uuid 生成存储路径,原始文件名只用来取后缀
    ext = file.filename.rsplit(".", 1)[1].lower()
    abs_path, url_path = build_save_path(ext, sub_dir="avatar")
    file.save(abs_path)

    # ④ 更新数据库里该用户的 avatar 字段
    g.user.avatar = url_path
    db.session.commit()

    return jsonify(code=200, msg="上传成功", data={"avatar": url_path})
运行输出Python 运行结果
运行结果: $ curl -X POST http://127.0.0.1:5000/api/user/avatar \ -H "Authorization: Bearer eyJhbGciOi..." \ -F "file=@/Users/me/me.png" { "code": 200, "msg": "上传成功", "data": { "avatar": "/static/upload/avatar/2026/10/3f9a1c2d4e5b6a7c.png" } } # 传到非图片文件 $ curl -X POST http://127.0.0.1:5000/api/user/avatar \ -H "Authorization: Bearer eyJhbGciOi..." -F "file=@shell.php" { "code": 400, "msg": "只允许上传 jpg/png/gif 图片", "data": null }
讲透
  • 返回一个 URL 而不是本地路径:前端拿到 /static/upload/avatar/2026/10/xxx.png 直接塞进 <img src> 就能显示。返回服务器绝对路径既没用又危险。
  • 顺序很重要:先校验,再落盘,最后写库。如果先存文件再校验,不合格的文件已经在磁盘上了,还得再写清理逻辑。
  • 换头像要不要删旧文件:教学阶段可以不删;生产环境建议异步清理,或干脆不删(保留历史)。但要记得旧 URL 失效后,浏览器缓存仍可能显示旧头像,这也是为什么很多站点头像 URL 里带 uuid。
  • 整个接口没有一行直接信任客户端:类型看白名单、路径自己拼、用户从 g.user 取——这三句就是上传接口的安全底线。
避坑MAX_CONTENT_LENGTH 一旦触发,Flask 会在读取请求体时直接返回 413,此时视图函数根本没有执行。所以「文件太大」的提示不能写在视图里,必须用 @app.errorhandler(413) 捕获。很多同学在视图里写了一堆 if file.content_length > ... 却从没生效,就是这个原因。

静态访问与下载

send_from_directory / send_file / Content-Disposition
静态访问与下载 浏览器 GET /uploads/a.png send_from_directory 按目录安全地取文件 磁盘文件 返回图片/二进制 在线预览 浏览器直接显示图片 强制下载 Content-Disposition: attachment

用 send_from_directory 从上传目录安全地取文件返回;默认浏览器直接预览,加上 Content-Disposition: attachment 就变成下载。切勿自己拼接路径读取,避免目录穿越。

先讲为什么文件存好了,还要能被访问到。两条路:放在 Flask 的 static 目录下由静态路由自动提供(简单,但目录必须固定在应用里);放在自定义目录里,用一个专门的视图函数去读(灵活,能加权限校验、能统计下载量)。
另外还有个高频需求:同一个文件,「在线预览」和「点一下下载到本地」是两种不同的响应头,靠 Content-Disposition 区分。
动手做用一个视图把「自定义上传目录」里的文件安全地送出去。send_from_directory(目录, 文件名) 会自动校验文件名、防止路径穿越,比自己 open() 读出来返回安全得多。
apps/file/views.pypython
import os
from flask import Blueprint, current_app, send_from_directory, jsonify

file_bp = Blueprint("file", __name__, url_prefix="/api/file")


@file_bp.get("/download/<path:filename>")
def download(filename):
    """从 UPLOAD_FOLDER 中读取文件并返回(可在此处加登录校验、下载计数)。"""
    folder = current_app.config["UPLOAD_FOLDER"]
    if not os.path.exists(os.path.join(folder, filename)):
        return jsonify(code=404, msg="文件不存在", data=None)
    # as_attachment=True 会让浏览器弹出「下载」而不是直接打开
    return send_from_directory(folder, filename, as_attachment=True)
运行输出Python 运行结果
运行结果: $ curl -I http://127.0.0.1:5000/api/file/download/avatar/2026/10/3f9a1c2d.png HTTP/1.1 200 OK Content-Type: image/png Content-Disposition: attachment; filename=3f9a1c2d.png Content-Length: 20481 # 浏览器看到 Content-Disposition: attachment 就转为「另存为」 # 若去掉 as_attachment,头变成 inline; filename=... 浏览器会直接显示图片
动手做再看 send_file():它接收一个已经打开的文件对象或路径,适合「文件是动态生成的」(比如导出 CSV、生成 PDF)。同时给你一个「在线预览」的例子——不下载,直接在浏览器里打开。
apps/file/views.py(续)python
import io
from flask import make_response, send_file


@file_bp.get("/export")
def export_csv():
    """动态生成 CSV 并作为附件下载(不用先落盘)。"""
    buf = io.BytesIO()
    buf.write("title,author\n静夜思,李白\n春望,杜甫\n".encode("utf-8-sig"))
    buf.seek(0)
    return send_file(
        buf,
        mimetype="text/csv",
        as_attachment=True,             # 强制下载
        download_name="article.csv",    # 下载时显示的文件名
    )


@file_bp.get("/preview/<filename>")
def preview(filename):
    """在线预览:不下载,浏览器内直接打开(图片 / PDF 常用)。"""
    resp = make_response(send_file(filename, mimetype="image/png"))
    # 显式声明为 inline,浏览器会尝试直接渲染而不是弹出保存框
    resp.headers["Content-Disposition"] = "inline; filename=" + filename
    return resp
运行输出Python 运行结果
运行结果: # 访问 /api/file/export Content-Type: text/csv; charset=utf-8 Content-Disposition: attachment; filename=article.csv → 浏览器开始下载 article.csv # 访问 /api/file/preview/a1b2.png Content-Type: image/png Content-Disposition: inline; filename=a1b2.png → 浏览器直接在标签页里显示这张图片
讲透
  • Content-Disposition 就是「怎么处理这个文件」的指令:attachment 下载、inline 预览;后面跟的 filename 是保存时的默认名。
  • send_from_directory 自带安全防护:它拒绝包含 .. 的路径,防止用户构造 /api/file/download/../../etc/passwd 读到系统文件。这就是「不要自己 open(path)」的原因。
  • 两者区别一句话记住:send_from_directory 给「目录 + 文件名」(从磁盘某个目录找文件);send_file 给「路径或文件对象」(更底层,能发内存里的字节流)。
  • 中文文件名下载会乱码:download_name 传中文时,Flask 会按 RFC 5987 编码成 filename*=UTF-8''...,现代浏览器都能正确识别;用 uuid 命名存盘、下载时再指定中文名是最稳的组合。

生产环境:上传目录不要放代码里

部署一次就丢文件的坑
先讲为什么开发时把上传目录放在 apps/static/upload 里很方便,因为 Flask 能直接访问。但到了生产环境,代码是用 Git / 打包 / 容器镜像部署的——每次重新部署,代码目录会被整份替换掉,用户上传的图片也就跟着没了。这是新手运维事故里出现频率最高的一类:上线一次,头像全变红叉。
讲透:正确姿势
  • 上传目录独立于代码目录:放在 /data/hiwen/upload 这类固定路径,或在 Docker 里挂一个 volume。部署换代码,数据目录原封不动。
  • 用环境变量注入路径:UPLOAD_FOLDER 从环境变量读,开发机和生产机各配各的,代码一份不改。
  • 静态文件交给 Nginx:Python 进程发图片既慢又占 worker,生产上应用 Nginx 的 alias 直接指向上传目录,Flask 只管业务接口。
config.py(生产版)python
import os


class Config:
    # 开发机默认放 static 下;生产用环境变量覆盖成独立目录
    UPLOAD_FOLDER = os.environ.get(
        "UPLOAD_FOLDER",
        os.path.join(os.path.dirname(__file__), "static", "upload"),
    )
    MAX_CONTENT_LENGTH = 2 * 1024 * 1024


# 生产环境启动前设置(示例):
#   export UPLOAD_FOLDER=/data/hiwen/upload
运行输出Python 运行结果
运行结果: # 开发机 >>> app.config["UPLOAD_FOLDER"] '/Users/me/hiflask/apps/static/upload' # 生产机(设了环境变量后) >>> app.config["UPLOAD_FOLDER"] '/data/hiwen/upload' # 同一份代码,两个环境各用各的目录,重新部署不会动到用户文件
nginx.conf(静态文件交给 Nginx)nginx
server {
    listen 80;
    server_name example.com;

    # 用户上传的文件由 Nginx 直接返回,不经过 Flask
    location /static/upload/ {
        alias /data/hiwen/upload/;
        # 上传目录不允许执行任何脚本,防止上传 webshell 后被解析
        location ~ \.(php|jsp|py)$ {
            deny all;
        }
    }

    # 其余接口转发给 Flask(gunicorn 监听 127.0.0.1:8000)
    location /api/ {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
效果预览运行结果
运行结果: $ nginx -s reload # 之后访问 http://example.com/static/upload/avatar/2026/10/3f9a1c2d.png # 由 Nginx 直接返回,Flask 进程完全不参与,速度快且不占 worker # 即便有人上传了伪装成 png 的脚本,也会被 deny all 挡住
避坑上传目录的每个子目录都建议加一份 .htaccess 之外的保护(Nginx 用上面的 deny all,Apache 用 php_flag engine off),并且确保上传目录和「可执行脚本」的目录是两个地方。只做后缀白名单而不做解析隔离,一旦有人绕过扩展名检查,服务器就会被拿下。另外别忘了:备份策略要覆盖上传目录,它和数据库一样是「用户数据」,代码仓库里反而没有。
动手练习
1把 MAX_CONTENT_LENGTH 改成 1MB,然后用一个 2MB 的图片调头像接口,观察返回的 JSON——确认你真的捕获到了 413。
2为「古典文学网」新增接口:上传作品封面 POST /api/article/cover,只允许 jpg/png、≤ 2MB,存到 upload/cover/年/月/,返回可访问的 URL(提示:把第 4 节的 build_save_path 换个 sub_dir 就能复用)。
3迁移练习:「红影赏析」要支持用户上传电影海报(upload/poster/),并提供一个「下载海报原图」的接口。请写出上传视图与下载视图,并说明 哪一行决定了「点开是预览还是下载」。
参考答案
练习答案.pypython
# 第 1 题:改配置
app.config["MAX_CONTENT_LENGTH"] = 1 * 1024 * 1024

@app.errorhandler(413)
def too_large(e):
    return jsonify(code=400, msg="文件太大,最多 1MB", data=None)
# 传 2MB 图片 -> {"code": 400, "msg": "文件太大,最多 1MB", "data": null}


# 第 2 题:作品封面上传
@article_bp.post("/cover")
@login_required
def upload_cover():
    if "file" not in request.files:
        return jsonify(code=400, msg="请选择封面图片", data=None)
    file = request.files["file"]
    if not allowed_file(file.filename):
        return jsonify(code=400, msg="只允许上传 jpg/png 图片", data=None)
    ext = file.filename.rsplit(".", 1)[1].lower()
    abs_path, url_path = build_save_path(ext, sub_dir="cover")   # 只换一个参数
    file.save(abs_path)
    return jsonify(code=200, msg="上传成功", data={"cover": url_path})


# 第 3 题:红影赏析 · 海报上传 + 下载
@poster_bp.post("/upload")
@login_required
def upload_poster():
    file = request.files.get("file")
    if file is None or not allowed_file(file.filename):
        return jsonify(code=400, msg="只允许上传 jpg/png/gif 图片", data=None)
    ext = file.filename.rsplit(".", 1)[1].lower()
    abs_path, url_path = build_save_path(ext, sub_dir="poster")
    file.save(abs_path)
    return jsonify(code=200, msg="上传成功", data={"poster": url_path})


@poster_bp.get("/download/<path:filename>")
def download_poster(filename):
    folder = current_app.config["UPLOAD_FOLDER"]
    # as_attachment=True -> Content-Disposition: attachment(下载)
    # 去掉它(或改 inline)      -> Content-Disposition: inline(预览)
    return send_from_directory(folder, filename, as_attachment=True)
运行输出Python 运行结果
运行结果: 第 1 题:{ "code": 400, "msg": "文件太大,最多 1MB", "data": null } 第 2 题:{ "code": 200, "msg": "上传成功", "data": { "cover": "/static/upload/cover/2026/10/8b2e7f1a.png" } } 第 3 题:决定「预览 vs 下载」的是那一行响应头 —— Content-Disposition: attachment → 下载 Content-Disposition: inline → 预览 在 Flask 里就是 send_from_directory / send_file 的 as_attachment 参数

第 2 题的要点是「学会复用」:build_save_path 抽出来之后,任何需要上传的地方都只改一个 sub_dir。第 3 题记住 as_attachment 这一个开关,比背整段响应头有用。

本章小结