Next.js 15 云端大文件直传架构:S3 / R2 预签名 URL、分片断点续传与元数据流

在现代企业级应用与数字内容创作平台中,大文件上传(如数 GB 的 4K 视频素材、高精度 3D 扫描模型、海量压缩归档包)是一项高频且高风险的核心功能。在传统的 Web 架构中,文件上传通常直接通过 HTTP POST 提交至应用后端服务器,由 Node.js 进程在内存中解析 multipart/form-data 二进制流,再由后端转发上传到云端对象存储(Object Storage)。

这种“中转上传模式(Proxy Upload)”在面对大文件与高并发时存在灾难性的系统性缺陷:服务端带宽双倍消耗、Node.js 单线程内存瞬间被巨型 Buffer 打爆引发 OOM 崩溃、一旦传输过程中网络发生短暂抖动几十分钟的上传前功尽弃。现代化工业级解决方案是 云端直传架构(Direct-to-Cloud):利用 AWS S3 / Cloudflare R2 的 预签名 URL(Presigned URLs),配合客户端分片(Multipart Upload)与断点续传,将巨型流量直接卸载至全球分布式云存储节点。本文将系统拆解该架构在 Next.js 15 中的端到端落地实践。


1. 传统服务端中转 vs 现代预签名直传架构对比

+-------------------------------------------------------------------------+
|                  传统后端中转上传 (致命瓶颈)                            |
+-------------------------------------------------------------------------+
[客户端 (5GB 视频)] ---> (巨额入站带宽) ---> [Next.js Node 进程] ---> (巨额出站带宽) ---> [S3 存储]
致命痛点: 服务器网络直接打满;Node 进程内存溢出;单点故障率极高。

                                   VS

+-------------------------------------------------------------------------+
|             现代化 S3 / R2 预签名 URL 客户端直传架构                    |
+-------------------------------------------------------------------------+
                                    |
            [1. 客户端向 Next.js 请求预签名凭据 (仅传递文件名和大小)]
                                    |
                    [2. Next.js 校验用户鉴权与文件合法性]
                                    |
            [3. Route Handler 调用 AWS SDK 签发 Presigned PUT URL]
               (有效期通常设置为 15 分钟,带有高强度的加密签名)
                                    |
            [4. 将预签名 URL 返回给浏览器 (网络消耗 < 1KB!)]
                                    |
            [5. 客户端直接发起 HTTP PUT 请求将数据块直传 Cloudflare R2 / S3]
               (完全不经过 Next.js 服务器,流量被完全卸载!)
                                    |
            [6. 上传完毕: 触发 Server Action 将文件元数据持久化入库]
核心评估指标传统 Node.js 服务端中转现代化预签名直传 (Presigned URLs)
服务器带宽消耗🔴 双倍带宽消耗 (双向占用)🟢 0 业务带宽消耗 (流量完全分流至云)
服务端内存占用🔴 动辄数百 MB 甚至 OOM 崩溃🟢 极低 (< 5MB,仅处理几百字节签名)
支持单文件最大体积通常受限于反向代理 100MB 限制🟢 支持单文件 5TB (分片直传)
网络中断断点续传❌ 难以实现,几乎必须重头来过🟢 原生支持 (仅重传失败的分片 Block)
全球多区域加速受限于单机房物理网络🟢 自动命中 S3 / R2 全球边缘 CDN 加速点

2. Next.js 15 Route Handler 签发预签名凭据实战

我们以完全兼容 AWS S3 协议且免除一切出站流量费用的 Cloudflare R2(或 AWS S3)为例,编写签发安全临时上传链接的端点:

// src/app/api/upload/presign/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getSession } from '@/lib/session';

// 初始化 S3 兼容客户端 (支持 AWS S3, Cloudflare R2, MinIO, 阿里云 OSS)
const s3Client = new S3Client({
  region: 'auto',
  endpoint: process.env.R2_ENDPOINT_URL, // 形如: https://<account_id>.r2.cloudflarestorage.com
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

export async function POST(request: NextRequest) {
  // 1. 严格身份鉴权
  const session = await getSession();
  if (!session.isLoggedIn || !session.user) {
    return NextResponse.json({ error: '未经授权的访问' }, { status: 401 });
  }

  const { filename, fileType, fileSize } = await request.json();

  // 2. 业务防御性规则校验 (限制格式与最大单文件 1GB)
  const allowedTypes = ['video/mp4', 'image/png', 'image/jpeg', 'application/zip', 'model/gltf-binary'];
  if (!allowedTypes.includes(fileType)) {
    return NextResponse.json({ error: '不支持的文件格式' }, { status: 400 });
  }

  const MAX_SIZE = 1024 * 1024 * 1024; // 1GB
  if (fileSize > MAX_SIZE) {
    return NextResponse.json({ error: '文件体积超出 1GB 上限' }, { status: 400 });
  }

  // 3. 生成全球唯一的对象存储 Key (防止重名覆盖)
  const fileKey = `uploads/${session.user.id}/${Date.now()}-${crypto.randomUUID()}.${filename.split('.').pop()}`;

  // 4. 构建 S3 PutObject 指令
  const command = new PutObjectCommand({
    Bucket: process.env.R2_BUCKET_NAME!,
    Key: fileKey,
    ContentType: fileType,
  });

  // 5. 生成 15 分钟内有效的预签名直传链接
  const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 900 });

  return NextResponse.json({
    presignedUrl,
    fileKey,
    publicUrl: `${process.env.R2_PUBLIC_DOMAIN}/${fileKey}`,
  });
}

3. 客户端多分片并发上传与精确进度条实现

利用 XMLHttpRequest,客户端不仅可以实现进度百分比的毫秒级高频更新,还能实时计算真实的上传网络速率与预估剩余时间:

// src/components/DirectUploader.tsx
'use client';

import React, { useState } from 'react';

export function DirectUploader() {
  const [file, setFile] = useState<File | null>(null);
  const [progress, setProgress] = useState(0);
  const [speed, setSpeed] = useState<string>('');
  const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
  const [uploadedUrl, setUploadedUrl] = useState<string>('');

  const handleUpload = async () => {
    if (!file) return;

    try {
      setUploadStatus('uploading');
      setProgress(0);

      // 1. 向 Next.js 申请预签名链接
      const res = await fetch('/api/upload/presign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          filename: file.name,
          fileType: file.type || 'application/octet-stream',
          fileSize: file.size,
        }),
      });

      if (!res.ok) throw new Error('申请预签名凭据失败');
      const { presignedUrl, publicUrl, fileKey } = await res.json();

      // 2. 利用 XMLHttpRequest 直传 S3/R2,监控原生 Progress
      const xhr = new XMLHttpRequest();
      xhr.open('PUT', presignedUrl, true);
      xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');

      let lastLoaded = 0;
      let lastTime = performance.now();

      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) {
          const percent = Math.round((e.loaded / e.total) * 100);
          setProgress(percent);

          // 计算即时传输速率 (MB/s)
          const now = performance.now();
          const elapsedSec = (now - lastTime) / 1000;
          if (elapsedSec > 0.5) {
            const bytesPerSec = (e.loaded - lastLoaded) / elapsedSec;
            setSpeed(`${(bytesPerSec / (1024 * 1024)).toFixed(2)} MB/s`);
            lastLoaded = e.loaded;
            lastTime = now;
          }
        }
      };

      xhr.onload = async () => {
        if (xhr.status >= 200 && xhr.status < 300) {
          setUploadStatus('success');
          setUploadedUrl(publicUrl);
          // 3. 调用 Server Action 将元数据入库
          // await recordUploadedFileAction({ fileKey, publicUrl, size: file.size });
        } else {
          setUploadStatus('error');
        }
      };

      xhr.onerror = () => setUploadStatus('error');

      // 3. 将原始二进制 Blob 直接推向云存储
      xhr.send(file);
    } catch (err) {
      setUploadStatus('error');
    }
  };

  return (
    <div className="p-8 max-w-xl mx-auto bg-slate-900 border border-slate-800 rounded-2xl">
      <h3 className="text-xl font-bold text-white mb-4">云端超高速直传引擎</h3>

      <input
        type="file"
        onChange={(e) => setFile(e.target.files?.[0] || null)}
        className="block w-full text-sm text-slate-400 file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-cyan-500/20 file:text-cyan-300 hover:file:bg-cyan-500/30 cursor-pointer"
      />

      {file && (
        <div className="mt-4 p-4 rounded-xl bg-slate-950 border border-slate-800 text-xs font-mono space-y-1">
          <p className="text-slate-300">文件名: {file.name}</p>
          <p className="text-slate-400">大小: {(file.size / (1024 * 1024)).toFixed(2)} MB</p>
        </div>
      )}

      {uploadStatus === 'uploading' && (
        <div className="mt-6 space-y-2">
          <div className="flex justify-between text-xs font-mono text-slate-400">
            <span>上传进度: {progress}%</span>
            <span>{speed}</span>
          </div>
          <div className="w-full h-2 bg-slate-800 rounded-full overflow-hidden">
            <div
              className="h-full bg-gradient-to-r from-cyan-400 to-blue-500 transition-all duration-150"
              style={{ width: `${progress}%` }}
            />
          </div>
        </div>
      )}

      <button
        onClick={handleUpload}
        disabled={!file || uploadStatus === 'uploading'}
        className="mt-6 w-full py-3 bg-cyan-500 hover:bg-cyan-400 text-slate-950 font-bold rounded-xl disabled:opacity-50 transition-all"
      >
        {uploadStatus === 'uploading' ? '正在直传云存储...' : '开始安全直传'}
      </button>

      {uploadStatus === 'success' && (
        <p className="mt-4 text-xs font-mono text-emerald-400 break-all">
          上传成功! 访问链接: {uploadedUrl}
        </p>
      )}
    </div>
  );
}

4. 巨型文件 Multipart 分片与断点续传原理

当单个文件体积突破 100MB 或处于移动弱网环境下,单纯使用单个 PUT 请求的故障风险急剧攀升。此时必须启用 S3 Multipart Upload 分片断点协议:

[巨型文件 4GB] ---> 客户端切片为 800 个 5MB 的 Chunk 块
                        |
[1. 调用 CreateMultipartUpload -> 获取 UploadId]
                        |
[2. 并发拉取 800 个分片预签名 URL (UploadPart)]
                        |
[3. 开启并发池 (Concurrency = 4): 异步并行直传分块]
- 块 1 成功 -> 记录 ETag 校验和
- 块 2 失败 -> 仅将 块 2 放回重试队列重新上传! (无需重传其余 799 块!)
                        |
[4. 全部块上传完毕 -> 调用 CompleteMultipartUpload]
- 云存储服务端依据 ETag 列表在毫秒内将所有分块在底层物理合并为完整文件!

5. 云存储 CORS 跨域配置与安全防盗链自检清单

由于客户端直接跨域访问 S3 域名发起 PUT 请求,必须在云存储桶配置合法的 CORS 规则:

[
  {
    "AllowedOrigins": ["https://waitwalker.github.io"],
    "AllowedMethods": ["PUT", "POST", "GET"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

生产级直传防坑清单

[✓] 1. 签名有效期短命化: 预签名 URL 有效期建议控制在 10~15 分钟,用完即毁。
[✓] 2. 文件扩展名严格随机化: 严禁直接使用用户本地的文件名作为存储 Key,杜绝路径穿越与覆盖攻击。
[✓] 3. Content-Type 严格锁定: 预签名指令中必须绑定具体 MIME 类型,防止被上传可执行 html/svg 进行同源钓鱼。
[✓] 4. 分片清理生命周期规则: 在 S3/R2 上配置 7 天清理未完成分片规则 (AbortIncompleteMultipartUpload)。
[✓] 5. CDN 鉴权防盗链: 生产公开链接前置接入 CDN 鉴权标头或私有只读策略,杜绝被恶意盗刷流量。

通过预签名直传与分片断点续传的精密协同,我们彻底将繁重的存储与网络开销移交给高弹性的公有云基础设施,打造出高吞吐、零内存泄漏且具备绝对容灾韧性的现代文件上传中枢。