import axios, { AxiosInstance } from 'axios'; import { ChunkDto, UploadProgress, UploadStatusInfoDto, UUIDGenerator } from '@nice/common'; import { UploadOptions } from './types'; import { calculateFileIdentifier } from '../tools'; export class UploadManager { private readonly axios: AxiosInstance; private readonly chunkSize: number; private readonly concurrency: number; private readonly retries: number; private readonly clientId: string; private activeUploads: Map = new Map(); private abortControllers: Map = new Map(); constructor(options: UploadOptions = {}) { const { baseUrl = '/upload', chunkSize = 10 * 1024 * 1024, concurrency = 3, retries = 3, } = options; this.axios = axios.create({ baseURL: baseUrl }); this.chunkSize = chunkSize; this.concurrency = concurrency; this.retries = retries; this.clientId = options.clientId || UUIDGenerator.generate(); } async uploadFile(file: File, options: UploadOptions = {}): Promise { const identifier = await calculateFileIdentifier(file); const controller = new AbortController(); this.abortControllers.set(identifier, controller); try { // Check if file is already uploaded const statusInfo = await this.checkUploadStatusInfo(identifier); if (statusInfo?.status === "completed") { options.onSuccess?.({ identifier, filename: file.name }); return; } const chunks = await this.prepareChunks(file, identifier); const uploadedChunks = statusInfo?.chunks || new Set(); // Filter out already uploaded chunks const remainingChunks = chunks.filter(chunk => !uploadedChunks.has(chunk.chunkNumber)); await this.uploadChunks(remainingChunks, file, options, controller.signal); } catch (error) { if (axios.isCancel(error)) { return; } options.onError?.(error as Error); throw error; } finally { this.abortControllers.delete(identifier); this.activeUploads.delete(identifier); } } private async prepareChunks(file: File, identifier: string): Promise { const chunks: ChunkDto[] = []; const totalChunks = Math.ceil(file.size / this.chunkSize); for (let i = 0; i < totalChunks; i++) { chunks.push({ identifier, filename: file.name, chunkNumber: i + 1, totalChunks, currentChunkSize: Math.min(this.chunkSize, file.size - i * this.chunkSize), totalSize: file.size, }); } return chunks; } private async uploadChunks( chunks: ChunkDto[], file: File, options: UploadOptions, signal: AbortSignal ): Promise { const chunkQueue = [...chunks]; // Create a copy of chunks array let activeUploads = 0; let completedChunks = 0; let uploadedBytes = 0; // 记录最近几次chunk的上传速度 const speedBuffer: number[] = []; const SPEED_BUFFER_SIZE = 5; // 保留最近5个chunk的速度 return new Promise((resolve, reject) => { const uploadNextChunk = async () => { if (completedChunks === chunks.length) { if (activeUploads === 0) { // Only resolve when all active uploads are done resolve(); } return; } while (activeUploads < this.concurrency && chunkQueue.length > 0) { const chunk = chunkQueue.shift(); if (!chunk) break; const chunkStartTime = Date.now(); activeUploads++; this.uploadChunk(chunk, file, signal) .then(() => { const chunkEndTime = Date.now(); const chunkUploadTime = (chunkEndTime - chunkStartTime) / 1000; // 秒 completedChunks++; uploadedBytes += chunk.currentChunkSize; activeUploads--; // 计算当前chunk的上传速度 const currentSpeed = chunkUploadTime > 0 ? chunk.currentChunkSize / chunkUploadTime : 0; // 维护速度缓冲区 speedBuffer.push(currentSpeed); if (speedBuffer.length > SPEED_BUFFER_SIZE) { speedBuffer.shift(); } // 计算平均速度 const averageSpeed = speedBuffer.length > 0 ? speedBuffer.reduce((a, b) => a + b, 0) / speedBuffer.length : 0; const totalUploadedBytes = uploadedBytes; const remainingBytes = file.size - totalUploadedBytes; // 使用平均速度计算剩余时间 const remainingTime = averageSpeed > 0 ? remainingBytes / averageSpeed : 0; const progress: UploadProgress = { identifier: chunk.identifier, percentage: (completedChunks / (chunks.length + completedChunks)) * 100, uploadedSize: totalUploadedBytes, totalSize: file.size, speed: averageSpeed, // 字节/秒 remainingTime: remainingTime // 秒 }; options.onProgress?.(progress); uploadNextChunk(); }) .catch(reject); } }; uploadNextChunk(); }); } private async uploadChunk( chunk: ChunkDto, file: File, signal: AbortSignal ): Promise { const start = (chunk.chunkNumber - 1) * this.chunkSize; const end = Math.min(start + this.chunkSize, file.size); const chunkBlob = file.slice(start, end); const formData = new FormData(); formData.append('chunk', JSON.stringify(chunk)); formData.append('clientId', this.clientId) formData.append('file', chunkBlob); let attempts = 0; while (attempts < this.retries) { try { await this.axios.post('/chunk', formData, { signal }); return; } catch (error) { attempts++; if (attempts === this.retries) throw error; await new Promise(resolve => setTimeout(resolve, 1000 * attempts)); } } } async pauseUpload(identifier: string): Promise { const controller = this.abortControllers.get(identifier); if (controller) { controller.abort(); this.abortControllers.delete(identifier); } try { // Call the pause API endpoint await this.axios.post(`/pause/${identifier}`); } catch (error) { console.error('Error pausing upload:', error); throw error; } } async resumeUpload(file: File, options: UploadOptions = {}): Promise { const identifier = await calculateFileIdentifier(file); try { // Call the resume API endpoint await this.axios.post(`/resume/${identifier}`); // Then continue with the upload process return this.uploadFile(file, options); } catch (error) { console.error('Error resuming upload:', error); throw error; } } private async checkUploadStatusInfo(identifier: string): Promise { try { const response = await this.axios.get(`/status/${identifier}`); return response.data; } catch { return null; } } }