- 使用uni.chooseVideo 获取文件信息,这里使用的是res.tempFilePath;
- 验证所选择文件的时长,同时计算整个文件的MD5哈希值;
- 创建任务id,获取文件预签地址(文件上传地址)、进行文件切片;
- 上传切片文件
- 合并全部切片
- 最后将切片合并完成的结果告知后端服务。
import {
request
} from './request.js'
import axios from 'axios'
import SparkMD5 from 'spark-md5';
const apiCreateTask = (data) => request({
url: `xxxxxxx`,
method: "post",
data
});
const apiPresignedUrls = (data) => request({
url: `xxxxxxxxxxx`,
method: "post",
data
});
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;
};
export const generateArray = (num) => Array.from({
length: num
}, (_, i) => i + 1).join(',');
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;
};
export const calculateFileMD5 = (file) => {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
const chunkSize = 2 * 1024 * 1024;
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();
});
};
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) => {
let retries = 0;
const attemptUpload = () => {
const source = axios.CancelToken.source();
const timeoutId = setTimeout(() => source.cancel('请求超时'), 60000);
axios.put(url, chunk, {
headers: {
'Content-Type': 'application/octet-stream',
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total && progressEvent.total >
0) {
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);
resolve(response.data);
})
.catch(async (error) => {
clearTimeout(timeoutId);
retries++;
if (retries < maxRetries) {
await new Promise(r => setTimeout(r, retries * 1000));
attemptUpload();
} else {
const errorMsg = `分片 ${index + 1} 上传失败`;
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;
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);
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}),请检查网络后重试。`;
throw new Error(errorMsg);
}
return results.map(r => r.value).filter(v => v && typeof v === 'object' && v.error !== true);
};
const validateAndCalculateMD5 = (tempFile, duration, size) => {
return new Promise((resolve, reject) => {
if (duration < 20 || duration > 30) {
const msg = '视频文件时长需在20-30秒之间!';
reject(new Error(msg));
return;
}
calculateFileMD5(tempFile)
.then(md5 => resolve(md5))
.catch(err => {
const msg = `文件MD5计算失败: ${err.message}`;
reject(new Error(msg));
});
});
};
const prepareUploadTask = async (tempFile, size, fileMd5) => {
const chunkSize = 1024 * 1024 * 5;
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 = '获取上传地址失败或数量不匹配';
throw new Error(msg);
}
const chunks = await getChunksFile(tempFile, chunkSize);
return {
taskId,
urls,
chunks,
chunkCount
};
} catch (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;
try {
const videoInfo = await new Promise((resolve, reject) => {
uni.getVideoInfo({
src: res.tempFilePath,
success: (infoRes) => {
resolve(infoRes);
},
fail: (err) => {
const msg = '无法读取视频文件信息';
reject(new Error(msg));
}
});
});
const fileMd5 = await validateAndCalculateMD5(res.tempFile, videoInfo.duration, videoInfo.size);
const {
taskId,
urls,
chunks
} = await prepareUploadTask(res.tempFile, videoInfo.size, fileMd5);
await startUploadChunks(chunks, urls, (progressInfo) => {
const {
percent,
hasStarted
} = progressInfo;
if (hasStarted) {
updateProgress(percent);
}
onProgressCallback?.(percent);
});
updateProgress(100);
onProgressCallback?.(100);
const mergeResult = await startMergeChunks(taskId);
return mergeResult;
} catch (error) {
throw error;
}
};
export const useUploadFiles = (id, callback, onProgress) => {
uni.chooseVideo({
sourceType: ['camera', 'album'],
success: (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 => {
showError(error.message || '上传失败');
});
},
fail: (err) => {
showError('选择视频失败');
}
});
};