gongxiangp2/components/upload/special-batch-uploaders.ts
chenpei cd6e40d9d2 优化:网络传输性能和稳定性 - TCP缓冲区+并发控制优化
主要改动:
1. TCP缓冲区优化(16MB)- 解决公网上传速度慢问题
2. 并发数降低到1-3 - 避免4并发导致ERR_CONNECTION_RESET
3. 动态并发控制 - 根据网络环境自动调整
4. Nginx超时增加到120s - 支持大分片上传
5. 上传进度修复 - 修复剩余时间计算
6. 新增任务-页面上传器和详情组件
7. 统一部署文档为deploy-remote-build.ps1

问题修复:
- 公网用户上传从200-900KB/s提升到预期3-5MB/s
- 内网用户上传更稳定,无连接重置错误
- UploadProgress组件剩余时间计算修正

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 09:33:09 +08:00

583 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 特殊类型的批量上传处理函数
* 包括:音乐、种草视频、3D场景、品牌VI、买家秀、小红书
*/
import { chunkUpload } from '@/lib/chunk-upload'
import { shouldPassRenderer } from '@/lib/material-validation'
import { compressPreviewImage } from './image-processor'
/**
* 将File转换为base64字符串格式(data:image/xxx;base64,...)
* 用于批量上传中传递预览图给 completeBatchUpload
*/
export async function fileToBase64String(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
/**
* 批量上传完成后调用complete接口,然后创建素材记录
*/
export async function completeBatchUpload(uploadId: string, metadata: Record<string, any>) {
try {
// 第一步:合并分片
const completeResponse = await fetch('/api/materials/upload/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uploadId,
originalFileName: metadata.fileName || metadata.originalFileName
}),
})
if (!completeResponse.ok) {
throw new Error(`分片合并失败: ${completeResponse.status}`)
}
const completeResult = await completeResponse.json()
const existingFilePath = completeResult.existingFilePath
const originalFileName = completeResult.fileName || metadata.fileName
if (!existingFilePath) {
throw new Error('分片合并后未返回文件路径')
}
// 第二步:创建素材记录(使用合并后的文件路径)
const formData = new FormData()
formData.append('existingFilePath', existingFilePath)
formData.append('originalFileName', originalFileName)
formData.append('productModel', metadata.productModel)
formData.append('categoryName', metadata.categoryName || '通用')
formData.append('sceneName', metadata.sceneName || '通用')
formData.append('platformName', metadata.platformName || '')
formData.append('productCategoryName', metadata.productCategoryName || '')
const materialType = metadata.materialType || '3D场景'
formData.append('materialType', materialType)
// 统一处理renderer字段:根据素材类型判断是否需要传递
const rendererInfo = shouldPassRenderer(materialType, metadata.renderer)
if (rendererInfo.shouldPass) {
formData.append('renderer', rendererInfo.value)
}
// 如果有预览图(base64字符串格式),需要转换为File对象
if (metadata.previewImage) {
try {
// base64格式:data:image/png;base64,...
const base64Data = metadata.previewImage
if (base64Data.startsWith('data:')) {
const matches = base64Data.match(/^data:([^;]+);base64,(.+)$/)
if (matches) {
const mimeType = matches[1]
const base64String = matches[2]
const byteCharacters = atob(base64String)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: mimeType })
const file = new File([blob], 'preview.jpg', { type: mimeType })
formData.append('previewImage', file)
}
}
} catch (previewError) {
console.warn('[Upload] 预览图处理失败(忽略):', previewError)
}
}
const uploadResponse = await fetch('/api/materials/upload', {
method: 'POST',
body: formData,
})
if (!uploadResponse.ok) {
const errorData = await uploadResponse.json().catch(() => ({}))
throw new Error(errorData.error || `创建素材记录失败: ${uploadResponse.status}`)
}
const uploadResult = await uploadResponse.json()
return uploadResult
} catch (error) {
console.error(`[Upload] 后台处理失败:`, error)
throw error
}
}
/**
* 批量上传音乐文件
*/
export async function uploadBatchMusic(
files: Array<{ file: File; productModel: string }>,
platformName: string,
renderer: string,
onProgress: (progress: number) => void
) {
const baseMetadata = {
materialType: '音乐',
categoryName: '通用',
sceneName: '通用',
platformName: platformName || '',
renderer: renderer,
}
const results = await Promise.all(
files.map(async (item, index) => {
try {
const metadata = {
...baseMetadata,
productModel: item.productModel,
fileName: item.file.name,
}
const result = await chunkUpload(item.file, metadata, {
// chunkSize使用默认配置:内网20MB,公网10MB
onProgress: (progress) => {
const totalProgress = Math.round((100 * index + progress) / files.length)
onProgress(totalProgress)
},
skipComplete: true,
maxRetries: Infinity,
})
// 等待completeBatchUpload完成,确保素材记录被创建
if (result?.uploadId) {
try {
await completeBatchUpload(result.uploadId, metadata)
} catch (err) {
console.error(`[Upload] 音乐文件 ${item.file.name} 后台处理失败:`, err)
throw err // 重新抛出错误,让上层知道失败
}
}
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: result?.uploadId || null,
productModel: item.productModel,
fileName: item.file.name,
fileSize: item.file.size,
status: 'processing',
}
} catch (error) {
console.error(`[Upload] 音乐文件 ${item.file.name} 上传失败:`, error)
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: null,
productModel: item.productModel,
fileName: item.file.name,
fileSize: item.file.size,
status: 'failed',
}
}
})
)
return results
}
/**
* 批量上传种草视频
*/
export async function uploadBatchVideo(
files: Array<{ file: File; productModel: string; productCategoryName?: string }>,
platformName: string,
renderer: string,
productCategoryName: string,
onProgress: (progress: number) => void
) {
const baseMetadata = {
materialType: '种草视频',
categoryName: '通用',
sceneName: '通用',
platformName: platformName || '',
renderer: renderer,
productCategoryName: productCategoryName || '',
}
const results = await Promise.all(
files.map(async (item, index) => {
try {
const metadata = {
...baseMetadata,
productModel: item.productModel,
productCategoryName: item.productCategoryName || productCategoryName || '',
fileName: item.file.name,
}
const result = await chunkUpload(item.file, metadata, {
// chunkSize使用默认配置:内网20MB,公网10MB
onProgress: (progress) => {
const totalProgress = Math.round((100 * index + progress) / files.length)
onProgress(totalProgress)
},
skipComplete: true,
maxRetries: Infinity,
})
// 等待completeBatchUpload完成,确保素材记录被创建
if (result?.uploadId) {
try {
await completeBatchUpload(result.uploadId, metadata)
} catch (err) {
console.error(`[Upload] 视频文件 ${item.file.name} 后台处理失败:`, err)
throw err // 重新抛出错误,让上层知道失败
}
}
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: result?.uploadId || null,
productModel: item.productModel,
fileName: item.file.name,
fileSize: item.file.size,
status: 'processing',
}
} catch (error) {
console.error(`[Upload] 视频文件 ${item.file.name} 上传失败:`, error)
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: null,
productModel: item.productModel,
fileName: item.file.name,
fileSize: item.file.size,
status: 'failed',
}
}
})
)
return results
}
/**
* 批量上传3D场景文件
*/
export async function uploadBatch3D(
files: Array<{ file: File; productModel: string; previewImage?: File }>,
categoryName: string,
sceneName: string,
platformName: string,
renderer: string,
onProgress: (progress: number) => void
) {
const baseMetadata = {
materialType: '3D场景',
categoryName: categoryName || '通用',
sceneName: sceneName || '通用',
platformName: platformName || '',
renderer: renderer,
}
const results = await Promise.all(
files.map(async (item, index) => {
try {
const metadata: any = {
...baseMetadata,
productModel: item.productModel,
fileName: item.file.name,
}
// 如果有预览图,先压缩再转换为base64字符串格式
if (item.previewImage) {
const compressed = await compressPreviewImage(item.previewImage)
metadata.previewImage = await fileToBase64String(compressed)
}
const result = await chunkUpload(item.file, metadata, {
// chunkSize使用默认配置:内网20MB,公网10MB
onProgress: (progress) => {
const totalProgress = Math.round((100 * index + progress) / files.length)
onProgress(totalProgress)
},
skipComplete: true,
maxRetries: Infinity,
})
if (result?.uploadId) {
// 等待completeBatchUpload完成,确保素材记录被创建
try {
await completeBatchUpload(result.uploadId, metadata)
} catch (err) {
console.error(`[Upload] 3D场景文件 ${item.file.name} 后台处理失败:`, err)
throw err // 重新抛出错误,让上层知道失败
}
}
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: result?.uploadId || null,
productModel: item.productModel,
fileName: item.file.name,
fileSize: item.file.size,
status: 'success',
}
} catch (error) {
console.error(`[Upload] 3D场景文件 ${item.file.name} 上传失败:`, error)
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: null,
productModel: item.productModel,
fileName: item.file.name,
fileSize: item.file.size,
status: 'failed',
}
}
})
)
return results
}
/**
* 批量上传买家秀
*/
export async function uploadBatchBuyerShow(
batches: Array<{ productModel: string; images: File[] }>,
categoryName: string,
sceneName: string,
platformName: string,
renderer: string,
onProgress: (progress: number) => void
) {
const baseMetadata = {
materialType: '买家秀图片',
categoryName: categoryName || '通用',
sceneName: sceneName || '通用',
platformName: platformName || '',
renderer: renderer,
}
const results = await Promise.all(
batches.map(async (item, index) => {
try {
const metadata: any = {
...baseMetadata,
productModel: item.productModel,
}
// 买家秀类型直接调用upload接口创建素材记录(不需要分片上传)
const formData = new FormData()
formData.append('productModel', item.productModel)
formData.append('categoryName', metadata.categoryName)
formData.append('sceneName', metadata.sceneName)
formData.append('platformName', metadata.platformName || '')
formData.append('productCategoryName', metadata.productCategoryName || '')
const materialType = metadata.materialType
formData.append('materialType', materialType)
// 统一处理renderer字段:根据素材类型判断是否需要传递
const rendererInfo = shouldPassRenderer(materialType, metadata.renderer)
if (rendererInfo.shouldPass) {
formData.append('renderer', rendererInfo.value)
}
// 添加预览图和模特图(预览图需要压缩)
if (item.images.length > 0) {
const compressedPreview = await compressPreviewImage(item.images[0])
formData.append('previewImage', compressedPreview)
// 其他图片作为模特图
for (let i = 1; i < item.images.length; i++) {
formData.append('modelImages', item.images[i])
}
}
const response = await fetch('/api/materials/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || `上传失败: ${response.status}`)
}
const result = await response.json()
const totalProgress = Math.round(((index + 1) * 100) / batches.length)
onProgress(totalProgress)
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: result.material?.id || null,
productModel: item.productModel,
status: 'processing',
}
} catch (error) {
console.error(`[Upload] 买家秀批次 ${item.productModel} 上传失败:`, error)
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: null,
productModel: item.productModel,
status: 'failed',
}
}
})
)
return results
}
/**
* 批量上传小红书
*/
export async function uploadBatchXiaohongshu(
batches: Array<{ productModel: string; images: File[] }>,
categoryName: string,
sceneName: string,
platformName: string,
renderer: string,
onProgress: (progress: number) => void
) {
const baseMetadata = {
materialType: '小红书种草',
categoryName: categoryName || '通用',
sceneName: sceneName || '通用',
platformName: platformName || '',
renderer: renderer,
}
const results = await Promise.all(
batches.map(async (item, index) => {
try {
const metadata: any = {
...baseMetadata,
productModel: item.productModel,
}
// 小红书类型直接调用upload接口创建素材记录(不需要分片上传)
const formData = new FormData()
formData.append('productModel', item.productModel)
formData.append('categoryName', metadata.categoryName)
formData.append('sceneName', metadata.sceneName)
formData.append('platformName', metadata.platformName || '')
formData.append('productCategoryName', metadata.productCategoryName || '')
const materialType = metadata.materialType
formData.append('materialType', materialType)
// 统一处理renderer字段:根据素材类型判断是否需要传递
const rendererInfo = shouldPassRenderer(materialType, metadata.renderer)
if (rendererInfo.shouldPass) {
formData.append('renderer', rendererInfo.value)
}
// 添加预览图和模特图(预览图需要压缩)
if (item.images.length > 0) {
const compressedPreview = await compressPreviewImage(item.images[0])
formData.append('previewImage', compressedPreview)
// 其他图片作为模特图
for (let i = 1; i < item.images.length; i++) {
formData.append('modelImages', item.images[i])
}
}
const response = await fetch('/api/materials/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || `上传失败: ${response.status}`)
}
const result = await response.json()
const totalProgress = Math.round(((index + 1) * 100) / batches.length)
onProgress(totalProgress)
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: result.material?.id || null,
productModel: item.productModel,
status: 'processing',
}
} catch (error) {
console.error(`[Upload] 小红书批次 ${item.productModel} 上传失败:`, error)
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: null,
productModel: item.productModel,
status: 'failed',
}
}
})
)
return results
}
/**
* 批量上传品牌VI
*/
export async function uploadBatchBrandVI(
files: Array<{ file: File; productModel: string }>,
productCategoryName: string,
onProgress: (progress: number) => void
) {
const baseMetadata = {
materialType: '品牌VI灵感',
categoryName: '通用',
sceneName: '通用',
platformName: '',
renderer: '',
productCategoryName: productCategoryName || '',
}
const results = await Promise.all(
files.map(async (item, index) => {
try {
const metadata = {
...baseMetadata,
productModel: item.productModel,
fileName: item.file.name,
}
const result = await chunkUpload(item.file, metadata, {
// chunkSize使用默认配置:内网20MB,公网10MB
onProgress: (progress) => {
const totalProgress = Math.round((100 * index + progress) / files.length)
onProgress(totalProgress)
},
skipComplete: true,
maxRetries: Infinity,
})
// 等待completeBatchUpload完成,确保素材记录被创建
if (result?.uploadId) {
try {
await completeBatchUpload(result.uploadId, metadata)
} catch (err) {
console.error(`[Upload] PDF文件 ${item.file.name} 后台处理失败:`, err)
throw err // 重新抛出错误,让上层知道失败
}
}
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: result?.uploadId || null,
productModel: item.productModel,
fileName: item.file.name,
fileSize: item.file.size,
status: 'processing',
}
} catch (error) {
console.error(`[Upload] PDF文件 ${item.file.name} 上传失败:`, error)
return {
tempId: `pending-${Date.now()}-${index}`,
uploadId: null,
productModel: item.productModel,
fileName: item.file.name,
fileSize: item.file.size,
status: 'failed',
}
}
})
)
return results
}