文件上传与存储管理

SaaS 产品普遍需要文件上传——头像、文档、附件、导入/导出。文件上传看似简单,但涉及安全(防恶意文件)、性能(大文件直传)、成本(存储 + CDN)、多租户隔离。本章讲解 Next.js SaaS 中的文件存储方案。

1. 存储方案选型

1.1 方案对比

方案特点价格推荐
UploadThingNext.js 原生、类型安全免费 2GB✅ 快速起步
Vercel BlobVercel 集成随 Vercel简单场景
Cloudflare R2S3 兼容、无出口费$0.015/GB✅ 大量存储
AWS S3最成熟$0.023/GB + 出口费企业级
Supabase StorageSupabase 全家桶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 套餐存储限制

套餐存储限制单文件限制文件数限制
Free100 MB5 MB50
Pro5 GB50 MB1,000
Business50 GB200 MB10,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 ImagesCDN 端处理大量图片
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
  • 租户隔离&#123;tenant_id&#125;/ 作为存储路径顶级目录
  • 文件名安全sanitizeFileName 移除特殊字符 + 文件类型白名单
  • 存储配额:按套餐限制(Free 100MB → Enterprise 无限),用量进度条组件
  • 图片处理:Next.js Image 自动优化 + WebP,remotePatterns 配置远程图片域名
  • 导入导出:CSV 导出 API Route + CSV 导入解析