对象存储与文件管理
深入学习如何在 NextDevKit 中使用对象存储,掌握预签名 URL、Image Proxy 和文件上传的完整功能。
在现代 SaaS 应用中,文件存储是不可缺少的功能。用户需要上传头像、文档、图片等文件,应用需要安全、高效地管理这些资源。NextDevKit 提供了完整的对象存储解决方案。
🤔 为什么需要对象存储?
传统文件存储的问题
本地文件存储的局限性:
- 扩展性差:服务器磁盘空间有限,难以处理大量文件
- 性能瓶颈:大文件传输会占用服务器带宽和计算资源
- 安全风险:文件直接暴露在服务器上,容易被恶意访问
- 维护成本高:需要处理文件备份、容灾等复杂问题
对象存储的优势
对象存储(Object Storage) 解决了这些问题:
✅ 无限扩展:理论上无限的存储空间
✅ 高可用性:自动备份和容灾机制
✅ 全球分发:CDN 加速,快速访问
✅ 安全控制:细粒度的访问权限控制
✅ 成本优化:按使用量付费,无需预置资源
🏗️ NextDevKit 存储架构
NextDevKit 采用了抽象化的存储架构,支持多种存储服务商:
架构设计理念
1. Provider 抽象:统一接口,支持多种存储服务
2. 预签名 URL:客户端直接上传,减少服务器负载
3. Image Proxy:安全的图片访问和缓存
4. 类型安全:完整的 TypeScript 支持
目录结构
核心模块功能:
- actions.ts:Server Actions,处理文件上传请求
- types.ts:存储相关的 TypeScript 类型定义
- providers/:存储服务商抽象层
- image-proxy/:图片代理路由,安全访问存储的图片
支持的存储服务
NextDevKit 默认支持:
- AWS S3:最流行的对象存储服务
- Cloudflare R2:S3 兼容,成本更低
- 可扩展:基于统一接口,轻松添加其他服务商
🔐 预签名 URL 核心概念
什么是预签名 URL?
预签名 URL(Pre-signed URL) 是一种临时的、带有签名的 URL,允许客户端直接访问存储服务,而无需暴露存储凭据。
预签名 URL 工作流程:
- 客户端请求:客户端向 NextDevKit 服务器请求上传权限
- 生成签名:服务器调用存储服务 API 生成预签名 URL
- 返回 URL:服务器将临时 URL 返回给客户端
- 直接上传:客户端使用预签名 URL 直接上传文件到存储服务
- 上传完成:存储服务确认上传成功
预签名 URL 的优势
1. 安全性:
- 临时有效,自动过期
- 不暴露存储凭据
- 限制特定操作权限
2. 性能:
- 客户端直传,不经过应用服务器
- 减少服务器带宽消耗
- 提高上传速度
3. 可控性:
- 灵活设置过期时间
- 限制文件类型和大小
- 指定存储位置
⚙️ 环境配置
NextDevKit 支持多种存储服务,根据你的部署平台选择合适的配置。
具体的 S3 和 R2 的配置可以参考 NextDevKit 存储 的配置。
NextDevKit 应用配置
无论选择哪种存储服务,都需要在应用配置中设置:
export const appConfig = {
storage: {
provider: "s3", // S3 兼容协议
bucketNames: {
avatars: process.env.NEXT_PUBLIC_AVATARS_BUCKET_NAME || "avatars",
},
},
} as const;不同部署模板的存储选择
Cloudflare Workers 模板:
- 使用 Cloudflare R2
- 原因:同一生态系统,延迟更低,成本更优
SST AWS 模板:
- 使用 AWS S3
- 原因:原生 AWS 集成,IAM 权限管理
Next.js 标准部署(Vercel/Docker):
- 可选择 Cloudflare R2 或 AWS S3
- 根据成本和地理位置需求决定
AWS S3 设置
环境变量
STORAGE_REGION=your_region # 例如 us-east-1
STORAGE_ACCESS_KEY_ID=your_access_key_id
STORAGE_SECRET_ACCESS_KEY=your_secret_access_key
# 存储桶名称(可选,可在应用配置中配置)
NEXT_PUBLIC_AVATARS_BUCKET_NAME=your-bucket-nameAWS S3 存储桶设置
-
创建 AWS 账户:在 aws.amazon.com 注册
-
创建 S3 存储桶:
- 转到 S3 对象存储
- 创建新存储桶
- 如有需要,禁用"阻止所有公共访问"
-
配置存储桶策略:
- 转到存储桶权限
- 添加以下策略:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": "*", "Action": "s3:*", "Resource": "arn:aws:s3:::your-bucket-name/*" } ] } -
配置 CORS:
- 转到存储桶 CORS
- 添加以下 CORS 配置:
[ { "AllowedHeaders": [ "*" ], "AllowedMethods": [ "PUT", "GET", "HEAD", "POST" ], "AllowedOrigins": [ "*" ], "ExposeHeaders": [], "MaxAgeSeconds": 3000 } ] -
生成 API 令牌:
- 创建具有 S3 权限的 API 令牌
- 用作访问密钥凭证
Cloudflare R2 设置
环境变量
STORAGE_REGION=auto
STORAGE_ACCESS_KEY_ID=your_access_key_id
STORAGE_SECRET_ACCESS_KEY=your_secret_access_key
STORAGE_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
# 存储桶名称(可选,可在应用配置中配置)
NEXT_PUBLIC_AVATARS_BUCKET_NAME=your-bucket-nameCloudflare R2 存储桶设置
-
创建 Cloudflare 账户:在 cloudflare.com 注册
-
创建 R2 存储桶:
- 转到 R2 对象存储
- 创建新存储桶
- 为您的存储桶设置自定义域名
-
配置 CORS:
- 转到存储桶 CORS
- 添加以下 CORS 配置:
[ { "AllowedOrigins": [ "*" ], "AllowedMethods": [ "PUT", "GET", "HEAD" ], "AllowedHeaders": [ "Content-Type" // 必须设置 ], "ExposeHeaders": [], "MaxAgeSeconds": 3000 } ] -
创建新的 API 令牌:
- 转到 R2/API/管理 API 令牌
- 点击
创建用户 API 令牌 - 将权限设置为存储桶的
对象读写 - 创建 API 令牌,获取
访问密钥 ID和秘密访问密钥
📤 文件上传功能详解
Server Action:获取上传 URL
NextDevKit 提供了 getSignedUploadUrl Server Action:
export const getSignedUploadUrl = actionClient
.inputSchema(z.object({
bucket: z.string().min(1),
key: z.string().min(1),
contentType: z.string().min(1),
}))
.outputSchema(z.string())
.action(async ({ parsedInput: { bucket, key, contentType } }) => {
const storageProvider = getStorageProvider();
return await storageProvider.getSignedUploadUrl({
bucket,
key,
contentType,
});
});客户端文件上传实现
"use client";
import { getSignedUploadUrl } from "@/storage/actions";
import { appConfig } from "@/config";
import { useState } from "react";
export default function AvatarUpload() {
const [uploading, setUploading] = useState(false);
const handleFileUpload = async (file: File) => {
setUploading(true);
try {
// 1. 生成唯一的文件名
const fileExtension = file.name.split('.').pop();
const fileName = `user-${Date.now()}.${fileExtension}`;
// 2. 获取预签名上传 URL
const signedUrl = await getSignedUploadUrl({
bucket: appConfig.storage.bucketNames.avatars,
key: fileName,
contentType: file.type,
});
// 3. 直接上传到存储服务
const uploadResponse = await fetch(signedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (uploadResponse.ok) {
console.log("文件上传成功!", fileName);
// 4. 更新用户信息或界面
await updateUserAvatar(fileName);
}
} catch (error) {
console.error("上传失败:", error);
} finally {
setUploading(false);
}
};
return (
<div className="space-y-4">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
}}
disabled={uploading}
className="file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0"
/>
{uploading && <p>上传中...</p>}
</div>
);
}上传流程解析
完整的文件上传流程:
- 前端选择文件:用户通过 input 选择文件
- 请求上传权限:调用
getSignedUploadUrlServer Action - 生成预签名 URL:服务器验证权限并生成临时 URL
- 直接上传:前端使用 fetch 将文件上传到存储服务
- 更新记录:上传成功后更新数据库或用户界面
关键优势:
- 文件不经过 NextJS 服务器,节省带宽
- 上传失败不影响应用性能
- 支持大文件上传
🖼️ Image Proxy 功能
什么是 Image Proxy?
Image Proxy 是一个代理服务,为存储的图片提供安全访问和缓存优化。
NextDevKit Image Proxy 实现
export const GET = async (
_req: Request,
{ params }: { params: Promise<{ path: string[] }> }
) => {
const { path } = await params;
const [bucket, filePath] = path;
// 验证存储桶权限
if (bucket === appConfig.storage.bucketNames.avatars) {
// 生成访问 URL
const signedUrl = await getStorageProvider().getSignedUrl({
bucket,
key: filePath,
expiresIn: 60 * 60, // 1 小时
});
// 重定向到签名 URL,添加缓存头
return NextResponse.redirect(signedUrl, {
headers: { "Cache-Control": "max-age=3600" },
});
}
return new Response("Not found", { status: 404 });
};Image Proxy 使用方式
import Image from "next/image";
export default function UserAvatar({ fileName }: { fileName: string }) {
// 通过 Image Proxy 访问图片
const imageUrl = `/image-proxy/${appConfig.storage.bucketNames.avatars}/${fileName}`;
return (
<Image
src={imageUrl}
alt="用户头像"
width={64}
height={64}
className="rounded-full"
/>
);
}Image Proxy 的核心优势
1. 安全性:
- 隐藏真实存储 URL
- 访问权限集中控制
- 防止直接访问存储服务
2. 性能优化:
- HTTP 缓存头优化
- 减少重复的签名生成
- 支持 CDN 缓存
3. 灵活性:
- 可添加图片处理功能
- 支持不同存储桶的差异化处理
- 易于监控和日志记录
🔧 存储系统架构详解
Provider 抽象层
NextDevKit 的存储系统基于 Provider 模式:
export interface StorageProvider {
// 获取文件访问 URL
getSignedUrl(params: SignedUrlParams): Promise<string>;
// 获取文件上传 URL
getSignedUploadUrl(params: SignedUploadUrlParams): Promise<string>;
}
export interface SignedUrlParams {
bucket: string;
key: string;
expiresIn: number;
}
export interface SignedUploadUrlParams {
bucket: string;
key: string;
contentType: string;
}🚀 实际应用场景
用户头像管理
"use client";
import { getSignedUploadUrl } from "@/storage/actions";
import { appConfig } from "@/config";
import { authClient } from "@/lib/auth/client";
import { useState } from "react";
import Image from "next/image";
export default function AvatarManager({ currentAvatar }: {
currentAvatar?: string;
}) {
const [uploading, setUploading] = useState(false);
const [avatarKey, setAvatarKey] = useState(currentAvatar);
const handleAvatarUpload = async (file: File) => {
if (file.size > 5 * 1024 * 1024) { // 5MB 限制
alert("文件大小不能超过 5MB");
return;
}
setUploading(true);
try {
// 生成文件名:user-{timestamp}.{extension}
const fileExtension = file.name.split('.').pop();
const fileName = `user-${Date.now()}.${fileExtension}`;
// 获取上传 URL
const result = await getSignedUploadUrl({
bucket: appConfig.storage.bucketNames.avatars,
key: fileName,
contentType: file.type,
});
// 上传文件
const uploadResponse = await fetch(result, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!uploadResponse.ok) {
throw new Error("Upload failed");
}
// 更新用户资料
const { error } = await authClient.updateUser({
image: fileName,
});
if (error) {
throw new Error(error.message);
}
setAvatarKey(fileName);
alert("头像更新成功!");
} catch (error) {
console.error("上传失败:", error);
alert("上传失败,请重试");
} finally {
setUploading(false);
}
};
const avatarUrl = avatarKey
? `/image-proxy/${appConfig.storage.bucketNames.avatars}/${avatarKey}`
: "/default-avatar.png";
return (
<div className="flex items-center space-x-4">
<Image
src={avatarUrl}
alt="用户头像"
width={64}
height={64}
className="rounded-full border-2 border-gray-300"
/>
<div>
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleAvatarUpload(file);
}}
disabled={uploading}
className="hidden"
id="avatar-upload"
/>
<label
htmlFor="avatar-upload"
className="cursor-pointer bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 disabled:opacity-50"
>
{uploading ? "上传中..." : "更换头像"}
</label>
</div>
</div>
);
}文档上传功能
export default function DocumentUpload() {
const handleDocumentUpload = async (file: File) => {
// 支持的文档类型
const allowedTypes = ['application/pdf', 'application/msword', 'text/plain'];
if (!allowedTypes.includes(file.type)) {
alert("只支持 PDF、Word 和文本文件");
return;
}
const fileName = `documents/${Date.now()}-${file.name}`;
const signedUrl = await getSignedUploadUrl({
bucket: "documents", // 假设有文档存储桶
key: fileName,
contentType: file.type,
});
await fetch(signedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
// 保存文档记录到数据库
await saveDocumentRecord({
fileName,
originalName: file.name,
size: file.size,
type: file.type,
});
};
return (
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
<input
type="file"
accept=".pdf,.doc,.docx,.txt"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleDocumentUpload(file);
}}
className="w-full"
/>
<p className="text-sm text-gray-500 mt-2">
支持 PDF、Word、文本文件,最大 10MB
</p>
</div>
);
}📚 总结
NextDevKit 的对象存储系统提供了:
💡 关键技术点
预签名 URL 机制:
- 客户端直传,减少服务器负载
- 临时权限,提高安全性
- 支持大文件上传
Image Proxy 功能:
- 隐藏真实存储 URL
- 缓存优化提高性能
- 集中访问控制
Provider 抽象:
- 统一接口,易于扩展
- 支持 S3、Cloudflare R2 等多种服务
- 配置灵活,环境变量控制
现在你可以在 NextDevKit 项目中构建完整的文件存储功能,为用户提供安全、高效的文件管理体验!
参考资源: