gongxiangp2/components/upload/special-batch-uploaders.ts
2026-05-27 18:16:30 +08:00

656 lines
21 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 { 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<string> {
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<Blob | null> {
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<string, any>) {
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
}