ProductReadyProductReady

文件上传与附件

使用 S3 预签名 URL 实现文件上传和附件的完整指南

文件上传与附件

ProductReady 提供了一个使用 S3 预签名 URL 的综合文件上传系统,用于安全、可扩展的文件处理。本指南涵盖了通用上传基础设施和具体实现。

架构:文件从客户端直接上传到 S3,无需通过您的服务器,节省带宽并提高性能。


概述

上传系统由三个主要组件组成:

  1. tRPC 附件路由器 - 服务器端预签名 URL 生成和验证
  2. 上传 Hooks - 客户端 React hooks,便于集成
  3. 存储工具 - sharelib/storage 中的共享工具

上传流程

客户端                    服务器                     S3
  |                         |                         |
  |--requestUploadUrl------>|                         |
  |                         |                         |
  |<--presignedURL + key----|                         |
  |                         |                         |
  |--PUT file------------------------------>|         |
  |                         |                         |
  |<--200 OK--------------------------------|         |
  |                         |                         |
  |--confirmUpload (可选)-->|                         |
  |<--publicURL-------------|                         |

快速开始

1. 环境设置

.env 文件中添加 S3 凭证:

# S3 存储配置
AWS_REGION=us-east-1
AWS_S3_BUCKET=your-bucket-name
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key

安全性:切勿将 .env 文件提交到版本控制。在生产环境中使用环境变量。

2. 基础图片上传

使用 useImageUpload hook 用于 RichEditor 或其他图片上传场景:

import { useImageUpload } from "~/hooks/useImageUpload";

export function MyComponent() {
  const { handleImageUpload, uploading } = useImageUpload({
    context: "post",
    onError: (error) => console.error("上传失败:", error),
  });

  const onFileSelect = async (file: File) => {
    try {
      const publicUrl = await handleImageUpload(file);
      console.log("图片已上传:", publicUrl);
    } catch (error) {
      // 错误已由 onError 回调处理
    }
  };

  return (
    <div>
      <input 
        type="file" 
        accept="image/*"
        onChange={(e) => e.target.files?.[0] && onFileSelect(e.target.files[0])}
        disabled={uploading}
      />
      {uploading && <p>上传中...</p>}
    </div>
  );
}

3. 附件上传(多文件)

使用 useAttachmentUpload 处理多个附件(例如,在聊天或表单中):

import { useAttachmentUpload } from "~/hooks/useAttachmentUpload";
import type { FileUIPart } from "ai";

export function ChatComponent() {
  const { handleFileUpload, uploading } = useAttachmentUpload({
    context: "general",
    onUploadStart: () => console.log("开始上传"),
    onUploadComplete: () => console.log("上传完成"),
  });

  const onFilesSelected = async (files: FileList) => {
    // 将 FileList 转换为 FileUIPart[] 格式
    const fileParts: FileUIPart[] = Array.from(files).map(file => ({
      type: "file",
      filename: file.name,
      mimeType: file.type,
      url: URL.createObjectURL(file), // blob URL
    }));

    // 上传到 S3 并获取公开 URL
    const uploadedFiles = await handleFileUpload(fileParts);
    console.log("已上传文件:", uploadedFiles);
  };

  return (
    <input 
      type="file" 
      multiple
      onChange={(e) => e.target.files && onFilesSelected(e.target.files)}
      disabled={uploading}
    />
  );
}

上传 Hooks API

useImageUpload

用于上传图片的 Hook(例如,在 RichEditor、个人资料图片、博客文章中)。

选项

interface UseImageUploadOptions {
  /** 上传上下文,用于在 S3 中组织文件 */
  context?: "post" | "general";
  /** 上传错误时调用 */
  onError?: (error: Error) => void;
}

返回值

{
  handleImageUpload: (file: File) => Promise<string>; // 返回公开 URL
  uploading: boolean; // 上传状态
}

示例:个人资料图片上传

import { useImageUpload } from "~/hooks/useImageUpload";
import { useState } from "react";

export function ProfilePictureUpload() {
  const [avatarUrl, setAvatarUrl] = useState("");
  const { handleImageUpload, uploading } = useImageUpload({
    context: "general",
    onError: (error) => alert(error.message),
  });

  const onAvatarSelect = async (file: File) => {
    const url = await handleImageUpload(file);
    setAvatarUrl(url);
    // 将 URL 保存到用户资料...
  };

  return (
    <div>
      {avatarUrl && <img src={avatarUrl} alt="头像" />}
      <input
        type="file"
        accept="image/*"
        onChange={(e) => e.target.files?.[0] && onAvatarSelect(e.target.files[0])}
        disabled={uploading}
      />
    </div>
  );
}

useAttachmentUpload

用于上传多个附件的 Hook(例如,聊天附件、文档上传)。

选项

interface UseAttachmentUploadOptions {
  /** 上传上下文,用于在 S3 中组织文件 */
  context?: "post" | "general";
  /** 上传开始时调用 */
  onUploadStart?: () => void;
  /** 上传完成时调用 */
  onUploadComplete?: () => void;
  /** 错误时调用 */
  onError?: (error: Error) => void;
}

返回值

{
  handleFileUpload: (files: FileUIPart[]) => Promise<FileUIPart[]>;
  uploading: boolean;
}

示例:多文件上传

import { useAttachmentUpload } from "~/hooks/useAttachmentUpload";

export function DocumentUploader() {
  const [attachments, setAttachments] = useState<string[]>([]);
  const { handleFileUpload, uploading } = useAttachmentUpload({
    context: "general",
    onUploadComplete: () => console.log("所有文件已上传!"),
  });

  const onFilesSelected = async (files: FileList) => {
    const fileParts = Array.from(files).map(file => ({
      type: "file" as const,
      filename: file.name,
      mimeType: file.type,
      url: URL.createObjectURL(file),
    }));

    const uploaded = await handleFileUpload(fileParts);
    setAttachments(prev => [...prev, ...uploaded.map(f => f.url)]);
  };

  return (
    <div>
      <input
        type="file"
        multiple
        onChange={(e) => e.target.files && onFilesSelected(e.target.files)}
        disabled={uploading}
      />
      {uploading && <p>上传中...</p>}
      <ul>
        {attachments.map((url, i) => (
          <li key={i}><a href={url} target="_blank" rel="noopener noreferrer">文件 {i + 1}</a></li>
        ))}
      </ul>
    </div>
  );
}

tRPC 附件路由器

服务器端路由器处理预签名 URL 生成和验证。

端点

attachments.requestUploadUrl

请求预签名上传 URL 以进行直接 S3 上传。

输入:

{
  fileName: string;     // 原始文件名(最多 255 个字符)
  mimeType: string;     // 文件 MIME 类型(例如 "image/png")
  sizeBytes: number;    // 文件大小(字节)(最大 10MB)
  context?: "post" | "general"; // 可选的上下文,用于组织文件
}

输出:

{
  uploadUrl: string;    // 预签名 PUT URL(1 小时后过期)
  storageKey: string;   // 文件的 S3 键
  publicUrl: string;    // 预签名 GET URL(24 小时后过期)
  expiresIn: number;    // 上传 URL 过期时间(秒)
}

用法:

import { trpc } from "~/lib/trpc/client";

const requestUploadUrl = trpc.attachments.requestUploadUrl.useMutation();

const uploadFile = async (file: File) => {
  // 步骤 1:请求预签名 URL
  const { uploadUrl, publicUrl } = await requestUploadUrl.mutateAsync({
    fileName: file.name,
    mimeType: file.type,
    sizeBytes: file.size,
    context: "post",
  });

  // 步骤 2:上传到 S3
  const response = await fetch(uploadUrl, {
    method: "PUT",
    body: file,
    headers: { "Content-Type": file.type },
  });

  if (!response.ok) {
    throw new Error("上传失败");
  }

  // 步骤 3:使用公开 URL
  return publicUrl;
};

attachments.confirmUpload(可选)

确认上传以进行跟踪/审计。这是可选的——文件已经可以通过 publicUrl 访问。

输入:

{
  storageKey: string;   // 来自 requestUploadUrl 的 S3 键
  fileName: string;     // 原始文件名
  mimeType: string;     // 文件 MIME 类型
  sizeBytes: number;    // 文件大小
  context?: "post" | "general";
}

输出:

{
  success: boolean;
  storageKey: string;
  publicUrl: string;    // 新的预签名 URL(24 小时)
}

存储工具

sharelib/storage 包提供了用于预签名 URL 工作流的可重用工具。

导入

import {
  uploadToS3WithPresignedUrl,
  extractFileExtension,
  validateFileSize,
  validateMimeType,
  formatBytes,
} from "sharelib/storage";

uploadToS3WithPresignedUrl

使用预签名 URL 将文件内容上传到 S3。

const response = await uploadToS3WithPresignedUrl(
  presignedUrl,
  file,        // Buffer | Blob | string | ArrayBuffer
  "image/png"
);

if (!response.ok) {
  throw new Error("上传失败");
}

extractFileExtension

从文件名提取文件扩展名。

extractFileExtension("document.pdf");     // 返回: "pdf"
extractFileExtension("archive.tar.gz");   // 返回: "gz"
extractFileExtension("no-extension");     // 返回: ""

validateFileSize

验证文件大小是否超过最大限制。

const MAX_SIZE = 10 * 1024 * 1024; // 10MB
validateFileSize(file.size, MAX_SIZE); // 如果超过则抛出错误

validateMimeType

验证 MIME 类型是否在允许的类型中。

validateMimeType("image/png", [
  "image/*",          // 通配符模式
  "application/pdf",
]); // 如果不允许则抛出错误

formatBytes

将字节格式化为可读大小。

formatBytes(1536);          // "1.5 KB"
formatBytes(1048576);       // "1 MB"
formatBytes(5242880, 0);    // "5 MB"

安全性与验证

文件大小限制

  • 默认最大值:每个文件 10MB
  • 强制执行:在预签名 URL 生成之前进行服务器端验证
  • 自定义:在 src/server/routers/attachments.ts 中修改 MAX_FILE_SIZE
// 服务器端验证
if (input.sizeBytes > MAX_FILE_SIZE) {
  throw new TRPCError({
    code: "BAD_REQUEST",
    message: `文件大小超过 ${MAX_FILE_SIZE / 1024 / 1024}MB 限制`,
  });
}

MIME 类型验证

默认只允许图片:

const allowedMimeTypes = [
  "image/jpeg",
  "image/jpg",
  "image/png",
  "image/gif",
  "image/webp",
  "image/svg+xml",
];

要支持文档,请在 src/server/routers/attachments.ts 中取消注释:

// 取消注释以允许 PDF 和 Word 文档
// "application/pdf",
// "application/msword",
// "application/vnd.openxmlformats-officedocument.wordprocessingml.document",

身份验证

所有上传端点都需要身份验证:

export const attachmentsRouter = createTRPCRouter({
  requestUploadUrl: protectedProcedure // 需要已登录用户
    .input(...)
    .mutation(...),
});

存储组织

文件按用户和上下文组织,以实现隔离和审计:

s3://bucket/attachments/{userId}/{context}/{nanoid}.{ext}

示例:
- s3://bucket/attachments/user_abc123/post/vY3kL9mN4pQ2.png
- s3://bucket/attachments/user_xyz789/general/pQ2vY3kL9mN4.jpg

高级用法

自定义文件上传处理器

使用底层工具构建自己的上传处理器:

import { trpc } from "~/lib/trpc/client";
import { uploadToS3WithPresignedUrl, validateFileSize } from "sharelib/storage";

export async function customUploadFile(file: File): Promise<string> {
  // 步骤 1:客户端验证
  const MAX_SIZE = 50 * 1024 * 1024; // 50MB 自定义限制
  validateFileSize(file.size, MAX_SIZE);

  // 步骤 2:请求预签名 URL
  const result = await trpc.attachments.requestUploadUrl.mutate({
    fileName: file.name,
    mimeType: file.type,
    sizeBytes: file.size,
    context: "general",
  });

  // 步骤 3:上传到 S3
  const response = await uploadToS3WithPresignedUrl(
    result.uploadUrl,
    file,
    file.type
  );

  if (!response.ok) {
    throw new Error(`上传失败: ${response.status}`);
  }

  // 步骤 4:返回公开 URL
  return result.publicUrl;
}

进度跟踪

使用 XMLHttpRequest 跟踪上传进度:

import { trpc } from "~/lib/trpc/client";

export async function uploadWithProgress(
  file: File,
  onProgress: (percent: number) => void
): Promise<string> {
  // 获取预签名 URL
  const { uploadUrl, publicUrl } = await trpc.attachments.requestUploadUrl.mutate({
    fileName: file.name,
    mimeType: file.type,
    sizeBytes: file.size,
  });

  // 使用进度跟踪上传
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();

    xhr.upload.addEventListener("progress", (e) => {
      if (e.lengthComputable) {
        const percent = (e.loaded / e.total) * 100;
        onProgress(percent);
      }
    });

    xhr.addEventListener("load", () => {
      if (xhr.status === 200) {
        resolve(publicUrl);
      } else {
        reject(new Error(`上传失败: ${xhr.status}`));
      }
    });

    xhr.addEventListener("error", () => reject(new Error("上传失败")));

    xhr.open("PUT", uploadUrl);
    xhr.setRequestHeader("Content-Type", file.type);
    xhr.send(file);
  });
}

自定义存储路径

在服务器路由器中修改存储键生成:

// 默认: attachments/{userId}/{context}/{nanoid}.{ext}
const storageKey = `attachments/${userId}/${context}/${generateNanoID()}${ext ? `.${ext}` : ""}`;

// 自定义: projects/{projectId}/documents/{timestamp}-{filename}
const storageKey = `projects/${projectId}/documents/${Date.now()}-${input.fileName}`;

集成示例

与 RichEditor 集成

有关详细的 RichEditor 集成和图片上传支持,请参阅富文本编辑器

import { RichEditor } from "sharelib/richeditor";
import { useImageUpload } from "~/hooks/useImageUpload";

export function BlogPostEditor() {
  const [content, setContent] = useState("");
  const { handleImageUpload } = useImageUpload({ context: "post" });

  return (
    <RichEditor
      content={content}
      onChange={setContent}
      onImageUpload={handleImageUpload}
      placeholder="撰写您的文章... 粘贴或拖放图片!"
    />
  );
}

与表单提交集成

在表单提交中处理附件:

import { useAttachmentUpload } from "~/hooks/useAttachmentUpload";
import { trpc } from "~/lib/trpc/client";

export function IssueForm() {
  const [attachments, setAttachments] = useState<string[]>([]);
  const { handleFileUpload, uploading } = useAttachmentUpload();
  const createIssue = trpc.issues.create.useMutation();

  const onSubmit = async (formData: { title: string; description: string }) => {
    await createIssue.mutateAsync({
      ...formData,
      attachmentUrls: attachments, // 包含已上传的文件 URL
    });
  };

  const onFilesAdded = async (files: FileList) => {
    const fileParts = Array.from(files).map(file => ({
      type: "file" as const,
      filename: file.name,
      mimeType: file.type,
      url: URL.createObjectURL(file),
    }));

    const uploaded = await handleFileUpload(fileParts);
    setAttachments(prev => [...prev, ...uploaded.map(f => f.url)]);
  };

  return (
    <form onSubmit={onSubmit}>
      {/* 表单字段 */}
      <input type="file" multiple onChange={(e) => e.target.files && onFilesAdded(e.target.files)} />
      {uploading && <p>上传中...</p>}
      <button type="submit" disabled={uploading}>提交</button>
    </form>
  );
}

故障排除

上传失败,返回 403 Forbidden

原因:S3 凭证无效或存储桶策略不正确。

解决方案

  1. 验证 .env 中的 AWS 凭证
  2. 检查 S3 存储桶 CORS 配置:
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "GET"],
    "AllowedOrigins": ["*"],
    "ExposeHeaders": ["ETag"]
  }
]

上传失败,提示"文件大小超过限制"

原因:文件大于 10MB。

解决方案

  1. 压缩文件
  2. 或在 src/server/routers/attachments.ts 中增加 MAX_FILE_SIZE

上传成功但公开 URL 返回 404

原因:文件已上传但预签名 URL 已过期。

解决方案:调用 attachments.confirmUpload 以获取新的预签名 URL。

MIME 类型不被允许

原因:文件类型不在允许列表中。

解决方案:在 src/server/routers/attachments.tsallowedMimeTypes 中添加 MIME 类型。


最佳实践

  1. 始终在服务器端验证 - 切勿仅依赖客户端验证
  2. 使用上下文进行组织 - 使用 "post" 用于博客文章,"general" 用于其他文件
  3. 设置合理的文件大小限制 - 默认 10MB 适用于图片;根据使用场景调整
  4. 优雅处理错误 - 显示用户友好的错误消息
  5. 清理 blob URL - 上传后调用 URL.revokeObjectURL() 以释放内存
  6. 明智使用预签名 URL - 它们会过期;如需长期存储,请重新生成
  7. 监控 S3 成本 - 在生产环境中跟踪上传和存储使用情况

相关文档


下一步

设置 S3 凭证

.env 文件中添加 AWS 凭证并配置 S3 存储桶 CORS。

尝试上传 hooks

在组件中使用 useImageUploaduseAttachmentUpload

自定义验证

根据需要调整文件大小限制和允许的 MIME 类型。

实现进度跟踪

添加上传进度指示器以改善用户体验。


准备就绪! 上传系统已具备生产就绪的安全性、验证和可扩展性。

On this page