new-ruoyi-geek/ruoyi-geek-app/utils/ChunkUpload.ts

721 lines
25 KiB
TypeScript
Raw Permalink 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.

import modal from '@/plugins/modal'
import { initChunkUpload, uploadChunk, completeChunkUpload } from '@/api/system/chunkUpload'
import { UploadOptions, PartETag, File, UploadData, ProgressInfo } from '@/types/upload'
import TaskQueue from '@/utils/TaskQueue'
// 声明微信小程序全局对象
declare const wx: any;
/**
* 分片上传工具类
*
*/
export class ChunkUpload {
/**
* 分片大小,单位字节
*/
private chunkSize: number;
/**
* 并发上传的分片数量限制
*/
private concurrentLimit: number;
/**
* 进度更新间隔(百分比)
*/
private static readonly PROGRESS_UPDATE_INTERVAL = 10;
/**
* 构造函数 - 初始化分片上传器
* 设置默认分片大小为15MB并发限制为2个分片
*/
constructor() {
this.chunkSize = 15 * 1024 * 1024; // 默认分片大小15MB
this.concurrentLimit = 2; // 并发上传的分片数量
}
/**
* 主要的分片上传方法
* @param params 上传参数对象
*
* @param params.file 要上传的文件对象包含path和size属性
* @param params.onSuccess 上传成功回调函数,接收 { success: true } 参数
* @param params.onError 上传失败回调函数,接收错误信息字符串参数
* @param params.options 上传配置选项 { chunkSize?: number; concurrentLimit?: number }
* @param params.options.chunkSize 分片大小字节默认15MB (15 * 1024 * 1024)
* @param params.options.concurrentLimit 并发上传分片数量限制默认2个分片同时上传
*
* @returns Promise<boolean> 返回Promise成功时resolve(true)失败时resolve(false)
*/
async upload(params: UploadOptions): Promise<boolean> {
const {
file,
onSuccess,
onError,
options = {} as { chunkSize?: number; concurrentLimit?: number }
} = params
try {
// 1.检验文件的参数
this._validateParams(file);
//2.获取文件信息
const { actualFilePath, actualFileSize, actualFileName } = this.getFileInfo(file);
modal.loading("准备上传...")
// 3.初始化分片数据
const chunkSize = options.chunkSize || this.chunkSize;
const chunkCount = Math.ceil(actualFileSize / chunkSize);
const concurrentLimit = options.concurrentLimit || this.concurrentLimit;
let partETags: PartETag[] = [];
//4.初始化分片上传
const initResult = await initChunkUpload(actualFileName, actualFileSize)
if (initResult.code !== 200) throw new Error("初始化上传失败")
const { uploadId, filePath: serverFilePath } = initResult.data
//5.将文件移动到应用 沙盒 目录
// #ifdef APP-PLUS
const localFilePath = await this.copyFileToSandbox(actualFilePath)
// #endif
//6.开始上传分片
modal.closeLoading()
modal.loading("上传中...")
const progressInfo: ProgressInfo = {
completedChunks: 0,
uploadProgress: 0,
chunkCount
}
// 7.并发上传数据
const uploadData = {
uploadId,
saveFilePath: serverFilePath,
fileSize: actualFileSize,
chunkCount,
filePath: actualFilePath
};
// #ifdef APP-PLUS
partETags = await this.uploadChunksWithTaskQueue(
uploadData,
chunkSize,
concurrentLimit,
localFilePath,
progressInfo
)
// #endif
// #ifdef MP-WEIXIN
partETags = await this._uploadChunks(uploadData, concurrentLimit, progressInfo);
// #endif
//8.合并分片
modal.closeLoading();
modal.loading("正在合并分片...")
//完成分片上传
await completeChunkUpload(
uploadId, serverFilePath, actualFileSize, actualFileName, partETags
)
// 9.将临时文件删除,防止占用空间
// #ifdef APP-PLUS
await this.deleteLocalFile(localFilePath)
// #endif
modal.closeLoading()
// 10.执行成功回调
onSuccess?.({ success: true })
return true
} catch (error) {
modal.closeLoading()
const errorMessage = error instanceof Error ? error.message : `上传失败`
onError?.(errorMessage)
return false
}
}
/**
* 校验上传参数
*
* @param file - 要上传的文件对象
* @throws {Error} 当文件路径不存在时抛出错误
* @throws {Error} 当文件大小不存在时抛出错误
*/
_validateParams(file: File) {
if (!file.path) throw new Error("文件路径不存在");
if (!file.size) throw new Error("文件大小不存在");
}
/**
* 获取文件信息(路径、大小、文件名)
*
* @param file 文件对象包含path和size属性
* @returns 包含文件信息的对象
* @returns actualFilePath 实际文件路径
* @returns actualFileSize 实际文件大小(字节)
* @returns actualFileName 实际文件名称(根据平台调整)
*/
getFileInfo(file: File): { actualFilePath: string; actualFileSize: number; actualFileName: string } {
const actualFilePath = file.path;
const actualFileSize = file.size;
let actualFileName: string;
// #ifdef APP-PLUS
actualFileName = this.getFileName(file.path);
// #endif
// #ifdef MP-WEIXIN
actualFileName = `weixin_${Date.now()}.${this.getFileExtension(file.path)}`;
// #endif
return {
actualFilePath,
actualFileSize,
actualFileName
};
}
/**
* 获取文件名称
* @param filePath 完整文件路径
* @returns string 从路径中提取的文件名
*/
getFileName(filePath: string): string {
if (!filePath) return ""
const slashIndex = filePath.lastIndexOf("/");
if (slashIndex === -1) return filePath;
return filePath.substring(slashIndex + 1);
};
/**
* 获取文件的扩展名称
* @param filePath 完整文件路径
* @returns string 文件扩展名(小写,不包含点号)
*/
getFileExtension(filePath: string): string {
if (!filePath) return ""
const dotIndex = filePath.lastIndexOf(".");
if (dotIndex === -1) return ""
return filePath.substring(dotIndex + 1).toLowerCase();
};
/**
* 将文件复制到应用沙盒目录
* @param srcFilePath 源文件路径
* @returns Promise<string> 复制后的文件完整路径
*/
copyFileToSandbox(srcFilePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const newName = `file_${Date.now()}.${this.getFileExtension(srcFilePath)}`;
plus.io.requestFileSystem(
plus.io.PRIVATE_DOC,
(dstEntry) => {
plus.io.resolveLocalFileSystemURL(
srcFilePath,
(srcEntry) => {
srcEntry.copyTo(
dstEntry.root,
newName,
(entry) => {
if (entry.fullPath) {
resolve(entry.fullPath);
} else {
reject(new Error('File path is undefined'));
}
},
(e) => reject(e)
);
},
(e) => reject(e)
);
},
(e) => reject(e)
);
});
};
/**
* 获取切片end位置
* @param start 切片开始位置
* @param chunkSize 切片大小
* @param fileSize 文件总大小
* @param index 当前切片索引
* @param totalChunks 总切片数量
* @returns number 切片结束位置
*/
getSliceEnd(start: number, chunkSize: number, fileSize: number, index: number, totalChunks: number) {
return index < totalChunks - 1 ? start + chunkSize - 1 : fileSize
}
/**
* 使用TaskQueue并发上传分片APP端
*
* @param uploadData 上传数据对象,包含 uploadId、saveFilePath、fileSize 等
* @param chunkSize 单个分片的大小(字节)
* @param concurrentLimit 并发上传的分片数量限制
* @param localFilePath APP端沙盒中的本地文件路径
* @param progressInfo 进度跟踪对象包含completedChunks、uploadProgress、chunkCount等属性
*
* @returns Promise<PartETag[]> 返回所有分片的ETag信息数组
*
* @throws {Error} 当任何分片上传失败时抛出错误
*
*/
async uploadChunksWithTaskQueue(
uploadData: UploadData,
chunkSize: number,
concurrentLimit: number,
localFilePath: string,
progressInfo: ProgressInfo
): Promise<PartETag[]> {
const { chunkCount, fileSize, uploadId, saveFilePath } = uploadData;
const taskQueue = new TaskQueue(concurrentLimit);
const partETags: PartETag[] = [];
// 创建所有分片上传任务
const uploadPromises: Promise<PartETag>[] = [];
for (let i = 0; i < chunkCount; i++) {
const task = {
index: i + 1,
start: i * chunkSize,
end: this.getSliceEnd(i * chunkSize, chunkSize, fileSize, i, chunkCount),
};
const promise = taskQueue.add(async () => {
const chunk = await this.readAppFileChunk(localFilePath, task.start, task.end - task.start);
const response = await this.uploadAppChunk(uploadId, saveFilePath, task.index, chunk) as any;
if (!response.data || !response.data.etag) throw new Error('分片上传失败');
// 更新进度
this.updateUploadProgress(progressInfo);
return {
partNumber: task.index,
ETag: response.data.etag,
};
});
uploadPromises.push(promise);
}
// 等待所有任务完成
try {
const results = await Promise.all(uploadPromises);
// 收集所有 partETags
results.forEach(partETag => {
if (partETag) partETags.push(partETag);
});
// 按 partNumber 排序确保顺序正确
partETags.sort((a, b) => a.partNumber - b.partNumber);
return partETags;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '分片上传失败';
throw new Error(`分片上传失败: ${errorMessage}`);
}
}
/**
* APP端分片上传单个分片
* @param uploadId 上传ID
* @param filePath 服务器文件路径
* @param chunkIndex 分片索引
* @param chunk 分片数据可以是ArrayBuffer或字符串
* @returns Promise<any> 上传响应结果
*/
async uploadAppChunk(uploadId: string, filePath: string, chunkIndex: number, chunk: ArrayBuffer | string) {
try {
const response = await this.startUploadAppChunk(uploadId, filePath, chunkIndex, chunk)
return response
} catch (error) {
throw new Error('分片上传失败')
}
}
/**
* 执行APP端分片上传
* @param uploadId 上传ID
* @param filePath 服务器文件路径
* @param chunkIndex 分片索引
* @param chunk 分片数据可以是ArrayBuffer或字符串
* @returns Promise 返回上传结果的Promise
*/
startUploadAppChunk(uploadId: string, filePath: string, chunkIndex: number, chunk: ArrayBuffer | string) {
return new Promise(async (resolve, reject) => {
try {
// 1. 准备临时文件信息
const tempFileName = `temp_chunk/chunk_${uploadId}_${chunkIndex}.bin`
const tempDirPath = plus.io.PRIVATE_DOC
// 2. 创建并写入临时文件
const tempFilePath = await this.createAndWriteTempFile(
tempDirPath,
tempFileName,
chunk
)
//设置文件的全路径
let formattedPath = tempFilePath
if (tempFilePath && !tempFilePath.startsWith("file://")) {
formattedPath = `file://${tempFilePath}`
}
// 3. 上传文件
const result = await uploadChunk(uploadId, filePath, chunkIndex, formattedPath)
// 4. 删除临时文件
await this.deleteTempFile(tempDirPath, tempFileName)
resolve(result)
} catch (error) {
reject(error)
}
})
}
/**
* 删除本地临时文件(临时文件是分片生成的)
* @param filePath 要删除的文件路径
* @returns Promise<boolean> 删除是否成功
*/
deleteLocalFile(filePath: string): Promise<boolean> {
return new Promise((resolve) => {
if (!filePath) {
resolve(false);
return;
}
plus.io.resolveLocalFileSystemURL(
filePath,
(entry) => {
entry.remove(
() => { resolve(true); },
() => { resolve(false); }
);
},
() => { resolve(false); }
);
});
};
/**
* 创建临时文件并写入数据
* @param dirPath 目录路径标识plus.io.PRIVATE_DOC等
* @param fileName 临时文件名
* @param data 要写入的数据可以是ArrayBuffer或字符串
* @returns Promise<string> 创建的临时文件完整路径
*/
createAndWriteTempFile(dirPath: number, fileName: String, data: ArrayBuffer | string): Promise<string> {
return new Promise((resolve, reject) => {
plus.io.requestFileSystem(
dirPath,
(dirEntry: any) => {
dirEntry.root.getFile(
fileName,
{ create: true, exclusive: false },
(fileEntry: any) => {
fileEntry.createWriter(
(writer: any) => {
const filePath = fileEntry.fullPath
writer.onwrite = function () { resolve(filePath) }
writer.onerror = function (e: any) { reject(e) }
try {
if (data) writer.writeAsBinary(data)
} catch (e) { reject(e) }
},
(err: any) => reject(err)
)
},
(err: any) => reject(err)
)
},
(err) => { reject(err) }
)
})
}
/**
* 删除临时文件
* @param dirPath 目录路径标识plus.io.PRIVATE_DOC等
* @param fileName 要删除的临时文件名
* @returns Promise<boolean> 删除是否成功
*/
deleteTempFile(dirPath: number, fileName: string): Promise<boolean> {
return new Promise((resolve, reject) => {
plus.io.requestFileSystem(
dirPath,
(dirEntry) => {
if (!dirEntry || !dirEntry.root) {
reject(new Error('Directory entry or root is undefined'));
return;
}
dirEntry.root.getFile(
fileName,
{ create: false },
(fileEntry) => {
fileEntry.remove(
() => { resolve(true); },
() => { resolve(true); }
);
},
() => resolve(true)
);
},
() => resolve(true)
);
});
}
/**
* 读取APP端文件分片的数据
* @param filePath 本地文件路径
* @param start 读取开始位置
* @param length 读取数据长度
* @returns Promise<string> Base64编码的分片数据
*/
readAppFileChunk(filePath: string, start: number, length: number): Promise<string> {
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
filePath,
(entry: any) => {
entry.file(
(file: any) => {
const reader = new plus.io.FileReader();
try {
const slice = file.slice(start, start + length);
reader.readAsDataURL(slice);
} catch (sliceError) {
reject(sliceError);
}
reader.onloadend = (e: any) => {
if (e.target.readyState == 2) {
try {
const base64 = e.target.result.split(",")[1];
resolve(base64);
} catch (err) {
reject(err);
}
}
};
reader.onerror = (err) => { reject(err); };
},
(error: any) => { reject(error); }
);
},
(error) => { reject(error); }
);
});
};
/**
* 使用TaskQueue并发上传分片微信小程序端
*
* @param uploadData 上传数据对象
* @param concurrentLimit 并发上传的分片数量限制,控制同时进行的上传任务数
* @param progressInfo 进度跟踪对象
*
* @returns Promise<PartETag[]> 返回所有分片的ETag信息数组按partNumber排序
*
* @throws {Error} 当任何分片上传失败时抛出错误,包含具体的错误信息
*/
async _uploadChunks(uploadData: UploadData, concurrentLimit: number, progressInfo: ProgressInfo) {
try {
const { uploadId, saveFilePath, fileSize, chunkCount, filePath } = uploadData;
const fileManager = uni.getFileSystemManager();
const partETags: PartETag[] = [];
const taskQueue = new TaskQueue(concurrentLimit);
// 创建所有分片上传任务
const uploadTasks = [];
for (let i = 0; i < chunkCount; i++) {
const task = taskQueue.add(async () => {
return await this._uploadSingleChunk(
fileManager,
uploadId,
saveFilePath,
filePath,
i,
fileSize,
progressInfo
);
});
uploadTasks.push(task);
}
// 等待所有任务完成
const results = await Promise.all(uploadTasks);
// 收集所有 partETags
results.forEach(partETag => {
if (partETag) partETags.push(partETag);
});
// 按 partNumber 排序确保顺序正确
partETags.sort((a, b) => a.partNumber - b.partNumber);
return partETags;
} catch (e) {
const errorMessage = e instanceof Error ? e.message : '上传分片失败';
throw new Error(errorMessage);
}
}
/**
* 上传单个分片(微信小程序端)
* @param fileManager 文件管理器
* @param uploadId 上传ID
* @param saveFilePath 服务器保存路径
* @param filePath 本地文件路径
* @param chunkIndex 分片索引从0开始
* @param fileSize 文件总大小
* @param progressInfo 进度信息对象
* @returns Promise<PartETag> 返回分片的ETag信息
*/
private async _uploadSingleChunk(
fileManager: UniApp.FileSystemManager,
uploadId: string,
saveFilePath: string,
filePath: string,
chunkIndex: number,
fileSize: number,
progressInfo: ProgressInfo
): Promise<PartETag> {
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, fileSize);
const tempChunkPath = `${wx.env.USER_DATA_PATH}/chunk_${chunkIndex}_${Date.now()}.tmp`;
try {
// 1. 处理分片数据
await this._processChunk(fileManager, filePath, tempChunkPath, start, end - start);
// 2. 上传分片
const partNumber = chunkIndex + 1;
const response = await uploadChunk(uploadId, saveFilePath, partNumber, tempChunkPath);
if (!response.data?.etag) {
throw new Error(`分片 ${partNumber} 上传失败,无效响应`);
}
// 3. 更新进度
this.updateUploadProgress(progressInfo);
return {
partNumber,
ETag: response.data.etag,
};
} finally {
// 4. 清理临时文件(无论成功失败都要清理)
this._cleanupTempFile(fileManager, tempChunkPath);
}
}
/**
* 处理单个分片数据
*
* @param fileManager - uni-app文件系统管理器实例
* @param filePath - 原始文件的完整路径
* @param tempChunkPath - 临时分片文件的保存路径
* @param start - 在原始文件中的起始位置
* @param length - 要读取的数据长度
* @returns Promise<void> - 操作完成的Promise
* @throws {Error} 当文件读取或写入失败时抛出错误
*/
async _processChunk(fileManager: UniApp.FileSystemManager, filePath: string, tempChunkPath: string, start: number, length: number) {
const readRes = await new Promise<ArrayBuffer | string>((resolve, reject) => {
fileManager.readFile({
filePath: filePath,
position: start,
length: length,
success: (res: any) => {
resolve(res.data as ArrayBuffer | string)
},
fail: (err) => {
reject(err)
},
});
});
// 写入临时文件
await new Promise((resolve, reject) => {
fileManager.writeFile({
filePath: tempChunkPath,
data: readRes,
success: () => {
resolve(true)
},
fail: (err) => {
reject(err)
},
});
});
}
/**
* 清理临时分片文件
*
* @param fileManager - uni-app文件系统管理器实例
* @param tempChunkPath - 要删除的临时文件路径
* @throws {Error} 当文件删除失败时抛出错误
*/
_cleanupTempFile(fileManager: UniApp.FileSystemManager, tempChunkPath: string) {
try {
fileManager.unlinkSync(tempChunkPath);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : '未知错误';
throw new Error(`删除临时文件失败: ${tempChunkPath}, 错误: ${errorMessage}`);
}
}
/**
* 更新上传进度并显示加载状态
*
* @param progressInfo 进度信息对象包含completedChunks、uploadProgress、chunkCount等属性
*/
private updateUploadProgress(progressInfo: ProgressInfo): void {
// 增加已完成分片数
progressInfo.completedChunks++;
// 计算当前进度百分比
const percent = Math.floor((progressInfo.completedChunks / progressInfo.chunkCount) * 100);
// 计算显示进度按间隔更新避免过于频繁的UI更新
const displayPercent = Math.floor(percent / ChunkUpload.PROGRESS_UPDATE_INTERVAL) * ChunkUpload.PROGRESS_UPDATE_INTERVAL;
// 当显示进度发生变化或上传完成时更新UI
if (displayPercent !== progressInfo.uploadProgress || progressInfo.completedChunks === progressInfo.chunkCount) {
modal.closeLoading();
const displayPercentForUI = progressInfo.completedChunks === progressInfo.chunkCount ? 100 : displayPercent;
modal.loading(`上传中 ${displayPercentForUI}% (请勿离开此页面)`);
progressInfo.uploadProgress = displayPercent;
}
}
}
export const chunkUpload = new ChunkUpload();