gongxiangp2/lib/chunk-upload.ts
chenpei ea9076df45 feat: 实现统一的下载错误处理系统并修复产品全案数据库Schema
- 创建统一的下载错误处理模块 (lib/download-errors.ts)
- 实现13种错误类型分类和中文错误消息
- 后端统一使用 createDownloadError 创建标准化错误响应
- 前端统一使用 parseDownloadError 解析错误并显示
- 修复正式系统数据库Schema,添加产品全案相关字段和表
- 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段
- 创建 material_assets, api_keys, api_call_logs 表
2026-01-05 17:52:42 +08:00

516 lines
16 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.

// 分块上传工具函数
// 支持断点续传,自动重试,静默处理错误
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<number> // 已上传的块索引
uploadId?: string // 服务器返回的上传ID
metadata?: Record<string, any> // 表单元数据(用于恢复时预填)
}
// 存储上传状态到IndexedDB
const DB_NAME = 'chunk-upload-db'
const STORE_NAME = 'upload-states'
async function getDB(): Promise<IDBDatabase> {
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<void> {
try {
const db = await getDB()
const transaction = db.transaction([STORE_NAME], 'readwrite')
const store = transaction.objectStore(STORE_NAME)
await new Promise<void>((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<ChunkUploadState | null> {
try {
const db = await getDB()
const transaction = db.transaction([STORE_NAME], 'readonly')
const store = transaction.objectStore(STORE_NAME)
return new Promise<ChunkUploadState | null>((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<void> {
try {
const db = await getDB()
const transaction = db.transaction([STORE_NAME], 'readwrite')
const store = transaction.objectStore(STORE_NAME)
await new Promise<void>((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<ChunkUploadState | null> {
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<string> {
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<void> {
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<string, any>
): Promise<any> {
// 添加超时控制(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<string, any>,
options: ChunkUploadOptions = {}
): Promise<any> {
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<boolean> {
try {
const db = await getDB()
const transaction = db.transaction([STORE_NAME], 'readonly')
const store = transaction.objectStore(STORE_NAME)
return new Promise<boolean>((resolve) => {
const request = store.count()
request.onsuccess = () => resolve(request.result > 0)
request.onerror = () => resolve(false)
})
} catch {
return false
}
}
// 获取所有未完成的上传状态
export async function getAllPendingUploads(): Promise<ChunkUploadState[]> {
try {
const db = await getDB()
const transaction = db.transaction([STORE_NAME], 'readonly')
const store = transaction.objectStore(STORE_NAME)
return new Promise<ChunkUploadState[]>((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<void> {
try {
const db = await getDB()
const transaction = db.transaction([STORE_NAME], 'readwrite')
const store = transaction.objectStore(STORE_NAME)
await new Promise<void>((resolve, reject) => {
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
} catch (error) {
console.error('[ChunkUpload] Failed to clear states:', error)
}
}