oss.service.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import OSS from 'ali-oss';
  2. import path from 'path';
  3. import fs from 'fs';
  4. interface OSSConfig {
  5. region: string;
  6. accessKeyId: string;
  7. accessKeySecret: string;
  8. bucket: string;
  9. endpoint?: string;
  10. }
  11. class OSSService {
  12. private client: OSS;
  13. private bucket: string;
  14. private cdnDomain?: string;
  15. constructor() {
  16. const config: OSSConfig = {
  17. region: process.env.OSS_REGION || 'oss-cn-hangzhou',
  18. accessKeyId: process.env.OSS_ACCESS_KEY_ID || '',
  19. accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '',
  20. bucket: process.env.OSS_BUCKET_NAME || '',
  21. endpoint: process.env.OSS_ENDPOINT || 'oss-cn-hangzhou.aliyuncs.com',
  22. };
  23. // 修复 #tts-oss-timeout: 默认 60s 超时太短,对大音频/慢网络不够。
  24. // 显式设 5 分钟响应超时 + 10 分钟 socket 超时,给 OSS 充足上传时间。
  25. // ali-oss 同时支持 timeout 和 connectTimeout。
  26. this.client = new OSS({
  27. ...config,
  28. timeout: parseInt(process.env.OSS_TIMEOUT_MS || '300000'), // 5 分钟
  29. connectTimeout: parseInt(process.env.OSS_CONNECT_TIMEOUT_MS || '60000'),
  30. });
  31. this.bucket = config.bucket;
  32. this.cdnDomain = process.env.OSS_CDN_DOMAIN;
  33. }
  34. /**
  35. * 上传文件到 OSS
  36. * @param localPath 本地文件路径
  37. * @param objectKey OSS 对象键(路径)
  38. * @returns OSS 文件 URL
  39. */
  40. async uploadFile(localPath: string, objectKey: string): Promise<string> {
  41. try {
  42. // 确保 objectKey 格式正确
  43. const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, '');
  44. const result = await this.client.put(normalizedKey, localPath, {
  45. headers: {
  46. 'Content-Type': this.getContentType(normalizedKey),
  47. },
  48. });
  49. console.log(`[OSS] 文件上传成功: ${normalizedKey}`);
  50. // 返回文件 URL
  51. return this.getFileUrl(normalizedKey);
  52. } catch (error) {
  53. console.error('[OSS] 文件上传失败:', error);
  54. throw new Error(`OSS 上传失败: ${(error as Error).message}`);
  55. }
  56. }
  57. /**
  58. * 上传 Buffer 到 OSS
  59. * @param buffer 文件 Buffer
  60. * @param objectKey OSS 对象键
  61. * @param contentType 内容类型
  62. * @returns OSS 文件 URL
  63. */
  64. async uploadBuffer(buffer: Buffer, objectKey: string, contentType?: string): Promise<string> {
  65. try {
  66. const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, '');
  67. const result = await this.client.put(normalizedKey, buffer, {
  68. headers: {
  69. 'Content-Type': contentType || this.getContentType(normalizedKey),
  70. },
  71. });
  72. console.log(`[OSS] Buffer 上传成功: ${normalizedKey}`);
  73. return this.getFileUrl(normalizedKey);
  74. } catch (error) {
  75. console.error('[OSS] Buffer 上传失败:', error);
  76. throw new Error(`OSS Buffer 上传失败: ${(error as Error).message}`);
  77. }
  78. }
  79. /**
  80. * 上传音频文件
  81. * @param localPath 本地音频文件路径
  82. * @param audioId 音频 ID
  83. * @returns OSS 音频 URL
  84. */
  85. async uploadAudio(localPath: string, audioId: string): Promise<string> {
  86. const objectKey = `audio/${audioId}/${path.basename(localPath)}`;
  87. return this.uploadFile(localPath, objectKey);
  88. }
  89. /**
  90. * 上传视频文件
  91. * @param localPath 本地视频文件路径
  92. * @param videoId 视频 ID
  93. * @returns OSS 视频 URL
  94. */
  95. async uploadVideo(localPath: string, videoId: string): Promise<string> {
  96. const objectKey = `video/${videoId}/${path.basename(localPath)}`;
  97. return this.uploadFile(localPath, objectKey);
  98. }
  99. /**
  100. * 上传封面图片
  101. * @param localPath 本地图片路径
  102. * @param bookId 书籍 ID
  103. * @returns OSS 图片 URL
  104. */
  105. async uploadCover(localPath: string, bookId: string | number): Promise<string> {
  106. const ext = path.extname(localPath);
  107. const objectKey = `cover/${bookId}/cover${ext}`;
  108. return this.uploadFile(localPath, objectKey);
  109. }
  110. /**
  111. * 删除文件
  112. * @param objectKey OSS 对象键
  113. */
  114. async deleteFile(objectKey: string): Promise<void> {
  115. try {
  116. const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, '');
  117. await this.client.delete(normalizedKey);
  118. console.log(`[OSS] 文件删除成功: ${normalizedKey}`);
  119. } catch (error) {
  120. console.error('[OSS] 文件删除失败:', error);
  121. throw new Error(`OSS 删除失败: ${(error as Error).message}`);
  122. }
  123. }
  124. /**
  125. * 删除整个目录
  126. * @param prefix 目录前缀
  127. */
  128. async deleteDirectory(prefix: string): Promise<void> {
  129. try {
  130. const normalizedPrefix = prefix.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '') + '/';
  131. // 列出目录下所有文件
  132. const result = await this.client.list({
  133. prefix: normalizedPrefix,
  134. 'max-keys': 1000,
  135. });
  136. // 批量删除
  137. if (result.objects && result.objects.length > 0) {
  138. const keys = result.objects.map(obj => obj.name);
  139. await this.client.deleteMulti(keys);
  140. console.log(`[OSS] 目录删除成功: ${normalizedPrefix} (${keys.length} 个文件)`);
  141. }
  142. } catch (error) {
  143. console.error('[OSS] 目录删除失败:', error);
  144. throw new Error(`OSS 目录删除失败: ${(error as Error).message}`);
  145. }
  146. }
  147. /**
  148. * 获取文件签名 URL(用于私有 bucket)
  149. * @param objectKey OSS 对象键
  150. * @param expires 过期时间(秒),默认 3600 秒
  151. * @returns 签名 URL
  152. */
  153. async getSignedUrl(objectKey: string, expires: number = 3600): Promise<string> {
  154. try {
  155. const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, '');
  156. const url = this.client.signatureUrl(normalizedKey, {
  157. expires,
  158. });
  159. return url;
  160. } catch (error) {
  161. console.error('[OSS] 获取签名 URL 失败:', error);
  162. throw new Error(`获取签名 URL 失败: ${(error as Error).message}`);
  163. }
  164. }
  165. /**
  166. * 下载文件
  167. * @param objectKey OSS 对象键
  168. * @returns 文件 Buffer
  169. */
  170. async downloadFile(objectKey: string): Promise<Buffer> {
  171. try {
  172. const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, '');
  173. const result = await this.client.get(normalizedKey);
  174. return result.content;
  175. } catch (error) {
  176. console.error('[OSS] 文件下载失败:', error);
  177. throw new Error(`OSS 文件下载失败: ${(error as Error).message}`);
  178. }
  179. }
  180. /**
  181. * 获取文件 URL
  182. * @param objectKey OSS 对象键
  183. * @returns 文件 URL
  184. */
  185. getFileUrl(objectKey: string): string {
  186. const normalizedKey = objectKey.replace(/\\/g, '/').replace(/^\/+/, '');
  187. // 如果配置了 CDN 域名,使用 CDN
  188. if (this.cdnDomain) {
  189. return `${this.cdnDomain}/${normalizedKey}`;
  190. }
  191. // 否则使用 OSS 默认域名
  192. return `https://${this.bucket}.${process.env.OSS_ENDPOINT}/${normalizedKey}`;
  193. }
  194. /**
  195. * 根据文件扩展名获取 Content-Type
  196. * @param filename 文件名
  197. * @returns Content-Type
  198. */
  199. private getContentType(filename: string): string {
  200. const ext = path.extname(filename).toLowerCase();
  201. const contentTypes: Record<string, string> = {
  202. '.mp3': 'audio/mpeg',
  203. '.wav': 'audio/wav',
  204. '.aac': 'audio/aac',
  205. '.mp4': 'video/mp4',
  206. '.avi': 'video/avi',
  207. '.mov': 'video/quicktime',
  208. '.jpg': 'image/jpeg',
  209. '.jpeg': 'image/jpeg',
  210. '.png': 'image/png',
  211. '.gif': 'image/gif',
  212. '.webp': 'image/webp',
  213. '.json': 'application/json',
  214. '.txt': 'text/plain',
  215. };
  216. return contentTypes[ext] || 'application/octet-stream';
  217. }
  218. /**
  219. * 测试 OSS 连接
  220. */
  221. async testConnection(): Promise<boolean> {
  222. try {
  223. await this.client.getBucketInfo(this.bucket);
  224. console.log('[OSS] 连接测试成功');
  225. return true;
  226. } catch (error) {
  227. console.error('[OSS] 连接测试失败:', error);
  228. return false;
  229. }
  230. }
  231. }
  232. // 导出单例
  233. export const ossService = new OSSService();
  234. export default ossService;