import fs from 'fs/promises'; import path from 'path'; import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; import type { StorageProvider, StoredFile } from './types'; /** * 本地文件系统存储 Provider * 开发/测试环境使用,文件落盘到 baseDir,通过 publicPath 对外暴露 URL */ export class LocalStorageProvider implements StorageProvider { readonly type = 'local' as const; constructor( private readonly baseDir: string, private readonly publicPath: string ) {} async save(fileName: string, buffer: Buffer, _mimeType: string): Promise { const filePath = path.join(this.baseDir, fileName); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, buffer); return { path: filePath, url: `${this.publicPath.replace(/\/$/, '')}/${fileName.replace(/^\//, '')}`, }; } async delete(filePath: string): Promise { try { await fs.unlink(filePath); } catch (error) { // 文件可能已被删除,忽略 ENOENT if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; } } } } /** * S3/OSS 兼容对象存储 Provider * 生产环境使用,通过环境变量配置 endpoint/credentials */ export class S3StorageProvider implements StorageProvider { readonly type = 's3' as const; constructor( private readonly client: S3Client, private readonly bucket: string, private readonly publicUrlPrefix?: string ) {} async save(fileName: string, buffer: Buffer, mimeType: string): Promise { const key = fileName.replace(/^\//, ''); await this.client.send( new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: buffer, ContentType: mimeType, }) ); const url = this.publicUrlPrefix ? `${this.publicUrlPrefix.replace(/\/$/, '')}/${key}` : `https://${this.bucket}.s3.amazonaws.com/${key}`; return { path: key, url }; } async delete(filePath: string): Promise { const key = filePath.startsWith('/') ? filePath.slice(1) : filePath; await this.client.send( new DeleteObjectCommand({ Bucket: this.bucket, Key: key, }) ); } } function createS3Client(): S3Client { const endpoint = process.env.S3_ENDPOINT; const region = process.env.S3_REGION || 'us-east-1'; const accessKeyId = process.env.S3_ACCESS_KEY_ID; const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY; return new S3Client({ region, endpoint, credentials: accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined, forcePathStyle: Boolean(endpoint), }); } /** * 根据环境变量获取当前存储 Provider * - 默认 local,文件落盘 public/uploads/ * - STORAGE_TYPE=s3 时要求 S3_BUCKET */ export function getStorageProvider(): StorageProvider { const storageType = process.env.STORAGE_TYPE || 'local'; if (storageType === 's3') { const bucket = process.env.S3_BUCKET; if (!bucket) { throw new Error('S3_BUCKET environment variable is required when STORAGE_TYPE=s3'); } return new S3StorageProvider( createS3Client(), bucket, process.env.S3_PUBLIC_URL_PREFIX ); } const baseDir = process.env.LOCAL_UPLOAD_DIR || path.join(process.cwd(), 'public', 'uploads'); const publicPath = process.env.LOCAL_UPLOAD_PATH || '/uploads'; return new LocalStorageProvider(baseDir, publicPath); }