// 分块上传工具函数 // 支持断点续传,自动重试,静默处理错误 interface ChunkUploadOptions { chunkSize?: number // 每块大小(字节),默认5MB onProgress?: (progress: number, speed?: number) => void // progress: 0-100, speed: 字节/秒 onError?: (error: Error) => void maxRetries?: number // 最大重试次数,默认无限重试 retryDelay?: number // 重试延迟(毫秒),默认1000ms onStageChange?: ( stage: 'uploading' | 'processing', payload?: { message?: string } ) => void onRequest?: (xhr: XMLHttpRequest | null) => void // 暴露当前的XHR请求以便取消 skipComplete?: boolean // 如果为true,分片上传完成后立即返回,不调用completeUpload(用于后台异步处理) } interface ChunkUploadState { fileId: string // 文件唯一ID(基于文件内容hash) fileName: string fileSize: number chunkSize: number totalChunks: number uploadedChunks: Set // 已上传的块索引 uploadId?: string // 服务器返回的上传ID metadata?: Record // 表单元数据(用于恢复时预填) } // 存储上传状态到IndexedDB const DB_NAME = 'chunk-upload-db' const STORE_NAME = 'upload-states' async function getDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, 1) request.onerror = () => reject(request.error) request.onsuccess = () => resolve(request.result) request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME, { keyPath: 'fileId' }) } } }) } async function saveUploadState(state: ChunkUploadState): Promise { try { const db = await getDB() const transaction = db.transaction([STORE_NAME], 'readwrite') const store = transaction.objectStore(STORE_NAME) await new Promise((resolve, reject) => { const request = store.put(state) request.onsuccess = () => resolve() request.onerror = () => reject(request.error) }) } catch (error) { console.error('[ChunkUpload] Failed to save state:', error) } } async function loadUploadState(fileId: string): Promise { try { const db = await getDB() const transaction = db.transaction([STORE_NAME], 'readonly') const store = transaction.objectStore(STORE_NAME) return new Promise((resolve, reject) => { const request = store.get(fileId) request.onsuccess = () => resolve(request.result || null) request.onerror = () => reject(request.error) }) } catch (error) { console.error('[ChunkUpload] Failed to load state:', error) return null } } export async function deleteUploadState(fileId: string): Promise { try { const db = await getDB() const transaction = db.transaction([STORE_NAME], 'readwrite') const store = transaction.objectStore(STORE_NAME) await new Promise((resolve, reject) => { const request = store.delete(fileId) request.onsuccess = () => resolve() request.onerror = () => reject(request.error) }) } catch (error) { console.error('[ChunkUpload] Failed to delete state:', error) } } // 导出:按fileId获取上传状态(用于恢复预填) export async function getUploadStateById(fileId: string): Promise { return loadUploadState(fileId) } // 生成文件唯一ID(基于文件名、大小、修改时间) export function generateFileId(file: File): string { const data = `${file.name}-${file.size}-${file.lastModified}` // 简单的稳定hash函数(不包含时间),确保同一文件生成相同ID以便恢复 let hash = 0 for (let i = 0; i < data.length; i++) { const char = data.charCodeAt(i) hash = ((hash << 5) - hash) + char hash = hash & hash // Convert to 32bit integer } return `file-${Math.abs(hash)}` } // 初始化上传(获取uploadId) async function initUpload(fileName: string, fileSize: number, totalChunks: number): Promise { const response = await fetch('/api/materials/upload/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName, fileSize, totalChunks }), }) if (!response.ok) { throw new Error(`初始化上传失败: ${response.status}`) } const data = await response.json() return data.uploadId } // 上传单个块 async function uploadChunk( uploadId: string, chunkIndex: number, chunk: Blob, fileName: string, options: { onChunkProgress?: (loaded: number, total: number) => void onRequest?: (xhr: XMLHttpRequest | null) => void } = {} ): Promise { const formData = new FormData() formData.append('uploadId', uploadId) formData.append('chunkIndex', chunkIndex.toString()) formData.append('chunk', chunk) formData.append('fileName', fileName) return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() options.onRequest?.(xhr) xhr.open('POST', '/api/materials/upload/chunk') xhr.upload.onprogress = (event) => { if (event.lengthComputable) { options.onChunkProgress?.(event.loaded, event.total) } } xhr.onload = () => { options.onRequest?.(null) if (xhr.status >= 200 && xhr.status < 300) { resolve() } else { reject(new Error(`上传块 ${chunkIndex} 失败: ${xhr.status}`)) } } xhr.onerror = () => { options.onRequest?.(null) reject(new Error(`上传块 ${chunkIndex} 失败: 网络错误`)) } xhr.onabort = () => { options.onRequest?.(null) reject(new Error('上传已取消')) } xhr.send(formData) }) } // 完成上传(合并所有块) export async function completeUpload( uploadId: string | null, metadata: Record ): Promise { // 添加超时控制(5分钟) const timeout = 5 * 60 * 1000 // 5分钟 const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) try { const response = await fetch('/api/materials/upload/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId: uploadId || null, metadata }), signal: controller.signal, }) clearTimeout(timeoutId) if (!response.ok) { const errorData = await response.json().catch(() => ({})) const errorMessage = errorData.error || `完成上传失败: ${response.status}` // 如果是会话过期错误,抛出特殊错误以便前端处理 if (response.status === 404 && errorMessage.includes('会话')) { throw new Error('UPLOAD_SESSION_EXPIRED') } throw new Error(errorMessage) } return response.json() } catch (error) { clearTimeout(timeoutId) if (error instanceof Error) { if (error.name === 'AbortError') { throw new Error('请求超时,请重试上传') } if (error.message === 'UPLOAD_SESSION_EXPIRED') { throw error } } throw error } } // 主上传函数 export async function chunkUpload( file: File, metadata: Record, options: ChunkUploadOptions = {} ): Promise { const { chunkSize = 5 * 1024 * 1024, // 默认5MB onProgress, maxRetries = Infinity, // 默认无限重试 retryDelay = 1000, onStageChange, onRequest, skipComplete = false, // 默认不跳过complete } = options onStageChange?.('uploading', { message: '正在上传素材文件…' }) const fileId = generateFileId(file) const totalChunks = Math.ceil(file.size / chunkSize) // 加载已有状态 let state = await loadUploadState(fileId) if (!state) { // 初始化新上传 state = { fileId, fileName: file.name, fileSize: file.size, chunkSize, totalChunks, uploadedChunks: new Set(), metadata, // 首次保存metadata } await saveUploadState(state) } else { // 恢复上传 console.log(`[ChunkUpload] 恢复上传: ${state.uploadedChunks.size}/${totalChunks} 块已上传`) // 用最新metadata覆盖旧值(以便完成时使用最新表单信息) state.metadata = metadata await saveUploadState(state) } // 初始化上传(如果需要) if (!state.uploadId) { try { state.uploadId = await initUpload(file.name, file.size, totalChunks) await saveUploadState(state) } catch (error) { // 自动重试初始化 let retries = 0 while (retries < maxRetries) { await new Promise(resolve => setTimeout(resolve, retryDelay)) try { state.uploadId = await initUpload(file.name, file.size, totalChunks) await saveUploadState(state) break } catch (e) { retries++ if (retries >= maxRetries) { throw new Error('初始化上传失败,已重试多次') } } } } } // 网速计算相关变量 let lastUpdateTime = Date.now() let lastUploadedBytes = 0 // 计算已上传的字节数(包括恢复上传时已上传的块) let completedBytes = 0 for (let i = 0; i < totalChunks; i++) { if (state.uploadedChunks.has(i)) { const start = i * chunkSize const end = Math.min(start + chunkSize, file.size) completedBytes += (end - start) } } lastUploadedBytes = completedBytes // 初始化已上传字节数 const speedHistory: number[] = [] // 用于平滑网速计算 const SPEED_HISTORY_SIZE = 5 // 保留最近5次的速度记录 const updateProgress = (uploadedBytes: number) => { const clampedBytes = Math.min(file.size, Math.max(0, uploadedBytes)) const progress = Math.max( uploadedBytes > 0 ? 1 : 0, Math.min(100, Math.round((clampedBytes / file.size) * 100)) ) const currentTime = Date.now() const timeDelta = (currentTime - lastUpdateTime) / 1000 // 秒 const bytesDelta = clampedBytes - lastUploadedBytes if (timeDelta > 0.1) { const currentSpeed = bytesDelta / timeDelta if (currentSpeed > 0 && currentSpeed < file.size * 100) { speedHistory.push(currentSpeed) if (speedHistory.length > SPEED_HISTORY_SIZE) { speedHistory.shift() } } let avgSpeed = 0 if (speedHistory.length > 0) { avgSpeed = speedHistory.reduce((sum, s) => sum + s, 0) / speedHistory.length } onProgress?.(progress, avgSpeed > 0 ? avgSpeed : undefined) lastUpdateTime = currentTime lastUploadedBytes = clampedBytes } else { if (speedHistory.length > 0) { const lastSpeed = speedHistory[speedHistory.length - 1] onProgress?.(progress, lastSpeed) } else { onProgress?.(progress) } } } if (completedBytes > 0) { updateProgress(completedBytes) } // 上传所有未完成的块 for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { if (state.uploadedChunks.has(chunkIndex)) { // 已上传,跳过 continue } // 读取块 const start = chunkIndex * chunkSize const end = Math.min(start + chunkSize, file.size) const chunk = file.slice(start, end) const chunkSizeBytes = end - start // 上传块(带自动重试) let retries = 0 let success = false let chunkUploadedBytes = 0 while (!success && retries < maxRetries) { try { await uploadChunk(state.uploadId!, chunkIndex, chunk, file.name, { onChunkProgress: (loaded) => { chunkUploadedBytes = loaded updateProgress(completedBytes + chunkUploadedBytes) }, onRequest, }) state.uploadedChunks.add(chunkIndex) await saveUploadState(state) success = true // 更新已上传字节数(完成整个块) completedBytes += chunkSizeBytes chunkUploadedBytes = 0 updateProgress(completedBytes) } catch (error) { if ((error as Error)?.message === '上传已取消') { onRequest?.(null) throw error } retries++ if (retries < maxRetries) { // 等待后重试 await new Promise(resolve => setTimeout(resolve, retryDelay * retries)) } else { throw new Error(`上传块 ${chunkIndex} 失败,已重试多次`) } } } } // 如果设置了skipComplete,分片上传完成后立即返回,不调用completeUpload if (skipComplete) { console.log('[ChunkUpload] 分片上传完成,跳过complete处理(将在后台异步处理)') try { await deleteUploadState(fileId) } catch (cleanupError) { console.warn('[ChunkUpload] 清理上传状态失败(将忽略并继续):', cleanupError) } return { uploadId: state.uploadId, metadata, chunksUploaded: true, } } // 完成上传 let retries = 0 let result: any = null while (!result && retries < maxRetries) { try { onStageChange?.('processing', { message: metadata?.materialType === '模特' || metadata?.materialType === '实拍图片' || metadata?.materialType === '买家秀图片' || metadata?.materialType === '小红书种草' || metadata?.materialType === '主图模版' || metadata?.materialType === 'SKU模版' ? `展示图处理中(共 ${metadata?.modelImages?.length || 0} 张)…` : '正在处理素材,请稍候…', }) result = await completeUpload(state.uploadId!, metadata) // 删除保存的状态 await deleteUploadState(fileId) return result } catch (error) { // 如果是会话过期错误,不再重试,直接抛出 if (error instanceof Error && error.message === 'UPLOAD_SESSION_EXPIRED') { await deleteUploadState(fileId) throw new Error('上传会话已过期,请重新上传文件') } retries++ if (retries < maxRetries) { await new Promise(resolve => setTimeout(resolve, retryDelay * retries)) } else { // 达到最大重试次数,清理状态以避免残留 await deleteUploadState(fileId).catch(() => { console.warn('[ChunkUpload] 清理上传状态失败(忽略)') }) throw new Error('完成上传失败,已重试多次') } } } return result } // 检查是否有未完成的上传 export async function hasPendingUploads(): Promise { try { const db = await getDB() const transaction = db.transaction([STORE_NAME], 'readonly') const store = transaction.objectStore(STORE_NAME) return new Promise((resolve) => { const request = store.count() request.onsuccess = () => resolve(request.result > 0) request.onerror = () => resolve(false) }) } catch { return false } } // 获取所有未完成的上传状态 export async function getAllPendingUploads(): Promise { try { const db = await getDB() const transaction = db.transaction([STORE_NAME], 'readonly') const store = transaction.objectStore(STORE_NAME) return new Promise((resolve) => { const request = store.getAll() request.onsuccess = () => resolve(request.result || []) request.onerror = () => resolve([]) }) } catch (error) { console.error('[ChunkUpload] Failed to get pending uploads:', error) return [] } } // 清理所有上传状态 export async function clearAllUploadStates(): Promise { try { const db = await getDB() const transaction = db.transaction([STORE_NAME], 'readwrite') const store = transaction.objectStore(STORE_NAME) await new Promise((resolve, reject) => { const request = store.clear() request.onsuccess = () => resolve() request.onerror = () => reject(request.error) }) } catch (error) { console.error('[ChunkUpload] Failed to clear states:', error) } }