You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

NextJS前端向Express后端传multipart视频文件及大文件S3上传方案咨询

视频上传解决方案:NextJS + Express + S3

问题背景

我需要实现用户上传视频文件的功能,可选两种路径:一是通过NextJS API中转到Express后端,最终存储到S3桶(服务器不保留文件);二是前端直接以分片方式上传到S3,再将视频URL传递给Express后端。目前遇到的核心问题是NextJS API无法读取multipart表单数据,尝试过multer、formidable但不知道如何在前端API层正确使用,同时大文件上传需要分片方案。项目环境为运行在3000端口的NextJS前端 + 5000端口的Express后端。

现有代码问题分析

  1. 前端表单:<form>未设置enctype="multipart/form-data",导致无法正确发送文件数据。
  2. NextJS API:仅关闭了默认bodyParser,但未用formidable解析multipart数据,直接读取req.body.file无效。
  3. Express后端:未配置multer接收文件,无法获取上传的文件对象。

方案一:通过NextJS API中转文件到Express后端

1. 修复前端表单(FileUpload.tsx)

给form添加enctype属性,确保文件能以multipart格式发送:

<form action="/api/uploadToAssemblyAi" method="post" enctype="multipart/form-data">
  <label htmlFor="file">File</label>
  <input
    type="file"
    id="file"
    name="file"
    onChange={generatePreview}
  />

  <button type="submit"> Upload </button>
  <button type="reset" onClick={clearFile}> Clear </button>
</form>

2. NextJS API中用formidable解析文件(/api/uploadToAssemblyAi.tsx)

先安装formidable:npm install formidable,再修改API handler:

import type { NextApiRequest, NextApiResponse } from 'next'
import axios from "axios"
import { IncomingForm } from 'formidable'

export const config = {
  api: {
    bodyParser: false
  }
}

export async function uploadData(file: any) {
  const BACKEND_API_URL = process.env.BACKEND_URL || 'http://localhost:5000'
  const assembly = axios.create({
    baseURL: BACKEND_API_URL,
    withCredentials: true,
  });
  
  const formData = new FormData();
  // 将formidable解析的临时文件转成Blob,再添加到FormData
  formData.append("file", new Blob([file.filepath]), file.originalFilename);

  const { data } = await assembly.post(`/api/synthesize/transcribe`, formData, {
    headers: { 'Content-Type': 'multipart/form-data' }
  })
  return data.transcriptionId
}

export default async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
  if (req.method !== 'POST') {
    return res.status(405).send({ msg: 'Method not allowed' })
  }

  const form = new IncomingForm({ keepExtensions: true })
  
  form.parse(req, async (err, fields, files) => {
    if (err) return res.status(500).send({ msg: 'Failed to parse file' })
    
    const file = files.file as any
    if (!file) return res.status(400).send({ msg: 'No file uploaded' })

    try {
      const transcriptionId = await uploadData(file)
      return res.redirect(307, `/calls/${transcriptionId}`)
    } catch (err) {
      console.error("Error:", err)
      return res.status(400).send({ msg: err instanceof Error ? err.message : 'Upload failed' })
    }
  })
}

3. Express后端用multer接收并流式上传到S3(uploadController.ts)

安装依赖:npm install multer aws-sdk,配置multer内存存储(不落地服务器)和S3流式上传:

import { Request, Response } from 'express'
import multer from 'multer'
import AWS from 'aws-sdk'

// 配置AWS SDK
AWS.config.update({
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  region: process.env.AWS_REGION
})
const s3 = new AWS.S3()

// 内存存储,文件仅在内存中暂存
const storage = multer.memoryStorage()
const upload = multer({ storage: storage })

// 绑定multer中间件处理单文件上传
export const transcribeController = [upload.single('file'), async (req: Request, res: Response) => {
  if (!req.file) {
    return res.status(400).send({ msg: 'No files were uploaded.' })
  }

  try {
    // 直接将内存中的文件流式上传到S3
    const s3Params = {
      Bucket: process.env.AWS_S3_BUCKET_NAME!,
      Key: `videos/${Date.now()}-${req.file.originalname}`,
      Body: req.file.buffer,
      ContentType: req.file.mimetype
    }

    const s3Response = await s3.upload(s3Params).promise()
    const videoUrl = s3Response.Location

    // 此处替换为实际业务逻辑,生成transcriptionId
    const transcriptionId = `trans-${Date.now()}`

    return res.status(200).send({ transcriptionId, videoUrl })
  } catch (err) {
    console.error("S3 upload error:", err)
    return res.status(500).send({ msg: 'Failed to upload to S3' })
  }
}]

Express路由需挂载该控制器:

router.post('/api/synthesize/transcribe', ...transcribeController)

方案二:前端直接分片上传到S3,传递URL给后端

针对大视频文件,推荐前端直接分片上传,避免中转服务器压力,步骤如下:

1. Express后端生成S3分片上传预签名信息

提供两个接口:生成分片预签名URL、完成分片上传:

import { Request, Response } from 'express'
import AWS from 'aws-sdk'

const s3 = new AWS.S3()

// 生成分片上传预签名URL
export const generateS3MultipartUrl = async (req: Request, res: Response) => {
  const { fileName, fileType, fileSize } = req.body
  const partSize = 5 * 1024 * 1024 // 5MB分片大小
  const partCount = Math.ceil(fileSize / partSize)

  try {
    // 初始化分片上传
    const { UploadId, Key } = await s3.createMultipartUpload({
      Bucket: process.env.AWS_S3_BUCKET_NAME!,
      Key: `videos/${Date.now()}-${fileName}`,
      ContentType: fileType
    }).promise()

    // 生成每个分片的预签名URL
    const presignedUrls = await Promise.all(
      Array.from({ length: partCount }, (_, i) => {
        return s3.getSignedUrlPromise('uploadPart', {
          Bucket: process.env.AWS_S3_BUCKET_NAME!,
          Key,
          UploadId,
          PartNumber: i + 1,
          Expires: 3600 // URL有效期1小时
        })
      })
    )

    return res.status(200).send({
      uploadId: UploadId,
      key: Key,
      presignedUrls,
      partSize,
      partCount
    })
  } catch (err) {
    console.error("Generate presigned URLs error:", err)
    return res.status(500).send({ msg: 'Failed to generate upload URLs' })
  }
}

// 完成分片上传
export const completeS3MultipartUpload = async (req: Request, res: Response) => {
  const { uploadId, key, parts } = req.body

  try {
    const { Location } = await s3.completeMultipartUpload({
      Bucket: process.env.AWS_S3_BUCKET_NAME!,
      Key: key,
      UploadId: uploadId,
      MultipartUpload: { Parts: parts }
    }).promise()

    // 生成transcriptionId
    const transcriptionId = `trans-${Date.now()}`

    return res.status(200).send({ transcriptionId, videoUrl: Location })
  } catch (err) {
    console.error("Complete multipart upload error:", err)
    return res.status(500).send({ msg: 'Failed to complete upload' })
  }
}

2. 前端实现分片上传逻辑(FileUpload.tsx)

修改前端表单,用自定义上传逻辑替代默认form提交:

import { useState } from 'react'
import axios from 'axios'

const FileUpload = () => {
  const [previewURL, setPreviewURL] = useState<string | null>(null)
  const [file, setFile] = useState<File | null>(null)

  const generatePreview = (e: React.ChangeEvent<HTMLInputElement>) => {
    const selectedFile = e.target.files?.[0]
    if (!selectedFile) return
    setFile(selectedFile)
    setPreviewURL(URL.createObjectURL(selectedFile))
  }

  const clearFile = () => {
    setFile(null)
    setPreviewURL(null)
    ;(document.getElementById('file') as HTMLInputElement).value = ''
  }

  const handleUpload = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!file) return

    const BACKEND_API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:5000'
    const partSize = 5 * 1024 * 1024

    try {
      // 1. 请求后端生成分片上传信息
      const { data } = await axios.post(`${BACKEND_API_URL}/api/synthesize/generate-s3-upload-url`, {
        fileName: file.name,
        fileType: file.type,
        fileSize: file.size
      })
      const { uploadId, key, presignedUrls, partCount } = data

      // 2. 分片上传文件
      const parts = []
      for (let i = 0; i < partCount; i++) {
        const start = i * partSize
        const end = Math.min(start + partSize, file.size)
        const chunk = file.slice(start, end)

        // 上传分片
        await axios.put(presignedUrls[i], chunk, {
          headers: { 'Content-Type': file.type }
        })

        // 获取分片ETag
        const headRes = await axios.head(presignedUrls[i])
        parts.push({
          PartNumber: i + 1,
          ETag: headRes.headers.etag.replace(/"/g, '')
        })
      }

      // 3. 通知后端完成上传
      const { data: completeData } = await axios.post(`${BACKEND_API_URL}/api/synthesize/complete-s3-upload`, {
        uploadId,
        key,
        parts
      })

      // 跳转到结果页
      window.location.href = `/calls/${completeData.transcriptionId}`
    } catch (err) {
      console.error("Upload error:", err)
      alert('上传失败,请重试')
    }
  }

  return (
    <div>
      <div>
        {previewURL && (
          file.type.includes("video") ?
            <video id="video" width="800" controls>
              <source src={previewURL} id="videoSrc" />
            </video> 
          : 
            <audio id="audio" controls>
              <source src={previewURL} id="audioSrc" />
            </audio> 
        )}

        <form onSubmit={handleUpload}>
          <label htmlFor="file">File</label>
          <input
            type="file"
            id="file"
            name="file"
            onChange={generatePreview}
          />

          <button type="submit"> Upload </button>
          <button type="button" onClick={clearFile}> Clear </button>
        </form>
      </div>
    </div>
  )
}

export default FileUpload

注意事项

  • 方案一中,formidable解析的临时文件会自动清理,Express用内存存储避免服务器留文件,直接流式上传到S3。
  • 方案二中,需给前端添加分片上传失败重试逻辑,可增加进度条提升用户体验;预签名URL有效期需根据业务调整。
  • AWS IAM角色需配置正确权限:后端需拥有S3的createMultipartUpload、uploadPart、completeMultipartUpload权限。

内容的提问来源于stack exchange,提问作者rafi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.04 23:55:30