Allow multi-file live clip uploads with shared date/host/platform and per-clip titles, filter materials by live platform and date range, and fix the migration to alter the mapped materials table.
892 lines
29 KiB
TypeScript
892 lines
29 KiB
TypeScript
/**
|
||
* 特殊类型的批量上传处理函数
|
||
* 包括:音乐、淘系-逛逛视频、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, compressVideoPreviewImage } 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)将操作推迟到下一个事件循环
|
||
})
|
||
}
|
||
|
||
// complete 接口服务端只允许 1 个处理中请求并排队少量后续请求。
|
||
// 文件级并发保持很小,单个文件内部的 chunkUpload 并发仍由其自身策略控制。
|
||
const SPECIAL_BATCH_FILE_CONCURRENCY = 3
|
||
const BRAND_VI_FILE_CONCURRENCY = 1
|
||
const COMPLETE_MAX_ATTEMPTS = 3
|
||
const MATERIAL_CREATE_TIMEOUT_MS = 15 * 60 * 1000
|
||
// PDF 工具自身最多运行 10 分钟;单文件串行后仍需覆盖一个在途请求和排队开销。
|
||
const BRAND_VI_MATERIAL_CREATE_TIMEOUT_MS = 30 * 60 * 1000
|
||
const MATERIAL_STATUS_REQUEST_TIMEOUT_MS = 15 * 1000
|
||
const MATERIAL_STATUS_POLL_INTERVAL_MS = 2 * 1000
|
||
|
||
async function mapWithConcurrency<T, R>(
|
||
items: T[],
|
||
concurrency: number,
|
||
mapper: (item: T, index: number) => Promise<R>,
|
||
): Promise<R[]> {
|
||
if (items.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const results = new Array<R>(items.length)
|
||
let nextIndex = 0
|
||
|
||
const worker = async () => {
|
||
while (true) {
|
||
const index = nextIndex
|
||
nextIndex += 1
|
||
|
||
if (index >= items.length) {
|
||
return
|
||
}
|
||
|
||
results[index] = await mapper(items[index], index)
|
||
}
|
||
}
|
||
|
||
const workerCount = Math.min(Math.max(1, concurrency), items.length)
|
||
await Promise.all(Array.from({ length: workerCount }, () => worker()))
|
||
return results
|
||
}
|
||
|
||
function isRetryableCompleteStatus(status: number): boolean {
|
||
// 507 是上传盘不足,501 是服务不支持;立即重试只会放大压力。
|
||
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504
|
||
}
|
||
|
||
function getRetryAfterDelayMs(value: string | null | undefined): number | null {
|
||
if (!value) {
|
||
return null
|
||
}
|
||
|
||
const seconds = Number(value)
|
||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||
return Math.ceil(seconds * 1000)
|
||
}
|
||
|
||
const retryAt = Date.parse(value)
|
||
return Number.isNaN(retryAt) ? null : Math.max(0, retryAt - Date.now())
|
||
}
|
||
|
||
function waitForRetry(delayMs: number): Promise<void> {
|
||
if (delayMs <= 0) {
|
||
return Promise.resolve()
|
||
}
|
||
|
||
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
||
}
|
||
|
||
function getDefaultRetryDelayMs(attempt: number): number {
|
||
return Math.min(2_000, 500 * (2 ** Math.max(0, attempt - 1)))
|
||
}
|
||
|
||
type MaterialUploadStatusResult = {
|
||
status?: 'completed' | 'processing' | 'not_found'
|
||
material?: Record<string, any>
|
||
error?: string
|
||
}
|
||
|
||
async function recoverAmbiguousMaterialCreation(
|
||
uploadId: string,
|
||
waitTimeoutMs: number,
|
||
originalError: unknown,
|
||
): Promise<Record<string, any>> {
|
||
const deadline = Date.now() + waitTimeoutMs
|
||
|
||
while (true) {
|
||
let response: Response
|
||
try {
|
||
response = await fetchWithTimeout(
|
||
`/api/materials/upload?uploadId=${encodeURIComponent(uploadId)}`,
|
||
{
|
||
method: 'GET',
|
||
timeout: MATERIAL_STATUS_REQUEST_TIMEOUT_MS,
|
||
},
|
||
)
|
||
} catch (statusError) {
|
||
throw new Error(
|
||
`创建素材请求中断,且状态查询失败;已保留上传断点,请稍后重试确认(${
|
||
statusError instanceof Error ? statusError.message : '网络错误'
|
||
})`,
|
||
)
|
||
}
|
||
|
||
if (response.status === 404) {
|
||
throw new Error('创建素材请求中断,服务器尚未确认建档;已保留上传断点,请稍后重试')
|
||
}
|
||
|
||
const statusResult = await response.json().catch(() => ({})) as MaterialUploadStatusResult
|
||
if (!response.ok) {
|
||
throw new Error(
|
||
statusResult.error || `创建素材请求中断,状态查询失败: ${response.status}`,
|
||
)
|
||
}
|
||
|
||
if (statusResult.status === 'completed' && statusResult.material) {
|
||
console.log(`[CompleteBatchUpload] 状态查询确认建档完成: ${uploadId}`)
|
||
return {
|
||
message: '上传成功',
|
||
material: statusResult.material,
|
||
replayed: true,
|
||
}
|
||
}
|
||
|
||
if (statusResult.status !== 'processing' || Date.now() >= deadline) {
|
||
const reason = originalError instanceof Error ? originalError.message : '网络连接中断'
|
||
throw new Error(`创建素材结果尚未确认(${reason});已保留上传断点,请稍后重试`)
|
||
}
|
||
|
||
await waitForRetry(Math.min(MATERIAL_STATUS_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())))
|
||
}
|
||
}
|
||
|
||
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分钟超时)
|
||
let completeResponse: Response | null = null
|
||
for (let attempt = 1; attempt <= COMPLETE_MAX_ATTEMPTS; attempt += 1) {
|
||
console.log(`[CompleteBatchUpload] 调用complete端点: ${uploadId}(第${attempt}次)`)
|
||
try {
|
||
completeResponse = await fetchWithTimeout('/api/materials/upload/complete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
uploadId,
|
||
originalFileName: fileName,
|
||
}),
|
||
timeout: 300000, // 5分钟超时
|
||
})
|
||
} catch (error) {
|
||
if (attempt >= COMPLETE_MAX_ATTEMPTS) {
|
||
throw error
|
||
}
|
||
const delayMs = getDefaultRetryDelayMs(attempt)
|
||
console.warn(
|
||
`[CompleteBatchUpload] complete请求中断,将用同一uploadId重试: ${uploadId}, `
|
||
+ `等待${delayMs}ms`,
|
||
error,
|
||
)
|
||
await waitForRetry(delayMs)
|
||
continue
|
||
}
|
||
|
||
if (completeResponse.ok) {
|
||
break
|
||
}
|
||
|
||
const errorText = await completeResponse.text().catch(() => 'Unknown error')
|
||
const canRetry = isRetryableCompleteStatus(completeResponse.status) && attempt < COMPLETE_MAX_ATTEMPTS
|
||
if (!canRetry) {
|
||
if (completeResponse.status === 404) {
|
||
// 建档成功后会删除临时目录;若浏览器在删除 IndexedDB 前退出,
|
||
// 下一次恢复会先看到 complete=404。此时只查 owner-scoped 结果。
|
||
return await recoverAmbiguousMaterialCreation(
|
||
uploadId,
|
||
0,
|
||
new Error('上传临时目录已清理'),
|
||
)
|
||
}
|
||
console.error(`[CompleteBatchUpload] complete端点返回错误: ${completeResponse.status}, ${errorText}`)
|
||
throw new Error(`分片合并失败: ${completeResponse.status}`)
|
||
}
|
||
|
||
const retryAfter = getRetryAfterDelayMs(completeResponse.headers?.get?.('Retry-After'))
|
||
?? getDefaultRetryDelayMs(attempt)
|
||
console.warn(
|
||
`[CompleteBatchUpload] complete端点暂时不可用,将重试: ${completeResponse.status}, `
|
||
+ `等待${retryAfter}ms`,
|
||
)
|
||
await waitForRetry(retryAfter)
|
||
}
|
||
|
||
if (!completeResponse || !completeResponse.ok) {
|
||
throw new Error('分片合并失败')
|
||
}
|
||
|
||
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 || '')
|
||
if (metadata.liveDate) formData.append('liveDate', String(metadata.liveDate))
|
||
if (metadata.liveHost) formData.append('liveHost', String(metadata.liveHost))
|
||
if (metadata.liveHostUserId) formData.append('liveHostUserId', String(metadata.liveHostUserId))
|
||
if (metadata.livePlatform) formData.append('livePlatform', String(metadata.livePlatform))
|
||
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 {
|
||
throw new Error('预览图数据无法解析,请重新选择图片后重试')
|
||
}
|
||
} catch (previewError) {
|
||
console.error(`[CompleteBatchUpload] 预览图处理失败: ${uploadId}`, previewError)
|
||
throw previewError instanceof Error
|
||
? previewError
|
||
: new Error('预览图处理失败,请重新选择图片后重试')
|
||
}
|
||
}
|
||
|
||
// 调用 upload 端点。品牌 VI 的 PDF 处理上限为 10 分钟,30 分钟窗口
|
||
// 同时覆盖一个在途任务、服务端排队和收尾;请求结果不确定时只查状态。
|
||
console.log(`[CompleteBatchUpload] 调用upload端点: ${uploadId}`)
|
||
formData.append('uploadId', uploadId)
|
||
const materialCreateTimeout = materialType === '品牌VI灵感'
|
||
? BRAND_VI_MATERIAL_CREATE_TIMEOUT_MS
|
||
: MATERIAL_CREATE_TIMEOUT_MS
|
||
let uploadResponse: Response
|
||
try {
|
||
uploadResponse = await fetchWithTimeout('/api/materials/upload', {
|
||
method: 'POST',
|
||
body: formData,
|
||
timeout: materialCreateTimeout,
|
||
})
|
||
} catch (error) {
|
||
// POST 可能已经在服务器完成,不能盲目再次建档;owner-scoped GET
|
||
// 是唯一恢复路径,失败时保留 IndexedDB 供用户稍后确认。
|
||
return await recoverAmbiguousMaterialCreation(uploadId, materialCreateTimeout, error)
|
||
}
|
||
|
||
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 mapWithConcurrency(files, SPECIAL_BATCH_FILE_CONCURRENCY, 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,
|
||
retainStateOnSkipComplete: true,
|
||
maxRetries: 5,
|
||
})
|
||
|
||
// 等待completeBatchUpload完成,确保素材记录被创建
|
||
if (result?.uploadId) {
|
||
try {
|
||
await completeBatchUpload(result.uploadId, metadata)
|
||
await result.confirmMaterialCreated?.()
|
||
} 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' as const,
|
||
}
|
||
} 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' as const,
|
||
}
|
||
}
|
||
})
|
||
|
||
return results
|
||
}
|
||
|
||
/**
|
||
* 批量上传淘系-逛逛视频
|
||
*/
|
||
export async function uploadBatchVideo(
|
||
files: Array<{
|
||
file: File
|
||
productModel: string
|
||
productCategoryName?: string
|
||
previewImage?: File
|
||
}>,
|
||
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 || '',
|
||
}
|
||
|
||
// 预览图可能达到 50MB。逐条处理可以把图片解码、压缩和 base64
|
||
// 转换的峰值限制在一个文件内,避免最多 8 条视频同时占满浏览器内存。
|
||
const results: Array<{
|
||
tempId: string
|
||
uploadId: string | null
|
||
productModel: string
|
||
fileName: string
|
||
fileSize: number
|
||
status: 'processing' | 'failed'
|
||
}> = []
|
||
|
||
for (let index = 0; index < files.length; index += 1) {
|
||
const item = files[index]
|
||
|
||
try {
|
||
const metadata: any = {
|
||
...baseMetadata,
|
||
productModel: item.productModel,
|
||
productCategoryName: item.productCategoryName || productCategoryName || '',
|
||
fileName: item.file.name,
|
||
}
|
||
|
||
if (item.previewImage) {
|
||
const compressedPreview = await compressVideoPreviewImage(item.previewImage)
|
||
metadata.previewImage = await fileToBase64String(compressedPreview)
|
||
}
|
||
|
||
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,
|
||
retainStateOnSkipComplete: true,
|
||
maxRetries: 5,
|
||
})
|
||
|
||
// 等待completeBatchUpload完成,确保素材记录被创建
|
||
if (result?.uploadId) {
|
||
try {
|
||
await completeBatchUpload(result.uploadId, metadata)
|
||
await result.confirmMaterialCreated?.()
|
||
} catch (err) {
|
||
console.error(`[Upload] 视频文件 ${item.file.name} 后台处理失败:`, err)
|
||
throw err // 重新抛出错误,让上层知道失败
|
||
}
|
||
}
|
||
|
||
results.push({
|
||
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)
|
||
results.push({
|
||
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 mapWithConcurrency(
|
||
files,
|
||
SPECIAL_BATCH_FILE_CONCURRENCY,
|
||
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,
|
||
retainStateOnSkipComplete: true,
|
||
maxRetries: 5,
|
||
})
|
||
|
||
if (result?.uploadId) {
|
||
// 等待completeBatchUpload完成,确保素材记录被创建
|
||
try {
|
||
await completeBatchUpload(result.uploadId, metadata)
|
||
await result.confirmMaterialCreated?.()
|
||
} 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: Array<{
|
||
tempId: string
|
||
uploadId: string | null
|
||
productModel: string
|
||
status: 'processing' | 'failed'
|
||
}> = []
|
||
|
||
// 一个买家秀批次可能包含多张原图。逐批提交可避免多个大 multipart
|
||
// 同时进入 Node.js 并占满内存;单批内仍保留原图质量和原有字段语义。
|
||
for (let index = 0; index < batches.length; index += 1) {
|
||
const item = batches[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)
|
||
|
||
results.push({
|
||
tempId: `pending-${Date.now()}-${index}`,
|
||
uploadId: result.material?.id || null,
|
||
productModel: item.productModel,
|
||
status: 'processing',
|
||
})
|
||
} catch (error) {
|
||
console.error(`[Upload] 买家秀批次 ${item.productModel} 上传失败:`, error)
|
||
results.push({
|
||
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: Array<{
|
||
tempId: string
|
||
uploadId: string | null
|
||
productModel: string
|
||
status: 'processing' | 'failed'
|
||
}> = []
|
||
|
||
// 与买家秀保持同一安全边界:逐批提交,避免多个图片 multipart
|
||
// 同时解析;某一批失败不会阻止后续批次继续。
|
||
for (let index = 0; index < batches.length; index += 1) {
|
||
const item = batches[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)
|
||
|
||
results.push({
|
||
tempId: `pending-${Date.now()}-${index}`,
|
||
uploadId: result.material?.id || null,
|
||
productModel: item.productModel,
|
||
status: 'processing',
|
||
})
|
||
} catch (error) {
|
||
console.error(`[Upload] 小红书批次 ${item.productModel} 上传失败:`, error)
|
||
results.push({
|
||
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 mapWithConcurrency(files, BRAND_VI_FILE_CONCURRENCY, 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,
|
||
retainStateOnSkipComplete: true,
|
||
maxRetries: 5,
|
||
})
|
||
|
||
// 等待completeBatchUpload完成,确保素材记录被创建
|
||
if (result?.uploadId) {
|
||
try {
|
||
await completeBatchUpload(result.uploadId, metadata)
|
||
await result.confirmMaterialCreated?.()
|
||
} 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' as const,
|
||
}
|
||
} 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' as const,
|
||
}
|
||
}
|
||
})
|
||
|
||
return results
|
||
}
|
||
|