/** * 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, registerOssFile } 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 返回签名信息对象 * @throws 如果API调用失败或返回数据格式不正确,会抛出错误 * * @example * try { * const signature = await getOssSignature(); * console.log('获取签名成功', signature); * } catch (error) { * console.error('获取签名失败', error); * } */ export async function getOssSignatureFromBackend(): Promise { 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 返回上传结果,包含文件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 { 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((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}`; registerOssFile({ url: fileUrl, source: 0, file_size: file.size, file_name: file.name }).catch((err) => { console.warn('OSS 文件登记失败:', err); }); 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); }); }