NextJS前端向Express后端传multipart视频文件及大文件S3上传方案咨询
视频上传解决方案:NextJS + Express + S3
问题背景
我需要实现用户上传视频文件的功能,可选两种路径:一是通过NextJS API中转到Express后端,最终存储到S3桶(服务器不保留文件);二是前端直接以分片方式上传到S3,再将视频URL传递给Express后端。目前遇到的核心问题是NextJS API无法读取multipart表单数据,尝试过multer、formidable但不知道如何在前端API层正确使用,同时大文件上传需要分片方案。项目环境为运行在3000端口的NextJS前端 + 5000端口的Express后端。
现有代码问题分析
- 前端表单:
<form>未设置enctype="multipart/form-data",导致无法正确发送文件数据。 - NextJS API:仅关闭了默认bodyParser,但未用formidable解析multipart数据,直接读取
req.body.file无效。 - 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
相关产品推荐
相关产品推荐

