Files
novalon-website/src/lib/media/storage.ts
T
张翔 10404dbb36 chore: sync marketing pages, CMS extensions, tests and project docs
同步工作区剩余变更,主要包括:
- 营销页面组件与布局持续优化(about/news/services/solutions/team 等)
- 详情页四层叙事组件、布局组件、UI 组件调整
- CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展
- 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等)
- ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整
- 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告
- 移除水墨装饰组件与大体积未使用字体文件
2026-07-25 08:04:01 +08:00

124 lines
3.5 KiB
TypeScript

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<StoredFile> {
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<void> {
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<StoredFile> {
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<void> {
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);
}