- 创建统一的下载错误处理模块 (lib/download-errors.ts) - 实现13种错误类型分类和中文错误消息 - 后端统一使用 createDownloadError 创建标准化错误响应 - 前端统一使用 parseDownloadError 解析错误并显示 - 修复正式系统数据库Schema,添加产品全案相关字段和表 - 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段 - 创建 material_assets, api_keys, api_call_logs 表
516 lines
16 KiB
TypeScript
516 lines
16 KiB
TypeScript
// 分块上传工具函数
|
||
// 支持断点续传,自动重试,静默处理错误
|
||
|
||
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)
|
||
}
|
||
}
|
||
|