文件上传与附件
使用 S3 预签名 URL 实现文件上传和附件的完整指南
文件上传与附件
ProductReady 提供了一个使用 S3 预签名 URL 的综合文件上传系统,用于安全、可扩展的文件处理。本指南涵盖了通用上传基础设施和具体实现。
架构:文件从客户端直接上传到 S3,无需通过您的服务器,节省带宽并提高性能。
概述
上传系统由三个主要组件组成:
- tRPC 附件路由器 - 服务器端预签名 URL 生成和验证
- 上传 Hooks - 客户端 React hooks,便于集成
- 存储工具 -
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 凭证无效或存储桶策略不正确。
解决方案:
- 验证
.env中的 AWS 凭证 - 检查 S3 存储桶 CORS 配置:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT", "GET"],
"AllowedOrigins": ["*"],
"ExposeHeaders": ["ETag"]
}
]上传失败,提示"文件大小超过限制"
原因:文件大于 10MB。
解决方案:
- 压缩文件
- 或在
src/server/routers/attachments.ts中增加MAX_FILE_SIZE
上传成功但公开 URL 返回 404
原因:文件已上传但预签名 URL 已过期。
解决方案:调用 attachments.confirmUpload 以获取新的预签名 URL。
MIME 类型不被允许
原因:文件类型不在允许列表中。
解决方案:在 src/server/routers/attachments.ts 的 allowedMimeTypes 中添加 MIME 类型。
最佳实践
- 始终在服务器端验证 - 切勿仅依赖客户端验证
- 使用上下文进行组织 - 使用
"post"用于博客文章,"general"用于其他文件 - 设置合理的文件大小限制 - 默认 10MB 适用于图片;根据使用场景调整
- 优雅处理错误 - 显示用户友好的错误消息
- 清理 blob URL - 上传后调用
URL.revokeObjectURL()以释放内存 - 明智使用预签名 URL - 它们会过期;如需长期存储,请重新生成
- 监控 S3 成本 - 在生产环境中跟踪上传和存储使用情况
相关文档
- 富文本编辑器 - 带图片上传的 RichEditor
- API - tRPC - tRPC 路由器设置和使用
- 身份验证 - 使用 Clerk 的用户身份验证
下一步
设置 S3 凭证
在 .env 文件中添加 AWS 凭证并配置 S3 存储桶 CORS。
尝试上传 hooks
在组件中使用 useImageUpload 或 useAttachmentUpload。
自定义验证
根据需要调整文件大小限制和允许的 MIME 类型。
实现进度跟踪
添加上传进度指示器以改善用户体验。
准备就绪! 上传系统已具备生产就绪的安全性、验证和可扩展性。