Sistine Starter

云存储

Cloudflare R2 / AWS S3 兼容存储配置与使用

云存储

Sistine Starter 使用 Cloudflare R2 或其他 S3 兼容存储来处理用户上传的图片、视频等文件。本指南将帮助你配置存储服务,实现文件上传和管理。

为什么需要云存储?

在生产环境中,将用户上传的文件存储到云端具有以下优势:

  • 可靠性: 文件持久化存储,不受服务器重启影响
  • 可扩展性: 自动扩容,无需担心磁盘空间
  • 性能: CDN 加速访问,全球快速分发
  • 成本效益: 按实际使用量付费
  • 安全性: 专业的访问控制和备份机制

选择存储提供商

Cloudflare R2 (推荐)

Cloudflare R2 是 S3 兼容的对象存储服务,具有零出站费用。

优势:

  • 免费额度: 10GB 存储 + 1000 万次读取/月
  • 零出站费用: 无数据传输费用
  • S3 兼容: 使用标准 AWS SDK
  • CDN 集成: 自动全球加速
  • 简单定价: 存储 $0.015/GB/月

设置步骤:

  1. 访问 Cloudflare Dashboard
  2. 进入 R2 页面创建存储桶(Bucket)
  3. 生成 API Token (R2 API Token)
  4. 配置自定义域名 (用于公开访问)

AWS S3

AWS S3 是业界标准的对象存储服务。

优势:

  • 成熟稳定
  • 功能丰富 (版本控制、生命周期管理等)
  • 全球可用

劣势:

  • 出站流量费用较高
  • 配置相对复杂

其他 S3 兼容存储

以下服务也支持 S3 兼容 API:

配置云存储

环境变量配置

.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"

获取这些值:

  1. 存储桶名称 (STORAGE_BUCKET_NAME):

    • 在 R2 控制台创建存储桶时指定的名称
  2. API Token (STORAGE_ACCESS_KEY_ID & STORAGE_SECRET_ACCESS_KEY):

    • R2 控制台 → 设置 → API Tokens
    • 创建新的 API Token
    • 权限: 对指定存储桶的读写权限
  3. Endpoint (STORAGE_ENDPOINT):

    • 格式: https://<account-id>.r2.cloudflarestorage.com
    • 在 R2 控制台的存储桶详情中可以找到
  4. 公开 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 万次/月
  • 出站流量: 完全免费

优化建议

  1. 图片压缩: 上传前压缩图片,减少存储空间
  2. CDN 缓存: 利用 Cloudflare CDN 减少源站访问
  3. 生命周期管理: 定期清理临时文件
  4. 延迟加载: 前端使用懒加载减少请求

成本估算示例

假设你的应用有 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)

建议:

  1. 使用流式上传处理大文件
  2. 大文件上传可以考虑使用边缘函数或 API 路由
  3. 启用 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

下一步