Files
dzc f55561c98d feat:上传到阿里云oss
feat:把以前的图片迁移到阿里云oss
2026-11-05 15:22:55 +08:00

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);
}