const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } = require('@aws-sdk/client-s3'); const database = require('../config/database'); const S3_CONFIG_TABLE = 's3_configs'; const STORAGE_CLASSES = new Set([ 'STANDARD', 'LINE', 'INTELLIGENT_TIERING', 'GLACIER_IR', 'GLACIER', 'DEEP_ARCHIVE', ]); let schemaReady = false; let schemaPromise = null; const IMAGE_EXTENSION_MAP = { 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/x-icon': 'ico', }; function normalizeProtocol(protocol = '', endpoint = '') { const normalized = String(protocol || '').trim().toLowerCase(); if (normalized === 'http' || normalized === 'https') { return normalized; } return String(endpoint || '').trim().toLowerCase().startsWith('http://') ? 'http' : 'https'; } function normalizeEndpoint(endpoint = '', protocol = '') { const normalized = String(endpoint || '').trim().replace(/\/+$/, ''); if (!normalized) { return ''; } const resolvedProtocol = normalizeProtocol(protocol, normalized); return /^https?:\/\//i.test(normalized) ? normalized.replace(/^https?/i, resolvedProtocol) : `${resolvedProtocol}://${normalized}`; } function normalizePublicDomain(domain = '', protocol = '') { const normalized = String(domain || '').trim().replace(/\/+$/, ''); if (!normalized) return ''; const resolvedProtocol = normalizeProtocol(protocol, normalized); return /^https?:\/\//i.test(normalized) ? normalized.replace(/^https?/i, resolvedProtocol) : `${resolvedProtocol}://${normalized}`; } function normalizeStorageClass(storageClass = '') { const normalized = String(storageClass || 'STANDARD').trim().toUpperCase(); return STORAGE_CLASSES.has(normalized) ? normalized : 'STANDARD'; } async function ensureObjectStorageSchema() { if (schemaReady) return; if (schemaPromise) { await schemaPromise; return; } schemaPromise = (async () => { try { await database.local.query( `CREATE TABLE IF NOT EXISTS ${S3_CONFIG_TABLE} ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID', provider VARCHAR(20) NOT NULL COMMENT '对象存储提供商', endpoint VARCHAR(255) NULL COMMENT 'S3 Endpoint', region VARCHAR(50) NULL COMMENT 'S3 Region', zone VARCHAR(32) NULL COMMENT '厂商区域短码', protocol VARCHAR(10) NOT NULL DEFAULT 'https' COMMENT '访问协议', storage_class VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '默认存储类型', bucket VARCHAR(100) NOT NULL COMMENT '存储桶名称', access_key_id VARCHAR(255) NOT NULL COMMENT 'Access Key ID', secret_access_key VARCHAR(255) NOT NULL COMMENT 'Secret Access Key', custom_domain VARCHAR(255) NULL COMMENT '公开访问域名', cdn_url VARCHAR(255) NULL COMMENT 'CDN地址', is_default BOOLEAN DEFAULT FALSE COMMENT '是否默认', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对象存储配置表'` ); } catch (error) { if (error.code !== 'ER_TABLEACCESS_DENIED_ERROR') throw error; const existingTables = await database.local.query( `SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1`, [S3_CONFIG_TABLE] ); if (!existingTables.length) throw error; } const rows = await database.local.query( `SELECT COLUMN_NAME AS columnName FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, [S3_CONFIG_TABLE] ); const columns = new Set(rows.map((row) => String(row.columnName || '').toLowerCase())); const protocolColumnMissing = !columns.has('protocol'); const missingColumns = [ ['zone', "zone VARCHAR(32) NULL COMMENT '厂商区域短码' AFTER region"], ['protocol', "protocol VARCHAR(10) NOT NULL DEFAULT 'https' COMMENT '访问协议' AFTER zone"], ['storage_class', "storage_class VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '默认存储类型' AFTER protocol"], ]; for (const [columnName, definition] of missingColumns) { if (!columns.has(columnName)) { await database.local.query(`ALTER TABLE ${S3_CONFIG_TABLE} ADD COLUMN ${definition}`); } } if (protocolColumnMissing) { await database.local.query( `UPDATE ${S3_CONFIG_TABLE} SET protocol = CASE WHEN LOWER(COALESCE(endpoint, '')) LIKE 'http://%' THEN 'http' ELSE 'https' END` ); } schemaReady = true; })(); try { await schemaPromise; } finally { schemaPromise = null; } } function encodeObjectKey(key) { return String(key) .split('/') .map((segment) => encodeURIComponent(segment)) .join('/'); } function isPathStyleProvider(provider = '') { return ['minio', 'custom', 'qiniu'].includes(String(provider).toLowerCase()); } function buildPublicUrl(config, key) { const encodedKey = encodeObjectKey(key); const publicBase = normalizePublicDomain(config.customDomain, config.protocol); if (publicBase) { return `${publicBase}/${encodedKey}`; } const endpoint = normalizeEndpoint(config.endpoint, config.protocol); if (!endpoint || !config.bucket) { return encodedKey; } return `${endpoint}/${encodeURIComponent(config.bucket)}/${encodedKey}`; } function parseBase64Image(dataUrl) { const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl || ''); if (!match) { throw new Error('图片数据格式不正确'); } const [, mimeType, base64Data] = match; const extension = IMAGE_EXTENSION_MAP[mimeType] || 'png'; return { mimeType, extension, buffer: Buffer.from(base64Data, 'base64'), }; } async function getDefaultS3Config(includeSecret = false) { await ensureObjectStorageSchema(); const fields = [ 'id', 'provider', 'endpoint', 'region', 'zone', 'protocol', 'storage_class AS storageClass', 'bucket', 'access_key_id AS accessKeyId', 'custom_domain AS customDomain', 'is_default AS isDefault', ]; if (includeSecret) { fields.push('secret_access_key AS secretAccessKey'); } const rows = await database.local.query( `SELECT ${fields.join(', ')} FROM s3_configs WHERE is_default = 1 LIMIT 1` ); return rows[0] || null; } function createS3Client(config) { const endpoint = normalizeEndpoint(config.endpoint, config.protocol); if (!endpoint || !config.bucket || !config.accessKeyId || !config.secretAccessKey) { throw new Error('对象存储配置不完整,请先保存 Endpoint、Bucket 和密钥'); } return new S3Client({ region: config.region || 'auto', endpoint, forcePathStyle: isPathStyleProvider(config.provider), credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, }, }); } async function testObjectStorageConnection(config) { const client = createS3Client(config); const result = await client.send(new ListObjectsV2Command({ Bucket: config.bucket, MaxKeys: 1, })); return { bucket: config.bucket, endpoint: normalizeEndpoint(config.endpoint, config.protocol), region: config.region || 'auto', zone: config.zone || '', storageClass: normalizeStorageClass(config.storageClass), keyCount: Number(result.KeyCount || 0), }; } async function uploadBase64ImageToDefaultS3(dataUrl, folder = 'images') { const config = await getDefaultS3Config(true); if (!config) { throw new Error('尚未配置默认对象存储'); } const { buffer, mimeType, extension } = parseBase64Image(dataUrl); const client = createS3Client(config); const key = `${folder.replace(/^\/+|\/+$/g, '')}/${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${extension}`; await client.send( new PutObjectCommand({ Bucket: config.bucket, Key: key, Body: buffer, ContentType: mimeType, StorageClass: normalizeStorageClass(config.storageClass), }) ); return { key, url: buildPublicUrl(config, key), }; } async function uploadBufferToDefaultS3(buffer, mimeType = 'application/octet-stream', folder = 'files', extension = '') { const config = await getDefaultS3Config(true); if (!config) { throw new Error('尚未配置默认对象存储'); } const client = createS3Client(config); const resolvedExtension = String(extension || '').replace(/^\./, '') || IMAGE_EXTENSION_MAP[mimeType] || 'bin'; const key = `${folder.replace(/^\/+|\/+$/g, '')}/${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${resolvedExtension}`; await client.send( new PutObjectCommand({ Bucket: config.bucket, Key: key, Body: buffer, ContentType: mimeType, StorageClass: normalizeStorageClass(config.storageClass), }) ); return { key, url: buildPublicUrl(config, key), }; } async function uploadFileToDefaultS3(filePath, options = {}) { const config = await getDefaultS3Config(true); if (!config) { throw new Error('尚未配置默认对象存储'); } const stats = await fs.promises.stat(filePath); const client = createS3Client(config); const folder = String(options.folder || 'files').replace(/^\/+|\/+$/g, ''); const extension = String(options.extension || path.extname(filePath) || '.bin').replace(/^\./, ''); const key = String(options.key || `${folder}/${Date.now()}-${crypto.randomBytes(6).toString('hex')}.${extension}`) .replace(/^\/+/, ''); await client.send( new PutObjectCommand({ Bucket: config.bucket, Key: key, Body: fs.createReadStream(filePath), ContentLength: stats.size, ContentType: options.mimeType || 'application/octet-stream', StorageClass: normalizeStorageClass(config.storageClass), }) ); return { key, size: stats.size, url: buildPublicUrl(config, key), }; } async function getObjectFromDefaultS3(key, options = {}) { const config = await getDefaultS3Config(true); if (!config) { throw new Error('尚未配置默认对象存储'); } const client = createS3Client(config); return client.send(new GetObjectCommand({ Bucket: config.bucket, Key: String(key || '').replace(/^\/+/, ''), ...(options.range ? { Range: options.range } : {}), })); } module.exports = { buildPublicUrl, createS3Client, ensureObjectStorageSchema, getDefaultS3Config, getObjectFromDefaultS3, normalizeEndpoint, normalizeProtocol, normalizePublicDomain, normalizeStorageClass, testObjectStorageConnection, uploadBase64ImageToDefaultS3, uploadBufferToDefaultS3, uploadFileToDefaultS3, };