import COS from 'cos-nodejs-sdk-v5'; import fs from 'fs'; import path from 'path'; import { randomUUID } from 'crypto'; import { database } from './database.js'; import { decryptSecret } from './configCrypto.js'; export interface StorageConfigRecord { id: number; region: string; bucket: string; public_base_url: string; cdn_domain: string; path_prefix: string; secret_id_encrypted: string; secret_key_encrypted: string; } export interface StoredUpload { url: string; storageProvider: 'local' | 'tencent_cos'; storageKey: string; } export async function getActiveStorageConfig(): Promise { return database.one(` SELECT id, region, bucket, public_base_url, cdn_domain, path_prefix, secret_id_encrypted, secret_key_encrypted FROM storage_configs WHERE status = 'active' ORDER BY id DESC LIMIT 1 `); } function createClient(config: StorageConfigRecord) { return new COS({ SecretId: decryptSecret(config.secret_id_encrypted), SecretKey: decryptSecret(config.secret_key_encrypted), }); } function cleanPrefix(value: string): string { return value.trim().replace(/^\/+|\/+$/g, '').replace(/\/{2,}/g, '/'); } function objectUrl(config: StorageConfigRecord, key: string): string { const base = (config.cdn_domain || config.public_base_url || `https://${config.bucket}.cos.${config.region}.myqcloud.com`).replace(/\/+$/, ''); return `${base}/${key.split('/').map(encodeURIComponent).join('/')}`; } function safeError(error: unknown): string { if (!error || typeof error !== 'object') return 'COS 连接失败'; const item = error as { code?: string; statusCode?: number; message?: string }; return [item.code, item.statusCode, item.message].filter(Boolean).join(' · ').slice(0, 300) || 'COS 连接失败'; } export async function testStorageConfig(config: StorageConfigRecord): Promise { const cos = createClient(config); const prefix = cleanPrefix(config.path_prefix); const key = `${prefix ? `${prefix}/` : ''}.delivery-desk-check/${randomUUID()}.txt`; try { await cos.putObject({ Bucket: config.bucket, Region: config.region, Key: key, Body: Buffer.from('delivery-desk storage check'), ContentType: 'text/plain' }); await cos.getObject({ Bucket: config.bucket, Region: config.region, Key: key }); await cos.deleteObject({ Bucket: config.bucket, Region: config.region, Key: key }); return '上传、读取和删除测试均已通过'; } catch (error) { try { await cos.deleteObject({ Bucket: config.bucket, Region: config.region, Key: key }); } catch { /* best-effort cleanup */ } throw new Error(safeError(error)); } } export async function storeUploadedFile(file: { filename: string; originalname?: string; mimetype?: string; path: string }): Promise { const config = await getActiveStorageConfig(); if (!config) return { url: file.filename, storageProvider: 'local', storageKey: file.filename }; const prefix = cleanPrefix(config.path_prefix); const now = new Date(); const extension = path.extname(file.originalname || file.filename).toLowerCase() || '.jpg'; const month = `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}`; const key = `${prefix ? `${prefix}/` : ''}originals/${month}/${randomUUID()}${extension}`; const cos = createClient(config); await cos.putObject({ Bucket: config.bucket, Region: config.region, Key: key, Body: fs.createReadStream(file.path), ContentLength: fs.statSync(file.path).size, ContentType: file.mimetype || 'application/octet-stream', }); fs.unlinkSync(file.path); return { url: objectUrl(config, key), storageProvider: 'tencent_cos', storageKey: key }; }