第 23 章

文件上传:本地存储与云存储方案

第23章:文件上传:本地存储与云存储方案

文件上传看似简单,但架构选择对性能和成本影响巨大。预签名 URL 直传是生产推荐方案——服务器只负责授权,客户端直接传到云存储。

本章核心问题:直接上传与代理上传各有什么优劣?S3 预签名 URL 如何工作?文件类型验证有哪些层次?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

文件上传的架构选择

文件上传看似简单,但架构选择对性能和成本影响巨大。最直观的方案是"文件发给服务器,服务器存到磁盘"。这在单机小项目里可行,但在无服务器(Vercel)、多实例(Docker Swarm)或高流量场景下会立刻暴露问题:无服务器函数没有持久化磁盘;多实例时上传到实例 A 的文件实例 B 看不到;大文件会占用宝贵的服务器带宽和内存。

成熟的文件上传方案分两个方向:直接上传(客户端直接传到云存储,服务器只负责授权)和代理上传(服务器接收文件再转发)。前者更高效,是生产推荐方案;后者更简单,适合小规模或对存储位置有特殊要求的场景。

Route Handler 中处理 multipart/form-data

最基础的服务端接收文件方式:

// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { auth } from '@/auth'

export async function POST(request: NextRequest) {
  const session = await auth()
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const formData = await request.formData()
  const file = formData.get('file') as File | null

  if (!file) {
    return NextResponse.json({ error: 'No file provided' }, { status: 400 })
  }

  // 服务端验证(客户端验证可被绕过,服务端验证是必须的)
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
  if (!allowedTypes.includes(file.type)) {
    return NextResponse.json({ error: 'File type not allowed' }, { status: 400 })
  }

  const maxSize = 5 * 1024 * 1024 // 5MB
  if (file.size > maxSize) {
    return NextResponse.json({ error: 'File too large' }, { status: 400 })
  }

  // 生成安全的文件名(永远不要使用用户提供的文件名)
  const ext = path.extname(file.name).toLowerCase()
  const fileName = `${crypto.randomUUID()}${ext}`
  const uploadDir = path.join(process.cwd(), 'public', 'uploads')

  await mkdir(uploadDir, { recursive: true })

  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)
  await writeFile(path.join(uploadDir, fileName), buffer)

  return NextResponse.json({ url: `/uploads/${fileName}` })
}

这里有几个安全要点:永远不要使用用户提供的文件名,原因是路径遍历攻击(../../etc/passwd)和文件名冲突;使用 crypto.randomUUID() 生成不可预测的唯一文件名。MIME 类型验证要在服务端做,因为 Content-Type 请求头可以被客户端伪造——但即便如此,更可靠的方式是检查文件的 magic bytes(文件头部的特征字节序列)。

Level 2 · 它是怎么运行的(3-5年经验)

Vercel Blob:最简方案

如果部署在 Vercel,Vercel Blob 是最零配置的云存储选项:

npm install @vercel/blob
// app/api/upload/route.ts(Vercel Blob 版本)
import { put } from '@vercel/blob'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'

export async function POST(request: NextRequest) {
  const session = await auth()
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const formData = await request.formData()
  const file = formData.get('file') as File

  if (!file) return NextResponse.json({ error: 'No file' }, { status: 400 })

  // Vercel Blob 直接接受 File 对象
  const blob = await put(`uploads/${crypto.randomUUID()}-${file.name}`, file, {
    access: 'public',
    contentType: file.type,
  })

  return NextResponse.json({ url: blob.url })
}

Vercel Blob 的文件通过全球 CDN 分发,URL 是永久有效的。但这个方案文件先经过你的服务器——不适合大文件或高并发上传。

AWS S3 预签名 URL:生产级直传方案

预签名 URL(Presigned URL)是大文件上传的正确姿势:服务器生成一个临时授权的 S3 URL,客户端直接把文件传给 S3,完全绕过你的服务器。你的服务器带宽和计算资源不参与文件传输。

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// lib/s3.ts
import { S3Client } from '@aws-sdk/client-s3'

export const s3Client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
})
// app/api/upload/presign/route.ts
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { s3Client } from '@/lib/s3'

export async function POST(request: NextRequest) {
  const session = await auth()
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const { fileName, fileType, fileSize } = await request.json()

  // 服务端验证
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
  if (!allowedTypes.includes(fileType)) {
    return NextResponse.json({ error: 'File type not allowed' }, { status: 400 })
  }

  const maxSize = 50 * 1024 * 1024 // 50MB
  if (fileSize > maxSize) {
    return NextResponse.json({ error: 'File too large' }, { status: 400 })
  }

  const key = `uploads/${session.user.id}/${crypto.randomUUID()}-${fileName}`

  const command = new PutObjectCommand({
    Bucket: process.env.AWS_S3_BUCKET!,
    Key: key,
    ContentType: fileType,
    ContentLength: fileSize,
    // 强制 Content-Disposition 防止 XSS(用户上传 HTML 文件)
    ContentDisposition: 'attachment',
  })

  // 预签名 URL 10 分钟内有效
  const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 600 })

  return NextResponse.json({
    presignedUrl,
    key,
    publicUrl: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`,
  })
}

客户端使用预签名 URL 直传,并用 XMLHttpRequest 追踪进度:

// hooks/useFileUpload.ts
'use client'

import { useState } from 'react'

interface UploadState {
  progress: number
  status: 'idle' | 'uploading' | 'success' | 'error'
  url: string | null
  error: string | null
}

export function useFileUpload() {
  const [state, setState] = useState<UploadState>({
    progress: 0,
    status: 'idle',
    url: null,
    error: null,
  })

  async function upload(file: File): Promise<string | null> {
    setState({ progress: 0, status: 'uploading', url: null, error: null })

    try {
      // 1. 从服务器获取预签名 URL
      const res = await fetch('/api/upload/presign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          fileName: file.name,
          fileType: file.type,
          fileSize: file.size,
        }),
      })

      if (!res.ok) {
        const { error } = await res.json()
        throw new Error(error)
      }

      const { presignedUrl, publicUrl } = await res.json()

      // 2. 用 XHR 直传 S3(fetch API 不支持进度事件)
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest()

        xhr.upload.addEventListener('progress', (e) => {
          if (e.lengthComputable) {
            const progress = Math.round((e.loaded / e.total) * 100)
            setState(prev => ({ ...prev, progress }))
          }
        })

        xhr.addEventListener('load', () => {
          if (xhr.status === 200) resolve()
          else reject(new Error(`Upload failed: ${xhr.status}`))
        })

        xhr.addEventListener('error', () => reject(new Error('Network error')))

        xhr.open('PUT', presignedUrl)
        xhr.setRequestHeader('Content-Type', file.type)
        xhr.send(file)
      })

      setState({ progress: 100, status: 'success', url: publicUrl, error: null })
      return publicUrl
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Upload failed'
      setState(prev => ({ ...prev, status: 'error', error: message }))
      return null
    }
  }

  return { ...state, upload }
}

fetch API 目前不支持上传进度事件,所以直传 S3 时需要使用 XMLHttpRequest。这是为数不多 XHR 仍然必要的场景。

Cloudflare R2:更经济的 S3 兼容方案

R2 是 Cloudflare 的对象存储,完全兼容 S3 API,最大优势是零出口流量费用(egress free)——从 R2 读取数据到互联网不收费,而 AWS S3 的出口费用在高流量场景下可以非常昂贵。

// lib/r2.ts — R2 使用 S3 Client,只是 endpoint 不同
import { S3Client } from '@aws-sdk/client-s3'

export const r2Client = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
})

R2 的预签名 URL 逻辑与 S3 完全一致,只需把 s3Client 换成 r2Client。如果你的应用有大量文件读取(图片、视频、附件),R2 的零出口费用可以节省大量成本。

Level 3 · 规范怎么定义的(资深)

上传组件:带预览和进度条

// components/FileUpload.tsx
'use client'

import { useState, useCallback } from 'react'
import { useFileUpload } from '@/hooks/useFileUpload'

interface FileUploadProps {
  onUploadComplete: (url: string) => void
  accept?: string
}

export function FileUpload({ onUploadComplete, accept = 'image/*' }: FileUploadProps) {
  const [preview, setPreview] = useState<string | null>(null)
  const { progress, status, error, upload } = useFileUpload()

  const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    // 客户端验证(用户体验优化,但不能替代服务端验证)
    if (file.size > 50 * 1024 * 1024) {
      alert('文件不能超过 50MB')
      return
    }

    // 图片预览
    if (file.type.startsWith('image/')) {
      const reader = new FileReader()
      reader.onload = (e) => setPreview(e.target?.result as string)
      reader.readAsDataURL(file)
    }

    const url = await upload(file)
    if (url) onUploadComplete(url)
  }, [upload, onUploadComplete])

  return (
    <div className="space-y-4">
      <input
        type="file"
        accept={accept}
        onChange={handleFileChange}
        disabled={status === 'uploading'}
        className="block w-full text-sm"
      />

      {preview && (
        <img src={preview} alt="Preview" className="h-32 w-32 object-cover rounded" />
      )}

      {status === 'uploading' && (
        <div className="space-y-1">
          <div className="flex justify-between text-sm">
            <span>上传中...</span>
            <span>{progress}%</span>
          </div>
          <div className="h-2 bg-gray-200 rounded">
            <div
              className="h-full bg-blue-500 rounded transition-all"
              style={{ width: `${progress}%` }}
            />
          </div>
        </div>
      )}

      {status === 'success' && (
        <p className="text-green-600 text-sm">上传成功</p>
      )}

      {error && (
        <p className="text-red-500 text-sm">{error}</p>
      )}
    </div>
  )
}

文件类型验证的深度

客户端的 accept 属性和 MIME 类型检查都可以被绕过。更可靠的方案是在服务端检查文件的 magic bytes——文件实际内容开头的特征字节序列,与文件扩展名无关:

// lib/validateFile.ts
export async function validateImageMagicBytes(file: File): Promise<boolean> {
  const buffer = await file.slice(0, 12).arrayBuffer()
  const bytes = new Uint8Array(buffer)

  // JPEG: FF D8 FF
  if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return true

  // PNG: 89 50 4E 47 0D 0A 1A 0A
  if (
    bytes[0] === 0x89 && bytes[1] === 0x50 &&
    bytes[2] === 0x4E && bytes[3] === 0x47
  ) return true

  // WebP: RIFF....WEBP
  if (
    bytes[0] === 0x52 && bytes[1] === 0x49 &&
    bytes[2] === 0x46 && bytes[3] === 0x46 &&
    bytes[8] === 0x57 && bytes[9] === 0x45 &&
    bytes[10] === 0x42 && bytes[11] === 0x50
  ) return true

  return false
}

孤立文件的清理

上传文件后,用户可能放弃表单提交,导致已上传的文件永远不被引用。定期清理这些孤立文件可以节省存储成本:

// app/api/cron/cleanup-uploads/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { DeleteObjectsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
import { s3Client } from '@/lib/s3'

// 在 Vercel 上配置为每日 Cron
export async function GET(request: Request) {
  // 验证 Cron 请求(防止未授权触发)
  const authHeader = request.headers.get('authorization')
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // 获取数据库中所有引用的文件 URL
  const referencedUrls = await prisma.user.findMany({
    where: { image: { not: null } },
    select: { image: true },
  })

  const referencedKeys = new Set(
    referencedUrls.map(u => u.image!.split('/').pop())
  )

  // 列出 S3 中的所有文件
  const listCommand = new ListObjectsV2Command({
    Bucket: process.env.AWS_S3_BUCKET!,
    Prefix: 'uploads/',
  })
  const listed = await s3Client.send(listCommand)

  // 找出超过 24 小时且未被引用的文件
  const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
  const orphans = listed.Contents?.filter(obj =>
    obj.LastModified! < oneDayAgo &&
    !referencedKeys.has(obj.Key!.split('/').pop())
  ) ?? []

  if (orphans.length > 0) {
    await s3Client.send(new DeleteObjectsCommand({
      Bucket: process.env.AWS_S3_BUCKET!,
      Delete: {
        Objects: orphans.map(obj => ({ Key: obj.Key! })),
      },
    }))
  }

  return NextResponse.json({ deleted: orphans.length })
}

文件上传是全栈应用的标配功能。本地存储适合开发环境和小规模部署;Vercel Blob 适合快速上线;S3 预签名直传是高流量生产环境的标准方案;R2 是成本敏感场景的优选。选择哪种方案取决于你的规模、部署环境和成本约束——但安全验证(服务端类型检查、随机文件名、权限验证)是所有方案共同的必须要求。

Level 4 · 边界与陷阱(所有人)

陷阱1:永远不要使用用户提供的文件名——路径遍历攻击(../../etc/passwd)和文件名冲突是主要风险。

陷阱2:fetch API 目前不支持上传进度事件——直传 S3 时需要使用 XMLHttpRequest,这是 XHR 仍然必要的为数不多场景。

陷阱3:Cloudflare R2 完全兼容 S3 API 且零出口流量费用——高流量场景可以显著节省成本。

本章评分
4.8  / 5  (6 评分)

💬 留言讨论