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

583 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { readFile, mkdir, writeFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
import { aliases, bit, downloadUrl, encodePath, ensureObject, errorText, mimeType, normalizeConfig, objectKey, ossSettings, parseStorage, qualified, quote, readHttp, retry, sha256, verifyContent } from './lib/core.mjs';
import { containsLegacyReference, discoverReferenceColumns, rewriteReferences } from './lib/references.mjs';
import { ensureJournal, applyChange, rollbackChanges } from './lib/journal.mjs';
import { loadProjectConfig } from './lib/project-config.mjs';
import { discoverStorageConfig } from './lib/auto-storage.mjs';
import { loadSourceIndex } from './lib/source-index.mjs';
import { createProgress } from './lib/progress.mjs';
import { switchFiles } from './lib/transition.mjs';
import { mapBounded } from './lib/concurrency.mjs';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(SCRIPT_DIR, '../..');
const TRANSITION_BATCH_SIZE = 50;
const VALIDATION_CONCURRENCY = 4;
const seconds = milliseconds => (milliseconds / 1000).toFixed(2);
const HELP = `数据库图片迁移到阿里云 OSS(Node.js 20.19+)
node migrate.mjs 自动读取项目配置,只读预检
node migrate.mjs init 自动生成本机配置,只查询存储配置及已有批次
node migrate.mjs apply 执行迁移;可加 --limit 50 先迁移少量
node migrate.mjs rollback 回滚当前批次的数据库修改
可选:--profile dev | --config 文件路径 | --run-id 新批次名称
默认复用项目当前环境的主数据库和默认 OSS,不需要填写数据库密码或存储编号。
保留原 BLOB、旧配置和 OSS 对象;rollback 只撤销本批次数据库修改。`;
export function parseArgs(argv) {
if (argv.includes('--help') || argv.includes('-h')) return { help: true };
const hasCommand = argv.length && !argv[0].startsWith('--');
const command = hasCommand ? argv[0] : 'plan';
const rest = hasCommand ? argv.slice(1) : argv;
if (!['init', 'plan', 'apply', 'rollback'].includes(command)) throw new Error('命令只能为 init、plan、apply 或 rollback');
const result = { command, limit: Infinity, configPath: resolve(SCRIPT_DIR, 'config.local.json') };
for (let i = 0; i < rest.length; i += 2) {
if (!['--config', '--limit', '--profile', '--run-id'].includes(rest[i]) || !rest[i + 1]) throw new Error('未知参数或缺少参数值');
if (rest[i] === '--config') { result.configPath = resolve(rest[i + 1]); result.explicitConfig = true; }
else if (rest[i] === '--profile') result.profile = rest[i + 1];
else if (rest[i] === '--run-id') result.runId = rest[i + 1];
else result.limit = Number(rest[i + 1]);
}
if (result.limit !== Infinity && (!Number.isSafeInteger(result.limit) || result.limit < 1 || command !== 'apply')) throw new Error('--limit 仅用于 apply,且必须为正整数');
return result;
}
export async function prepareInput(args, projectRoot = PROJECT_ROOT) {
let raw = {}, exists = false;
try { raw = JSON.parse(await readFile(args.configPath, 'utf8')); exists = true; }
catch (error) { if (error.code !== 'ENOENT' || args.explicitConfig) throw error; }
const placeholder = raw.mysql?.database === 'YOUR_DATABASE';
const projectManaged = !raw.mysql || placeholder || raw._autoDiscovery === true;
let input;
if (projectManaged) {
const project = await loadProjectConfig({ projectRoot, profile: args.profile || raw.projectProfile });
input = { ...raw, projectProfile: project.profile, mysql: project.mysql,
_projectDownloadBaseUrl: project.downloadBaseUrl, _autoDiscovery: true };
} else {
if (args.profile) throw new Error('手动 mysql 配置不能同时使用 --profile;删除 mysql 项即可复用项目配置');
input = { ...raw, mysql: { ...raw.mysql } };
}
if (args.runId) input.runId = args.runId;
if (input.targetConfigId === null) delete input.targetConfigId;
if (Array.isArray(input.sourceConfigIds) && input.sourceConfigIds.length === 0) delete input.sourceConfigIds;
const password = input.mysql.passwordEnv ? process.env[input.mysql.passwordEnv] : input.mysql.password;
if (password === undefined) throw new Error('数据库密码未提供;自动模式无需填写 mysql,手动模式请设置指定环境变量');
input.mysql.password = password;
return { input, exists, projectManaged, raw };
}
export async function persistGeneratedConfig(config, args, prepared) {
if (!prepared.projectManaged || (prepared.exists && prepared.raw._autoDiscovery !== true)) return;
const saved = {
projectProfile: config.projectProfile, runId: config.runId,
sourceConfigIds: config.sourceConfigIds, targetConfigId: config.targetConfigId,
sourceMode: config.sourceMode, oldDomains: config.oldDomains,
_autoDiscovery: true, _databaseIdentity: config._databaseIdentity
};
// Persist discovery/batch choices, but read credentials from the project's
// YAML on each invocation. No database or OSS secret is copied here.
const defaults = normalizeConfig({ runId: config.runId, mysql: config.mysql, sourceConfigIds: config.sourceConfigIds, targetConfigId: config.targetConfigId, sourceMode: 'database' });
for (const key of ['targetUrlMode', 'targetPublicDomain', 'objectPrefix', 'referenceSchemas', 'excludeReferenceTables', 'imagesOnly', 'batchSize', 'timeoutMs', 'maxFileBytes', 'retries']) {
if (config[key] !== undefined && JSON.stringify(config[key]) !== JSON.stringify(defaults[key])) saved[key] = config[key];
}
if (config.sourceMode === 'http' || config.targetUrlMode === 'backend') saved.downloadBaseUrl = config.downloadBaseUrl;
const serialized = JSON.stringify(saved, null, 2) + '\n';
if (prepared.exists && JSON.stringify(prepared.raw) === JSON.stringify(saved)) return;
// Never overwrite a pre-existing manual file; generated files may be
// refreshed to pin an explicitly requested new batch.
await writeFile(args.configPath, serialized, { mode: 0o600, flag: prepared.exists ? 'w' : 'wx' });
}
const table = (config, name) => qualified(config.mysql.database, name);
const runs = config => table(config, 'infra_file_oss_migration_runs');
const items = config => table(config, 'infra_file_oss_migration_items');
const parseJson = value => typeof value === 'string' ? JSON.parse(value) : value;
const itemKey = item => sha256(`${item.configId}\0${item.path}`);
const samePlannedItem = (left, right) => {
const canonical = item => ({ ...item, aliases: [...item.aliases].sort(), files: [...item.files].sort((a, b) => BigInt(a.id) < BigInt(b.id) ? -1 : BigInt(a.id) > BigInt(b.id) ? 1 : 0) });
return isDeepStrictEqual(canonical(left), canonical(right));
};
export async function loadSettings(db, config) {
const ids = [...config.sourceConfigIds, config.targetConfigId];
const [rows] = await db.execute(`SELECT id, storage, master, config FROM ${table(config, 'infra_file_config')} WHERE deleted=0 AND id IN (${ids.map(() => '?').join(',')})`, ids);
const byId = new Map(rows.map(row => [String(row.id), row]));
for (const sourceId of config.sourceConfigIds) {
const row = byId.get(sourceId);
if (!row || Number(row.storage) !== 1) throw new Error(`源配置 ${sourceId} 不存在或不是数据库存储`);
if (bit(row.master)) throw new Error(`源配置 ${sourceId} 仍是默认上传配置,请先切换默认配置到 OSS`);
}
const targetRow = byId.get(config.targetConfigId);
if (!targetRow) throw new Error('目标存储配置不存在');
const oss = ossSettings(targetRow, config);
const [identity] = await db.query('SELECT @@server_uuid AS server_uuid');
const safe = {
version: 1, server: identity[0].server_uuid, schema: config.mysql.database,
sourceConfigIds: config.sourceConfigIds, targetConfigId: config.targetConfigId,
sourceDomains: config.sourceConfigIds.map(sourceId => parseStorage(byId.get(sourceId)).domain || ''),
sourceMode: config.sourceMode, downloadBaseUrl: config.downloadBaseUrl || '',
oldDomains: config.oldDomains, targetUrlMode: config.targetUrlMode,
endpoint: oss.endpoint, bucket: oss.bucket, publicDomain: oss.publicDomain,
objectPrefix: config.objectPrefix, imagesOnly: config.imagesOnly,
referenceSchemas: config.referenceSchemas, excludeReferenceTables: config.excludeReferenceTables
};
return { byId, oss, safe };
}
const progressOf = config => config._progress || { stage: message => console.log(message), update() {} };
function sourceItem(group, config, settings) {
const type = mimeType(group.path, group.files);
if (config.imagesOnly && !type.toLowerCase().startsWith('image/')) return null;
encodePath(group.path);
const item = { contentId: String(group.contentId), configId: String(group.configId), path: group.path, type, files: group.files };
const aliasRows = [...group.files, ...group.spellings.map(path => ({ path }))];
item.aliases = aliases(item, aliasRows, parseStorage(settings.byId.get(item.configId)).domain, config);
return item;
}
const missingCount = (index, config) => index.missingFiles.filter(file => !config.imagesOnly || mimeType(file.path, [file]).toLowerCase().startsWith('image/')).length;
export async function sourceCurrent(db, config, item, lock = false) {
// Keep large content out of the unindexed path/order lookup. On the actual
// database the combined lookup + BLOB projection took seconds per image;
// a metadata lookup followed by a primary-key hash read takes milliseconds.
// With lock=true the first SELECT retains the original range-lock semantics.
const [[latest]] = await db.execute(`SELECT id FROM ${table(config, 'infra_file_content')} WHERE deleted=0 AND config_id=? AND path=? ORDER BY id DESC LIMIT 1${lock ? ' FOR UPDATE' : ''}`, [item.configId, item.path]);
if (!latest || String(latest.id) !== String(item.contentId)) throw new Error('源图片在任务建立后发生变化或已删除,请检查该文件');
const [[current]] = await db.execute(`SELECT id,OCTET_LENGTH(content) AS size,LOWER(SHA2(content,256)) AS sha256 FROM ${table(config, 'infra_file_content')} WHERE id=? AND config_id=? AND path=? AND deleted=0${lock ? ' FOR UPDATE' : ''}`, [item.contentId, item.configId, item.path]);
if (!current || String(current.id) !== String(item.contentId) || current.sha256 !== item.sha256 || Number(current.size) !== item.size) throw new Error('源图片在任务建立后发生变化或已删除,请检查该文件');
}
export async function* inventory(db, config, settings, maxId, summary, sourceIndex) {
const progress = progressOf(config);
const index = sourceIndex || await loadSourceIndex(db, config, { maxId, progress });
const candidates = [];
for (const group of index.groups) {
summary.sourceGroups++;
const item = sourceItem(group, config, settings);
if (item) candidates.push(item); else summary.skippedNonImages++;
}
progress.stage(`建立迁移清单:按主键读取并校验 ${candidates.length} 个文件的内容`);
for (let start = 0; start < candidates.length; start += config.batchSize) {
const batch = candidates.slice(start, start + config.batchSize);
progress.update(`建立迁移清单:已校验 ${start}/${candidates.length} 个文件,正在读取下一批`);
const [rows] = await db.execute(`SELECT id,config_id,path,OCTET_LENGTH(content) AS size,LOWER(SHA2(content,256)) AS sha256 FROM ${table(config, 'infra_file_content')} WHERE deleted=0 AND id IN (${batch.map(() => '?').join(',')})`, batch.map(item => item.contentId));
const byId = new Map(rows.map(row => [String(row.id), row]));
for (const item of batch) {
const row = byId.get(item.contentId);
if (!row || String(row.config_id) !== item.configId || row.path !== item.path) throw new Error(`内容记录 ${item.contentId} 在建立清单期间发生变化,请检查后重试`);
item.size = Number(row.size);
item.sha256 = row.sha256;
if (!item.size || item.size > config.maxFileBytes) throw new Error(`内容记录 ${item.contentId} 为空或超过 maxFileBytes`);
item.key = objectKey(config, item);
item.url = config.targetUrlMode === 'backend' ? downloadUrl(config.downloadBaseUrl, config.targetConfigId, item.key) : `${settings.oss.publicDomain}/${encodePath(item.key)}`;
if (item.key.length > 512 || item.url.length > 1024) throw new Error('目标路径或访问地址超出 infra_file 字段长度');
summary.candidates++;
summary.bytes += item.size;
if (!item.files.length) summary.withoutFileRecord++;
yield item;
}
}
progress.stage(`迁移清单已建立:${summary.candidates} 个文件,内容校验完成`);
}
function addMappings(map, item) {
for (const oldUrl of item.aliases) {
if (map.has(oldUrl) && map.get(oldUrl) !== item.url) throw new Error('同一旧地址对应多个目标,必须先解决数据冲突');
map.set(oldUrl, item.url);
}
}
const emptyInventory = () => ({ sourceGroups: 0, candidates: 0, bytes: 0, skippedNonImages: 0, withoutFileRecord: 0 });
async function scanReferences(db, config, map, { apply = false } = {}) {
const progress = progressOf(config);
progress.stage('检查需要扫描的业务表和图片地址字段');
const discovery = await discoverReferenceColumns(db, config.referenceSchemas, config.excludeReferenceTables);
const groups = new Map();
for (const column of discovery.columns) {
const key = JSON.stringify([column.schema, column.table]);
if (!groups.has(key)) groups.set(key, { ...column, columns: [] });
groups.get(key).columns.push(column.column);
}
const result = { scannedRows: 0, changedRows: 0, replacements: 0, unresolvedRows: 0, conflicts: 0, skippedTables: discovery.skipped.filter(entry => entry.reason !== 'excluded table'), excludedTables: discovery.skipped.filter(entry => entry.reason === 'excluded table'), tables: [] };
let tableNumber = 0;
const readBatchSize = Math.max(config.batchSize, 1000);
for (const group of groups.values()) {
tableNumber++;
let scanned = 0;
progress.stage(`引用扫描 ${tableNumber}/${groups.size}:${group.schema}.${group.table}`);
const stat = { schema: group.schema, table: group.table, changedRows: 0, unresolvedRows: 0 };
let cursor;
for (;;) {
const columns = [group.primaryKey, ...group.columns];
const [rows] = await db.query(`SELECT ${columns.map(quote).join(',')} FROM ${qualified(group.schema, group.table)} ${cursor === undefined ? '' : `WHERE ${quote(group.primaryKey)}>?`} ORDER BY ${quote(group.primaryKey)} LIMIT ${readBatchSize}`, cursor === undefined ? [] : [cursor]);
if (!rows.length) break;
for (const row of rows) {
cursor = row[group.primaryKey];
result.scannedRows++;
scanned++;
progress.update(`引用扫描 ${tableNumber}/${groups.size}:${group.schema}.${group.table},已检查 ${scanned} 行,匹配 ${stat.changedRows} 行`);
const before = {}, after = {};
let unresolved = false, replacements = 0;
for (const column of group.columns) {
const rewritten = rewriteReferences(row[column], map);
if (rewritten.changed) { before[column] = row[column]; after[column] = rewritten.value; replacements += rewritten.replacements; }
if (containsLegacyReference(rewritten.value, config.sourceConfigIds)) unresolved = true;
}
if (unresolved) { result.unresolvedRows++; stat.unresolvedRows++; }
if (!Object.keys(after).length) continue;
if (apply) {
await db.beginTransaction();
try {
await applyChange(db, config.mysql.database, config.runId, { schema: group.schema, table: group.table, primaryKey: group.primaryKey, pkValue: cursor, before, after });
await db.commit();
} catch (error) {
await db.rollback();
result.conflicts++;
console.error(`引用更新未完成 ${group.schema}.${group.table}:${errorText(error)}`);
continue;
}
}
result.changedRows++; stat.changedRows++; result.replacements += replacements;
}
}
if (stat.changedRows || stat.unresolvedRows) result.tables.push(stat);
console.log(`引用扫描 ${group.schema}.${group.table}:匹配 ${stat.changedRows} 行,仍含旧地址 ${stat.unresolvedRows} 行`);
}
return result;
}
async function setItem(db, config, item, status, error = null) {
await db.execute(`UPDATE ${items(config)} SET status=?,error=?,updated_at=CURRENT_TIMESTAMP WHERE run_id=? AND item_key=?`, [status, error, config.runId, itemKey(item)]);
}
async function* savedItems(db, config, statuses = null) {
let cursor = '';
for (;;) {
const [rows] = await db.execute(`SELECT item_key,data,status FROM ${items(config)} WHERE run_id=? AND item_key>? ${statuses ? `AND status IN (${statuses.map(() => '?').join(',')})` : ''} ORDER BY item_key LIMIT ${config.batchSize}`, [config.runId, cursor, ...(statuses || [])]);
if (!rows.length) return;
for (const row of rows) { cursor = row.item_key; yield { ...parseJson(row.data), status: row.status }; }
}
}
async function migrateItem(db, config, oss, item) {
const timings = { sourceMs: 0, ossMs: 0, publicMs: 0, recordMs: 0 };
const measure = async (key, label, action) => {
progressOf(config).update(`文件 ${item.contentId}:${label}`);
const started = performance.now();
try { return await action(); } finally { timings[key] += performance.now() - started; }
};
await measure('sourceMs', '复核源文件版本', () => sourceCurrent(db, config, item));
let bytes;
if (config.sourceMode === 'http') {
bytes = await measure('sourceMs', '读取原图片', () => readHttp(downloadUrl(config.downloadBaseUrl, item.configId, item.path), config, item.size));
} else {
const [[row]] = await measure('sourceMs', '读取数据库原图片', () => db.execute(`SELECT content FROM ${table(config, 'infra_file_content')} WHERE id=? AND deleted=0`, [item.contentId]));
if (!row) throw new Error('源内容已删除');
bytes = row.content;
}
verifyContent(bytes, item);
await measure('ossMs', '上传 OSS 并读取校验', () => ensureObject(oss, item.key, bytes, item));
// SDK 可读不代表页面可读:按最终将写入业务表的地址匿名访问并再次校验。
verifyContent(await measure('publicMs', '校验最终图片访问地址', () => readHttp(item.url, config, item.size)), item);
await measure('sourceMs', '复核源图片未发生变化', () => sourceCurrent(db, config, item));
await measure('recordMs', '保存迁移进度', () => setItem(db, config, item, 'verified'));
return timings;
}
export async function verifyCheckpoint(oss, config, item) {
// A verified checkpoint needs no source download or PUT. Still check the
// authenticated object and its final public URL before trusting it again.
const head = await oss.head(item.key);
if (Number(head.res.headers['content-length']) !== item.size || item.size > config.maxFileBytes) {
throw new Error('OSS 文件长度不一致或超过限制');
}
verifyContent((await oss.get(item.key)).content, item);
verifyContent(await readHttp(item.url, config, item.size), item);
}
export async function verifySwitchedBatch(db, config, batch) {
// A resumed run must not reuse a mapping whose object/metadata was deleted
// or changed after the earlier successful batch. Read metadata once; only
// HTTP validation is concurrent, never transactions on the shared connection.
const ids = [...new Set(batch.flatMap(item => item.files.map(file => String(file.id))))];
const byId = new Map();
for (let start = 0; start < ids.length; start += 1000) {
const chunk = ids.slice(start, start + 1000);
const [rows] = await db.execute(`SELECT id,config_id,path,url,deleted FROM ${table(config, 'infra_file')} WHERE id IN (${chunk.map(() => '?').join(',')})`, chunk);
for (const row of rows) byId.set(String(row.id), row);
}
return mapBounded(batch, VALIDATION_CONCURRENCY, async item => {
try {
for (const original of item.files) {
const current = byId.get(String(original.id));
if (!current || bit(current.deleted) || String(current.config_id) !== config.targetConfigId || current.path !== item.key || current.url !== item.url) {
throw new Error('已迁移文件记录后来发生变化或已删除,停止复用该地址映射');
}
}
await retry(async () => verifyContent(await readHttp(item.url, config, item.size), item), config.retries);
return { item };
} catch (error) {
return { item, error };
}
});
}
async function* itemBatches(iterable, size) {
let batch = [];
for await (const item of iterable) {
batch.push(item);
if (batch.length === size) { yield batch; batch = []; }
}
if (batch.length) yield batch;
}
export async function runPlan(db, config, settings) {
const progress = progressOf(config);
progress.stage('开始只读预检:只读取文件信息,不读取图片内容或计算 SHA-256');
const index = await loadSourceIndex(db, config, { progress });
const summary = { command: 'plan', ...emptyInventory(), bytes: null, estimatedBytes: 0, sizePending: 0, contentVerified: false, missingContent: missingCount(index, config) };
const mappings = new Map();
for (const group of index.groups) {
summary.sourceGroups++;
const item = sourceItem(group, config, settings);
if (!item) { summary.skippedNonImages++; continue; }
summary.candidates++;
const sizes = item.files.map(file => Number(file.size)).filter(size => Number.isSafeInteger(size) && size >= 0);
if (sizes.length) summary.estimatedBytes += Math.max(...sizes); else summary.sizePending++;
if (!item.files.length) summary.withoutFileRecord++;
// Preview markers exist only in memory. Actual object keys require the
// verified content hash and are created exclusively during apply.
item.url = `migration-preview://${item.configId}/${item.contentId}`;
addMappings(mappings, item);
}
progress.stage(`已识别 ${summary.candidates} 个图片文件;开始检查业务引用(实际内容在迁移时校验)`);
summary.references = await scanReferences(db, config, mappings);
summary.status = summary.missingContent || summary.references.unresolvedRows || summary.references.skippedTables.length ? 'needs-review' : 'planned';
progress.stage('只读预检完成');
console.log('预检未读取图片二进制内容、未计算 SHA-256;容量仅为文件表估算。未验证 HTTP/OSS 连通性,也未创建迁移表、上传或修改数据。');
return summary;
}
export async function runApply(db, config, settings, limit = Infinity, createOss = null) {
const progress = progressOf(config);
progress.stage('读取迁移批次和断点记录');
// Batch source checks depend on InnoDB next-key locks and a snapshot first
// established AFTER their range lock, even if the server defaults to RC.
await db.query('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
const [engines] = await db.execute(`SELECT TABLE_NAME AS name,ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME IN ('infra_file_content','infra_file')`, [config.mysql.database]);
if (engines.length !== 2 || engines.some(row => String(row.engine).toLowerCase() !== 'innodb')) {
throw new Error('infra_file_content 和 infra_file 必须为 InnoDB,才能保证源校验与记录更新的事务保护');
}
await ensureJournal(db, config.mysql.database);
const [[saved]] = await db.execute(`SELECT settings,status FROM ${runs(config)} WHERE run_id=?`, [config.runId]);
let runSettings, sourceIndex;
if (saved) {
runSettings = parseJson(saved.settings);
if (JSON.stringify(runSettings.binding) !== JSON.stringify(settings.safe)) throw new Error('同一 runId 的数据库、源/目标或引用范围发生变化,请恢复原配置或使用新 runId');
if (saved.status.startsWith('rollback') || saved.status === 'rolled_back') throw new Error('本批次已开始回滚,不能继续迁移,请继续回滚或使用新 runId');
} else {
sourceIndex = await loadSourceIndex(db, config, { progress });
const missing = missingCount(sourceIndex, config);
if (missing) throw new Error(`发现 ${missing} 条图片记录没有数据库内容,请先执行 plan 并处理`);
runSettings = { binding: settings.safe, maxId: sourceIndex.maxId };
await db.execute(`INSERT INTO ${runs(config)} (run_id,settings,status) VALUES (?,?,'inventory')`, [config.runId, JSON.stringify(runSettings)]);
}
const summary = { command: 'apply', ...emptyInventory(), ...runSettings.inventory, uploaded: 0, revalidated: 0, switched: 0, failed: 0, limited: false };
if (!saved || saved.status === 'inventory') {
for await (const item of inventory(db, config, settings, runSettings.maxId, summary, sourceIndex)) {
const [[existing]] = await db.execute(`SELECT data FROM ${items(config)} WHERE run_id=? AND item_key=?`, [config.runId, itemKey(item)]);
if (existing) {
if (!samePlannedItem(parseJson(existing.data), item)) throw new Error('建立清单期间源数据发生变化,请检查后建立新批次');
} else {
await db.execute(`INSERT INTO ${items(config)} (run_id,item_key,data,status) VALUES (?,?,?,'planned')`, [config.runId, itemKey(item), JSON.stringify(item)]);
}
}
runSettings.inventory = Object.fromEntries(Object.keys(emptyInventory()).map(key => [key, summary[key]]));
await db.execute(`UPDATE ${runs(config)} SET settings=?,status='active',updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [JSON.stringify(runSettings), config.runId]);
}
// 先验证所有映射没有歧义,再发生 OSS 写入。
const allMappings = new Map();
let initialSwitched = 0, totalItems = 0;
progress.stage('检查迁移地址映射是否存在冲突');
for await (const item of savedItems(db, config)) {
addMappings(allMappings, item);
totalItems++;
if (item.status === 'switched') initialSwitched++;
}
const { publicDomain, ...ossOptions } = settings.oss;
const oss = createOss ? createOss() : new (await import('ali-oss')).default({ ...ossOptions, timeout: config.timeoutMs });
let processed = 0;
const selected = [], checkpoints = [];
progress.stage('开始上传并校验 OSS 文件');
for await (const item of savedItems(db, config, ['planned', 'failed', 'verified'])) {
if (processed >= limit) { summary.limited = true; break; }
processed++;
if (item.status === 'verified') { checkpoints.push(item); continue; }
try {
const started = performance.now();
const timings = await retry(() => migrateItem(db, config, oss, item), config.retries);
summary.uploaded++;
selected.push(item);
console.log(`上传校验通过:内容记录 ${item.contentId}(本次 ${processed},总计 ${seconds(performance.now() - started)} 秒;源读取/复核 ${seconds(timings.sourceMs)} 秒,OSS 上传/校验 ${seconds(timings.ossMs)} 秒,访问校验 ${seconds(timings.publicMs)} 秒)`);
} catch (error) {
await setItem(db, config, item, 'failed', errorText(error));
summary.failed++;
console.error(`上传未完成:内容记录 ${item.contentId},${errorText(error)}`);
}
}
if (checkpoints.length) {
let checked = 0;
progress.stage(`复核已上传断点:0/${checkpoints.length},最多 ${VALIDATION_CONCURRENCY} 张同时校验`);
const results = await mapBounded(checkpoints, VALIDATION_CONCURRENCY, async item => {
try {
await retry(() => verifyCheckpoint(oss, config, item), config.retries);
return { item };
} catch (error) {
return { item, error };
} finally {
progress.update(`复核已上传断点:${++checked}/${checkpoints.length}`);
}
});
// All network workers have settled before using the database again.
for (const { item, error } of results) {
if (error) {
await setItem(db, config, item, 'failed', errorText(error));
summary.failed++;
console.error(`已上传断点校验失败:内容记录 ${item.contentId},${errorText(error)}`);
} else { selected.push(item); summary.revalidated++; }
}
}
if (summary.failed) {
summary.status = 'partial';
const [counts] = await db.execute(`SELECT status,COUNT(*) AS count FROM ${items(config)} WHERE run_id=? GROUP BY status`, [config.runId]);
summary.items = Object.fromEntries(counts.map(row => [row.status, Number(row.count)]));
await db.execute(`UPDATE ${runs(config)} SET status='partial',updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [config.runId]);
return summary;
}
const switchProgress = () => `更新已校验文件记录:本次完成 ${summary.switched}/${selected.length},累计 ${initialSwitched + summary.switched}/${totalItems}`;
progress.stage(switchProgress());
for (let start = 0; start < selected.length; start += TRANSITION_BATCH_SIZE) {
const batch = selected.slice(start, start + TRANSITION_BATCH_SIZE);
const started = performance.now();
try {
const timing = await switchFiles(db, config, batch);
summary.switched += batch.length;
console.log(`文件记录批次完成:${batch.length} 张,${seconds(performance.now() - started)} 秒(源校验 ${seconds(timing.sourceMs)} 秒,文件及回滚日志 ${seconds(timing.writeMs)} 秒,进度 ${seconds(timing.statusMs)} 秒,提交 ${seconds(timing.commitMs)} 秒);累计 ${initialSwitched + summary.switched}/${totalItems}`);
} catch (error) {
// Isolate known data conflicts after the whole batch rolled back. Never
// replay a transaction with an ambiguous commit or broken connection.
if (error.fatal || !['MIGRATION_CAS_CONFLICT', 'MIGRATION_SOURCE_CHANGED'].includes(error.code)) throw error;
console.error(`该批文件存在修改冲突,事务已回滚;逐项定位:${errorText(error)}`);
for (const item of batch) {
try { await switchFiles(db, config, [item]); summary.switched++; }
catch (itemError) {
if (itemError.fatal || !['MIGRATION_CAS_CONFLICT', 'MIGRATION_SOURCE_CHANGED'].includes(itemError.code)) throw itemError;
summary.failed++;
console.error(`文件记录未切换:内容记录 ${item.contentId},${errorText(itemError)}`);
}
}
}
progress.update(switchProgress());
}
const mappings = new Map();
let checkedSwitched = 0;
progress.stage(`复核已迁移文件的访问地址:0/${initialSwitched + summary.switched}`);
for await (const batch of itemBatches(savedItems(db, config, ['switched']), TRANSITION_BATCH_SIZE)) {
for (const { item, error } of await verifySwitchedBatch(db, config, batch)) {
if (error) {
summary.failed++;
console.error(`目标地址不可复用:内容记录 ${item.contentId},${errorText(error)}`);
} else addMappings(mappings, item);
}
checkedSwitched += batch.length;
progress.update(`复核已迁移文件的访问地址:${checkedSwitched}/${initialSwitched + summary.switched}`);
}
summary.references = await scanReferences(db, config, mappings, { apply: true });
const [counts] = await db.execute(`SELECT status,COUNT(*) AS count FROM ${items(config)} WHERE run_id=? GROUP BY status`, [config.runId]);
summary.items = Object.fromEntries(counts.map(row => [row.status, Number(row.count)]));
const unfinished = counts.some(row => row.status !== 'switched');
summary.status = unfinished || summary.failed || summary.references.conflicts ? 'partial'
: summary.references.unresolvedRows || summary.references.skippedTables.length ? 'needs-review' : 'complete';
await db.execute(`UPDATE ${runs(config)} SET status=?,updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [summary.status, config.runId]);
return summary;
}
export async function runRollback(db, config) {
const [[run]] = await db.execute(`SELECT settings,status FROM ${runs(config)} WHERE run_id=?`, [config.runId]);
if (!run) throw new Error('找不到此 runId 的迁移日志');
const [[identity]] = await db.query('SELECT @@server_uuid AS server_uuid');
const binding = parseJson(run.settings).binding;
if (binding.schema !== config.mysql.database || binding.server !== identity.server_uuid) throw new Error('回滚数据库与迁移时不一致');
await db.execute(`UPDATE ${runs(config)} SET status='rollback',updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [config.runId]);
const result = await rollbackChanges(db, config.mysql.database, config.runId);
const status = result.conflicts.length ? 'rollback_partial' : 'rolled_back';
await db.execute(`UPDATE ${runs(config)} SET status=?,updated_at=CURRENT_TIMESTAMP WHERE run_id=?`, [status, config.runId]);
return { command: 'rollback', status, ...result };
}
export async function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
if (args.help) { console.log(HELP); return; }
const prepared = await prepareInput(args);
const { default: mysql } = await import('mysql2/promise');
const { passwordEnv, ...mysqlOptions } = prepared.input.mysql;
console.log(`读取${prepared.input.projectProfile ? `项目 ${prepared.input.projectProfile} 环境` : '指定配置'}:MySQL ${mysqlOptions.host || 'localhost'}:${mysqlOptions.port || 3306}/${mysqlOptions.database}`);
const db = await mysql.createConnection({ connectTimeout: 10000, ...mysqlOptions, multipleStatements: false, supportBigNumbers: true, bigNumberStrings: true, jsonStrings: true, dateStrings: true });
const progress = createProgress();
let locked = false;
const lockName = `oss-file-migration:${sha256(mysqlOptions.database).slice(0, 40)}`;
try {
progress.stage('识别旧存储、默认 OSS 和迁移批次');
await db.query("SET SESSION sql_mode = CONCAT_WS(',', NULLIF(@@SESSION.sql_mode,''), 'STRICT_ALL_TABLES')");
if (['apply', 'rollback'].includes(args.command)) {
const [[lock]] = await db.execute('SELECT GET_LOCK(?,0) AS acquired', [lockName]);
if (Number(lock.acquired) !== 1) throw new Error('已有文件迁移或回滚任务运行,请等待其结束');
locked = true;
}
const auto = prepared.projectManaged || !prepared.input.runId || !prepared.input.sourceConfigIds?.length || !prepared.input.targetConfigId;
const config = normalizeConfig(auto ? await discoverStorageConfig(db, prepared.input, { command: args.command }) : prepared.input);
config._progress = progress;
await db.query(`SET SESSION max_execution_time=${config.timeoutMs}`);
await persistGeneratedConfig(config, args, prepared);
if (args.command === 'init') {
await loadSettings(db, config);
progress.close();
console.log(`配置已就绪:旧数据库存储 ${config.sourceConfigIds.join(',')} → 默认 OSS ${config.targetConfigId};批次 ${config.runId}`);
console.log(`配置文件:${args.configPath};数据库密码和 OSS 密钥未复制到此文件。`);
console.log('只读取了项目和数据库配置,未上传图片或修改数据库。');
return { status: 'configured', configPath: args.configPath };
}
let summary;
if (args.command === 'rollback') summary = await runRollback(db, config);
else {
const settings = await loadSettings(db, config);
console.log(`源配置 ${config.sourceConfigIds.join(',')} → OSS 配置 ${config.targetConfigId};批次 ${config.runId}`);
summary = args.command === 'plan' ? await runPlan(db, config, settings) : await runApply(db, config, settings, args.limit);
}
const reportDir = resolve(dirname(args.configPath), 'reports');
await mkdir(reportDir, { recursive: true, mode: 0o700 });
const reportPath = resolve(reportDir, `${config.runId}-${args.command}-${Date.now()}.json`);
await writeFile(reportPath, JSON.stringify(summary, null, 2), { mode: 0o600 });
progress.close();
console.log(JSON.stringify(summary, null, 2));
console.log(`报告:${reportPath}`);
if (['partial', 'needs-review', 'rollback_partial'].includes(summary.status)) process.exitCode = 2;
return summary;
} finally {
progress.close();
try { if (locked) await db.execute('SELECT RELEASE_LOCK(?)', [lockName]); }
finally { await db.end(); }
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch(error => { console.error(`执行停止:${errorText(error)}`); process.exitCode = 1; });
}