373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* 根据 File 推断上传用 Content-Type(浏览器可能给空 type)
|
||
*/
|
||
function resolveUploadContentType(file: File): string {
|
||
if (file.type && file.type !== 'application/octet-stream') {
|
||
return file.type;
|
||
}
|
||
const name = (file.name || '').toLowerCase();
|
||
if (name.endsWith('.pdf')) return 'application/pdf';
|
||
if (name.endsWith('.png')) return 'image/png';
|
||
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg';
|
||
if (name.endsWith('.gif')) return 'image/gif';
|
||
if (name.endsWith('.webp')) return 'image/webp';
|
||
if (name.endsWith('.bmp')) return 'image/bmp';
|
||
return file.type || 'application/octet-stream';
|
||
}
|
||
|
||
/**
|
||
* 从后端获取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');
|
||
|
||
// Content-Type:按文件 MIME 写入,避免 PDF 被当成 octet-stream 导致浏览器直接下载
|
||
const contentType = resolveUploadContentType(file);
|
||
formData.append('Content-Type', contentType);
|
||
// Content-Disposition: inline —— 浏览器倾向预览而非附件下载
|
||
formData.append('Content-Disposition', 'inline');
|
||
|
||
// 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}`;
|
||
|
||
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);
|
||
});
|
||
}
|
||
|