import fs from "node:fs/promises"; import path from "node:path"; import COS from "cos-nodejs-sdk-v5"; import { config } from "./config.js"; import { ensureDir, removeIfExists, safeStoragePath } from "./utils.js"; export type StorageProvider = "local" | "cos"; export type StoredObject = { key: string; provider: StorageProvider; }; type StorageService = { provider: StorageProvider; putObject(key: string, buffer: Buffer): Promise; deleteObject(key: string): Promise; publicUrl(key: string): string; }; function normalizeKey(key: string) { return key.replace(/\\/g, "/").replace(/^\/+/, ""); } function joinUrl(base: string, key: string) { return `${base.replace(/\/+$/g, "")}/${normalizeKey(key).split("/").map(encodeURIComponent).join("/")}`; } class LocalStorageService implements StorageService { provider: StorageProvider = "local"; async putObject(key: string, buffer: Buffer) { const normalized = normalizeKey(key); const fullPath = safeStoragePath(normalized); await ensureDir(path.dirname(fullPath)); await fs.writeFile(fullPath, buffer); return { key: normalized, provider: this.provider }; } async deleteObject(key: string) { await removeIfExists(safeStoragePath(normalizeKey(key))); } publicUrl(key: string) { const normalized = normalizeKey(key); if (config.publicBaseUrl) { return joinUrl(`${config.publicBaseUrl}/storage`, normalized); } return `/storage/${normalized}`; } } class CosStorageService implements StorageService { provider: StorageProvider = "cos"; private cos: COS; constructor() { const missing = [ ["COS_SECRET_ID", config.cos.secretId], ["COS_SECRET_KEY", config.cos.secretKey], ["COS_BUCKET", config.cos.bucket], ["COS_REGION", config.cos.region], ["CDN_BASE_URL", config.cdnBaseUrl] ].filter(([, value]) => !value).map(([key]) => key); if (missing.length > 0) { throw new Error(`COS 存储配置缺失:${missing.join(", ")}`); } this.cos = new COS({ SecretId: config.cos.secretId, SecretKey: config.cos.secretKey }); } async putObject(key: string, buffer: Buffer) { const objectKey = normalizeKey(config.cos.prefix ? `${config.cos.prefix}/${key}` : key); await new Promise((resolve, reject) => { this.cos.putObject({ Bucket: config.cos.bucket, Region: config.cos.region, Key: objectKey, Body: buffer }, (error) => { if (error) reject(error); else resolve(); }); }); return { key: objectKey, provider: this.provider }; } async deleteObject(key: string) { await new Promise((resolve, reject) => { this.cos.deleteObject({ Bucket: config.cos.bucket, Region: config.cos.region, Key: normalizeKey(key) }, (error) => { if (error) reject(error); else resolve(); }); }); } publicUrl(key: string) { return joinUrl(config.cdnBaseUrl, key); } } export const storage = config.storageDriver === "cos" ? new CosStorageService() : new LocalStorageService(); const localStorage = storage.provider === "local" ? storage : new LocalStorageService(); export function publicObjectUrl(key: string, provider: StorageProvider) { if (provider === "cos") { if (!config.cdnBaseUrl) return normalizeKey(key); return joinUrl(config.cdnBaseUrl, key); } return localStorage.publicUrl(key); } export async function deleteStoredObject(key: string, provider: StorageProvider) { if (provider === storage.provider) { await storage.deleteObject(key); return; } if (provider === "local") { await localStorage.deleteObject(key); } } export function storageStatus() { return { driver: storage.provider, cdnEnabled: Boolean(config.cdnBaseUrl), cosConfigured: Boolean(config.cos.bucket && config.cos.region && config.cos.secretId && config.cos.secretKey) }; }