Files
delivery-desk/api/storage.ts

91 lines
3.7 KiB
TypeScript
Raw Normal View History

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<StorageConfigRecord | undefined> {
return database.one<StorageConfigRecord>(`
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<string> {
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<StoredUpload> {
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 };
}