文件存储
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.devAWS 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) 提供:
- 拖放文件上传
- 带分页的文件列表
- 文件预览(图片)
- 删除功能
- 存储用量显示
最佳实践
- 验证文件类型 - 上传前检查 MIME 类型
- 设置大小限制 - 防止过大的文件上传
- 使用预签名 URL - 永远不要将凭证暴露给客户端
- 清理孤立文件 - 删除数据库记录时同时删除 S3 对象
- 正确配置 CORS - 为你的域名配置存储桶的 CORS