开发指南文件存储
文件存储
🚀 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 支持
文件生命周期
- 上传验证: 文件类型、大小、安全检查
- 处理优化: 压缩、格式转换、尺寸调整
- 存储保存: 选择合适的存储提供商
- 访问控制: 权限验证、临时链接
- 清理管理: 自动清理过期文件
安全机制
- 文件类型白名单: 只允许安全的文件类型
- 病毒扫描: 可选的文件安全扫描
- 访问权限: 基于用户角色的文件访问控制
- 临时链接: 生成有时效的安全访问链接
📊 性能优化
CDN 集成
- 全球节点加速
- 智能缓存策略
- 自动压缩传输
- 防盗链保护
图片优化
- 自动格式选择 (WebP > JPEG)
- 渐进式加载
- 响应式图片
- 懒加载支持
存储优化
- 重复文件去重
- 生命周期管理
- 冷热数据分层
- 成本监控
🔒 安全特性
文件安全
- 上传文件扫描
- 恶意文件检测
- 文件类型验证
- 大小限制控制
访问控制
- 基于角色的权限
- 私有文件保护
- 临时访问链接
- 访问日志记录
数据保护
- 加密存储支持
- 备份策略
- 版本控制
- 删除保护
💰 成本管理
使用监控
- 存储用量统计
- 流量消耗分析
- 成本预算控制
- 异常告警
优化策略
- 自动清理过期文件
- 重复文件去重
- 冷数据归档
- 提供商成本对比
🚀 最佳实践
文件命名
- 使用 UUID 避免冲突
- 保留原始文件名
- 按日期分层存储
- 统一命名规范
性能优化
- 图片延迟加载
- 缩略图预生成
- CDN 缓存策略
- 并发上传控制
用户体验
- 上传进度显示
- 拖拽上传支持
- 预览功能
- 错误提示友好
为什么这样设计: 抽象层设计让我们可以灵活选择存储提供商,根据业务需求和成本考虑进行调整。同时保证了代码的一致性和可维护性。