655 lines
21 KiB
TypeScript
655 lines
21 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接口,然后创建素材记录
|
||
*/
|
||
/**
|
||
* 带超时的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: 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
|
||
}
|
||
|