storage 文件存储
S3 兼容的文件存储解决方案,支持预签名 URL、直接上传和图片水印
概述
@mono/storage 是项目的文件存储包,提供了完整的 S3 兼容存储解决方案。它基于 AWS SDK v3 构建,支持腾讯云 COS,并提供了预签名 URL、直接上传、图片水印等功能。
核心功能
- S3 集成: 完整的 AWS S3 SDK v3 支持,兼容腾讯云 COS
- 预签名 URL: 安全的客户端直传,无需暴露凭证
- 直接上传: 服务端文件上传,自动错误处理
- 图片水印: 使用 Sharp 为图片添加 Logo 水印
- URL 工具: 构建公共存储 URL 的辅助函数
- 客户端上传: 浏览器友好的上传,自动降级处理
快速开始
环境配置
在 .env.local 中配置:
# 必需
S3_ENDPOINT=https://your-bucket.cos.ap-region.myqcloud.com
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key
# 可选
S3_REGION=auto # 默认: "auto"
S3_APPID=1234567890 # 腾讯云 COS(会从 bucket 名称自动提取)
S3_PUBLIC_ENDPOINT=https://cdn.example.com # CDN 端点,用于公共访问服务端上传文件
import { uploadFileToS3 } from "@mono/storage";
// 上传文件
await uploadFileToS3("uploads/image.jpg", {
bucket: "my-bucket",
body: fileBuffer,
contentType: "image/jpeg",
});生成预签名 URL
import { getSignedUploadUrl } from "@mono/storage";
// 生成预签名 URL,供客户端直传
const signedUrl = await getSignedUploadUrl("uploads/file.pdf", {
bucket: "my-bucket",
contentType: "application/pdf",
});
// 客户端可以直接 PUT 到这个 URL客户端上传
"use client";
import { uploadWithSignedUrlFallback } from "@mono/storage/client";
async function handleUpload(file: File) {
const publicUrl = await uploadWithSignedUrlFallback({
file,
bucket: "my-bucket",
path: `uploads/${file.name}`,
contentType: file.type,
publicEndpoint: process.env.NEXT_PUBLIC_S3_ENDPOINT,
});
console.log("文件已上传:", publicUrl);
}添加图片水印
import { addWatermark } from "@mono/storage";
// 为图片添加水印
const watermarkedBuffer = await addWatermark(imageBuffer, {
logoSize: 600,
opacity: 0.7,
position: "top-left",
});
// 上传带水印的图片
await uploadFileToS3("photos/watermarked.jpg", {
bucket: "my-bucket",
body: watermarkedBuffer,
contentType: "image/jpeg",
});实战案例
案例 1: 用户头像上传
完整的头像上传流程,包括验证、压缩和上传:
// app/api/avatar/upload/route.ts
import { uploadFileToS3 } from "@mono/storage";
import sharp from "sharp";
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get("avatar") as File;
// 验证文件类型
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) {
return Response.json(
{ error: "只支持 JPG、PNG、WebP 格式" },
{ status: 400 }
);
}
// 验证文件大小(最大 5MB)
if (file.size > 5 * 1024 * 1024) {
return Response.json(
{ error: "文件大小不能超过 5MB" },
{ status: 400 }
);
}
// 压缩图片到 400x400
const buffer = Buffer.from(await file.arrayBuffer());
const compressed = await sharp(buffer)
.resize(400, 400, { fit: "cover" })
.jpeg({ quality: 85 })
.toBuffer();
// 生成唯一文件名
const userId = "user-123"; // 从 session 获取
const filename = `avatars/${userId}-${Date.now()}.jpg`;
// 上传到 S3
await uploadFileToS3(filename, {
bucket: process.env.S3_BUCKET!,
body: compressed,
contentType: "image/jpeg",
});
// 构建公共 URL
const publicUrl = `${process.env.S3_PUBLIC_ENDPOINT}/${filename}`;
return Response.json({ url: publicUrl });
}客户端代码:
// components/AvatarUpload.tsx
"use client";
import { useState } from "react";
export function AvatarUpload() {
const [uploading, setUploading] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append("avatar", file);
const response = await fetch("/api/avatar/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
const { error } = await response.json();
throw new Error(error);
}
const { url } = await response.json();
setAvatarUrl(url);
} catch (error) {
alert(error instanceof Error ? error.message : "上传失败");
} finally {
setUploading(false);
}
}
return (
<div>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleUpload}
disabled={uploading}
/>
{uploading && <p>上传中...</p>}
{avatarUrl && <img src={avatarUrl} alt="Avatar" />}
</div>
);
}案例 2: 客户端直传大文件
使用预签名 URL 实现大文件直传,减轻服务器压力:
// app/api/uploads/signed-upload-url/route.ts
import { getSignedUploadUrl } from "@mono/storage";
import { getPublicStorageUrl } from "@mono/storage/url";
export async function POST(request: Request) {
const { searchParams } = new URL(request.url);
const bucket = searchParams.get("bucket");
const path = searchParams.get("path");
const contentType = searchParams.get("contentType");
if (!bucket || !path) {
return Response.json(
{ error: "缺少必需参数" },
{ status: 400 }
);
}
// 生成预签名 URL
const signedUrl = await getSignedUploadUrl(path, {
bucket,
contentType: contentType || undefined,
});
// 构建公共访问 URL
const publicUrl = getPublicStorageUrl(
path,
process.env.S3_PUBLIC_ENDPOINT
);
return Response.json({ signedUrl, publicUrl });
}客户端使用:
// components/FileUpload.tsx
"use client";
import { uploadWithSignedUrlFallback } from "@mono/storage/client";
import { useState } from "react";
export function FileUpload() {
const [progress, setProgress] = useState(0);
const [fileUrl, setFileUrl] = useState<string | null>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
try {
// 生成唯一路径
const path = `uploads/${Date.now()}-${file.name}`;
// 上传文件(自动使用预签名 URL,失败时降级到直传)
const url = await uploadWithSignedUrlFallback({
file,
bucket: process.env.NEXT_PUBLIC_S3_BUCKET!,
path,
contentType: file.type,
publicEndpoint: process.env.NEXT_PUBLIC_S3_ENDPOINT,
});
setFileUrl(url);
alert("上传成功!");
} catch (error) {
alert("上传失败: " + (error as Error).message);
}
}
return (
<div>
<input type="file" onChange={handleUpload} />
{fileUrl && (
<a href={fileUrl} target="_blank" rel="noopener noreferrer">
查看文件
</a>
)}
</div>
);
}案例 3: 社区照片水印
为社区活动照片自动添加 Logo 水印:
// app/api/photos/upload/route.ts
import { uploadFileToS3, addWatermark, isLogoAvailable } from "@mono/storage";
import sharp from "sharp";
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get("photo") as File;
const addWatermarkFlag = formData.get("watermark") === "true";
// 读取文件
const buffer = Buffer.from(await file.arrayBuffer());
// 优化图片(压缩但保持高质量)
let processedBuffer = await sharp(buffer)
.resize(2000, 2000, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 90 })
.toBuffer();
// 添加水印
if (addWatermarkFlag && (await isLogoAvailable())) {
processedBuffer = await addWatermark(processedBuffer, {
logoSize: 600,
opacity: 0.7,
position: "bottom-right",
});
}
// 上传
const filename = `photos/${Date.now()}-${file.name}`;
await uploadFileToS3(filename, {
bucket: process.env.S3_BUCKET!,
body: processedBuffer,
contentType: "image/jpeg",
});
const publicUrl = `${process.env.S3_PUBLIC_ENDPOINT}/${filename}`;
return Response.json({ url: publicUrl });
}案例 4: 批量文件上传
上传多个文件并跟踪进度:
// components/BatchUpload.tsx
"use client";
import { uploadWithSignedUrlFallback } from "@mono/storage/client";
import { useState } from "react";
type UploadStatus = {
file: File;
status: "pending" | "uploading" | "success" | "error";
url?: string;
error?: string;
};
export function BatchUpload() {
const [uploads, setUploads] = useState<UploadStatus[]>([]);
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files || []);
// 初始化状态
const initialUploads = files.map((file) => ({
file,
status: "pending" as const,
}));
setUploads(initialUploads);
// 并发上传(最多 3 个)
const concurrency = 3;
for (let i = 0; i < files.length; i += concurrency) {
const batch = files.slice(i, i + concurrency);
await Promise.all(
batch.map(async (file, batchIndex) => {
const index = i + batchIndex;
// 更新状态为上传中
setUploads((prev) =>
prev.map((u, idx) =>
idx === index ? { ...u, status: "uploading" } : u
)
);
try {
const path = `uploads/${Date.now()}-${file.name}`;
const url = await uploadWithSignedUrlFallback({
file,
bucket: process.env.NEXT_PUBLIC_S3_BUCKET!,
path,
contentType: file.type,
});
// 更新状态为成功
setUploads((prev) =>
prev.map((u, idx) =>
idx === index ? { ...u, status: "success", url } : u
)
);
} catch (error) {
// 更新状态为失败
setUploads((prev) =>
prev.map((u, idx) =>
idx === index
? {
...u,
status: "error",
error: (error as Error).message,
}
: u
)
);
}
})
);
}
}
return (
<div>
<input type="file" multiple onChange={handleFiles} />
<ul>
{uploads.map((upload, index) => (
<li key={index}>
{upload.file.name} - {upload.status}
{upload.url && <a href={upload.url}>查看</a>}
{upload.error && <span>错误: {upload.error}</span>}
</li>
))}
</ul>
</div>
);
}案例 5: 组织信息 URL 转换
自动转换数据库中的存储路径为公共 URL:
// lib/organization.ts
import { withOrganizationPublicUrls } from "@mono/storage/url";
import { prisma } from "@/lib/prisma";
export async function getOrganization(id: string) {
const org = await prisma.organization.findUnique({
where: { id },
});
if (!org) return null;
// 自动转换 logo、coverImage、audienceQrCode、memberQrCode
return withOrganizationPublicUrls(
org,
process.env.S3_PUBLIC_ENDPOINT
);
}
// 使用
const org = await getOrganization("org-123");
console.log(org.logo); // https://cdn.example.com/logos/org-123.png自定义字段转换:
import { mapPublicStorageUrls } from "@mono/storage/url";
const user = await prisma.user.findUnique({ where: { id } });
// 转换多个字段
const userWithUrls = mapPublicStorageUrls(
user,
["avatar", "coverImage", "idCardFront", "idCardBack"],
process.env.S3_PUBLIC_ENDPOINT
);API 参考
存储操作
getSignedUploadUrl(path, options)
生成用于客户端直传的预签名 URL。
参数:
path(string): 文件在 bucket 中的路径options.bucket(string): Bucket 名称options.contentType(string, 可选): 文件 MIME 类型
返回: Promise<string> - 有效期 60 秒的预签名 URL
uploadFileToS3(path, options)
从服务端直接上传文件到 S3。
参数:
path(string): 文件在 bucket 中的路径options.bucket(string): Bucket 名称options.body(Buffer | Uint8Array | string): 文件内容options.contentType(string, 可选): MIME 类型
返回: Promise<void>
deleteFileFromS3(path, options)
从 S3 删除文件。
参数:
path(string): 文件在 bucket 中的路径options.bucket(string): Bucket 名称
返回: Promise<void>
客户端上传
uploadWithSignedUrlFallback(options)
从浏览器上传文件,自动降级到直传。
参数:
options.file(File): 要上传的文件options.bucket(string): Bucket 名称options.path(string): 目标路径options.contentType(string, 可选): MIME 类型options.publicEndpoint(string, 可选): CDN 端点
返回: Promise<string> - 文件的公共 URL
流程:
- 请求预签名 URL (
/api/uploads/signed-upload-url) - 使用预签名 URL 直接 PUT 到 S3
- 失败时降级到
/api/uploads/direct-upload
buildPublicUrl(path, publicEndpoint?, signedUrl?)
构建文件的公共访问 URL。
参数:
path(string): 文件路径publicEndpoint(string, 可选): CDN 端点signedUrl(string, 可选): 用于提取 origin 的签名 URL
返回: string - 公共 URL
图片水印
addWatermark(imageBuffer, options?)
为图片添加 Logo 水印。
参数:
imageBuffer(Buffer): 原始图片 bufferoptions.logoSize(number, 可选): Logo 宽度(像素),默认 600options.opacity(number, 可选): 透明度 0-1,默认 0.7options.position(string, 可选): 位置,默认 "top-left"- "top-left" | "top-right" | "bottom-left" | "bottom-right"
返回: Promise<Buffer> - 带水印的图片 buffer
要求:
- Logo 文件必须存在于
public/images/logo-white.png - 使用 Sharp 进行图片处理
- 自动处理 EXIF 方向
isLogoAvailable()
检查 Logo 文件是否存在。
返回: Promise<boolean>
URL 工具
getPublicStorageUrl(value, publicEndpoint?)
将存储路径转换为公共 URL。
参数:
value(string | null | undefined): 存储路径或 URLpublicEndpoint(string, 可选): CDN 端点
返回: string | null
行为:
- 如果 value 为 null/undefined,返回 null
- 如果已经是绝对 URL,原样返回
- 如果提供了 publicEndpoint,添加前缀
- 否则返回相对路径
mapPublicStorageUrls(record, keys, publicEndpoint?)
批量转换对象中的多个存储路径。
参数:
record(T): 包含存储路径的对象keys(Array<keyof T>): 要转换的键publicEndpoint(string, 可选): CDN 端点
返回: T - 转换后的新对象
withOrganizationPublicUrls(organization, publicEndpoint?)
转换组织对象的存储路径为公共 URL。
参数:
organization(T): 组织对象publicEndpoint(string, 可选): CDN 端点
返回: T - 带公共 URL 的组织对象
转换的字段:
logocoverImageaudienceQrCodememberQrCode
架构设计
双 S3 客户端
包维护两个独立的 S3 客户端:
-
标准客户端 (
getS3Client):- 用于直接操作(PUT、DELETE)
- 包含腾讯云 COS 中间件,注入 Appid 头
- 支持虚拟主机式 URL
-
预签名客户端 (
getS3ClientForPresign):- 仅用于生成预签名 URL
- 不包含 Appid 中间件(避免签名问题)
- 移除灵活校验和中间件
腾讯云 COS 兼容性
包含腾讯云 COS 的特殊处理:
// 中间件自动注入 Appid 头
const tencentCosMiddleware = {
name: "tencentCosMiddleware",
middleware: (next, _context) => {
return async (args) => {
// 检测 COS 端点
if (endpoint.includes(".myqcloud.com")) {
// 从 bucket 名称提取 Appid
const appidMatch = bucket.match(/-(\d+)$/);
if (appidMatch) {
args.request.headers.Appid = appidMatch[1];
}
}
return next(args);
};
},
};上传流程
客户端预签名 URL 上传:
客户端 服务端 S3
| | |
|-- POST /signed-url ---->| |
| |-- 生成 URL ----------->|
|<----- 签名 URL ---------| |
| |
|-- PUT 文件(直传)-------------------------> |
|<----- 200 OK ----------------------------------|降级到直传:
客户端 服务端 S3
| | |
|-- POST /direct-upload ->| |
| (FormData) | |
| |-- PUT 文件 ----------->|
| |<----- 200 OK ---------|
|<----- 公共 URL ---------| |最佳实践
1. 客户端上传使用预签名 URL
预签名 URL 更安全、更高效:
// ✅ 推荐:客户端直传到 S3
const signedUrl = await getSignedUploadUrl(path, { bucket });
await fetch(signedUrl, { method: "PUT", body: file });
// ❌ 避免:通过服务器中转
const formData = new FormData();
formData.append("file", file);
await fetch("/api/upload", { method: "POST", body: formData });2. 设置正确的 Content-Type
始终指定内容类型,确保浏览器正确处理:
await uploadFileToS3(path, {
bucket: "my-bucket",
body: buffer,
contentType: "image/jpeg", // 重要!
});3. 使用 CDN 加速访问
配置 CDN 端点以提升性能:
S3_PUBLIC_ENDPOINT=https://cdn.example.comconst url = getPublicStorageUrl(path, process.env.S3_PUBLIC_ENDPOINT);4. 验证文件类型和大小
上传前始终验证:
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) {
throw new Error("不支持的文件类型");
}
if (file.size > 10 * 1024 * 1024) {
throw new Error("文件过大(最大 10MB)");
}5. 使用唯一文件名
防止文件覆盖:
const uniquePath = `uploads/${Date.now()}-${crypto.randomUUID()}-${file.name}`;6. 清理失败的上传
如果后续操作失败,删除已上传的文件:
try {
await uploadFileToS3(path, options);
await saveToDatabase(path);
} catch (error) {
await deleteFileFromS3(path, { bucket });
throw error;
}7. 图片优化
上传前压缩图片:
import sharp from "sharp";
const optimized = await sharp(buffer)
.resize(1920, 1080, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer();
await uploadFileToS3(path, {
bucket: "my-bucket",
body: optimized,
contentType: "image/jpeg",
});故障排查
预签名 URL 签名错误
如果遇到 SignatureDoesNotMatch 错误(腾讯云 COS):
- 确保 bucket 名称包含 APPID:
bucket-name-1234567890 - 如果 bucket 名称不包含 APPID,设置
S3_APPID环境变量 - 验证端点格式:
https://bucket-name-appid.cos.region.myqcloud.com
CORS 错误
在 S3 bucket 上配置 CORS:
{
"CORSRules": [
{
"AllowedOrigins": ["https://yourdomain.com"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3000
}
]
}上传超时
预签名 URL 60 秒后过期。对于大文件:
- 增加
getSignedUploadUrl的超时时间 - 对于 > 100MB 的文件使用分片上传
- 向用户显示上传进度
水印 Logo 缺失
确保 Logo 存在于 public/images/logo-white.png:
if (!(await isLogoAvailable())) {
console.warn("Logo 不存在,跳过水印");
// 不添加水印直接上传
}环境变量未设置
检查所有必需的环境变量:
// 在应用启动时验证
if (!process.env.S3_ENDPOINT) {
throw new Error("缺少 S3_ENDPOINT 环境变量");
}
if (!process.env.S3_ACCESS_KEY_ID) {
throw new Error("缺少 S3_ACCESS_KEY_ID 环境变量");
}
if (!process.env.S3_SECRET_ACCESS_KEY) {
throw new Error("缺少 S3_SECRET_ACCESS_KEY 环境变量");
}