主要改动: 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>
583 lines
19 KiB
TypeScript
583 lines
19 KiB
TypeScript
/**
|
||
* 特殊类型的批量上传处理函数
|
||
* 包括:音乐、种草视频、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
|
||
}
|
||
|