← 返回题目列表

FastAPI 怎么处理文件上传下载?UploadFile 和 bytes 有什么区别?

中等 第 23 / 27 题 更新于 2026/08/03
FastAPI文件上传UploadFile下载对象存储

简化版

FastAPI 接收文件有两种声明方式,区别很关键file: bytes = File()把整个文件读进内存(一个 100MB 的文件就占 100MB 内存,几个并发就 OOM);file: UploadFile 拿到的是一个包装了 SpooledTemporaryFile 的对象——小文件留在内存、超过阈值(默认 1MB)自动落到磁盘临时文件,还提供了 filenamecontent_typeasync 的读写方法await file.read()await file.seek(0))。所以除了「确定很小的文件」,一律用 UploadFile安全上和其他框架一样有五道防线:文件名不可信(用 UUID 自己生成,别信 file.filename)、大小要限(FastAPI 没有内置的 MAX_CONTENT_LENGTH,要靠 Nginx 的 client_max_body_size 或自己在中间件里检查 Content-Length)、类型查魔数而不是信 content_type(那是客户端声明的)、存到 Web 根目录之外、下发前校验权限。下载侧FileResponse 发送文件(内部是流式的,支持 Range 断点续传)、StreamingResponse 发送生成的内容、受保护文件的最佳方案是 X-Accel-Redirect 交给 Nginx(应用只做鉴权,传输零拷贝)。最重要的架构判断大文件不该经过应用服务器——用对象存储的预签名 URL 让客户端直传,服务端只签发凭证和接收回调,这样不占 worker、不占带宽、天然支持断点续传。核心记忆:UploadFile 而不是 bytes文件名自己生成、类型查魔数FastAPI 没有内置大小限制大文件走预签名直传

详细版

两种接收方式对比

bytes = File()UploadFile
内存全部读进内存超过 1MB 落磁盘
元数据❌ 只有内容✅ filename/content_type
读取已是 bytesawait file.read()
大文件会 OOM
适用头像缩略图等确定很小的默认选它
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Depends
from fastapi.responses import FileResponse, StreamingResponse
import uuid, os, shutil, aiofiles

# ① ★基本上传★
@app.post("/upload")
async def upload(file: UploadFile):                    # ★★默认写法★★
    content = await file.read()                        # ★异步读★
    await file.seek(0)                                 # ★★读完要 seek 回去★★
    return {"filename": file.filename,
            "content_type": file.content_type,
            "size": file.size}                         # ★0.100+ 有 size★

# ② ★多文件 + 表单字段混合★
@app.post("/upload-many")
async def upload_many(
    files: list[UploadFile],
    title: str = Form(...),                            # ★★同时有表单字段时要用 Form★★
    tags: list[str] = Form([]),
):
    for f in files: ...

# ③ ★★流式保存(大文件必须这样)★★
CHUNK = 1024 * 1024
async def save_upload(file: UploadFile, dest: str) -> int:
    size = 0
    async with aiofiles.open(dest, "wb") as out:       # ★异步文件 IO★
        while chunk := await file.read(CHUNK):         # ★★分块读★★
            size += len(chunk)
            if size > MAX_SIZE:                        # ★★边读边校验大小★★
                await out.close(); os.remove(dest)
                raise HTTPException(413, "文件过大")
            await out.write(chunk)
    return size
# ✗ 反例:content = await file.read()  → ★整个文件进内存★

# ④ ★安全校验★
from werkzeug.utils import secure_filename   # 或自己实现
MAGIC = {b"\xff\xd8\xff": ".jpg", b"\x89PNG\r\n\x1a\n": ".png",
         b"%PDF-": ".pdf", b"GIF8": ".gif"}
ALLOWED = {".jpg", ".jpeg", ".png", ".pdf", ".gif"}

async def validate(file: UploadFile) -> str:
    ext = os.path.splitext(file.filename or "")[1].lower()
    if ext not in ALLOWED:
        raise HTTPException(400, "不支持的类型")
    head = await file.read(16)
    await file.seek(0)                                 # ★★必须 seek 回去★★
    if not any(head.startswith(m) for m in MAGIC):     # ★★查魔数★★
        raise HTTPException(400, "文件内容与扩展名不符")
    return ext

@app.post("/upload-safe")
async def upload_safe(file: UploadFile, user: CurrentUser):
    ext = await validate(file)
    name = f"{uuid.uuid4().hex}{ext}"                  # ★★自己生成文件名★★
    subdir = datetime.now().strftime("%Y/%m")          # ★目录分片★
    path = os.path.join(UPLOAD_ROOT, subdir, name)     # ★Web 根目录之外★
    os.makedirs(os.path.dirname(path), exist_ok=True)
    size = await save_upload(file, path)
    await Upload.create(path=f"{subdir}/{name}", size=size,
                        original_name=file.filename, user_id=user.id)
    return {"id": ..., "name": name}

# ⑤ ★★请求体大小限制(FastAPI 没有内置!)★★
class LimitUploadSizeMiddleware:                       # ★纯 ASGI 中间件★
    def __init__(self, app, max_size: int):
        self.app, self.max_size = app, max_size
    async def __call__(self, scope, receive, send):
        if scope["type"] == "http":
            headers = dict(scope["headers"])
            cl = headers.get(b"content-length")
            if cl and int(cl) > self.max_size:         # ★★先看 Content-Length★★
                await send({"type": "http.response.start", "status": 413,
                            "headers": [(b"content-type", b"application/json")]})
                await send({"type": "http.response.body",
                            "body": b'{"detail":"文件过大"}'})
                return
        await self.app(scope, receive, send)
app.add_middleware(LimitUploadSizeMiddleware, max_size=10*1024*1024)
# ★★但真正的防线是 Nginx 的 client_max_body_size★★

# ⑥ ★下载★
@app.get("/files/{fid}")
async def download(fid: int, user: CurrentUser, db: DbDep):
    up = await db.get(Upload, fid)
    if not up or up.user_id != user.id:                 # ★★权限校验★★
        raise HTTPException(404)
    return FileResponse(                                # ★内部流式 + 支持 Range★
        os.path.join(UPLOAD_ROOT, up.path),
        filename=up.original_name,                      # ★★自动处理中文名★★
        media_type=up.mime,
    )

# ★★受保护文件的最佳方案:X-Accel-Redirect★★
@app.get("/files/{fid}/fast")
async def download_fast(fid: int, user: CurrentUser, db: DbDep):
    up = await db.get(Upload, fid); check(up, user)
    return Response(headers={
        "X-Accel-Redirect": f"/protected/{up.path}",    # ★Nginx internal location★
        "Content-Disposition": f'attachment; filename="{quote(up.original_name)}"',
    })

⚠️ 三个必须记住的点:① UploadFile 而不是 bytes。声明成 file: bytes = File() 时,FastAPI 会把整个文件读进内存再交给你——上传一个 200MB 的视频就是 200MB 常驻内存,几个并发请求直接 OOM。UploadFile 底层是 SpooledTemporaryFile内容小于阈值(Starlette 默认 1MB)时留在内存,超过就自动写入磁盘临时文件,对使用者接口完全一致。而且它还提供了 filenamecontent_typesize 以及异步的 read/write/seek/close。② FastAPI/Starlette 没有内置的请求体大小限制——不像 Flask 有 MAX_CONTENT_LENGTH。这意味着默认情况下,任何人都能往你的上传接口发一个 10GB 的请求(虽然 UploadFile 会落盘不至于 OOM,但会打满磁盘)。三层防护:最外层用 Nginx 的 client_max_body_size(超出直接 413,请求体根本不进应用)、中间层写一个检查 Content-Length 的 ASGI 中间件内层在分块读取时累加实际字节数(因为 Content-Length 可以伪造,chunked 编码时甚至没有这个头)。③ file.filenamefile.content_type 都是客户端提供的,完全不可信filename 可能包含 ../ 做路径穿越、可能是超长字符串、可能为空;content_type 改一下就能让 .php 伪装成 image/png。所以:文件名自己用 UUID 生成(原始名存数据库用于展示和下载时还原),类型靠读取文件头的魔数判断(读完记得 await file.seek(0),否则后续保存会得到空文件或残缺文件)。

完整版教学

一、UploadFile 的机制

★ ★UploadFile 的本质★:
  class UploadFile:
      file: BinaryIO          # ★★SpooledTemporaryFile★★
      filename: str | None    # ★客户端提供,不可信★
      size: int | None        # ★0.100+★
      headers: Headers
      content_type: str | None
      # ★异步方法(内部用 run_in_threadpool 包装同步 IO)★
      async def read(self, size: int = -1) -> bytes
      async def write(self, data: bytes) -> None
      async def seek(self, offset: int) -> None
      async def close(self) -> None
★ ★SpooledTemporaryFile 的分级策略★:
  ┌──────────────────────────────────────────────────┐
  │ 内容 ★< max_size(默认 1MB)★  → ★留在 BytesIO★    │
  │ 内容 ★≥ max_size★             → ★自动 rollover★   │
  │                                  ★写入磁盘临时文件★ │
  └──────────────────────────────────────────────────┘
  ★ ★对使用者透明★:接口完全一样
  ★ ★所以上传 1GB 文件不会撑爆内存★

★ ★★但 await file.read() 会把整个文件读进内存★★:
  content = await file.read()      # ★★1GB → 1GB 内存★★
  ✓ ★分块读★:
    while chunk := await file.read(1024 * 1024):
        process(chunk)
  ✓ ★或用 shutil.copyfileobj(同步,要丢线程池)★:
    await run_in_threadpool(shutil.copyfileobj, file.file, out, 1024*1024)

★ ★调整 spool 阈值★:
  # Starlette 的 MultiPartParser
  from starlette.formparsers import MultiPartParser
  MultiPartParser.spool_max_size = 10 * 1024 * 1024    # ★★改成 10MB★★
  ★ ★权衡:阈值大 = 更多小文件走内存(快)但内存占用高★

★ ★async 方法其实是线程池包装★:
  await file.read()
  # 内部:await run_in_threadpool(self.file.read, size)
  ★ 因为 ★文件 IO 在 Linux 上没有真正的异步★(O_NONBLOCK 对普通文件无效)
  ★ ★所以它占用的是那 40 个线程池 token★
  → ★大量并发上传时线程池可能成为瓶颈★

★ ★临时文件的清理★:
  ★ Starlette 在请求结束后会 close 掉 UploadFile
  → SpooledTemporaryFile 的 close 会删除临时文件
  ★ ✗ 但如果你把 file 对象存到了别处(比如 BackgroundTasks 里用)
    → ★请求结束时文件已经被关闭/删除了★
  ✓ ★要在后台处理就先把内容复制出来★:
    @app.post("/upload")
    async def upload(file: UploadFile, bt: BackgroundTasks):
        tmp = f"/tmp/{uuid4().hex}"
        await save_upload(file, tmp)        # ★★先落到自己的临时文件★★
        bt.add_task(process_file, tmp)      # 后台处理这个路径
        return {"ok": True}

★ ★UploadFile 与 Form 混用★:
  @app.post("/x")
  async def x(file: UploadFile, title: str = ★Form(...)★):
  ★ ★注意:一旦有文件,其他字段必须声明成 Form 而不是普通参数或 Body★
  ★ 因为整个请求是 multipart/form-data
  ★ ✗ 不能同时有 File 和 Body(JSON)→ ★协议上互斥★
  ✓ 需要传复杂结构:把 JSON 作为一个 Form 字段传,然后手动解析
    metadata: str = Form(...)   →  json.loads(metadata)

★ ★依赖形式的复用★:
  async def validated_image(file: UploadFile) -> UploadFile:
      await check_magic(file); return file
  ImageDep = Annotated[UploadFile, Depends(validated_image)]
  @app.post("/avatar")
  async def avatar(file: ImageDep): ...    # ★★校验逻辑复用★★

UploadFile 的核心是 SpooledTemporaryFile 的分级策略——小于阈值(默认 1MB)留在内存、超过就自动写入磁盘临时文件,对使用者完全透明,所以上传 1GB 文件不会撑爆内存await file.read() 会把整个文件读进内存,大文件必须分块读。有个容易忽略的细节:UploadFileasync 方法其实是 run_in_threadpool 包装的同步 IO(因为普通文件在 Linux 上没有真正的异步),所以它占用那 40 个线程池 token,大量并发上传时可能成为瓶颈。临时文件在请求结束后会被自动删除——所以想在 BackgroundTasks 里处理必须先把内容复制到自己的临时文件。还有个协议层面的限制:有文件时其他字段必须用 Form,而且不能同时有 File 和 JSON Body(需要复杂结构就把 JSON 当作一个 Form 字段传)。

二、上传的安全防线

★ ★★① 文件名:路径穿越★★
  file.filename = "../../../etc/cron.d/evil"
  ✗ open(os.path.join(UPLOAD_DIR, file.filename), "wb")
  → ★写到了系统目录 → RCE★
  ✓ ★服务端自己生成★:
    ext = os.path.splitext(file.filename or "")[1].lower()[:10]  # ★限长★
    if ext not in ALLOWED: raise HTTPException(400)
    name = f"{uuid.uuid4().hex}{ext}"
  ★ 好处:★杜绝穿越 + 不会同名覆盖 + 中文名照常存数据库 + 不泄露原名★

★ ★★② 大小:FastAPI 没有内置限制★★
  ★三层防护(缺一不可):★
  ┌────────────────────────────────────────────────────┐
  │ ★① Nginx:client_max_body_size 10m★                 │
  │    → ★超出直接 413,请求体不进应用★(最有效)        │
  │ ★② ASGI 中间件:检查 Content-Length★                │
  │    → ★快速拒绝,但 Content-Length 可伪造★           │
  │ ★③ 读取时累加实际字节★                              │
  │    → ★最可靠(chunked 编码时前两层都可能失效)★      │
  └────────────────────────────────────────────────────┘
  ★ ★为什么三层都要:★
    - ★chunked 传输编码没有 Content-Length★
    - ★恶意客户端可以声明小的 Content-Length 却发大量数据★

★ ★★③ 类型:查魔数★★
  ✗ 信 file.content_type      # ★客户端声明的★
  ✗ 信扩展名                  # ★改名就绕过★
  ✓ 读文件头:
    head = await file.read(16); await file.seek(0)     # ★★seek 回去★★
    if not head.startswith(b"\x89PNG"): raise ...
  ✓ ★用 python-magic★:
    import magic
    mime = magic.from_buffer(await file.read(2048), mime=True)
    await file.seek(0)
  ✓ ★★图片最彻底:重新编码★★
    from PIL import Image
    img = Image.open(file.file)
    img.verify(); file.file.seek(0)
    img = Image.open(file.file).convert("RGB")
    img.thumbnail((2000, 2000))                        # ★顺便限尺寸★
    img.save(dest, "JPEG", quality=85)
    ★ → ★清除 EXIF(含 GPS)+ 销毁藏在图片里的载荷★

★ ★④ 存储位置★:
  ✗ ★存在 StaticFiles 挂载的目录下★
    app.mount("/static", StaticFiles(directory="static"))
    → ★上传的文件可被直接 URL 访问 = 无权限控制★
  ✓ ★存 Web 根目录之外★(/var/data/uploads)
  ✓ ★或对象存储★

★ ★⑤ 下发前校验权限★:
  @app.get("/files/{fid}")
  async def get_file(fid: int, user: CurrentUser):
      up = await get_upload(fid)
      if up.user_id != user.id and not user.is_admin:
          raise HTTPException(404)          # ★★用 404 而不是 403(不泄露存在性)★★
      return FileResponse(...)

★ ★其他要防的★:
  □ ★图片炸弹★:10KB 的 PNG 解压成 10GB
    Image.MAX_IMAGE_PIXELS = 50_000_000
  □ ★SVG 是 XML,能内嵌 <script>★ → ★禁止或用 nh3 清洗★
  □ ★ZipSlip★:解压时成员名可能是 ../../x
  □ ★上传目录禁止执行★(Nginx 配置 + 文件权限)
  □ ★病毒扫描★(ClamAV,企业场景)
  □ ★用户配额★(总大小/数量)

★ ★目录分片(大量文件时必须)★:
  ✗ 所有文件塞一个目录 → ★几十万文件时 ls/stat 极慢★
  ✓ 按日期:uploads/2026/08/03/xxx.jpg
  ✓ 按 hash 前缀:uploads/ab/cd/abcdef...jpg

上传安全的五道防线和其他框架一致,但 FastAPI 的特殊点是「没有内置的大小限制」——三层防护缺一不可:Nginx 的 client_max_body_size(最有效,超出直接 413)、ASGI 中间件检查 Content-Length(快速拒绝但可伪造)、读取时累加实际字节数(最可靠,因为 chunked 编码时前两层都可能失效)。文件名要自己用 UUID 生成(杜绝穿越、不会同名覆盖、中文名照常存数据库)。类型必须查魔数(读完记得 await file.seek(0)),图片最彻底的做法是用 Pillow 重新编码——清除 EXIF(含 GPS)并销毁藏在图片里的载荷。存储位置绝不能放在 StaticFiles 挂载的目录下(那等于没有权限控制)。下发时用 404 而不是 403(不泄露资源存在性)。

三、下载与文件下发

★ ★三个 Response 类★:
  ┌────────────────────┬──────────────────────────────────┐
  │ ★FileResponse★      │ ★发送磁盘上的文件★                │
  │                     │ ✓ ★内部流式、支持 Range 断点续传★ │
  │                     │ ✓ ★自动设 Content-Type/Length★    │
  │                     │ ✓ ★用 anyio 的线程池读文件★       │
  │ ★StreamingResponse★ │ ★发送生成的内容★                  │
  │ Response           │ 小的内存内容                      │
  └────────────────────┴──────────────────────────────────┘

  return FileResponse(path,
      filename="报告.pdf",              # ★★自动生成 RFC 5987 的中文文件名★★
      media_type="application/pdf",
      headers={"Cache-Control": "private, max-age=3600"})
  ★ filename 参数会生成:
    Content-Disposition: attachment; filename*=utf-8''%E6%8A%A5%E5%91%8A.pdf

★ ★发送内存里的内容★:
  buf = io.BytesIO(pdf_bytes)
  return StreamingResponse(buf, media_type="application/pdf",
      headers={"Content-Disposition": 'attachment; filename="x.pdf"'})

★ ★★生产环境:不要让应用发文件★★:
  ┌──────────────────┬────────────────────────────────┐
  │ ★FastAPI 发★      │ ★占用 worker + 线程池、无零拷贝★│
  │ ★Nginx 发★        │ ★sendfile 零拷贝、支持 Range★   │
  │ ★对象存储/CDN★    │ ★最优:完全不经过应用★          │
  └──────────────────┴────────────────────────────────┘

  ★★方案:X-Accel-Redirect(兼得鉴权和性能)★★
  # FastAPI 只做权限校验
  @app.get("/files/{fid}")
  async def download(fid: int, user: CurrentUser):
      up = await get_and_check(fid, user)              # ★应用层鉴权★
      return Response(headers={
          "X-Accel-Redirect": f"/protected/{up.path}",  # ★★Nginx 接管★★
          "Content-Disposition":
              f"attachment; filename*=utf-8''{quote(up.original_name)}",
          "Content-Type": up.mime,
      })
  # nginx.conf
  location /protected/ {
      ★internal;★                     # ★★外部无法直接访问★★
      alias /var/data/uploads/;
  }
  ★ ★Apache 用 X-Sendfile★

★ ★预签名 URL(★云上最优★)★:
  @app.get("/files/{fid}/url")
  async def get_url(fid: int, user: CurrentUser):
      up = await get_and_check(fid, user)
      url = await s3.generate_presigned_url(
          "get_object", Params={"Bucket": B, "Key": up.key},
          ExpiresIn=300)                                # ★★5 分钟有效★★
      return {"url": url}
  ★ ✓ ★传输完全不经过应用服务器 + 可走 CDN★

★ ★Range 请求(断点续传/视频拖动)★:
  ★ FileResponse ★自动支持★(返回 206 Partial Content)
  ★ StreamingResponse ★不自动支持★,要自己处理:
    range_header = request.headers.get("range")
    # bytes=0-1023 → 解析 → seek → 返回 206 + Content-Range

★ ★大文件导出(生成型)★:
  @app.get("/export.csv")
  async def export(db: DbDep):
      async def gen():
          yield "id,name\n"
          result = await db.stream(select(User))
          async for u in result.scalars():
              yield f"{u.id},{u.name}\n"
      return StreamingResponse(gen(), media_type="text/csv",
          headers={"Content-Disposition": 'attachment; filename="export.csv"'})
  ★ ✗ ★注意 BaseHTTPMiddleware 会破坏流式★
  ★ ✓ 超大导出更应该:★异步任务生成文件 → 通知用户下载★

下载侧有三个 Response 类:FileResponse 发磁盘文件(内部流式、自动支持 Range 断点续传filename 参数会自动生成 RFC 5987 的中文文件名)、StreamingResponse 发生成的内容不自动支持 Range)、Response 发小的内存内容。生产环境的核心判断是「不要让应用发文件」——受保护文件用 X-Accel-Redirect(应用只做鉴权、Nginx 零拷贝传输,location 要配 internal),云上最优是预签名 URL(传输完全不经过应用服务器,还能走 CDN)。超大导出更应该做成异步任务生成文件再通知用户下载,而不是在请求里流式生成(那会长时间占用连接)。

四、大文件与直传

★ ★★WSGI/ASGI 都逃不掉的问题★★:
  上传期间★连接一直占着★
  ASGI 下比 WSGI 好(不占整个 worker,占一个 task + 线程池 token)
  ★ 但仍然:
    - ★带宽经过应用服务器★
    - ★磁盘临时文件★
    - ★线程池 token 被占(UploadFile 的 read 是线程池)★

★ ★★正解:对象存储预签名直传★★
  ┌──────────────────────────────────────────────────────┐
  │ ① 前端 → 后端:★请求上传凭证★                          │
  │ ② 后端:★校验权限 + 生成预签名 POST/PUT★               │
  │    (★带大小限制、类型限制、过期时间★)                 │
  │ ③ 前端 → ★对象存储:直接上传(不经过后端)★             │
  │ ④ 前端 → 后端:★通知上传完成★                          │
  │ ⑤ 后端:★head_object 确认 + 落库★                      │
  └──────────────────────────────────────────────────────┘

  @app.post("/upload-token")
  async def upload_token(req: UploadTokenIn, user: CurrentUser):
      key = f"uploads/{user.id}/{uuid4().hex}{req.ext}"
      presigned = s3.generate_presigned_post(
          Bucket=BUCKET, Key=key,
          Fields={"Content-Type": req.content_type},
          Conditions=[
              ["content-length-range", 0, 10 * 1024 * 1024],   # ★★大小限制★★
              {"Content-Type": req.content_type},              # ★★类型限制★★
          ],
          ExpiresIn=600)
      return {"key": key, **presigned}

  @app.post("/upload-callback")
  async def callback(key: str, user: CurrentUser, db: DbDep):
      if not key.startswith(f"uploads/{user.id}/"):     # ★★校验归属★★
          raise HTTPException(403)
      obj = await s3.head_object(Bucket=BUCKET, Key=key) # ★★确认真实存在★★
      await Upload.create(key=key, size=obj["ContentLength"],
                          mime=obj["ContentType"], user_id=user.id)
  ★ ★关键:回调必须服务端确认,不能信前端报的 size/type★

★ ★分片上传(超大文件)★:
  ★ 优先用对象存储的 multipart upload API:
    ① create_multipart_upload → upload_id
    ② 前端逐片上传(可并发、可重试单片)
    ③ complete_multipart_upload
  ★ 自己实现的话要处理:
    - ★分片归属校验(防止覆盖别人的分片)★
    - ★过期分片清理(定时任务)★
    - ★合并时校验总大小和 hash★
    - ★秒传:先用文件 hash 查库★

★ ★秒传(hash 去重)★:
  @app.post("/check-hash")
  async def check(sha256: str, user: CurrentUser):
      existing = await Upload.get_by_hash(sha256)
      if existing:
          await Upload.create_ref(existing, user)   # ★★只建引用★★
          return {"instant": True, "id": ...}
      return {"instant": False}
  ★ ✓ 省存储、省带宽、体验好

★ ★上传进度★:
  ★ ✗ 服务端很难提供(数据是一次性交给应用的)
  ✓ ★前端用 XMLHttpRequest 的 upload.onprogress★
  ✓ 或分片上传时按片数算
  ✓ ★直传到 S3 时,SDK 自带进度回调★

★ ★超时配置★:
  # uvicorn / gunicorn
  --timeout-keep-alive 75
  # gunicorn + uvicorn worker
  --timeout 300                     # ★★大文件必须调大★★
  # nginx
  client_max_body_size 100m;
  client_body_timeout 300s;
  proxy_request_buffering on;       # ★★先收完再转发,保护应用★★

大文件的正解是「对象存储预签名直传」——后端只做两件事:签发带大小限制、类型限制、过期时间的凭证,以及在回调里 head_object 确认文件真实存在并落库关键安全点是「回调必须服务端确认,不能信前端报的 size 和 type」,还要校验 key 的归属(防止用户伪造别人的路径)。超大文件用对象存储的 multipart upload API(可并发、可重试单片),自己实现的话要处理分片归属校验、过期清理、合并校验。秒传靠内容 hash 去重(只建引用)。上传进度应该由前端做XMLHttpRequest.upload.onprogress),服务端很难提供。超时配置上,gunicorn 的 --timeout 必须调大,Nginx 的 proxy_request_buffering on(先收完再转发)能保护应用

五、图片处理与实践

★ ★图片处理的典型流程★:
  @app.post("/avatar")
  async def upload_avatar(file: UploadFile, user: CurrentUser):
      # ★① 快速校验(大小、扩展名)★
      if file.size and file.size > 5 * 1024 * 1024:
          raise HTTPException(413, "图片不能超过 5MB")
      # ★② 魔数校验★
      head = await file.read(16); await file.seek(0)
      if not is_image(head): raise HTTPException(400)
      # ★③ ★CPU 密集的处理丢线程池★★
      result = await run_in_threadpool(process_image, file.file, user.id)
      return result

  def process_image(fp, user_id):                # ★同步函数★
      Image.MAX_IMAGE_PIXELS = 50_000_000        # ★★防图片炸弹★★
      img = Image.open(fp)
      img.verify(); fp.seek(0)                   # ★verify 后必须重新 open★
      img = Image.open(fp).convert("RGB")        # ★★丢弃 EXIF 和异常数据★★
      # 生成多个尺寸
      out = {}
      for name, size in [("thumb", 128), ("medium", 512), ("large", 1024)]:
          im = img.copy(); im.thumbnail((size, size))
          path = f"{UPLOAD_ROOT}/{user_id}/{name}_{uuid4().hex}.jpg"
          im.save(path, "JPEG", quality=85, optimize=True)
          out[name] = path
      return out
  ★ ★为什么要 run_in_threadpool★:
    ★图片处理是 CPU 密集的★,在 async def 里直接做会★卡住事件循环★
  ★ ★更重的处理(视频转码)→ 丢 Celery★

★ ★为什么重新编码是最好的安全措施★:
  ┌────────────────────────────────────────────────┐
  │ ★清除 EXIF★(★可能含 GPS 位置、设备信息★)        │
  │ ★销毁图片马★(附加在文件尾部的 PHP/脚本代码)      │
  │ ★统一格式和尺寸★(省存储、省带宽)                │
  │ ★验证了它确实是一张能解码的图片★                  │
  └────────────────────────────────────────────────┘

★ ★后台处理的正确姿势★:
  ✗ @app.post("/upload")
    async def upload(file: UploadFile, bt: BackgroundTasks):
        bt.add_task(process, file)          # ★★请求结束文件就没了★★
  ✓ 先落到自己的持久位置,再传路径:
    path = await save_upload(file, f"/data/tmp/{uuid4().hex}")
    bt.add_task(process, path)
  ✓ ★更重的活交给 Celery★(BackgroundTasks 仍占用当前进程)

★ ★清理与运维★:
  □ ★孤儿文件清理★(数据库记录删了但文件还在 → 定时对账)
  □ ★上传中断的临时文件清理★
  □ ★用户配额检查(上传前)★
  □ ★磁盘水位告警★
  □ ★访问日志(谁下载了什么)★

★ ★测试文件上传★:
  def test_upload(client):
      files = {"file": ("test.png", io.BytesIO(PNG_BYTES), "image/png")}
      r = client.post("/upload", files=files)
      assert r.status_code == 200

  def test_upload_wrong_magic(client):
      files = {"file": ("evil.png", io.BytesIO(b"<?php ..."), "image/png")}
      r = client.post("/upload", files=files)
      assert r.status_code == 400              # ★★魔数校验生效★★

  def test_upload_too_large(client):
      files = {"file": ("big.png", io.BytesIO(b"x" * 20_000_000), "image/png")}
      assert client.post("/upload", files=files).status_code == 413

图片处理的关键是把 CPU 密集的部分丢线程池run_in_threadpool)——async def 里直接用 Pillow 处理会卡住事件循环;更重的活(视频转码)要交给 Celery。重新编码是最好的安全措施——一次性完成「清除 EXIF(含 GPS)、销毁图片马、统一格式尺寸、验证确实是能解码的图片」四件事。后台处理有个必踩的坑BackgroundTasks 里不能直接用 UploadFile 对象(请求结束时临时文件已被删除),必须先把内容保存到自己的持久位置再传路径。测试上传用 files={"file": (name, BytesIO(content), content_type)},重点测魔数校验和大小限制真的生效

六、实践清单

★ 检查清单:
  【接收】
  □ ★用 UploadFile 而不是 bytes★
  □ ★大文件分块读,不用 await file.read() 一次读完★
  □ ★读了内容后 await file.seek(0)★
  【安全】
  □ ★文件名自己生成(UUID),原名存数据库★
  □ ★扩展名白名单★
  □ ★魔数校验(不信 content_type)★
  □ ★图片重新编码(清 EXIF 和载荷)★
  □ ★Image.MAX_IMAGE_PIXELS 防图片炸弹★
  □ ★禁止或清洗 SVG★
  □ ★存 Web 根目录之外(不在 StaticFiles 目录下)★
  □ ★下发前校验权限(用 404 不用 403)★
  【大小】
  □ ★Nginx client_max_body_size★
  □ ★ASGI 中间件检查 Content-Length★
  □ ★读取时累加实际字节★
  【性能】
  □ ★CPU 密集处理 run_in_threadpool 或 Celery★
  □ ★下载用 X-Accel-Redirect 或预签名 URL★
  □ ★大文件走对象存储直传★
  □ ★gunicorn --timeout 调大★
  【运维】
  □ ★目录分片★
  □ ★孤儿文件清理★
  □ ★用户配额★

★ 报错速查:
  ┌────────────────────────────────────┬──────────────────┐
  │ 保存出来是空文件                     │ ★读了内容没 seek(0)★│
  │ 内存暴涨/OOM                        │ ★用了 bytes 或一次★ │
  │                                     │ ★性 read()★        │
  │ 413(自己没配)                      │ ★Nginx 限制★       │
  │ 大文件上传超时                       │ ★gunicorn timeout★ │
  │ BackgroundTasks 里文件不存在          │ ★临时文件已删除★    │
  │ 中文文件名下载后乱码                  │ ★没用 filename 参数★│
  │ 上传时其他字段收不到                  │ ★没声明成 Form★     │
  └────────────────────────────────────┴──────────────────┘

★ 一句话总结:
  ★"用 UploadFile 而不是 bytes(前者超过 1MB 自动落盘);
    FastAPI 没有内置大小限制,要靠 Nginx + 中间件 + 读取时累加三层;
    文件名自己生成、类型查魔数(读完 seek(0))、存 Web 根目录之外;
    下载用 X-Accel-Redirect 交给 Nginx,大文件走对象存储预签名直传。"★

检查清单分四块。报错速查表里最高频的两条:「保存出来是空文件」就是读了内容没 seek(0)「上传时其他字段收不到」是没声明成 Form。还有一个容易忽略的:BackgroundTasks 里文件不存在是因为临时文件在请求结束时已被删除

记忆钩子:「FastAPI 接收文件有两种声明,★区别关键★:★file: bytes = File() 会把整个文件读进内存★(200MB 视频就是 200MB 常驻内存,几个并发直接 OOM);★file: UploadFile 底层是 SpooledTemporaryFile——小于阈值(默认 1MB)留内存、超过自动落磁盘临时文件★,还提供 filename/content_type/size 和异步的 read/write/seek。★所以除了确定很小的文件一律用 UploadFile★。但注意★await file.read() 仍会把整个文件读进内存,大文件必须分块读★,而且★UploadFile 的 async 方法其实是 run_in_threadpool 包装的同步 IO(普通文件没有真异步),所以占那 40 个线程池 token★。★FastAPI/Starlette 没有内置的请求体大小限制★(不像 Flask 有 MAX_CONTENT_LENGTH)→ ★三层防护缺一不可:① Nginx 的 client_max_body_size(最有效,超出直接 413 请求体不进应用)② ASGI 中间件检查 Content-Length(快但可伪造)③ 读取时累加实际字节(最可靠,因为 chunked 编码时前两层都可能失效)★。安全五道防线:★① filename 和 content_type 都是客户端提供的完全不可信★——文件名可能含 ../ 做路径穿越,★所以自己用 UUID 生成、原名存数据库★;★② 类型查魔数,读完必须 await file.seek(0)★(否则保存出空文件,这是最高频的低级错误);★③ 图片最彻底的做法是用 Pillow 重新编码★——一次性清除 EXIF(含 GPS)、销毁图片马、统一格式、验证确实能解码,还要设 ★Image.MAX_IMAGE_PIXELS 防图片炸弹★、★禁止或清洗 SVG(XML 能内嵌 script)★;★④ 存 Web 根目录之外,绝不能放在 StaticFiles 挂载的目录下★(那等于没有权限控制);★⑤ 下发前校验权限,用 404 而不是 403(不泄露资源存在性)★。下载侧:★FileResponse 内部流式且自动支持 Range 断点续传,filename 参数会自动生成 RFC 5987 的中文文件名★;★StreamingResponse 不自动支持 Range★;★生产环境不要让应用发文件——受保护文件用 X-Accel-Redirect(应用只鉴权、Nginx 零拷贝,location 要配 internal),云上最优是预签名 URL★。★大文件的正解是对象存储预签名直传★:后端只签发★带 content-length-range 和类型限制的凭证★,★回调必须服务端 head_object 确认,不能信前端报的 size/type,还要校验 key 的归属★。两个易忽略的坑:★BackgroundTasks 里不能直接用 UploadFile 对象(请求结束时临时文件已被删除),要先保存到自己的位置再传路径★;★有文件时其他字段必须声明成 Form,且不能同时有 File 和 JSON Body(协议上互斥)★。★CPU 密集的图片处理要 run_in_threadpool,更重的丢 Celery★。」

七、常见误区与追问

  • 误区:file: bytes = File()file: UploadFile 差不多,前者用起来还更方便。 内存行为完全不同。声明成 bytes 时,FastAPI 会在调用你的函数之前就把整个文件读进内存,然后把 bytes 对象传给你——上传一个 200MB 的视频,进程内存立刻涨 200MB(而且因为 Python 的内存管理,释放后也未必立刻还给操作系统);5 个并发上传就是 1GB,很容易 OOM。而 UploadFile 底层是 SpooledTemporaryFile内容小于阈值(Starlette 默认 1MB)时留在 BytesIO 里、超过就自动 rollover 到磁盘临时文件,对使用者来说接口完全一致,但内存占用恒定。此外 UploadFile 还给了你 filenamecontent_typesize 这些元数据和异步方法。所以规则是:除非你确定文件一定很小(比如限制了 100KB 的头像),否则一律用 UploadFile。注意即使用了 UploadFileawait file.read() 不带参数仍然会把全部内容读进内存——大文件要分块读。
  • 误区:FastAPI 会自动限制上传大小,不用额外配置。 它没有任何内置限制——这和 Flask 的 MAX_CONTENT_LENGTH 不同,是很多人从 Flask 转过来后的盲区。默认情况下,任何人都可以往你的上传接口发一个 10GB 的请求UploadFile 会老老实实地把它写进磁盘临时文件,结果就是磁盘被打满/tmp 满了之后整个系统都会出问题)。需要三层防护:① Nginx 的 client_max_body_size 是最有效的一层,超出时 Nginx 直接返回 413,请求体根本不会转发给应用② 一个检查 Content-Length 请求头的 ASGI 中间件,能在读取任何数据之前快速拒绝;③ 在分块读取时累加实际字节数,超过限制就中止并删除已写入的部分。为什么三层都要?因为 Content-Length 可以被伪造(声明 1MB 却发 1GB),而且 chunked 传输编码时根本没有这个头——只有第三层是真正可靠的。
  • 误区:读了文件头做魔数校验之后,直接保存就行。 会保存出空文件或残缺文件——因为读取会移动文件指针head = await file.read(16) 之后,指针停在第 16 字节;接下来如果你 await file.read() 保存,得到的是第 17 字节之后的内容(前 16 字节丢了);如果之前已经用 await file.read() 读完了全部内容做校验,那么再读就是空的,保存出来是 0 字节文件。每次读取之后都必须 await file.seek(0) 把指针复位。这是文件上传最高频的低级错误,而且现象很迷惑:接口返回成功、数据库有记录、但文件打不开。同样的道理适用于用 Pillow 校验:img.verify() 之后必须重新 Image.open()(verify 会消耗掉文件对象),而且中间要 seek(0)
  • 误区:BackgroundTasks 里可以直接处理上传的 UploadFile 请求结束时临时文件已经被删除了。Starlette 在响应发送完成后会关闭 UploadFile,而 SpooledTemporaryFileclose()删除底层的临时文件。所以 bt.add_task(process_image, file) 这种写法,任务真正执行时拿到的是一个已关闭的文件对象——报 ValueError: I/O operation on closed file 或者读到空内容。正确做法是先把内容保存到你自己控制的持久位置/data/tmp/{uuid}.tmp),然后把路径传给后台任务,任务处理完再删除。顺带说,即使解决了这个问题,BackgroundTasks 仍然运行在当前的 Web 进程里——视频转码、大图批处理这类重活会占用应用资源、影响正常请求的响应,应该交给 Celery 这类外部队列
  • 误区:把上传的文件存到 StaticFiles 挂载的目录下最方便,前端直接拿 URL 就能访问。 这等于放弃了所有权限控制app.mount("/static", StaticFiles(directory="static")) 之后,那个目录下的任何文件都能被直接 URL 访问——用户的身份证照片、合同、私密图片全部裸奔;如果文件名用了自增 id 还能被直接枚举。更严重的是执行风险:虽然 StaticFiles 本身不会执行脚本,但如果你的部署架构里 Nginx 也指向了同一个目录,配置疏漏就可能让上传的 webshell 被执行。正确做法是存到 Web 根目录之外/var/data/uploads),通过一个做了权限校验的路由下发;更进一步用 X-Accel-Redirect(应用只鉴权、Nginx 零拷贝传输)或对象存储的预签名 URL。只有确实完全公开且经过重新编码的资源(如公开头像)才适合放在可直接访问的目录。
  • 追问:UploadFile 的异步方法是真异步吗? 不是真异步,是线程池包装await file.read() 内部实际执行的是 await run_in_threadpool(self.file.read, size)——因为普通文件的 IO 在 Linux 上没有真正的异步支持O_NONBLOCK 对普通文件无效,磁盘 IO 总是”就绪”的,实际会阻塞),所以 asyncio 生态里的文件操作(包括 aiofiles本质上都是把同步 IO 丢进线程池。这带来两个实际影响:① 它占用 anyio 那个默认 40 token 的线程池——大量并发上传时,文件读写会和 def 路由抢线程,可能成为瓶颈(可以调大 total_tokens);② 不要以为「加了 await 就不阻塞」——如果你在 async def 里用普通的 open()f.write() 写一个大文件,那是真的会阻塞事件循环的,必须用 aiofilesrun_in_threadpool。真正的异步文件 IO 需要 io_uring(Linux 5.1+),Python 生态还没有成熟的封装。
  • 追问:为什么说「重新编码」是图片上传最好的安全措施? 因为它一次性解决了四个问题① 销毁隐藏的载荷——攻击者可以把 PHP 代码或 JS 附加在图片文件尾部(「图片马」),或者藏在 EXIF 的注释字段里;这些数据在图片解码时会被忽略,但如果服务器配置有疏漏就可能被执行。重新编码后输出的文件只包含像素数据,任何附加内容都消失了。② 清除 EXIF 隐私信息——手机拍的照片默认包含 GPS 坐标、拍摄时间、设备型号,用户上传头像时并不知道自己泄露了家庭住址;convert("RGB") 加重新 save() 会丢弃所有 EXIF。③ 验证它确实是一张合法图片——能被 Pillow 完整解码并重新编码,就排除了「伪装成图片的其他文件」。④ 顺便统一格式和尺寸——省存储、省带宽、前端展示也更一致。代价是 CPU 开销(所以要 run_in_threadpool)和有损压缩的画质损失(可以用 quality=85~90 平衡)。要注意配合 Image.MAX_IMAGE_PIXELS 防「图片炸弹」——一个 10KB 的 PNG 可以解压成 50000×50000 像素、占用 10GB 内存。
  • 追问:什么时候该用对象存储直传而不是经过应用上传? 三个判断维度。① 文件大小——超过 10MB 就该考虑,超过 100MB 基本必须。因为上传期间连接一直占着:ASGI 下虽然不像 WSGI 那样占满一个 worker,但仍然占一个 task 加线程池 token,而且带宽和磁盘 IO 全部经过你的应用服务器(云上的出入带宽是要花钱的)。② 并发量——即使单个文件不大,如果有几百个用户同时上传,应用服务器的带宽和临时磁盘都会成为瓶颈。③ 是否需要断点续传——对象存储的 multipart upload API 原生支持分片、并发上传、单片重试,自己实现一套非常麻烦。直传的流程是:后端签发一个带 content-length-range(大小限制)、Content-Type 限制和过期时间的预签名凭证 → 前端直接 POST 到对象存储 → 前端回调通知后端 → 后端 head_object 确认后落库最关键的安全点在最后一步必须服务端主动查询对象元数据确认文件真实存在及其大小类型,绝不能相信前端上报的信息(否则用户可以不上传就调回调,或者声称传了一个 10MB 的图片实际是 1KB 的垃圾);同时要校验 key 的前缀属于当前用户,防止伪造别人的路径。

八、加强记忆

FastAPI 接收文件有两种声明方式,区别很关键file: bytes = File() 会把整个文件读进内存(200MB 视频就是 200MB 常驻内存,几个并发直接 OOM);file: UploadFile 底层是 SpooledTemporaryFile——小于阈值(默认 1MB)留在内存、超过自动落磁盘临时文件,还提供了 filename/content_type/size 和异步的 read/write/seek所以除了确定很小的文件,一律用 UploadFile。但要注意 await file.read() 不带参数时仍会把整个文件读进内存,大文件必须分块读;而且 UploadFile 的 async 方法其实是 run_in_threadpool 包装的同步 IO(普通文件没有真异步),所以它占用那 40 个线程池 tokenFastAPI/Starlette 没有内置的请求体大小限制(不像 Flask 有 MAX_CONTENT_LENGTH)——三层防护缺一不可:① Nginx 的 client_max_body_size(最有效,超出直接 413、请求体不进应用)、② ASGI 中间件检查 Content-Length(快但可伪造)、③ 读取时累加实际字节(最可靠,因为 chunked 编码时前两层都可能失效)。安全五道防线:filenamecontent_type 都是客户端提供的、完全不可信——文件名可能含 ../ 做路径穿越,所以自己用 UUID 生成、原名存数据库② 类型查魔数,读完必须 await file.seek(0)(否则保存出空文件,这是最高频的低级错误);③ 图片最彻底的做法是用 Pillow 重新编码——一次性清除 EXIF(含 GPS)、销毁图片马、统一格式、验证确实能解码,还要设 Image.MAX_IMAGE_PIXELS 防图片炸弹禁止或清洗 SVG(XML 能内嵌 script);④ 存 Web 根目录之外,绝不能放在 StaticFiles 挂载的目录下(那等于没有权限控制);⑤ 下发前校验权限,用 404 而不是 403(不泄露资源存在性)。下载侧:FileResponse 内部流式且自动支持 Range 断点续传,filename 参数会自动生成 RFC 5987 的中文文件名StreamingResponse 不自动支持 Range生产环境不要让应用发文件——受保护文件用 X-Accel-Redirect(应用只鉴权、Nginx 零拷贝,location 要配 internal),云上最优是预签名 URL大文件的正解是对象存储预签名直传:后端只签发content-length-range 和类型限制的凭证回调必须服务端 head_object 确认、不能信前端报的 size 和 type,还要校验 key 的归属。两个容易忽略的坑:BackgroundTasks 里不能直接用 UploadFile 对象(请求结束时临时文件已被删除),要先保存到自己的位置再传路径;有文件时其他字段必须声明成 Form,且不能同时有 File 和 JSON Body(协议上互斥)。最后,CPU 密集的图片处理要 run_in_threadpool,更重的活丢 Celery