文件上传与存储管理
SaaS 产品普遍需要文件上传——头像、文档、附件、导入/导出。文件上传看似简单,但涉及安全(防恶意文件)、性能(大文件直传)、成本(存储 + CDN)、多租户隔离。本章讲解 Next.js SaaS 中的文件存储方案。
1. 存储方案选型
1.1 方案对比
| 方案 | 特点 | 价格 | 推荐 |
|---|
| UploadThing | Next.js 原生、类型安全 | 免费 2GB | ✅ 快速起步 |
| Vercel Blob | Vercel 集成 | 随 Vercel | 简单场景 |
| Cloudflare R2 | S3 兼容、无出口费 | $0.015/GB | ✅ 大量存储 |
| AWS S3 | 最成熟 | $0.023/GB + 出口费 | 企业级 |
| Supabase Storage | Supabase 全家桶 | 1GB 免费 | Supabase 用户 |
1.2 架构选择
| 方式 | 流程 | 适用 |
|---|
| 服务端中转 | 客户端 → 服务器 → 存储 | 小文件(< 5MB) |
| 客户端直传 | 客户端 → 签名 URL → 存储 | 大文件(推荐) |
推荐客户端直传——服务器不经手文件数据,避免内存压力和超时。
2. UploadThing 集成
2.1 路由配置
// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from 'uploadthing/next'
import { getSession } from '@/lib/auth/session'
import { getCurrentOrgId } from '@/lib/auth/tenant'
const f = createUploadthing()
export const ourFileRouter = {
avatar: f({ image: { maxFileSize: '2MB', maxFileCount: 1 } })
.middleware(async () => {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
return { userId: session.user.id }
})
.onUploadComplete(async ({ metadata, file }) => {
await db.update(users)
.set({ avatarUrl: file.url })
.where(eq(users.id, metadata.userId))
}),
attachment: f({ blob: { maxFileSize: '16MB', maxFileCount: 5 } })
.middleware(async () => {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
const orgId = await getCurrentOrgId()
// 检查存储配额
await checkStorageQuota(orgId!)
return { userId: session.user.id, orgId }
})
.onUploadComplete(async ({ metadata, file }) => {
await db.insert(files).values({
tenantId: metadata.orgId,
uploadedBy: metadata.userId,
name: file.name,
url: file.url,
size: file.size,
})
}),
} satisfies FileRouter
2.2 UploadThing Route Handler
// app/api/uploadthing/route.ts
import { createRouteHandler } from 'uploadthing/next'
import { ourFileRouter } from './core'
export const { GET, POST } = createRouteHandler({ router: ourFileRouter })
2.3 客户端上传按钮
'use client'
import { UploadButton } from '@uploadthing/react'
import type { OurFileRouter } from '@/app/api/uploadthing/core'
export function AvatarUpload() {
return (
<UploadButton<OurFileRouter, 'avatar'>
endpoint="avatar"
onClientUploadComplete={(res) => {
toast.success('Avatar updated')
}}
onUploadError={(err) => {
toast.error(err.message)
}}
/>
)
}
2.4 拖拽上传区域
'use client'
import { UploadDropzone } from '@uploadthing/react'
import type { OurFileRouter } from '@/app/api/uploadthing/core'
export function AttachmentUpload({ onComplete }: { onComplete: () => void }) {
return (
<UploadDropzone<OurFileRouter, 'attachment'>
endpoint="attachment"
onClientUploadComplete={() => {
toast.success('Files uploaded')
onComplete()
}}
onUploadError={(err) => toast.error(err.message)}
appearance={{
container: 'border-2 border-dashed rounded-lg p-8',
label: 'text-sm text-muted-foreground',
allowedContent: 'text-xs text-muted-foreground',
}}
/>
)
}
3. S3 兼容存储(R2/S3)
3.1 签名 URL 上传
// lib/storage/s3.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const s3 = new S3Client({
region: 'auto',
endpoint: process.env.S3_ENDPOINT, // R2: https://<account>.r2.cloudflarestorage.com
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
})
// 生成上传签名 URL(有效期 10 分钟)
export async function createUploadUrl(key: string, contentType: string) {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: contentType,
})
return getSignedUrl(s3, command, { expiresIn: 600 })
}
// 生成下载签名 URL(私有文件)
export async function createDownloadUrl(key: string) {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
})
return getSignedUrl(s3, command, { expiresIn: 3600 })
}
3.2 上传 Server Action
// app/(dashboard)/actions.ts
'use server'
export async function getUploadUrl(fileName: string, contentType: string) {
const { session, membership } = await requirePermission('project:create')
await checkStorageQuota(membership.tenantId)
// 租户隔离的存储路径
const key = `${membership.tenantId}/${nanoid()}/${fileName}`
const uploadUrl = await createUploadUrl(key, contentType)
return { uploadUrl, key }
}
export async function confirmUpload(key: string, fileName: string, size: number) {
const { membership } = await requirePermission('project:create')
await db.insert(files).values({
tenantId: membership.tenantId,
key,
name: fileName,
size,
uploadedBy: membership.userId,
})
}
3.3 存储路径规范
{bucket}/
├── {tenant_id}/
│ ├── avatars/{user_id}.jpg
│ ├── attachments/{nanoid}/{original_name}
│ └── exports/{date}/{file_name}.csv
租户 ID 作为顶级目录——便于按租户统计用量、批量删除、访问控制。
3.4 客户端直传组件
// components/file-upload.tsx
'use client'
import { useState, useRef } from 'react'
import { getUploadUrl, confirmUpload } from '@/app/(dashboard)/actions'
export function FileUpload({ onSuccess }: { onSuccess?: () => void }) {
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setProgress(0)
try {
// 1. 获取签名 URL
const { uploadUrl, key } = await getUploadUrl(file.name, file.type)
// 2. 直传到 S3/R2
await uploadWithProgress(uploadUrl, file, setProgress)
// 3. 确认上传,写入数据库
await confirmUpload(key, file.name, file.size)
onSuccess?.()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ''
}
}
return (
<div>
<input
ref={inputRef}
type="file"
onChange={handleFileSelect}
disabled={uploading}
className="hidden"
id="file-upload"
/>
<label
htmlFor="file-upload"
className="cursor-pointer rounded-lg border-2 border-dashed p-8 text-center block hover:bg-muted/50"
>
{uploading ? (
<div>
<p className="text-sm">Uploading... {progress}%</p>
<div className="mt-2 h-2 w-full rounded bg-muted overflow-hidden">
<div className="h-full bg-primary transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Click to upload a file</p>
)}
</label>
</div>
)
}
async function uploadWithProgress(
url: string, file: File,
onProgress: (pct: number) => void
) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round(e.loaded / e.total * 100))
}
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300)
? resolve()
: reject(new Error(`Upload failed: ${xhr.status}`))
xhr.onerror = () => reject(new Error('Network error'))
xhr.send(file)
})
}
3.5 文件数据模型
// lib/db/schema.ts
export const files = pgTable('files', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').references(() => tenants.id, { onDelete: 'cascade' }).notNull(),
key: text('key').notNull(), // S3 object key
name: text('name').notNull(), // 原始文件名
size: integer('size').notNull(), // 字节数
contentType: text('content_type'),
url: text('url'), // 公开 URL(可选)
uploadedBy: uuid('uploaded_by').references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
3.6 文件列表与删除
// lib/actions/file.ts
'use server'
import { DeleteObjectCommand } from '@aws-sdk/client-s3'
export async function getFiles() {
const { membership } = await requirePermission('project:read')
return db.select({
id: files.id,
name: files.name,
size: files.size,
createdAt: files.createdAt,
uploadedBy: users.name,
})
.from(files)
.leftJoin(users, eq(files.uploadedBy, users.id))
.where(eq(files.tenantId, membership.tenantId))
.orderBy(desc(files.createdAt))
}
export async function deleteFile(fileId: string) {
const { membership } = await requirePermission('project:create')
const [file] = await db.select().from(files)
.where(and(
eq(files.id, fileId),
eq(files.tenantId, membership.tenantId),
))
if (!file) throw new Error('File not found')
// 从 S3 删除
await s3.send(new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: file.key,
}))
// 从数据库删除
await db.delete(files).where(eq(files.id, fileId))
revalidatePath('/files')
}
4. 安全与限制
4.1 文件类型验证
| 层级 | 验证方式 | 说明 |
|---|
| 客户端 | accept 属性 | UX 提示(可绕过) |
| 服务端 | MIME type 检查 | 检查 Content-Type |
| 深度检查 | Magic Number | 读取文件头字节判断真实类型 |
4.2 安全清单
| 检查项 | 说明 |
|---|
| 文件类型白名单 | 只允许特定类型 |
| 文件大小限制 | 按套餐设置上限 |
| 文件名清理 | 移除特殊字符 |
| 签名 URL 短过期 | 上传 10min,下载 1h |
| 存储路径隔离 | 按 tenant_id 隔离 |
| 防病毒扫描 | 企业级可选 |
4.3 文件名清理
// lib/storage/sanitize.ts
export function sanitizeFileName(name: string): string {
return name
.replace(/[^a-zA-Z0-9._-]/g, '_') // 只保留安全字符
.replace(/_{2,}/g, '_') // 合并连续下划线
.slice(0, 255) // 限制长度
}
// 文件类型白名单
const ALLOWED_TYPES: Record<string, string[]> = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
document: ['application/pdf', 'text/plain', 'text/csv'],
archive: ['application/zip'],
}
export function isAllowedType(contentType: string, category?: keyof typeof ALLOWED_TYPES): boolean {
if (category) return ALLOWED_TYPES[category]?.includes(contentType) ?? false
return Object.values(ALLOWED_TYPES).flat().includes(contentType)
}
4.4 存储配额
// lib/storage/quota.ts
export async function checkStorageQuota(tenantId: string) {
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId))
const plan = PLANS[tenant.plan as PlanKey]
const [{ totalSize }] = await db
.select({ totalSize: sql<number>`coalesce(sum(${files.size}), 0)` })
.from(files)
.where(eq(files.tenantId, tenantId))
const limitBytes = plan.limits.storageMB * 1024 * 1024
if (totalSize >= limitBytes) {
throw new Error('Storage limit reached. Upgrade your plan.')
}
return {
usedBytes: totalSize,
limitBytes,
usedPercent: Math.round(totalSize / limitBytes * 100),
}
}
4.5 套餐存储限制
| 套餐 | 存储限制 | 单文件限制 | 文件数限制 |
|---|
| Free | 100 MB | 5 MB | 50 |
| Pro | 5 GB | 50 MB | 1,000 |
| Business | 50 GB | 200 MB | 10,000 |
| Enterprise | 无限 | 1 GB | 无限 |
4.6 存储用量面板
// components/storage-usage.tsx
export async function StorageUsage() {
const { membership } = await requirePermission('project:read')
const usage = await checkStorageQuota(membership.tenantId)
return (
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Storage</span>
<span className="text-xs text-muted-foreground">
{formatBytes(usage.usedBytes)} / {formatBytes(usage.limitBytes)}
</span>
</div>
<div className="h-2 w-full rounded bg-muted overflow-hidden">
<div
className={`h-full transition-all ${
usage.usedPercent > 90 ? 'bg-red-500' :
usage.usedPercent > 70 ? 'bg-yellow-500' : 'bg-primary'
}`}
style={{ width: `${Math.min(usage.usedPercent, 100)}%` }}
/>
</div>
</div>
)
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
}
5. 图片处理
5.1 方案对比
| 方案 | 说明 | 推荐 |
|---|
| Next.js Image | 内置优化、自动 WebP | ✅ 默认 |
| Cloudflare Images | CDN 端处理 | 大量图片 |
| sharp | 服务端处理 | 自定义需求 |
5.2 头像处理
// 利用 Next.js Image 组件自动优化
import Image from 'next/image'
export function Avatar({ url, name, size = 40 }: { url?: string; name: string; size?: number }) {
if (!url) {
return (
<div
className="flex items-center justify-center rounded-full bg-primary text-primary-foreground font-medium"
style={{ width: size, height: size, fontSize: size * 0.4 }}
>
{name[0]?.toUpperCase()}
</div>
)
}
return (
<Image
src={url}
alt={name}
width={size}
height={size}
className="rounded-full object-cover"
/>
)
}
5.3 Next.js Image 远程图片配置
// next.config.ts
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.r2.cloudflarestorage.com',
},
{
protocol: 'https',
hostname: 'utfs.io', // UploadThing
},
],
},
}
6. 文件导入导出
6.1 CSV 导出
// lib/export/csv.ts
export async function exportProjectsCSV(tenantId: string) {
const projectList = await db.select().from(projects)
.where(eq(projects.tenantId, tenantId))
const csv = [
'id,name,status,created_at',
...projectList.map(p =>
[p.id, `"${p.name}"`, p.status, p.createdAt.toISOString()].join(',')
),
].join('\n')
return csv
}
6.2 导出 API Route
// app/api/export/projects/route.ts
export async function GET() {
const { membership } = await requirePermission('project:read')
const csv = await exportProjectsCSV(membership.tenantId)
return new Response(csv, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="projects.csv"',
},
})
}
6.3 CSV 导入
// lib/import/csv.ts
import { parse } from 'csv-parse/sync'
export async function importProjectsCSV(tenantId: string, csvContent: string) {
const records = parse(csvContent, { columns: true, skip_empty_lines: true })
const results = { success: 0, errors: [] as string[] }
for (const record of records) {
try {
await db.insert(projects).values({
tenantId,
name: record.name,
status: record.status || 'active',
})
results.success++
} catch (err) {
results.errors.push(`Row "${record.name}": ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}
return results
}
本章小结
- 方案选型:UploadThing 快速起步,R2 大量存储无出口费
- UploadThing:FileRouter 定义上传端点,middleware 鉴权 + 配额检查
- S3 直传:签名 URL 上传,XHR 进度条,服务器不经手文件数据
- 文件数据模型:files 表记录 key/name/size,关联 tenant 和 uploader
- 租户隔离:
{tenant_id}/ 作为存储路径顶级目录
- 文件名安全:
sanitizeFileName 移除特殊字符 + 文件类型白名单
- 存储配额:按套餐限制(Free 100MB → Enterprise 无限),用量进度条组件
- 图片处理:Next.js Image 自动优化 + WebP,remotePatterns 配置远程图片域名
- 导入导出:CSV 导出 API Route + CSV 导入解析