gongxiangp2/components/upload/special-batch-uploaders.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

888 lines
29 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, 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 || '')
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
}