import OSS from 'ali-oss'; import path from 'path'; import fs from 'fs'; interface OSSConfig { region: string; accessKeyId: string; accessKeySecret: string; bucket: string; endpoint?: string; } class OSSService { private client: OSS; private bucket: string; private cdnDomain?: string; constructor() { const config: OSSConfig = { region: process.env.OSS_REGION || 'oss-cn-hangzhou', accessKeyId: process.env.OSS_ACCESS_KEY_ID || '', accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '', bucket: process.env.OSS_BUCKET_NAME || '', endpoint: process.env.OSS_ENDPOINT || 'oss-cn-hangzhou.aliyuncs.com', }; // 修复 #tts-oss-timeout: 默认 60s 超时太短,对大音频/慢网络不够。 // 显式设 5 分钟响应超时 + 10 分钟 socket 超时,给 OSS 充足上传时间。 // ali-oss 同时支持 timeout 和 connectTimeout。 this.client = new OSS({ ...config, timeout: parseInt(process.env.OSS_TIMEOUT_MS || '300000'), // 5 分钟 connectTimeout: parseInt(process.env.OSS_CONNECT_TIMEOUT_MS || '60000'), }); this.bucket = config.bucket; this.cdnDomain = process.env.OSS_CDN_DOMAIN; } /** * 上传文件到 OSS * @param localPath 本地文件路径 * @param objectKey OSS 对象键(路径) * @returns OSS 文件 URL */ async uploadFile(localPath: string, objectKey: string): Promise { try { // 确保 objectKey 格式正确 const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, ''); const result = await this.client.put(normalizedKey, localPath, { headers: { 'Content-Type': this.getContentType(normalizedKey), }, }); console.log(`[OSS] 文件上传成功: ${normalizedKey}`); // 返回文件 URL return this.getFileUrl(normalizedKey); } catch (error) { console.error('[OSS] 文件上传失败:', error); throw new Error(`OSS 上传失败: ${(error as Error).message}`); } } /** * 上传 Buffer 到 OSS * @param buffer 文件 Buffer * @param objectKey OSS 对象键 * @param contentType 内容类型 * @returns OSS 文件 URL */ async uploadBuffer(buffer: Buffer, objectKey: string, contentType?: string): Promise { try { const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, ''); const result = await this.client.put(normalizedKey, buffer, { headers: { 'Content-Type': contentType || this.getContentType(normalizedKey), }, }); console.log(`[OSS] Buffer 上传成功: ${normalizedKey}`); return this.getFileUrl(normalizedKey); } catch (error) { console.error('[OSS] Buffer 上传失败:', error); throw new Error(`OSS Buffer 上传失败: ${(error as Error).message}`); } } /** * 上传音频文件 * @param localPath 本地音频文件路径 * @param audioId 音频 ID * @returns OSS 音频 URL */ async uploadAudio(localPath: string, audioId: string): Promise { const objectKey = `audio/${audioId}/${path.basename(localPath)}`; return this.uploadFile(localPath, objectKey); } /** * 上传视频文件 * @param localPath 本地视频文件路径 * @param videoId 视频 ID * @returns OSS 视频 URL */ async uploadVideo(localPath: string, videoId: string): Promise { const objectKey = `video/${videoId}/${path.basename(localPath)}`; return this.uploadFile(localPath, objectKey); } /** * 上传封面图片 * @param localPath 本地图片路径 * @param bookId 书籍 ID * @returns OSS 图片 URL */ async uploadCover(localPath: string, bookId: string | number): Promise { const ext = path.extname(localPath); const objectKey = `cover/${bookId}/cover${ext}`; return this.uploadFile(localPath, objectKey); } /** * 删除文件 * @param objectKey OSS 对象键 */ async deleteFile(objectKey: string): Promise { try { const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, ''); await this.client.delete(normalizedKey); console.log(`[OSS] 文件删除成功: ${normalizedKey}`); } catch (error) { console.error('[OSS] 文件删除失败:', error); throw new Error(`OSS 删除失败: ${(error as Error).message}`); } } /** * 删除整个目录 * @param prefix 目录前缀 */ async deleteDirectory(prefix: string): Promise { try { const normalizedPrefix = prefix.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '') + '/'; // 列出目录下所有文件 const result = await this.client.list({ prefix: normalizedPrefix, 'max-keys': 1000, }); // 批量删除 if (result.objects && result.objects.length > 0) { const keys = result.objects.map(obj => obj.name); await this.client.deleteMulti(keys); console.log(`[OSS] 目录删除成功: ${normalizedPrefix} (${keys.length} 个文件)`); } } catch (error) { console.error('[OSS] 目录删除失败:', error); throw new Error(`OSS 目录删除失败: ${(error as Error).message}`); } } /** * 获取文件签名 URL(用于私有 bucket) * @param objectKey OSS 对象键 * @param expires 过期时间(秒),默认 3600 秒 * @returns 签名 URL */ async getSignedUrl(objectKey: string, expires: number = 3600): Promise { try { const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, ''); const url = this.client.signatureUrl(normalizedKey, { expires, }); return url; } catch (error) { console.error('[OSS] 获取签名 URL 失败:', error); throw new Error(`获取签名 URL 失败: ${(error as Error).message}`); } } /** * 下载文件 * @param objectKey OSS 对象键 * @returns 文件 Buffer */ async downloadFile(objectKey: string): Promise { try { const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, ''); const result = await this.client.get(normalizedKey); return result.content; } catch (error) { console.error('[OSS] 文件下载失败:', error); throw new Error(`OSS 文件下载失败: ${(error as Error).message}`); } } /** * 获取文件 URL * @param objectKey OSS 对象键 * @returns 文件 URL */ getFileUrl(objectKey: string): string { const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, ''); // 如果配置了 CDN 域名,使用 CDN if (this.cdnDomain) { return `${this.cdnDomain}/${normalizedKey}`; } // 否则使用 OSS 默认域名 return `https://${this.bucket}.${process.env.OSS_ENDPOINT}/${normalizedKey}`; } /** * 根据文件扩展名获取 Content-Type * @param filename 文件名 * @returns Content-Type */ private getContentType(filename: string): string { const ext = path.extname(filename).toLowerCase(); const contentTypes: Record = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.aac': 'audio/aac', '.mp4': 'video/mp4', '.avi': 'video/avi', '.mov': 'video/quicktime', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.json': 'application/json', '.txt': 'text/plain', }; return contentTypes[ext] || 'application/octet-stream'; } /** * 测试 OSS 连接 */ async testConnection(): Promise { try { await this.client.getBucketInfo(this.bucket); console.log('[OSS] 连接测试成功'); return true; } catch (error) { console.error('[OSS] 连接测试失败:', error); return false; } } } // 导出单例 export const ossService = new OSSService(); export default ossService;