01MVP 标识01MVP
包文档基础设施服务storage 文件存储

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

流程:

  1. 请求预签名 URL (/api/uploads/signed-upload-url)
  2. 使用预签名 URL 直接 PUT 到 S3
  3. 失败时降级到 /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): 原始图片 buffer
  • options.logoSize (number, 可选): Logo 宽度(像素),默认 600
  • options.opacity (number, 可选): 透明度 0-1,默认 0.7
  • options.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): 存储路径或 URL
  • publicEndpoint (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 的组织对象

转换的字段:

  • logo
  • coverImage
  • audienceQrCode
  • memberQrCode

架构设计

双 S3 客户端

包维护两个独立的 S3 客户端:

  1. 标准客户端 (getS3Client):

    • 用于直接操作(PUT、DELETE)
    • 包含腾讯云 COS 中间件,注入 Appid 头
    • 支持虚拟主机式 URL
  2. 预签名客户端 (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.com
const 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):

  1. 确保 bucket 名称包含 APPID:bucket-name-1234567890
  2. 如果 bucket 名称不包含 APPID,设置 S3_APPID 环境变量
  3. 验证端点格式: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 秒后过期。对于大文件:

  1. 增加 getSignedUploadUrl 的超时时间
  2. 对于 > 100MB 的文件使用分片上传
  3. 向用户显示上传进度

水印 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 环境变量");
}

相关资源