文档
开发指南文件存储

文件存储

🚀 Cheatsheet (快速执行)

目标: 10分钟内实现文件上传功能,让用户可以上传头像和文档

最快上手方式:

# 1. 配置存储提供商 (本地开发用本地存储)
# 生产环境推荐 AWS S3 或 Cloudinary

# 2. 环境变量配置 (可选,本地存储无需配置)
echo 'AWS_ACCESS_KEY_ID=你的密钥' >> .env.local
echo 'AWS_SECRET_ACCESS_KEY=你的私钥' >> .env.local
echo 'AWS_S3_BUCKET=你的bucket名' >> .env.local

# 3. 测试文件上传
# 访问 /app/settings 页面上传头像
# 或访问 /app/upload 测试文件上传功能

立即生效: 用户可以拖拽上传文件,自动压缩优化,支持预览

❓ 为什么要做文件存储系统

对 MVP 的核心价值:

  • 用户体验: 头像上传、文档分享是基础功能,用户期望
  • 内容丰富: 支持图片、视频让产品更生动有趣
  • 数据收集: 用户上传的文件是宝贵的数据资产
  • 商业模式: 存储空间可以作为付费功能点

真实场景举例: 你做了个团队协作工具,用户需要上传项目文档、分享截图、设置团队头像,没有文件存储功能,用户根本无法正常使用产品。

🧠 简要原理 & 最简案例

文件上传流程: 用户选择文件 → 前端上传到API → 存储到云服务 → 返回文件URL → 保存到数据库

最简上传示例:

// 1. 上传组件 (components/FileUpload.tsx)
'use client'
import { useState } from 'react'

export function FileUpload() {
  const [uploading, setUploading] = useState(false)
  
  const handleUpload = async (file: File) => {
    setUploading(true)
    
    const formData = new FormData()
    formData.append('file', file)
    
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData
    })
    
    const { url } = await response.json()
    console.log('文件上传成功:', url)
    setUploading(false)
  }
  
  return (
    <input 
      type="file" 
      onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
      disabled={uploading}
    />
  )
}

// 2. API 路由 (app/api/upload/route.ts)
import { uploadFile } from '@/lib/storage'

export async function POST(request: Request) {
  const formData = await request.formData()
  const file = formData.get('file') as File
  
  // 上传到存储服务
  const url = await uploadFile(file, 'uploads')
  
  return Response.json({ url })
}

🛠️ 实际操作步骤

1. 配置存储提供商 (5分钟)

本地存储 (开发环境):

# 无需配置,直接使用
# 文件保存在 public/uploads/ 目录
mkdir -p public/uploads

AWS S3 (生产环境):

# 1. 创建 S3 Bucket
# 访问 AWS Console > S3 > Create bucket

# 2. 获取访问密钥
# IAM > Users > Create user > Attach policies: S3FullAccess

# 3. 配置环境变量
echo 'AWS_ACCESS_KEY_ID=AKIA...' >> .env.local
echo 'AWS_SECRET_ACCESS_KEY=...' >> .env.local
echo 'AWS_S3_BUCKET=your-bucket-name' >> .env.local
echo 'AWS_REGION=us-east-1' >> .env.local

Cloudinary (图片优化):

# 1. 注册 Cloudinary 账号
# 访问 https://cloudinary.com

# 2. 获取配置信息
# Dashboard > Account Details

# 3. 配置环境变量
echo 'CLOUDINARY_CLOUD_NAME=your-cloud-name' >> .env.local
echo 'CLOUDINARY_API_KEY=your-api-key' >> .env.local
echo 'CLOUDINARY_API_SECRET=your-api-secret' >> .env.local

2. 创建文件上传组件 (10分钟)

拖拽上传组件:

// components/upload/FileDropzone.tsx
'use client'
import { useCallback, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, X, CheckCircle } from 'lucide-react'

interface FileDropzoneProps {
  onUpload: (files: File[]) => void
  accept?: Record<string, string[]>
  maxSize?: number
  multiple?: boolean
}

export function FileDropzone({ 
  onUpload, 
  accept = { 'image/*': [] }, 
  maxSize = 5 * 1024 * 1024, // 5MB
  multiple = false 
}: FileDropzoneProps) {
  const [uploading, setUploading] = useState(false)
  const [uploadedFiles, setUploadedFiles] = useState<string[]>([])
  
  const onDrop = useCallback(async (acceptedFiles: File[]) => {
    setUploading(true)
    
    try {
      const uploadPromises = acceptedFiles.map(async (file) => {
        const formData = new FormData()
        formData.append('file', file)
        
        const response = await fetch('/api/upload', {
          method: 'POST',
          body: formData
        })
        
        const { url } = await response.json()
        return url
      })
      
      const urls = await Promise.all(uploadPromises)
      setUploadedFiles(prev => [...prev, ...urls])
      onUpload(acceptedFiles)
      
    } catch (error) {
      console.error('上传失败:', error)
      alert('上传失败,请重试')
    } finally {
      setUploading(false)
    }
  }, [onUpload])
  
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept,
    maxSize,
    multiple
  })
  
  return (
    <div className="w-full">
      <div
        {...getRootProps()}
        className={`
          border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
          ${isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}
          ${uploading ? 'pointer-events-none opacity-50' : ''}
        `}
      >
        <input {...getInputProps()} />
        
        <div className="flex flex-col items-center space-y-4">
          <Upload className="w-12 h-12 text-gray-400" />
          
          {uploading ? (
            <p className="text-gray-600">上传中...</p>
          ) : isDragActive ? (
            <p className="text-blue-600">拖拽文件到这里</p>
          ) : (
            <div>
              <p className="text-gray-600 mb-2">
                拖拽文件到这里或点击选择文件
              </p>
              <p className="text-sm text-gray-400">
                支持 {Object.keys(accept).join(', ')},最大 {Math.round(maxSize / 1024 / 1024)}MB
              </p>
            </div>
          )}
        </div>
      </div>
      
      {/* 上传成功的文件列表 */}
      {uploadedFiles.length > 0 && (
        <div className="mt-4 space-y-2">
          <h4 className="font-medium text-gray-900">已上传文件:</h4>
          {uploadedFiles.map((url, index) => (
            <div key={index} className="flex items-center space-x-2 text-sm">
              <CheckCircle className="w-4 h-4 text-green-500" />
              <a href={url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
                {url.split('/').pop()}
              </a>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

3. 实现上传API路由 (10分钟)

通用上传API:

// app/api/upload/route.ts
import { NextRequest } from 'next/server'
import { auth } from '@/lib/auth'
import { uploadFile } from '@/lib/storage'

export async function POST(request: NextRequest) {
  try {
    // 验证用户登录
    const session = await auth()
    if (!session?.user) {
      return Response.json({ error: '请先登录' }, { status: 401 })
    }
    
    const formData = await request.formData()
    const file = formData.get('file') as File
    
    if (!file) {
      return Response.json({ error: '没有选择文件' }, { status: 400 })
    }
    
    // 文件类型验证
    const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
    if (!allowedTypes.includes(file.type)) {
      return Response.json({ error: '不支持的文件类型' }, { status: 400 })
    }
    
    // 文件大小验证 (5MB)
    if (file.size > 5 * 1024 * 1024) {
      return Response.json({ error: '文件大小不能超过 5MB' }, { status: 400 })
    }
    
    // 上传文件
    const folder = file.type.startsWith('image/') ? 'images' : 'documents'
    const url = await uploadFile(file, folder)
    
    // 可选:保存文件记录到数据库
    // await db.file.create({
    //   data: {
    //     userId: session.user.id,
    //     filename: file.name,
    //     url,
    //     size: file.size,
    //     type: file.type
    //   }
    // })
    
    return Response.json({ url, filename: file.name })
    
  } catch (error) {
    console.error('文件上传错误:', error)
    return Response.json({ error: '上传失败' }, { status: 500 })
  }
}

4. 配置存储抽象层 (15分钟)

存储接口定义:

// lib/storage/types.ts
export interface StorageProvider {
  upload(file: File, folder: string): Promise<string>
  delete(url: string): Promise<void>
  getSignedUrl(key: string, expiresIn?: number): Promise<string>
}

export interface UploadOptions {
  folder?: string
  filename?: string
  compress?: boolean
  resize?: { width: number; height: number }
}

本地存储实现:

// lib/storage/providers/local.ts
import { writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { StorageProvider } from '../types'

export class LocalStorageProvider implements StorageProvider {
  private baseDir = 'public/uploads'
  
  async upload(file: File, folder: string): Promise<string> {
    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)
    
    // 创建目录
    const uploadDir = join(this.baseDir, folder)
    await mkdir(uploadDir, { recursive: true })
    
    // 生成文件名
    const timestamp = Date.now()
    const filename = `${timestamp}-${file.name}`
    const filepath = join(uploadDir, filename)
    
    // 保存文件
    await writeFile(filepath, buffer)
    
    // 返回公开访问URL
    return `/uploads/${folder}/${filename}`
  }
  
  async delete(url: string): Promise<void> {
    // 实现文件删除逻辑
    const filepath = join('public', url)
    // await unlink(filepath)
  }
  
  async getSignedUrl(key: string): Promise<string> {
    // 本地存储直接返回公开URL
    return key
  }
}

AWS S3 存储实现:

// lib/storage/providers/s3.ts
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { StorageProvider } from '../types'

export class S3StorageProvider implements StorageProvider {
  private client: S3Client
  private bucket: string
  
  constructor() {
    this.client = new S3Client({
      region: process.env.AWS_REGION!,
      credentials: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
      },
    })
    this.bucket = process.env.AWS_S3_BUCKET!
  }
  
  async upload(file: File, folder: string): Promise<string> {
    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)
    
    const key = `${folder}/${Date.now()}-${file.name}`
    
    const command = new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
      Body: buffer,
      ContentType: file.type,
    })
    
    await this.client.send(command)
    
    return `https://${this.bucket}.s3.amazonaws.com/${key}`
  }
  
  async delete(url: string): Promise<void> {
    const key = url.split('/').slice(-2).join('/')
    
    const command = new DeleteObjectCommand({
      Bucket: this.bucket,
      Key: key,
    })
    
    await this.client.send(command)
  }
  
  async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
    const command = new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
    })
    
    return await getSignedUrl(this.client, command, { expiresIn })
  }
}

存储工厂函数:

// lib/storage/index.ts
import { LocalStorageProvider } from './providers/local'
import { S3StorageProvider } from './providers/s3'
import { StorageProvider } from './types'

function getStorageProvider(): StorageProvider {
  const provider = process.env.STORAGE_PROVIDER || 'local'
  
  switch (provider) {
    case 's3':
      return new S3StorageProvider()
    case 'local':
    default:
      return new LocalStorageProvider()
  }
}

const storage = getStorageProvider()

export async function uploadFile(file: File, folder: string = 'uploads'): Promise<string> {
  return await storage.upload(file, folder)
}

export async function deleteFile(url: string): Promise<void> {
  return await storage.delete(url)
}

export async function getSignedUrl(key: string, expiresIn?: number): Promise<string> {
  return await storage.getSignedUrl(key, expiresIn)
}

💡 高级功能实现

抽象层模式

  • 统一接口: 所有提供商实现相同的接口
  • 配置驱动: 通过配置切换提供商
  • 插件化: 新增提供商只需实现接口
  • 类型安全: 完整的 TypeScript 支持

文件生命周期

  1. 上传验证: 文件类型、大小、安全检查
  2. 处理优化: 压缩、格式转换、尺寸调整
  3. 存储保存: 选择合适的存储提供商
  4. 访问控制: 权限验证、临时链接
  5. 清理管理: 自动清理过期文件

安全机制

  • 文件类型白名单: 只允许安全的文件类型
  • 病毒扫描: 可选的文件安全扫描
  • 访问权限: 基于用户角色的文件访问控制
  • 临时链接: 生成有时效的安全访问链接

📊 性能优化

CDN 集成

  • 全球节点加速
  • 智能缓存策略
  • 自动压缩传输
  • 防盗链保护

图片优化

  • 自动格式选择 (WebP > JPEG)
  • 渐进式加载
  • 响应式图片
  • 懒加载支持

存储优化

  • 重复文件去重
  • 生命周期管理
  • 冷热数据分层
  • 成本监控

🔒 安全特性

文件安全

  • 上传文件扫描
  • 恶意文件检测
  • 文件类型验证
  • 大小限制控制

访问控制

  • 基于角色的权限
  • 私有文件保护
  • 临时访问链接
  • 访问日志记录

数据保护

  • 加密存储支持
  • 备份策略
  • 版本控制
  • 删除保护

💰 成本管理

使用监控

  • 存储用量统计
  • 流量消耗分析
  • 成本预算控制
  • 异常告警

优化策略

  • 自动清理过期文件
  • 重复文件去重
  • 冷数据归档
  • 提供商成本对比

🚀 最佳实践

文件命名

  • 使用 UUID 避免冲突
  • 保留原始文件名
  • 按日期分层存储
  • 统一命名规范

性能优化

  • 图片延迟加载
  • 缩略图预生成
  • CDN 缓存策略
  • 并发上传控制

用户体验

  • 上传进度显示
  • 拖拽上传支持
  • 预览功能
  • 错误提示友好

为什么这样设计: 抽象层设计让我们可以灵活选择存储提供商,根据业务需求和成本考虑进行调整。同时保证了代码的一致性和可维护性。