/** * 特殊类型的批量上传处理函数 * 包括:音乐、淘系-逛逛视频、3D场景、品牌VI、买家秀、小红书 */ import { chunkUpload } from '@/lib/chunk-upload' import { shouldPassRenderer } from '@/lib/material-validation' import { TAOXI_GUANGGUANG_VIDEO_TYPE } from '@/lib/video-material-types' import { compressPreviewImage } from './image-processor' /** * 将File转换为base64字符串格式(data:image/xxx;base64,...) * 用于批量上传中传递预览图给 completeBatchUpload */ export async function fileToBase64String(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(reader.result as string) reader.onerror = reject reader.readAsDataURL(file) }) } /** * 批量上传完成后调用complete接口,然后创建素材记录 */ /** * 带超时的fetch辅助函数 */ async function fetchWithTimeout(url: string, options: RequestInit & { timeout?: number }) { const { timeout = 60000, ...fetchOptions } = options // 默认60秒超时 const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) try { const response = await fetch(url, { ...fetchOptions, signal: controller.signal, }) clearTimeout(timeoutId) return response } catch (error) { clearTimeout(timeoutId) if (error instanceof Error && error.name === 'AbortError') { throw new Error(`请求超时(${timeout / 1000}秒)`) } throw error } } /** * 异步转换base64为Blob(避免阻塞主线程) */ async function base64ToBlob(base64Data: string): Promise { if (!base64Data.startsWith('data:')) { return null } const matches = base64Data.match(/^data:([^;]+);base64,(.+)$/) if (!matches) { return null } const mimeType = matches[1] const base64String = matches[2] // 使用Promise包裹,避免阻塞主线程 return new Promise((resolve) => { setTimeout(() => { try { 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 }) resolve(blob) } catch (error) { console.warn('[Upload] base64转换失败:', error) resolve(null) } }, 0) // 使用setTimeout(0)将操作推迟到下一个事件循环 }) } export async function completeBatchUpload(uploadId: string, metadata: Record) { const fileName = metadata.fileName || metadata.originalFileName || 'unknown' console.log(`[CompleteBatchUpload] 开始处理: ${fileName}, uploadId: ${uploadId}`) try { // 第一步:合并分片(设置5分钟超时) console.log(`[CompleteBatchUpload] 调用complete端点: ${uploadId}`) const completeResponse = await fetchWithTimeout('/api/materials/upload/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId, originalFileName: fileName, }), timeout: 300000, // 5分钟超时 }) if (!completeResponse.ok) { const errorText = await completeResponse.text().catch(() => 'Unknown error') console.error(`[CompleteBatchUpload] complete端点返回错误: ${completeResponse.status}, ${errorText}`) throw new Error(`分片合并失败: ${completeResponse.status}`) } const completeResult = await completeResponse.json() const existingFilePath = completeResult.existingFilePath const originalFileName = completeResult.fileName || metadata.fileName if (!existingFilePath) { console.error(`[CompleteBatchUpload] complete端点未返回文件路径: ${uploadId}`) throw new Error('分片合并后未返回文件路径') } console.log(`[CompleteBatchUpload] complete成功,开始调用upload端点: ${uploadId}, 路径: ${existingFilePath}`) // 第二步:创建素材记录(使用合并后的文件路径) const formData = new FormData() formData.append('existingFilePath', existingFilePath) if (originalFileName) { formData.append('originalFileName', originalFileName) } else if (metadata.fileName) { formData.append('originalFileName', metadata.fileName) } 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) { console.log(`[CompleteBatchUpload] 开始处理预览图: ${uploadId}`) try { const blob = await base64ToBlob(metadata.previewImage) if (blob) { const file = new File([blob], 'preview.jpg', { type: blob.type }) formData.append('previewImage', file) console.log(`[CompleteBatchUpload] 预览图处理完成: ${uploadId}, 大小: ${blob.size} bytes`) } else { console.warn(`[CompleteBatchUpload] 预览图转换返回null: ${uploadId}`) } } catch (previewError) { console.error(`[CompleteBatchUpload] 预览图处理失败: ${uploadId}`, previewError) // 预览图处理失败不影响整体流程,继续执行 } } // 调用upload端点(设置5分钟超时) console.log(`[CompleteBatchUpload] 调用upload端点: ${uploadId}`) const uploadResponse = await fetchWithTimeout('/api/materials/upload', { method: 'POST', body: formData, timeout: 300000, // 5分钟超时 }) if (!uploadResponse.ok) { const errorData = await uploadResponse.json().catch(() => ({ error: `HTTP ${uploadResponse.status}` })) console.error(`[CompleteBatchUpload] upload端点返回错误: ${uploadId}, ${errorData.error}`) throw new Error(errorData.error || `创建素材记录失败: ${uploadResponse.status}`) } const uploadResult = await uploadResponse.json() console.log(`[CompleteBatchUpload] 全部完成: ${uploadId}, materialId: ${uploadResult.material?.id || 'N/A'}`) return uploadResult } catch (error) { console.error(`[CompleteBatchUpload] 处理失败: ${uploadId}`, 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: 5, }) // 等待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: TAOXI_GUANGGUANG_VIDEO_TYPE, 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: 5, }) // 等待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: 5, }) 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: 5, }) // 等待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 }