import { baseUrl, bit, id, normalizeConfig, ossSettings, qualified, sha256 } from './core.mjs'; const RUNS_TABLE = 'infra_file_oss_migration_runs'; const RESTORED_FIELDS = [ 'sourceConfigIds', 'targetConfigId', 'sourceMode', 'downloadBaseUrl', 'oldDomains', 'objectPrefix', 'imagesOnly', 'referenceSchemas', 'excludeReferenceTables', 'targetUrlMode' ]; const hasValue = (value, key) => Object.prototype.hasOwnProperty.call(value, key) && value[key] !== undefined; const sortedIds = values => [...new Set(values.map(id))].sort((left, right) => BigInt(left) < BigInt(right) ? -1 : BigInt(left) > BigInt(right) ? 1 : 0); function assertIdentity(expected, current) { if (!expected || expected.server !== current.server || expected.schema !== current.schema) { throw new Error('数据库身份与原迁移配置不一致,请检查数据库连接和批次编号'); } } function parseRunSettings(value) { let settings; try { settings = typeof value === 'string' ? JSON.parse(value) : value; } catch { throw new Error('迁移批次的配置记录损坏,不能自动恢复'); } if (!settings || typeof settings !== 'object' || !settings.binding || typeof settings.binding !== 'object') { throw new Error('迁移批次缺少原始配置绑定,不能自动恢复'); } return settings; } function selectRun(rows, requestedRunId) { if (requestedRunId !== undefined) return rows.find(row => row.run_id === requestedRunId); const unfinished = rows.filter(row => !['complete', 'rolled_back'].includes(row.status)); if (unfinished.length > 1) throw new Error('存在多个未完成迁移批次,请使用 --run-id 指定要继续的批次'); if (unfinished.length === 1) return unfinished[0]; if (rows.length > 1) throw new Error('存在多个历史迁移批次,请使用 --run-id 指定批次,或指定新的批次编号'); return rows[0]; } function contentId(value) { if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^\d+$/.test(String(value))) { throw new Error('迁移批次的内容上界无效,不能安全判断增量数据'); } return BigInt(value); } /** * Read-only discovery of storage configuration and a stable migration run. * This function never emits logs, persists configuration, or returns the OSS * credentials read from infra_file_config. Explicit input fields take priority * over a restored binding; loadSettings/runApply validate the final fingerprint. */ export async function discoverStorageConfig(db, input, { command = 'plan' } = {}) { if (!input?.mysql?.database) throw new Error('缺少本地数据库名称,无法自动识别文件存储'); const schema = input.mysql.database; const [[serverRow]] = await db.execute('SELECT @@server_uuid AS server_uuid'); if (!serverRow?.server_uuid) throw new Error('无法读取数据库服务器身份'); const identity = { server: serverRow.server_uuid, schema }; if (hasValue(input, '_databaseIdentity')) assertIdentity(input._databaseIdentity, identity); const [runTable] = await db.execute( "SELECT 1 AS present FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND TABLE_TYPE='BASE TABLE' LIMIT 1", [schema, RUNS_TABLE] ); let history = []; if (runTable.length) { [history] = await db.execute(`SELECT run_id,settings,status FROM ${qualified(schema, RUNS_TABLE)}`); } const selected = selectRun(history, input.runId); const settings = selected ? parseRunSettings(selected.settings) : undefined; const config = { ...input, mysql: { ...input.mysql } }; if (selected) { assertIdentity(settings.binding, identity); for (const key of RESTORED_FIELDS) { if (!hasValue(input, key) && hasValue(settings.binding, key)) { config[key] = structuredClone(settings.binding[key]); } } if (!hasValue(input, 'targetPublicDomain') && hasValue(settings.binding, 'publicDomain')) { config.targetPublicDomain = settings.binding.publicDomain; } config.runId = selected.run_id; if (command === 'apply' && (selected.status.startsWith('rollback') || selected.status === 'rolled_back')) { throw new Error('该批次已开始或完成回滚,不能继续迁移;请继续 rollback,或使用 --run-id 指定新批次'); } } else if (command === 'rollback') { throw new Error('未找到可回滚的历史迁移批次,请使用 --run-id 指定已有批次'); } config._databaseIdentity = identity; config._autoDiscovery = true; delete config._projectDownloadBaseUrl; // Rollback only restores journalled database values. Current storage rows, // default targets and OSS credentials are deliberately irrelevant here. if (command === 'rollback') return normalizeConfig(config); if (!hasValue(config, 'sourceMode')) config.sourceMode = 'database'; if (!hasValue(config, 'downloadBaseUrl') && input._projectDownloadBaseUrl) { config.downloadBaseUrl = input._projectDownloadBaseUrl; } const [storageRows] = await db.execute(`SELECT id,storage,master,config FROM ${qualified(schema, 'infra_file_config')} WHERE deleted=0`); const byId = new Map(storageRows.map(row => [id(row.id), row])); const masters = storageRows.filter(row => bit(row.master)); const requireDefault = !hasValue(config, 'targetConfigId') || input._autoDiscovery === true || Boolean(selected); if (requireDefault && masters.length !== 1) { throw new Error('默认文件存储配置必须唯一,请先在文件配置中设置唯一的阿里云 OSS 默认存储'); } if (!hasValue(config, 'targetConfigId')) config.targetConfigId = id(masters[0].id); config.targetConfigId = id(config.targetConfigId); if (requireDefault && (id(masters[0].id) !== config.targetConfigId || (selected && id(masters[0].id) !== id(settings.binding.targetConfigId)))) { throw new Error('当前默认 OSS 与原迁移目标不同,不能自动续跑;请恢复原默认配置或使用 --run-id 建立新批次'); } const target = byId.get(config.targetConfigId); if (!target) throw new Error('目标文件存储配置不存在或已删除'); try { ossSettings(target, config); } catch { // JSON/URL parsing errors must not include raw configuration or secrets. throw new Error('目标默认存储不是有效的阿里云 OSS 配置,请检查类型、endpoint、Bucket 和访问凭据'); } if (!hasValue(config, 'sourceConfigIds')) { const databaseIds = sortedIds(storageRows.filter(row => Number(row.storage) === 1).map(row => row.id)); let contents = []; if (databaseIds.length) { [contents] = await db.execute( `SELECT DISTINCT config_id FROM ${qualified(schema, 'infra_file_content')} WHERE deleted=0 AND config_id IN (${databaseIds.map(() => '?').join(',')})`, databaseIds ); } config.sourceConfigIds = sortedIds(contents.map(row => row.config_id)); } if (!Array.isArray(config.sourceConfigIds) || !config.sourceConfigIds.length) { throw new Error('未发现包含未删除内容的数据库存储配置,无需迁移'); } config.sourceConfigIds = [...new Set(config.sourceConfigIds.map(id))]; for (const sourceId of config.sourceConfigIds) { const source = byId.get(sourceId); if (!source || Number(source.storage) !== 1) throw new Error('原数据库存储配置不存在、已删除或类型发生变化,请检查原迁移绑定'); if (bit(source.master)) throw new Error('源配置仍是默认上传配置,请先切换默认配置到阿里云 OSS'); } if (selected && selected.status !== 'inventory' && command === 'apply') { const sourceIds = settings.binding.sourceConfigIds.map(id); if (!sourceIds.length) throw new Error('历史迁移批次的源配置列表为空,不能安全续跑'); const [[maximum]] = await db.execute( `SELECT COALESCE(MAX(id),0) AS max_id FROM ${qualified(schema, 'infra_file_content')} WHERE deleted=0 AND config_id IN (${sourceIds.map(() => '?').join(',')})`, sourceIds ); if (contentId(maximum.max_id) > contentId(settings.maxId)) { throw new Error('原批次建立后出现新的数据库文件,请使用 --run-id 指定新批次,避免遗漏新增内容'); } } if (!hasValue(config, 'oldDomains')) { const [domains] = await db.execute( `SELECT DISTINCT SUBSTRING_INDEX(url,'/admin-api/infra/file/',1) AS domain FROM ${qualified(schema, 'infra_file')} WHERE deleted=0 AND config_id IN (${config.sourceConfigIds.map(() => '?').join(',')}) AND url LIKE '%/admin-api/infra/file/%'`, config.sourceConfigIds ); const validDomains = new Set(); for (const row of domains) { try { validDomains.add(baseUrl(row.domain)); } catch { /* Ignore malformed and credential-bearing historical URLs. */ } } config.oldDomains = [...validDomains].sort(); } if (!hasValue(config, 'runId')) { const fingerprint = { ...identity, sourceConfigIds: sortedIds(config.sourceConfigIds), targetConfigId: config.targetConfigId }; config.runId = `db-images-${sha256(JSON.stringify(fingerprint)).slice(0, 16)}`; } return normalizeConfig(config); }