同步工作区剩余变更,主要包括: - 营销页面组件与布局持续优化(about/news/services/solutions/team 等) - 详情页四层叙事组件、布局组件、UI 组件调整 - CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展 - 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等) - ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整 - 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告 - 移除水墨装饰组件与大体积未使用字体文件
124 lines
3.5 KiB
TypeScript
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);
|
|
}
|