此文章部分使用AI生成的内容。
技术栈
**对象存储**:华为云 OBS(兼容 AWS S3 协议)
**MD5 计算**:spark-md5
大概步骤:
1、安装依赖spark-md5
2、计算文件 MD5,使用 `spark-md5` 进行分片计算,避免大文件一次性加载到内存
3、获取预上传信息,调用后端接口获取分片上传所需的签名 URL
**重要参数说明:**
`fileName`:原始文件名
`md5Value`:文件 MD5 值,用于秒传判断
`chunkNum`:分片数量,后端根据此生成对应数量的签名 URL
`expiresSeconds`:签名 URL 有效期,默认 1 小时
4、上传单个分片,使用 XMLHttpRequest 上传分片并获取 ETag
**⚠️ 关键注意点:CORS 配置**
这是最容易踩坑的地方!必须在华为云 OBS 控制台配置 CORS 规则:
```xml
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>*</AllowedOrigin>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedMethod>GET</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<!-- 必须暴露 ETag 响应头,否则前端取不到 -->
<ExposeHeader>ETag</ExposeHeader>
<ExposeHeader>x-amz-request-id</ExposeHeader>
<MaxAgeSeconds>3000</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>
```
**如果没有配置 `<ExposeHeader>ETag</ExposeHeader>`,浏览器将无法获取 ETag,导致合并分片失败!**
bash
pnpm add spark-md5
工具函数文件
javascript
import SparkMD5 from 'spark-md5';
import { getPreUploadUrl, finishFile } from '@/api/global';
import { message } from 'ant-design-vue';
interface PreUploadData {
fileId: string;
filePath: string;
fileUrl: string;
uploadUrl: string;
storageType: string;
objectKey: string;
uploadId: string;
partSignList: {
partNumber: number;
signedUrl: string;
}[];
exists: boolean;
}
interface UploadOptions {
file: File;
chunkSize?: number; // 分片大小(MB),默认 5MB
bizId?: string; // 业务ID
bizType?: string; // 业务类型
onProgress?: (progress: number) => void; // 进度回调
}
interface UploadResult {
exists: boolean; // 文件是否存在
fileUrl: string;
fileId?: string;
fileName?: string;
fileSize?: number;
filePath?: string;
fileMd5?: string;
}
/**
* 计算文件 MD5 值(使用 spark-md5 分片计算)
*/
const calculateFileMD5 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
try {
const spark = new SparkMD5.ArrayBuffer();
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB 分片
let offset = 0;
async function readNextChunk() {
const chunkEnd = Math.min(offset + CHUNK_SIZE, file.size);
if (offset >= file.size) {
// 读取完成
resolve(spark.end());
return;
}
try {
// 使用 FileReader 读取分片数据
const slice = file.slice(offset, chunkEnd);
const reader = new FileReader();
reader.onload = e => {
const arrayBuffer = e.target?.result as ArrayBuffer;
if (arrayBuffer) {
spark.append(arrayBuffer);
offset += arrayBuffer.byteLength;
readNextChunk();
}
};
reader.onerror = err => {
console.error('MD5 分片计算失败:', err);
reject(new Error('MD5 计算失败'));
};
reader.readAsArrayBuffer(slice);
} catch (e) {
console.error('MD5 分片计算失败:', e);
reject(new Error('MD5 计算失败'));
}
}
readNextChunk();
} catch (e) {
console.error('MD5 计算初始化失败:', e);
reject(new Error('MD5 计算失败'));
}
});
};
/**
* 获取预上传链接
*/
const handlePreUploadUrl = async (
fileName: string,
md5Value: string,
chunkNum: number,
expiresSeconds = 3600
): Promise<any> => {
try {
const response = await getPreUploadUrl({
fileName,
md5Value,
chunkNum,
expiresSeconds
});
if (response && +response.code === 0 && response.data) {
return response.data;
} else {
throw new Error(response?.message || '获取预上传链接失败');
}
} catch (error) {
console.error('获取预上传链接失败:', error);
throw new Error('网络请求失败');
}
};
/**
* 上传单个分片到 OBS,并返回 ETag
*/
const uploadChunk = (chunk: Blob, signedUrl: string): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', signedUrl);
// 上传完成
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
// 从响应头中获取 ETag
// 注意:OBS 返回的 ETag 可能包含双引号,需要去除
let etag = xhr.getResponseHeader('ETag') || xhr.getResponseHeader('Etag');
// 调试信息:打印所有响应头
// console.log('分片上传响应头:', xhr.getAllResponseHeaders());
// console.log('获取到的 ETag:', etag);
if (etag) {
// 去除 ETag 两端的引号(如果有的话)
etag = etag.replace(/^"|"$/g, '');
resolve(etag);
} else {
console.error('未获取到 ETag');
console.error('请检查 OBS CORS 配置,确保 Access-Control-Expose-Headers 包含 ETag');
reject(new Error('分片上传成功但未获取到 ETag,请检查 OBS CORS 配置'));
}
} else {
console.error('OBS 分片上传失败:', xhr.status, xhr.responseText);
reject(new Error(`分片上传失败: ${xhr.status}`));
}
};
// 上传错误
xhr.onerror = err => {
console.error('OBS 分片上传错误:', err);
console.error('可能的原因:');
console.error('1. OBS CORS 配置未设置或配置不正确');
console.error('2. 预签名 URL 已过期');
console.error('3. 网络问题');
console.error('请在华为云 OBS 控制台配置 CORS 规则,允许来自 localhost 的 PUT 请求');
reject(new Error('分片上传失败,请检查 OBS CORS 配置'));
};
// 发送分片
xhr.send(chunk);
});
};
/**
* 分片上传文件
*/
export const chunkedUpload = async (options: UploadOptions): Promise<UploadResult> => {
const { file, chunkSize = 5, onProgress } = options;
if (!file) {
throw new Error('文件不能为空');
}
// 1. 计算文件 MD5
// console.log('开始计算文件 MD5...');
const md5Value = await calculateFileMD5(file);
// console.log('文件 MD5:', md5Value);
// 2. 计算分片数量
const totalChunks = Math.ceil(file.size / (chunkSize * 1024 * 1024));
console.log('文件分片数:', totalChunks);
// 3. 获取预上传链接
console.log('获取预上传链接...');
const preUploadData: PreUploadData = await handlePreUploadUrl(file.name, md5Value, totalChunks, 3600);
const { exists, fileUrl, fileId, uploadId, objectKey, partSignList } = preUploadData;
if (exists) {
message.success('文件上传成功');
console.log('文件已存在', fileUrl);
return {
exists: true,
fileId,
fileName: file.name || preUploadData.filePath,
filePath: preUploadData.filePath || fileUrl,
fileUrl: fileUrl
};
}
console.log('预上传链接获取成功:', { uploadId, objectKey, partCount: partSignList.length });
// 4. 分片上传
console.log('开始分片上传...');
const parts: { etag: string; partNumber: number }[] = [];
for (let i = 0; i < partSignList.length; i++) {
const partSign = partSignList[i];
const start = i * chunkSize * 1024 * 1024;
const end = Math.min(start + chunkSize * 1024 * 1024, file.size);
const chunk = file.slice(start, end);
// 更新进度
const progress = Math.round(((i + 1) / partSignList.length) * 100);
if (onProgress) {
onProgress(progress);
}
// console.log(`上传进度: ${progress}% (${i + 1}/${partSignList.length})`);
// 上传分片并获取 ETag
const etag = await uploadChunk(chunk, partSign.signedUrl);
parts.push({
etag,
partNumber: partSign.partNumber
});
}
// 5. 上传完成后更新文件信息
console.log('所有分片上传完成,更新文件信息...');
const finishRes = await finishFile({
id: fileId,
uploadId,
objectKey,
bizId: options.bizId || '',
bizType: options.bizType || '',
parts
});
console.log('文件上传成功finishRes', finishRes);
return {
exists: false,
fileId,
fileName: file.name || finishRes.fileName,
fileSize: file.size,
filePath: fileUrl || finishRes.filePath,
fileUrl: fileUrl || finishRes.fileUrl,
fileMd5: md5Value
};
};
在组件中使用
javascript
<a-upload-dragger
:show-upload-list="false"
:before-upload="() => false"
name="file"
@change="(info: any) => handleFileChange(info, 'left')"
>
<a-spin :spinning="fileUploading.left" :tip="`文件上传中...${fileUploadProgress.left}%`">
<div class="upload-box">
<video
v-if="modalForm.leftVideoPath"
class=""
controls="true"
:src="modalForm.leftVideoPath"
initial-time="0"
:autoplay="false"
loop="false"
muted="false"
width="100%"
height="100%"
></video>
<div class="upload-content" v-else>
<span class="upload-icon">+</span>
<span class="upload-text">上传视频</span>
</div>
</div>
</a-spin>
</a-upload-dragger>
javascript
const handleFileChange = async (info: any) => {
const file = info.file.originFileObj || info.file;
if (!file) {
message.error('文件选择失败');
return;
}
// 检查文件类型
if (!file.type.startsWith('video/')) {
message.error('请选择视频文件');
return;
}
// 检查文件大小(最大 500MB)
const maxSize = 500 * 1024 * 1024;
if (file.size > maxSize) {
message.error(`文件大小不能超过 500MB`);
return;
}
try {
// 执行分片上传
const result = await chunkedUpload({
file,
chunkSize: 5, // 5MB 分片
bizId: modalForm.value.id || `temp${new Date().getTime()}`, // 使用活动ID作为业务ID
bizType: 'video_analysis', // 业务类型:视频分析
onProgress: progress => {
console.log(`${side} 侧上传进度: ${progress}%`);
fileUploadProgress[side] = progress;
}
});
console.log('分片上传result', result);
message.success(`${result.fileName} 上传成功`);
} catch (error: any) {
console.error('上传失败:', error);
message.error(error.message || '上传失败');
}
};
常见问题与解决方案
1. 获取不到 ETag
**问题现象:** 分片上传成功,但 `xhr.getResponseHeader('ETag')` 返回 null
**解决方案:**
检查 OBS CORS 配置,确保包含 `<ExposeHeader>ETag</ExposeHeader>`
尝试同时获取 `ETag` 和 `Etag`(大小写兼容)
使用 `xhr.getAllResponseHeaders()` 调试查看所有响应头
2. ETag 格式问题
**问题现象:** 获取到的 ETag 包含双引号,如 `"abc123"`
**解决方案:**
```typescript
etag = etag.replace(/^"|"$/g, '');
```
3. 跨域错误
**问题现象:** 浏览器控制台报 CORS 错误
**解决方案:**
在 OBS 控制台配置 CORS 规则
确保 `AllowedOrigin` 包含你的域名(开发环境可以是 `*` 或 `http://localhost:*`)
确保 `AllowedMethod` 包含 `PUT`
4. 签名 URL 过期
**问题现象:** 上传过程中提示签名过期
**解决方案:**
增加 `expiresSeconds` 参数(最大 7200 秒)
对于超大文件,考虑减小分片大小或优化上传速度
实现重试机制,检测到过期后重新获取签名
5. 内存溢出
**问题现象:** 计算大文件 MD5 时浏览器卡死或崩溃
**解决方案:**
使用分片计算 MD5(如示例中的 2MB 分片)
避免一次性将整个文件读入内存
使用 `FileReader` 异步读取
参考资料
华为云 OBS 官方文档\](https://support.huaweicloud.com/obs/index.html)
spark-md5 GitHub\](https://github.com/satazor/js-spark-md5)