云存储
Cloudflare R2 / AWS S3 兼容存储配置与使用
云存储
Sistine Starter 使用 Cloudflare R2 或其他 S3 兼容存储来处理用户上传的图片、视频等文件。本指南将帮助你配置存储服务,实现文件上传和管理。
为什么需要云存储?
在生产环境中,将用户上传的文件存储到云端具有以下优势:
- 可靠性: 文件持久化存储,不受服务器重启影响
- 可扩展性: 自动扩容,无需担心磁盘空间
- 性能: CDN 加速访问,全球快速分发
- 成本效益: 按实际使用量付费
- 安全性: 专业的访问控制和备份机制
选择存储提供商
Cloudflare R2 (推荐)
Cloudflare R2 是 S3 兼容的对象存储服务,具有零出站费用。
优势:
- 免费额度: 10GB 存储 + 1000 万次读取/月
- 零出站费用: 无数据传输费用
- S3 兼容: 使用标准 AWS SDK
- CDN 集成: 自动全球加速
- 简单定价: 存储 $0.015/GB/月
设置步骤:
- 访问 Cloudflare Dashboard
- 进入 R2 页面创建存储桶(Bucket)
- 生成 API Token (R2 API Token)
- 配置自定义域名 (用于公开访问)
AWS S3
AWS S3 是业界标准的对象存储服务。
优势:
- 成熟稳定
- 功能丰富 (版本控制、生命周期管理等)
- 全球可用
劣势:
- 出站流量费用较高
- 配置相对复杂
其他 S3 兼容存储
以下服务也支持 S3 兼容 API:
- Backblaze B2 - 低成本存储
- DigitalOcean Spaces - 简单易用
- MinIO - 自托管方案
配置云存储
环境变量配置
在 .env.local
中添加以下配置:
# ====================================
# Storage (Optional)
# ====================================
# Cloudflare R2 或 S3 兼容存储配置
# 存储访问密钥 ID
STORAGE_ACCESS_KEY_ID="your-access-key-id"
# 存储访问密钥
STORAGE_SECRET_ACCESS_KEY="your-secret-access-key"
# 存储桶名称
STORAGE_BUCKET_NAME="your-bucket-name"
# S3 兼容 API 端点
STORAGE_ENDPOINT="https://your-account-id.r2.cloudflarestorage.com"
# 公开访问 URL (配置自定义域名后的地址)
STORAGE_PUBLIC_URL="https://cdn.yourdomain.com"
Cloudflare R2 配置示例
# Cloudflare R2 配置
STORAGE_ACCESS_KEY_ID="a1b2c3d4e5f6g7h8i9j0"
STORAGE_SECRET_ACCESS_KEY="a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
STORAGE_BUCKET_NAME="sistine-uploads"
STORAGE_ENDPOINT="https://abc123.r2.cloudflarestorage.com"
STORAGE_PUBLIC_URL="https://cdn.yourdomain.com"
获取这些值:
-
存储桶名称 (STORAGE_BUCKET_NAME):
- 在 R2 控制台创建存储桶时指定的名称
-
API Token (STORAGE_ACCESS_KEY_ID & STORAGE_SECRET_ACCESS_KEY):
- R2 控制台 → 设置 → API Tokens
- 创建新的 API Token
- 权限: 对指定存储桶的读写权限
-
Endpoint (STORAGE_ENDPOINT):
- 格式:
https://<account-id>.r2.cloudflarestorage.com
- 在 R2 控制台的存储桶详情中可以找到
- 格式:
-
公开 URL (STORAGE_PUBLIC_URL):
- R2 控制台 → 你的存储桶 → 设置 → 自定义域名
- 绑定你自己的域名 (例如
cdn.yourdomain.com
) - 如果不配置自定义域名,可以使用 R2 的公开 URL
AWS S3 配置示例
# AWS S3 配置
STORAGE_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
STORAGE_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
STORAGE_BUCKET_NAME="my-app-uploads"
STORAGE_ENDPOINT="https://s3.us-east-1.amazonaws.com"
STORAGE_PUBLIC_URL="https://my-app-uploads.s3.us-east-1.amazonaws.com"
存储库的使用
核心函数
项目在 lib/r2-storage.ts
中提供了存储操作的封装:
上传图片 (从 URL)
import { uploadImageFromUrl } from "@/lib/r2-storage";
// 从 URL 下载图片并上传到 R2
const publicUrl = await uploadImageFromUrl(
"https://example.com/image.jpg",
userId,
"image" // 或 "video"
);
console.log("上传成功:", publicUrl);
// 输出: https://cdn.yourdomain.com/images/user123/1234567890_abc123.jpg
上传文件 (从 Buffer)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const STORAGE_ACCESS_KEY_ID = process.env.STORAGE_ACCESS_KEY_ID!;
const STORAGE_SECRET_ACCESS_KEY = process.env.STORAGE_SECRET_ACCESS_KEY!;
const STORAGE_ENDPOINT = process.env.STORAGE_ENDPOINT!;
const STORAGE_BUCKET_NAME = process.env.STORAGE_BUCKET_NAME!;
const STORAGE_PUBLIC_URL = process.env.STORAGE_PUBLIC_URL!;
// 创建 S3 客户端
const r2Client = new S3Client({
region: "auto",
endpoint: STORAGE_ENDPOINT,
credentials: {
accessKeyId: STORAGE_ACCESS_KEY_ID,
secretAccessKey: STORAGE_SECRET_ACCESS_KEY,
},
});
// 上传文件
const key = `uploads/${userId}/${Date.now()}_file.jpg`;
const command = new PutObjectCommand({
Bucket: STORAGE_BUCKET_NAME,
Key: key,
Body: buffer, // Buffer 或 Uint8Array
ContentType: "image/jpeg",
});
await r2Client.send(command);
const publicUrl = `${STORAGE_PUBLIC_URL}/${key}`;
删除文件
import { deleteFromR2 } from "@/lib/r2-storage";
// 删除文件
await deleteFromR2("images/user123/1234567890_abc123.jpg");
检查存储是否配置
import { isR2Configured } from "@/lib/r2-storage";
if (isR2Configured()) {
console.log("存储已配置");
} else {
console.log("存储未配置,将使用备用方案");
}
API 端点
上传图片 API
端点: POST /api/upload/image
功能: 上传图片到云存储,支持视频生成所需的尺寸验证。
请求:
// 前端上传代码
const formData = new FormData();
formData.append('file', file); // File 对象
const response = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
});
const data = await response.json();
console.log('上传成功:', data.url);
响应:
{
"url": "https://cdn.yourdomain.com/uploads/user123/1234567890_abc.jpg",
"filename": "my-image.jpg",
"size": 1024567,
"type": "image/jpeg"
}
限制:
- 文件类型: 仅限图片 (
image/*
) - 文件大小: 最大 10MB
- 最小尺寸: 300x300 像素 (用于视频生成)
实现位置: app/api/upload/image/route.ts:55-171
简单上传 API
端点: POST /api/upload/simple
功能: 简单的图片上传 API,返回 base64 Data URL (适合小图片和测试)。
限制:
- 文件类型: 仅限图片 (
image/*
) - 文件大小: 最大 5MB
实现位置: app/api/upload/simple/route.ts:4-66
文件组织结构
项目使用以下目录结构组织存储的文件:
bucket-name/
├── images/ # 图片文件
│ ├── user123/
│ │ ├── 1234567890_abc123.jpg
│ │ └── 1234567891_def456.png
│ └── user456/
│ └── 1234567892_ghi789.webp
├── videos/ # 视频文件
│ ├── user123/
│ │ └── 1234567893_jkl012.mp4
│ └── user456/
│ └── 1234567894_mno345.mp4
└── uploads/ # 通用上传
└── user123/
└── 1234567895_pqr678.jpg
目录说明:
images/{userId}/
- 用户的图片文件videos/{userId}/
- 用户的视频文件uploads/{userId}/
- 用户的通用上传文件
文件命名规则:
{timestamp}_{random}.{extension}
timestamp
: Unix 时间戳 (毫秒)random
: 6 位随机字符串extension
: 文件扩展名 (jpg, png, webp, mp4 等)
实际应用场景
场景 1: AI 图片生成后上传
当用户使用 AI 生成图片后,自动上传到 R2:
// app/api/image/generate/route.ts
import { uploadImageFromUrl } from "@/lib/r2-storage";
// AI 生成图片后
const generatedImageUrl = "https://ai-provider.com/generated/abc123.jpg";
// 上传到 R2 (如果已配置)
const finalUrl = await uploadImageFromUrl(
generatedImageUrl,
userId,
"image"
);
// 保存到数据库
await db.insert(generationHistory).values({
userId,
resultUrl: finalUrl, // 使用 R2 的 URL
type: "image",
// ...
});
实现位置: app/api/image/generate/route.ts:119
场景 2: 用户头像上传
用户在个人资料页面上传头像:
// 前端上传组件
async function uploadAvatar(file: File) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
});
const data = await response.json();
// 更新用户头像
await updateUserAvatar(data.url);
}
场景 3: 视频生成的输入图片
用户上传图片用于图生视频:
// 前端代码
const [imageUrl, setImageUrl] = useState<string>('');
async function handleImageUpload(file: File) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
});
const data = await response.json();
setImageUrl(data.url);
}
// 使用上传的图片生成视频
async function generateVideo() {
const response = await fetch('/api/video/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageUrl, // 使用 R2 的 URL
prompt: "A beautiful sunset scene"
}),
});
}
降级策略
当存储未配置时,项目会自动使用降级策略:
策略 1: 返回原始 URL
// lib/r2-storage.ts
export async function uploadImageFromUrl(
imageUrl: string,
userId: string,
type: 'image' | 'video' = 'image'
): Promise<string> {
// 如果 R2 未配置,直接返回原始 URL
if (!isR2Configured()) {
console.log('R2 not configured, using original URL');
return imageUrl;
}
// 正常上传逻辑...
}
策略 2: 返回 Base64 Data URL
// app/api/upload/image/route.ts
if (!STORAGE_ACCESS_KEY_ID || !STORAGE_SECRET_ACCESS_KEY) {
// 如果 R2 未配置,返回 base64 编码的 Data URL
const base64 = buffer.toString('base64');
const dataUrl = `data:${file.type};base64,${base64}`;
return NextResponse.json({ url: dataUrl });
}
注意: Data URL 适合小图片和测试,生产环境建议配置云存储。
安全最佳实践
1. 访问控制
公开读取,服务器写入:
// R2 存储桶权限配置 (在 Cloudflare Dashboard)
// 1. 存储桶设置 → 公开访问 → 启用
// 2. API Token 权限 → 仅限写入
2. 文件类型验证
// 验证文件类型
if (!file.type.startsWith('image/')) {
return NextResponse.json({
error: "File must be an image"
}, { status: 400 });
}
// 支持的 MIME 类型
const ALLOWED_TYPES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
'image/gif'
];
3. 文件大小限制
// 限制文件大小 (10MB)
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json({
error: "File size must be less than 10MB"
}, { status: 400 });
}
4. 文件名安全
// 使用时间戳和随机字符串生成安全的文件名
const timestamp = Date.now();
const random = Math.random().toString(36).substring(7);
const extension = file.type.split('/')[1];
const safeFilename = `${timestamp}_${random}.${extension}`;
5. 用户隔离
// 为每个用户创建独立目录
const key = `images/${userId}/${safeFilename}`;
成本优化
Cloudflare R2 免费额度
- 存储: 10 GB/月
- Class A 操作 (写入): 100 万次/月
- Class B 操作 (读取): 1000 万次/月
- 出站流量: 完全免费
优化建议
- 图片压缩: 上传前压缩图片,减少存储空间
- CDN 缓存: 利用 Cloudflare CDN 减少源站访问
- 生命周期管理: 定期清理临时文件
- 延迟加载: 前端使用懒加载减少请求
成本估算示例
假设你的应用有 1000 个活跃用户:
每用户平均:
- 上传 10 张图片/月 (每张 500KB)
- 查看 100 次/月
总计:
- 存储: 1000 用户 × 10 图片 × 0.5MB = 5 GB (免费额度内)
- 写入: 1000 用户 × 10 次 = 10,000 次 (免费额度内)
- 读取: 1000 用户 × 100 次 = 100,000 次 (免费额度内)
费用: $0/月 (完全在免费额度内)
生产环境配置
配置 Cloudflare R2
步骤 1: 创建 R2 存储桶
# 或在 Cloudflare Dashboard 中创建
1. 登录 Cloudflare Dashboard
2. 导航到 R2 页面
3. 点击 "Create bucket"
4. 输入存储桶名称: sistine-uploads
步骤 2: 生成 API Token
1. R2 页面 → 设置 → API Tokens
2. 点击 "Create API Token"
3. 权限: 对 sistine-uploads 存储桶的读写权限
4. 复制 Access Key ID 和 Secret Access Key
步骤 3: 配置自定义域名
1. 选择存储桶 → 设置 → 自定义域名
2. 点击 "Connect Domain"
3. 输入域名: cdn.yourdomain.com
4. 添加 DNS 记录 (Cloudflare 会自动配置)
步骤 4: 配置环境变量
在 Vercel 或其他部署平台的环境变量中添加:
STORAGE_ACCESS_KEY_ID="your-access-key-id"
STORAGE_SECRET_ACCESS_KEY="your-secret-access-key"
STORAGE_BUCKET_NAME="sistine-uploads"
STORAGE_ENDPOINT="https://abc123.r2.cloudflarestorage.com"
STORAGE_PUBLIC_URL="https://cdn.yourdomain.com"
部署到 Vercel
Vercel 的 Serverless Functions 有以下限制:
- 响应大小限制: 4.5MB (Hobby) / 5MB (Pro)
- 执行时间: 10 秒 (Hobby) / 60 秒 (Pro)
建议:
- 使用流式上传处理大文件
- 大文件上传可以考虑使用边缘函数或 API 路由
- 启用 R2 的自动压缩和优化
常见问题
如何在本地开发环境测试?
方法 1: 使用 Cloudflare R2
在 .env.local
中配置真实的 R2 凭据:
STORAGE_ACCESS_KEY_ID="your-dev-access-key"
STORAGE_SECRET_ACCESS_KEY="your-dev-secret-key"
STORAGE_BUCKET_NAME="sistine-uploads-dev"
STORAGE_ENDPOINT="https://abc123.r2.cloudflarestorage.com"
STORAGE_PUBLIC_URL="https://dev-cdn.yourdomain.com"
方法 2: 不配置存储 (使用降级策略)
留空存储环境变量,系统会自动使用 Data URL:
# 留空或不设置
# STORAGE_ACCESS_KEY_ID=""
# STORAGE_SECRET_ACCESS_KEY=""
如何清理旧文件?
手动清理:
import { deleteFromR2 } from "@/lib/r2-storage";
// 删除指定文件
await deleteFromR2("images/user123/old-file.jpg");
自动清理 (使用定时任务):
// app/api/cron/cleanup/route.ts
import { db } from "@/lib/db";
import { generationHistory } from "@/lib/db/schema";
import { deleteFromR2 } from "@/lib/r2-storage";
import { lt } from "drizzle-orm";
export async function GET() {
// 查找 30 天前的记录
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const oldRecords = await db
.select()
.from(generationHistory)
.where(lt(generationHistory.createdAt, thirtyDaysAgo));
// 删除文件
for (const record of oldRecords) {
if (record.resultUrl.includes(process.env.STORAGE_PUBLIC_URL!)) {
const key = record.resultUrl.replace(
`${process.env.STORAGE_PUBLIC_URL}/`,
''
);
await deleteFromR2(key);
}
}
// 删除数据库记录
await db
.delete(generationHistory)
.where(lt(generationHistory.createdAt, thirtyDaysAgo));
return new Response("Cleanup completed");
}
如何处理大文件上传?
对于大文件 (>10MB),考虑使用分片上传:
import {
S3Client,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand
} from "@aws-sdk/client-s3";
// 分片上传大文件
async function uploadLargeFile(file: File) {
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB 每片
const chunks = Math.ceil(file.size / CHUNK_SIZE);
// 创建分片上传
const createResponse = await r2Client.send(
new CreateMultipartUploadCommand({
Bucket: STORAGE_BUCKET_NAME,
Key: filename,
})
);
const uploadId = createResponse.UploadId!;
const parts: { ETag: string; PartNumber: number }[] = [];
// 上传每一片
for (let i = 0; i < chunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const partResponse = await r2Client.send(
new UploadPartCommand({
Bucket: STORAGE_BUCKET_NAME,
Key: filename,
UploadId: uploadId,
PartNumber: i + 1,
Body: await chunk.arrayBuffer(),
})
);
parts.push({
ETag: partResponse.ETag!,
PartNumber: i + 1,
});
}
// 完成分片上传
await r2Client.send(
new CompleteMultipartUploadCommand({
Bucket: STORAGE_BUCKET_NAME,
Key: filename,
UploadId: uploadId,
MultipartUpload: { Parts: parts },
})
);
}
如何处理图片格式转换?
使用 Sharp 库进行图片处理:
pnpm add sharp
import sharp from 'sharp';
// 转换为 WebP 并压缩
const buffer = await file.arrayBuffer();
const processedBuffer = await sharp(Buffer.from(buffer))
.webp({ quality: 80 })
.resize(1920, 1080, { fit: 'inside' })
.toBuffer();
// 上传处理后的图片
await uploadToR2(key, processedBuffer, 'image/webp');
如何实现图片的临时访问链接?
使用预签名 URL (Presigned URL):
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
// 生成 1 小时有效的临时访问链接
async function generatePresignedUrl(key: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: STORAGE_BUCKET_NAME,
Key: key,
});
const url = await getSignedUrl(r2Client, command, {
expiresIn: 3600 // 1 小时
});
return url;
}
注意: 需要安装 @aws-sdk/s3-request-presigner
:
pnpm add @aws-sdk/s3-request-presigner