174 lines
8.9 KiB
JavaScript
174 lines
8.9 KiB
JavaScript
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);
|
|
}
|