h5页面【文件切片】上传

64 阅读3分钟
  1. 使用uni.chooseVideo 获取文件信息,这里使用的是res.tempFilePath;
  2. 验证所选择文件的时长,同时计算整个文件的MD5哈希值;
  3. 创建任务id,获取文件预签地址(文件上传地址)、进行文件切片;
  4. 上传切片文件
  5. 合并全部切片
  6. 最后将切片合并完成的结果告知后端服务。
import {
  request
} from './request.js'  //封装请求
import axios from 'axios'
import SparkMD5 from 'spark-md5';  //MDS

// --- API 调用函数 ---

/** 调用后端API创建上传任务 */
const apiCreateTask = (data) => request({
	url: `xxxxxxx`,
	method: "post",
	data
});

/** 调用后端API获取分片上传的预签名URL */
const apiPresignedUrls = (data) => request({
	url: `xxxxxxxxxxx`,
	method: "post",
	data
});

/** 调用后端API通知所有分片已上传完毕,请求合并文件 */
const apiFinish = (id) => request({
	url: `xxxxxxxxxxxx`,
	method: "post",
	data: {
		id,
		type: 'checkIn'
	}
});

// --- 任务相关函数 ---
let currentSessionId = '';

/** 启动创建上传任务流程 */
export const startCreateTask = async (file, chunkSize, fileMd5) => {
	const res = await apiCreateTask({
		id: currentSessionId,
		businessType: 'checkIn',
		fileMetadata: {
			name: file.name,
			size: file.size,
			md5: fileMd5,
			open: true
		},
		partSize: chunkSize,
	});
	if (res.code !== 200) {
		throw new Error(`创建任务失败: ${res.message || res.code}`);
	}
	return res.data.id;
};

/** 生成一个从1到num的数字字符串,以逗号分隔 */
export const generateArray = (num) => Array.from({
	length: num
}, (_, i) => i + 1).join(',');

/** 启动获取预签名URL流程 */
export const startGetPreSignedUrl = async (id, chunkCount) => {
	const res = await apiPresignedUrls({
		id,
		partNums: generateArray(chunkCount)
	});
	if (res.code !== 200) {
		throw new Error(`获取预签名URL失败: ${res.message || res.code}`);
	}
	return Object.values(res.data['presignedUrlLists']).map(item => item[2]);
};

/** 启动合并文件流程 */
export const startMergeChunks = async (id) => {
	const res = await apiFinish(id);
	if (res.code !== 200) {
		throw new Error(`合并文件失败: ${res.message || res.code}`);
	}
	return res.data;
};

// --- 文件处理函数 ---

/** 计算整个文件的MD5哈希值 */
export const calculateFileMD5 = (file) => {
	return new Promise((resolve, reject) => {
		const spark = new SparkMD5.ArrayBuffer();
		const fileReader = new FileReader();
		const chunkSize = 2 * 1024 * 1024; // 2MB per chunk
		let chunks = Math.ceil(file.size / chunkSize);
		let currentChunk = 0;

		fileReader.onload = function(e) {
			spark.append(e.target.result);
			currentChunk++;
			if (currentChunk < chunks) loadNext();
			else resolve(spark.end());
		};

		fileReader.onerror = () => {
			reject(new Error('文件读取失败'));
		};

		function loadNext() {
			const start = currentChunk * chunkSize;
			const end = Math.min(start + chunkSize, file.size);
			fileReader.readAsArrayBuffer(file.slice(start, end));
		}
		loadNext();
	});
};

/** 将文件分割成指定大小的分片(Blob) */
export const getChunksFile = (file, chunkSize) => {
	return new Promise((resolve) => {
		const chunks = [];
		const chunkCount = Math.ceil(file.size / chunkSize);
		for (let i = 0; i < chunkCount; i++) {
			const start = i * chunkSize;
			const end = Math.min(start + chunkSize, file.size);
			chunks.push(file.slice(start, end));
		}
		resolve(chunks);
	});
};

// --- 上传核心逻辑 ---

/** 上传单个分片,并带有重试机制 */
const uploadChunkWithRetry = (chunk, url, index, total, onProgress, maxRetries = 3) => {
	return new Promise((resolve) => { // 注意:总是 resolve
		let retries = 0;

		const attemptUpload = () => {
			const source = axios.CancelToken.source();
			const timeoutId = setTimeout(() => source.cancel('请求超时'), 60000); // 60s timeout
			//生产 PUT  预发 post
			axios.put(url, chunk, {
					headers: {
						//生产
						'Content-Type': 'application/octet-stream',
						// 预发
						// 'X-HTTP-Method-Override': 'PUT',
						// 'Content-Type': '',
					},
					onUploadProgress: (progressEvent) => {
						if (progressEvent.total && progressEvent.total >
							0) { // Ensure total is known
							const percentCompleted = Math.round((progressEvent.loaded * 100) /
								progressEvent.total);
							onProgress?.(index, percentCompleted, total);
						}
					},
					maxContentLength: Infinity,
					maxBodyLength: Infinity,
					timeout: 60000,
					cancelToken: source.token
				})
				.then(response => {
					clearTimeout(timeoutId);
					// console.log(`✅ 分片 ${index + 1}/${total} 上传成功`); // 可选:保留成功日志
					resolve(response.data); // 成功时 resolve 数据
				})
				.catch(async (error) => {
					clearTimeout(timeoutId);
					retries++;
					// 不再在控制台打印 error 或 warn

					if (retries < maxRetries) {
						// console.log(`🔄 分片 ${index + 1} ${retries * 1000}ms 后重试...`); // 可选:保留重试日志
						await new Promise(r => setTimeout(r, retries * 1000));
						attemptUpload();
					} else {
						// 达到最大重试次数,resolve 一个错误对象
						const errorMsg = `分片 ${index + 1} 上传失败`;
						// console.error(`🛑 ${errorMsg}:`, error.message || error);
						resolve({
							error: true,
							message: errorMsg,
							index: index + 1,
							originalError: error.message || '未知错误'
						});
					}
				});
		};

		attemptUpload();
	});
};

/** 并行上传所有分片,并带有重试和进度计算 */
export const startUploadChunks = async (chunks, urls, onProgress) => {
	const maxConcurrency = 3; // 并发数
	const maxRetries = 3; // 最大重试次数
	const totalChunks = chunks.length;

	// 存储每个分片的最新进度
	const chunkProgress = new Array(totalChunks).fill(0);
	let lastReportedTotalProgress = -1;
	let hasProgressStarted = false;

	// 包装 onProgress 回调以计算总体进度
	const wrappedOnProgress = (index, percent, total) => {
		chunkProgress[index] = percent;
		const totalProgress = Math.round(chunkProgress.reduce((sum, p) => sum + p, 0) / totalChunks);
		if (totalProgress !== lastReportedTotalProgress) {
			lastReportedTotalProgress = totalProgress;
			onProgress?.({
				percent: totalProgress,
				hasStarted: hasProgressStarted
			});
			hasProgressStarted = true;
		}
	};

	const results = [];
	const executing = [];

	for (let i = 0; i < chunks.length; i++) {
		const chunk = chunks[i];
		const url = urls[i];
		const index = i;

		const promise = uploadChunkWithRetry(chunk, url, index, totalChunks, wrappedOnProgress, maxRetries)
			.then(value => {
				results[index] = {
					status: 'fulfilled',
					value
				};
			});

		executing.push(promise);

		if (executing.length >= maxConcurrency) {
			await Promise.race(executing);
			executing.shift();
		}
	}

	await Promise.all(executing);

	// console.log("📊 所有分片上传尝试完成,开始检查结果..."); // 可选:保留检查日志
	const failedResults = [];

	for (let i = 0; i < results.length; i++) {
		const result = results[i];
		if (result && result.status === 'fulfilled' && result.value && result.value.error === true) {
			failedResults.push(result.value);
		}
	}

	if (failedResults.length > 0) {
		// 构造用户友好的错误信息
		const failedListStr = failedResults.map(r => r.index).join(', ');
		const errorMsg = `部分分片上传失败 (${failedListStr}),请检查网络后重试。`;
		// console.error("💥 汇总上传失败详情:", errorMsg);

		// Reject 整个 startUploadChunks Promise,让上层处理
		throw new Error(errorMsg);
	}

	// console.log("✅ 所有分片上传成功"); // 可选:保留成功日志
	return results.map(r => r.value).filter(v => v && typeof v === 'object' && v.error !== true);
};

// --- 封装的上传步骤 ---

/** 验证视频文件(时长)并计算其MD5 */
const validateAndCalculateMD5 = (tempFile, duration, size) => {
	return new Promise((resolve, reject) => {
		if (duration < 20 || duration > 30) {
			const msg = '视频文件时长需在20-30秒之间!';
			// console.error(msg);
			reject(new Error(msg));
			return;
		}

		calculateFileMD5(tempFile)
			.then(md5 => resolve(md5))
			.catch(err => {
				const msg = `文件MD5计算失败: ${err.message}`;
				// console.error(msg);
				reject(new Error(msg));
			});
	});
};

/** 准备上传任务:创建任务、获取URL、切片 */
const prepareUploadTask = async (tempFile, size, fileMd5) => {
	const chunkSize = 1024 * 1024 * 5; // 5M
	const chunkCount = Math.ceil(size / chunkSize);

	try {
		const taskId = await startCreateTask(tempFile, chunkSize, fileMd5);
		const urls = await startGetPreSignedUrl(taskId, chunkCount);

		if (!urls || urls.length !== chunkCount) {
			const msg = '获取上传地址失败或数量不匹配';
			// console.error(msg);
			throw new Error(msg);
		}

		const chunks = await getChunksFile(tempFile, chunkSize);
		return {
			taskId,
			urls,
			chunks,
			chunkCount
		};
	} catch (error) {
		// console.error("准备上传任务失败:", error);
		throw error;
	}
};


// --- 主上传函数 ---

/** 显示错误信息给用户 */
const showError = (message) => {
	uni.hideLoading(); // 确保隐藏任何加载指示器
	uni.showToast({
		icon: 'none',
		title: message
	});
};

/** 更新上传进度显示 */
const updateProgress = (percent) => {
	uni.showLoading({
		title: `上传进度 ${percent}%`,
		mask: true
	});
};

/** 执行完整的上传流程 */
const performUpload = async (res, sessionId, onProgressCallback) => {
	currentSessionId = sessionId;
	// console.log("🚀 开始执行上传流程..."); // 可选:保留流程日志

	try {
		// console.log("🔍 获取视频信息..."); // 可选:保留步骤日志
		const videoInfo = await new Promise((resolve, reject) => {
			uni.getVideoInfo({
				src: res.tempFilePath,
				success: (infoRes) => {
					// console.log("📹 视频信息获取成功:", infoRes); // 可选:保留成功日志
					resolve(infoRes);
				},
				fail: (err) => {
					const msg = '无法读取视频文件信息';
					// console.error("❌ 获取视频信息失败:", err); // 可选:保留调试日志
					reject(new Error(msg));
				}
			});
		});

		// console.log("🔐 验证视频时长并计算MD5..."); // 可选:保留步骤日志
		const fileMd5 = await validateAndCalculateMD5(res.tempFile, videoInfo.duration, videoInfo.size);

		// console.log("⚙️ 准备上传任务 (创建、获取URL、切片)..."); // 可选:保留步骤日志
		const {
			taskId,
			urls,
			chunks
		} = await prepareUploadTask(res.tempFile, videoInfo.size, fileMd5);

		// console.log("📤 开始上传分片..."); // 可选:保留步骤日志
		await startUploadChunks(chunks, urls, (progressInfo) => {
			const {
				percent,
				hasStarted
			} = progressInfo;
			// console.log(`📈 总体上传进度: ${percent}% (已开始: ${hasStarted})`); // 可选:保留进度日志
			if (hasStarted) {
				updateProgress(percent);
			}
			onProgressCallback?.(percent);
		});

		// console.log("🔗 所有分片上传完成,开始合并文件..."); // 可选:保留步骤日志
		updateProgress(100);
		onProgressCallback?.(100);

		const mergeResult = await startMergeChunks(taskId);
		// console.log("🎉 文件合并成功:", mergeResult); // 可选:保留成功日志
		return mergeResult;

	} catch (error) {
		// console.error("💥 performUpload 流程中发生错误 (调试):", error); // 可选:保留调试日志
		// 重新抛出,让 useUploadFiles 处理
		throw error;
	}
};


/** 对外暴露的上传入口函数,触发文件选择并开始上传流程 */
export const useUploadFiles = (id, callback, onProgress) => {
	uni.chooseVideo({
		sourceType: ['camera', 'album'],
		success: (res) => {
			// console.log("📁 用户选择了视频文件:", res); // 可选:保留用户操作日志
			uni.showLoading({
				title: '请等待...',
				mask: true
			});

			performUpload(res, id, onProgress)
				.then(result => {
					setTimeout(() => {
						uni.hideLoading();
						if (result) {
							uni.showToast({
								title: '上传成功'
							});
							callback?.(result);
						} else {
							showError('文件合并返回结果异常');
						}
					}, 500);
				})
				.catch(error => {
					// console.error("🚨 上传流程失败 (调试):", error); // 可选:保留调试日志
					// 使用统一的 showError 函数向用户提示
					showError(error.message || '上传失败');
				});

		},
		fail: (err) => {
			// console.error("📷 选择视频失败 (调试):", err); // 可选:保留调试日志
			showError('选择视频失败');
		}
	});
};