| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- 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<string> {
- 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<string> {
- 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<string> {
- 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<string> {
- 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<string> {
- const ext = path.extname(localPath);
- const objectKey = `cover/${bookId}/cover${ext}`;
- return this.uploadFile(localPath, objectKey);
- }
- /**
- * 删除文件
- * @param objectKey OSS 对象键
- */
- async deleteFile(objectKey: string): Promise<void> {
- 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<void> {
- 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<string> {
- 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<Buffer> {
- 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<string, string> = {
- '.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<boolean> {
- 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;
|