fix: OSS客户端直传

This commit is contained in:
2025-12-23 14:34:22 +08:00
parent e9b79b2963
commit 84cafd66ae
7 changed files with 686 additions and 38 deletions

View File

@@ -0,0 +1,345 @@
/**
* OSS直传工具函数
*
* 该模块实现了阿里云OSS的Web直传功能支持客户端直接上传文件到OSS存储桶
* 参考文档https://help.aliyun.com/zh/oss/user-guide/obtain-signature-information-from-the-server-and-upload-data-to-oss
*
* 主要功能:
* 1. 从后端获取OSS上传签名信息
* 2. 生成全局唯一的文件名UUID + 时间戳)
* 3. 使用FormData构造POST请求直接上传到OSS
* 4. 处理上传进度和错误
* 5. 返回上传后的文件URL
*/
import { getOssSignature } from '#/api/core/upload';
/**
* OSS签名信息接口
* 后端返回的签名数据结构
*/
export interface OssSignature {
/** 阿里云AccessKeyId */
accessKeyId: string;
/** Base64编码的PostPolicy策略 */
policy: string;
/** 签名值 */
signature: string;
/** OSS服务端点URL */
host: string;
/** 存储桶名称(从后端返回,不能硬编码) */
bucket: string;
/** 文件路径前缀(仅作为路径前缀,完整文件名由前端生成) */
key: string;
/** 签名过期时间戳Unix时间戳秒级 */
expire: number;
}
/**
* 上传选项接口
*/
export interface UploadOptions {
/** 要上传的文件对象 */
file: File;
/** 上传进度回调函数参数为0-100的进度百分比 */
onProgress?: (percent: number) => void;
}
/**
* 上传结果接口
*/
export interface UploadResult {
/** 上传后的文件完整URL */
url: string;
/** 文件在OSS中的objectName相对路径 */
objectName: string;
}
/**
* 生成全局唯一的文件名
*
* 文件名格式xk_upload_日期/UUID_时间戳.扩展名
* 例如xk_upload_20250108/550e8400-e29b-41d4-a716-446655440000_1736323200.jpg
*
* 规则说明:
* - 基础路径xk_upload_日期/ 日期格式YYYYMMDD
* - 文件名UUID_时间戳.扩展名
* - UUID使用浏览器原生crypto.randomUUID()生成,确保全局唯一
* - 时间戳使用Unix时间戳秒级通过Math.floor(Date.now() / 1000)获取
* - 扩展名:从原始文件名提取
*
* @param file 文件对象,用于获取文件名和扩展名
* @returns 返回完整的objectName路径
*
* @example
* const objectName = generateObjectName(file);
* // 返回: "xk_upload_20250108/550e8400-e29b-41d4-a716-446655440000_1736323200.jpg"
*/
export function generateObjectName(file: File): string {
// 获取当前日期格式YYYYMMDD
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const dateStr = `${year}${month}${day}`;
// 生成UUID使用浏览器原生API确保全局唯一
// crypto.randomUUID() 是Web Crypto API的一部分支持现代浏览器
let uuid: string;
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
uuid = crypto.randomUUID();
} else {
// 降级方案如果浏览器不支持randomUUID使用替代方案
// 生成类似UUID格式的随机字符串
uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// 获取Unix时间戳秒级
// Date.now()返回毫秒级时间戳除以1000并向下取整得到秒级时间戳
const timestamp = Math.floor(Date.now() / 1000);
// 获取文件扩展名
// 从文件名中提取扩展名,如果没有扩展名则使用空字符串
const fileName = file.name;
const lastDotIndex = fileName.lastIndexOf('.');
const extension = lastDotIndex > 0 ? fileName.substring(lastDotIndex + 1).toLowerCase() : '';
// 组合成完整的objectName路径
// 格式xk_upload_日期/UUID_时间戳.扩展名
const objectName = `xk_upload_${dateStr}/${uuid}_${timestamp}${extension ? '.' + extension : ''}`;
return objectName;
}
/**
* 从后端获取OSS上传签名信息
*
* 该函数调用后端API获取OSS上传所需的签名信息包括
* - accessKeyId: 阿里云访问密钥ID
* - policy: Base64编码的上传策略
* - signature: 签名值
* - host: OSS服务端点
* - bucket: 存储桶名称(从后端返回,确保配置灵活性)
* - key: 文件路径前缀
* - expire: 签名过期时间
*
* 错误处理:
* - 如果API调用失败会抛出错误
* - 调用方需要捕获错误并进行相应处理
*
* @returns Promise<OssSignature> 返回签名信息对象
* @throws 如果API调用失败或返回数据格式不正确会抛出错误
*
* @example
* try {
* const signature = await getOssSignature();
* console.log('获取签名成功', signature);
* } catch (error) {
* console.error('获取签名失败', error);
* }
*/
export async function getOssSignatureFromBackend(): Promise<OssSignature> {
try {
// 调用后端API获取签名信息
const response = await getOssSignature();
// 验证返回数据的完整性
if (!response || !response.accessKeyId || !response.policy || !response.signature) {
throw new Error('后端返回的签名信息不完整');
}
// 确保bucket字段存在重要bucket必须从后端返回
if (!response.bucket) {
throw new Error('后端未返回bucket信息无法进行上传');
}
return response as OssSignature;
} catch (error) {
// 记录错误信息,便于调试
console.error('获取OSS签名失败:', error);
throw error;
}
}
/**
* 上传文件到OSS
*
* 该函数实现了完整的OSS直传流程
* 1. 从后端获取签名信息
* 2. 生成全局唯一的文件名
* 3. 构造FormData并设置必要的字段
* 4. 使用XMLHttpRequest发送POST请求到OSS
* 5. 处理上传进度和错误
* 6. 返回上传后的文件URL
*
* 上传流程说明:
* - 使用FormData构造multipart/form-data格式的请求
* - 必须包含的字段key, policy, OSSAccessKeyId, signature, file
* - key字段使用生成的objectName
* - 上传到OSS的host端点
* - 上传成功后文件URL格式host/objectName
*
* 进度处理:
* - 使用XMLHttpRequest的upload.onprogress事件监听上传进度
* - 进度值范围0-100表示上传百分比
* - 通过onProgress回调函数通知调用方
*
* 错误处理:
* - 网络错误捕获XMLHttpRequest的错误事件
* - OSS错误解析OSS返回的XML错误响应
* - 签名过期:检查签名是否在有效期内
*
* @param options 上传选项,包含文件和进度回调
* @returns Promise<UploadResult> 返回上传结果包含文件URL和objectName
* @throws 如果上传失败,会抛出包含错误信息的异常
*
* @example
* try {
* const result = await uploadToOss({
* file: fileObject,
* onProgress: (percent) => {
* console.log(`上传进度: ${percent}%`);
* }
* });
* console.log('上传成功文件URL:', result.url);
* } catch (error) {
* console.error('上传失败', error);
* }
*/
export async function uploadToOss(options: UploadOptions): Promise<UploadResult> {
const { file, onProgress } = options;
// 步骤1从后端获取OSS签名信息
// 签名信息包含上传所需的所有认证参数
const signature = await getOssSignatureFromBackend();
// 步骤2生成全局唯一的文件名
// 使用UUID + 时间戳确保文件名全局唯一,避免冲突
const objectName = generateObjectName(file);
// 步骤3构造FormData设置OSS上传所需的字段
// FormData用于构造multipart/form-data格式的请求体
const formData = new FormData();
// key: 文件在OSS中的完整路径objectName
// 这是OSS中文件的唯一标识
formData.append('key', objectName);
// policy: Base64编码的上传策略
// 策略定义了上传的条件,如文件大小限制、允许的文件类型等
formData.append('policy', signature.policy);
// OSSAccessKeyId: 阿里云访问密钥ID
// 用于标识上传请求的发起者
formData.append('OSSAccessKeyId', signature.accessKeyId);
// signature: 签名值
// 使用AccessKeySecret对policy进行签名确保策略未被篡改
formData.append('signature', signature.signature);
// success_action_status: 指定上传成功后OSS返回的HTTP状态码
// 默认是204无内容设置为200后OSS返回200状态码
// 这样前端可以通过200状态码明确判断上传成功
// 注意此字段必须与后端Policy中的条件一致否则会上传失败
formData.append('success_action_status', '200');
// x-oss-object-acl: 指定文件的访问权限ACL
// 设置为 public-read 表示文件可以被公开访问无需签名URL
// 这与后端上传保持一致后端上传后会调用putObjectAcl设置公共读
// 注意此字段必须与后端Policy中的条件一致否则会上传失败
formData.append('x-oss-object-acl', 'public-read');
// file: 要上传的文件
// 必须是最后一个字段OSS要求file字段在FormData的最后
formData.append('file', file);
// 步骤4使用XMLHttpRequest发送POST请求到OSS
// 使用XMLHttpRequest而不是fetch因为需要监听上传进度
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
// 监听上传进度
// upload.onprogress事件在上传过程中会多次触发
if (onProgress && xhr.upload) {
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
// 计算上传进度百分比
// loaded: 已上传的字节数
// total: 文件总字节数
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
});
}
// 监听请求完成事件
xhr.addEventListener('load', () => {
// 检查HTTP状态码
// 200-299表示成功204表示成功但无内容
if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 204) {
// 上传成功构造文件URL
// URL格式host/objectName
// 注意使用后端返回的host确保配置的灵活性
const fileUrl = `${signature.host}/${objectName}`;
resolve({
url: fileUrl,
objectName: objectName,
});
} else {
// HTTP状态码表示错误尝试解析OSS返回的错误信息
let errorMessage = `上传失败HTTP状态码: ${xhr.status}`;
try {
// OSS错误响应通常是XML格式
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xhr.responseText, 'text/xml');
const errorNode = xmlDoc.querySelector('Error');
if (errorNode) {
const codeNode = errorNode.querySelector('Code');
const messageNode = errorNode.querySelector('Message');
if (codeNode && messageNode) {
errorMessage = `OSS错误 [${codeNode.textContent}]: ${messageNode.textContent}`;
}
}
} catch (parseError) {
// 如果解析失败,使用默认错误信息
console.warn('解析OSS错误响应失败:', parseError);
}
reject(new Error(errorMessage));
}
});
// 监听网络错误事件
// 当网络请求失败时触发(如网络断开、超时等)
xhr.addEventListener('error', () => {
reject(new Error('网络错误无法连接到OSS服务器'));
});
// 监听请求超时事件
// 如果请求时间超过timeout设置的时间会触发此事件
xhr.addEventListener('timeout', () => {
reject(new Error('上传超时:请求时间过长'));
});
// 设置请求超时时间30秒
// 对于大文件,可能需要增加超时时间
xhr.timeout = 30000;
// 打开POST请求
// 使用后端返回的host作为请求URL
xhr.open('POST', signature.host, true);
// 发送请求
// 将FormData作为请求体发送
xhr.send(formData);
});
}