Docs/modules/文件存储

文件存储

NextShip 内置了使用 Cloudflare R2 或 AWS S3 的文件存储功能,通过预签名 URL 实现安全上传。

功能特性

  • S3 兼容存储(R2 或 S3)
  • 预签名 URL 直接上传
  • 数据库中的文件元数据追踪
  • 用户文件管理界面
  • 图片优化支持

配置

Cloudflare R2

R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_BUCKET_NAME=your-bucket
R2_PUBLIC_URL=https://pub-xxx.r2.dev

AWS S3

AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_REGION=us-east-1
S3_BUCKET_NAME=your-bucket

数据库 Schema

// src/lib/db/schema.ts
export const files = pgTable("files", {
  id: text("id").primaryKey(),
  userId: text("user_id").references(() => users.id),
  name: text("name").notNull(),
  key: text("key").notNull(),           // S3 对象键
  size: integer("size").notNull(),
  mimeType: text("mime_type"),
  url: text("url"),                     // 公开 URL
  createdAt: timestamp("created_at").defaultNow(),
});

Server Actions

获取上传 URL

// src/server/actions/files.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
 
const s3 = new S3Client({
  region: "auto",
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});
 
export async function getUploadUrl(params: {
  filename: string;
  contentType: string;
}) {
  const session = await requireAuth();
 
  const key = `${session.user.id}/${crypto.randomUUID()}-${params.filename}`;
 
  const command = new PutObjectCommand({
    Bucket: process.env.R2_BUCKET_NAME,
    Key: key,
    ContentType: params.contentType,
  });
 
  const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
 
  return {
    uploadUrl,
    key,
    publicUrl: `${process.env.R2_PUBLIC_URL}/${key}`,
  };
}

保存文件元数据

export async function saveFile(params: {
  name: string;
  key: string;
  size: number;
  mimeType: string;
  url: string;
}) {
  const session = await requireAuth();
 
  const [file] = await db.insert(files).values({
    id: crypto.randomUUID(),
    userId: session.user.id,
    ...params,
  }).returning();
 
  return { data: file };
}

列出用户文件

export async function getMyFiles(params: {
  page: number;
  limit: number;
}) {
  const session = await requireAuth();
 
  const userFiles = await db.query.files.findMany({
    where: eq(files.userId, session.user.id),
    orderBy: desc(files.createdAt),
    limit: params.limit,
    offset: (params.page - 1) * params.limit,
  });
 
  const total = await db
    .select({ count: count() })
    .from(files)
    .where(eq(files.userId, session.user.id));
 
  return {
    items: userFiles,
    total: total[0].count,
  };
}

删除文件

import { DeleteObjectCommand } from "@aws-sdk/client-s3";
 
export async function deleteFile(fileId: string) {
  const session = await requireAuth();
 
  // 获取文件以验证所有权
  const file = await db.query.files.findFirst({
    where: and(
      eq(files.id, fileId),
      eq(files.userId, session.user.id)
    ),
  });
 
  if (!file) {
    return { success: false, error: { code: "NOT_FOUND", message: "File not found" } };
  }
 
  // 从 S3 删除
  await s3.send(new DeleteObjectCommand({
    Bucket: process.env.R2_BUCKET_NAME,
    Key: file.key,
  }));
 
  // 从数据库删除
  await db.delete(files).where(eq(files.id, fileId));
 
  return { success: true, data: { deleted: true } };
}

客户端使用

上传组件

"use client";
 
import { getUploadUrl, saveFile } from "@/server/actions/files";
 
export function FileUpload() {
  const [uploading, setUploading] = useState(false);
 
  const handleUpload = async (file: File) => {
    setUploading(true);
 
    try {
      // 1. 获取预签名 URL
      const { uploadUrl, key, publicUrl } = await getUploadUrl({
        filename: file.name,
        contentType: file.type,
      });
 
      // 2. 直接上传到 S3/R2
      await fetch(uploadUrl, {
        method: "PUT",
        body: file,
        headers: {
          "Content-Type": file.type,
        },
      });
 
      // 3. 保存元数据到数据库
      await saveFile({
        name: file.name,
        key,
        size: file.size,
        mimeType: file.type,
        url: publicUrl,
      });
    } finally {
      setUploading(false);
    }
  };
 
  return (
    <input
      type="file"
      onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
      disabled={uploading}
    />
  );
}

文件列表组件

"use client";
 
import { getMyFiles, deleteFile } from "@/server/actions/files";
 
export function FileList() {
  const [files, setFiles] = useState([]);
 
  useEffect(() => {
    getMyFiles({ page: 1, limit: 20 }).then(setFiles);
  }, []);
 
  const handleDelete = async (fileId: string) => {
    await deleteFile(fileId);
    setFiles(files.filter(f => f.id !== fileId));
  };
 
  return (
    <ul>
      {files.map(file => (
        <li key={file.id}>
          <a href={file.url}>{file.name}</a>
          <button onClick={() => handleDelete(file.id)}>删除</button>
        </li>
      ))}
    </ul>
  );
}

文件管理页面

文件页面 (/files) 提供:

  • 拖放文件上传
  • 带分页的文件列表
  • 文件预览(图片)
  • 删除功能
  • 存储用量显示

最佳实践

  1. 验证文件类型 - 上传前检查 MIME 类型
  2. 设置大小限制 - 防止过大的文件上传
  3. 使用预签名 URL - 永远不要将凭证暴露给客户端
  4. 清理孤立文件 - 删除数据库记录时同时删除 S3 对象
  5. 正确配置 CORS - 为你的域名配置存储桶的 CORS