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

319 lines
16 KiB
JavaScript

import { qualified, quote } from './core.mjs';
const RUNS = 'infra_file_oss_migration_runs';
const ITEMS = 'infra_file_oss_migration_items';
const CHANGES = 'infra_file_oss_migration_changes';
const metadataCache = new WeakMap();
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
// Snapshots must preserve BIT/Buffer and 64-bit keys without Number conversion.
function pack(value) {
if (Buffer.isBuffer(value)) return { type: 'buffer', value: value.toString('base64') };
if (typeof value === 'bigint') return { type: 'bigint', value: value.toString() };
if (value instanceof Date) return { type: 'date', value: value.toISOString() };
if (value === undefined) throw new Error('变更快照不能包含 undefined');
return { type: 'json', value };
}
function unpack(value) {
if (value.type === 'buffer') return Buffer.from(value.value, 'base64');
if (value.type === 'bigint') return BigInt(value.value);
if (value.type === 'date') return new Date(value.value);
if (value.type === 'json') return value.value;
throw new Error('变更日志值类型不受支持');
}
function snapshot(record) {
return JSON.stringify(Object.fromEntries(Object.entries(record).map(([key, value]) => [key, pack(value)])));
}
function restoreSnapshot(text) {
return Object.fromEntries(Object.entries(JSON.parse(text)).map(([key, value]) => [key, unpack(value)]));
}
function parameter(value) {
if (typeof value === 'bigint') return value.toString();
if (value !== null && typeof value === 'object' && !Buffer.isBuffer(value) && !(value instanceof Date)) return JSON.stringify(value);
return value;
}
const equal = (left, right) => JSON.stringify(pack(left)) === JSON.stringify(pack(right));
function condition(values) {
const entries = Object.entries(values);
return {
sql: entries.map(([key]) => `CAST(${quote(key)} AS BINARY) <=> CAST(? AS BINARY)`).join(' AND '),
params: entries.map(([, value]) => parameter(value))
};
}
async function tableMetadata(connection, schema, table) {
let cache = metadataCache.get(connection);
if (!cache) { cache = new Map(); metadataCache.set(connection, cache); }
const key = JSON.stringify([schema, table]);
if (!cache.has(key)) {
const [rows] = await connection.execute(
`SELECT c.COLUMN_NAME AS name, c.EXTRA AS extra, t.ENGINE AS engine
FROM information_schema.COLUMNS c JOIN information_schema.TABLES t
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME
WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ?`, [schema, table]);
if (!rows.length || rows.some(row => String(row.engine).toLowerCase() !== 'innodb')) {
throw new Error(`表 ${schema}.${table} 必须为 InnoDB,才能保证更新和日志原子提交`);
}
cache.set(key, {
names: new Set(rows.map(row => row.name)),
automatic: rows.filter(row => /on update/i.test(row.extra || '')).map(row => row.name)
});
}
return cache.get(key);
}
function assertFields(metadata, primaryKey, before, after) {
if (own(after, primaryKey)) throw new Error('迁移不能修改主键');
for (const name of [primaryKey, ...Object.keys(before), ...Object.keys(after)]) {
if (!metadata.names.has(name)) throw new Error(`变更列 ${name} 不存在`);
}
for (const name of Object.keys(after)) {
if (!own(before, name)) throw new Error(`变更列 ${name} 缺少原值快照`);
}
}
function assignments(values, metadata) {
const entries = Object.entries(values);
return {
sql: [
...entries.map(([name]) => `${quote(name)} = ?`),
...metadata.automatic.filter(name => !own(values, name)).map(name => `${quote(name)} = ${quote(name)}`)
].join(', '),
params: entries.map(([, value]) => parameter(value))
};
}
export async function ensureJournal(connection, schema) {
await connection.execute(`CREATE TABLE IF NOT EXISTS ${qualified(schema, RUNS)} (
run_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
settings LONGTEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`);
await connection.execute(`CREATE TABLE IF NOT EXISTS ${qualified(schema, ITEMS)} (
run_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
item_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
data LONGTEXT NOT NULL,
status VARCHAR(32) NOT NULL,
error TEXT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (run_id, item_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`);
await connection.execute(`CREATE TABLE IF NOT EXISTS ${qualified(schema, CHANGES)} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
run_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
schema_name VARCHAR(64) NOT NULL,
table_name VARCHAR(64) NOT NULL,
pk_name VARCHAR(64) NOT NULL,
pk_value TEXT NOT NULL,
before_json LONGTEXT NOT NULL,
after_json LONGTEXT NOT NULL,
rolled_back TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY ix_run_id_id (run_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`);
for (const table of [RUNS, ITEMS, CHANGES]) await tableMetadata(connection, schema, table);
}
/** The caller owns BEGIN/COMMIT/ROLLBACK, including the change-journal INSERT. */
export async function applyChange(connection, journalSchema, runId, change) {
const { schema, table, primaryKey, pkValue, before, after } = change;
const metadata = await tableMetadata(connection, schema, table);
assertFields(metadata, primaryKey, before, after);
const changed = Object.fromEntries(Object.entries(after).filter(([name, value]) => !equal(before[name], value)));
if (!Object.keys(changed).length) return { changed: false };
const update = assignments(changed, metadata);
const guard = condition(before);
const [result] = await connection.execute(
`UPDATE ${qualified(schema, table)} SET ${update.sql}
WHERE ${quote(primaryKey)} = ?${guard.sql ? ` AND ${guard.sql}` : ''}`,
[...update.params, parameter(pkValue), ...guard.params]);
if (result.affectedRows !== 1) {
const error = new Error(`数据已变化或不存在:${schema}.${table},本次更新已停止`);
error.code = 'MIGRATION_CAS_CONFLICT';
throw error;
}
if (result.warningStatus > 0) {
throw new Error('数据库更新产生警告,可能发生字段截断或类型转换;事务必须回滚');
}
// Native JSON columns normalize whitespace/key order, and MySQL may convert
// numeric request values. Rollback must compare against the bytes/type that
// the database actually persisted, not the JavaScript UPDATE parameters.
const [storedRows] = await connection.execute(
`SELECT ${Object.keys(changed).map(quote).join(', ')} FROM ${qualified(schema, table)}
WHERE ${quote(primaryKey)} = ? FOR UPDATE`, [parameter(pkValue)]);
if (storedRows.length !== 1 || Object.keys(changed).some(name => !own(storedRows[0], name))) {
throw new Error('无法读取更新后的记录,事务必须回滚');
}
const persisted = Object.fromEntries(Object.keys(changed).map(name => [name, storedRows[0][name]]));
const [journal] = await connection.execute(
`INSERT INTO ${qualified(journalSchema, CHANGES)}
(run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[runId, schema, table, primaryKey, JSON.stringify(pack(pkValue)), snapshot(before), snapshot(persisted)]);
return { changed: true, journalId: String(journal.insertId) };
}
function primaryKeyIdentity(value) {
if (Buffer.isBuffer(value)) return `buffer:${value.toString('base64')}`;
if (!['string', 'number', 'bigint'].includes(typeof value)
|| (typeof value === 'number' && !Number.isSafeInteger(value))) {
throw new Error('批量变更主键必须是字符串、安全整数、BigInt 或 Buffer');
}
// mysql2 may return integer primary keys as either strings or numbers.
return `scalar:${String(value)}`;
}
/**
* Apply one homogeneous batch with one CASE UPDATE, one persisted-value read,
* and one journal INSERT. The caller must roll back the entire transaction on
* any error: the UPDATE can have matched only part of a concurrently edited
* batch before affectedRows reports the conflict.
*/
export async function applyChanges(connection, journalSchema, runId, changes) {
if (!Array.isArray(changes)) throw new Error('批量变更必须为数组');
if (!changes.length) return { changed: 0, skipped: 0 };
const { schema, table, primaryKey } = changes[0];
const afterFields = Object.keys(changes[0].after).sort();
const identities = new Set();
for (const change of changes) {
if (change.schema !== schema || change.table !== table || change.primaryKey !== primaryKey) {
throw new Error('批量变更必须使用相同的数据库、表和主键列');
}
if (JSON.stringify(Object.keys(change.after).sort()) !== JSON.stringify(afterFields)) {
throw new Error('批量变更的目标字段集合必须一致');
}
const identity = primaryKeyIdentity(change.pkValue);
if (identities.has(identity)) throw new Error('批量变更包含重复主键');
identities.add(identity);
}
const metadata = await tableMetadata(connection, schema, table);
const active = [];
for (const change of changes) {
assertFields(metadata, primaryKey, change.before, change.after);
const changed = Object.fromEntries(Object.entries(change.after).filter(([name, value]) => !equal(change.before[name], value)));
if (Object.keys(changed).length) active.push({ ...change, changed });
}
const skipped = changes.length - active.length;
if (!active.length) return { changed: 0, skipped };
const fields = afterFields.filter(name => active.some(change => own(change.changed, name)));
const updateParams = [];
const set = fields.map(name => {
const branches = active.filter(change => own(change.changed, name)).map(change => {
updateParams.push(parameter(change.pkValue), parameter(change.changed[name]));
return 'WHEN ? THEN ?';
});
return `${quote(name)} = CASE ${quote(primaryKey)} ${branches.join(' ')} ELSE ${quote(name)} END`;
});
set.push(...metadata.automatic.filter(name => !fields.includes(name)).map(name => `${quote(name)} = ${quote(name)}`));
const where = active.map(change => {
const guard = condition(change.before);
updateParams.push(parameter(change.pkValue), ...guard.params);
return `(${quote(primaryKey)} = ?${guard.sql ? ` AND ${guard.sql}` : ''})`;
});
const [updated] = await connection.execute(
`UPDATE ${qualified(schema, table)} SET ${set.join(', ')} WHERE ${where.join(' OR ')}`, updateParams);
if (updated.affectedRows !== active.length) {
const error = new Error(`批量更新发现数据已变化或不存在:${schema}.${table},必须回滚整批事务`);
error.code = 'MIGRATION_CAS_CONFLICT';
throw error;
}
if (updated.warningStatus > 0) throw new Error('数据库批量更新产生警告,可能发生字段截断或类型转换;事务必须回滚');
const [storedRows] = await connection.execute(
`SELECT ${[primaryKey, ...fields].map(quote).join(', ')} FROM ${qualified(schema, table)}
WHERE ${quote(primaryKey)} IN (${active.map(() => '?').join(', ')}) FOR UPDATE`,
active.map(change => parameter(change.pkValue)));
if (storedRows.length !== active.length) throw new Error('无法读取批量更新后的全部记录,事务必须回滚');
const storedByKey = new Map();
for (const row of storedRows) {
if (![primaryKey, ...fields].every(name => own(row, name))) throw new Error('批量更新读回记录缺少字段,事务必须回滚');
const identity = primaryKeyIdentity(row[primaryKey]);
if (storedByKey.has(identity)) throw new Error('批量更新读回记录包含重复主键,事务必须回滚');
storedByKey.set(identity, row);
}
const journalParams = [];
for (const change of active) {
const row = storedByKey.get(primaryKeyIdentity(change.pkValue));
if (!row) throw new Error('批量更新读回主键与请求不一致,事务必须回滚');
const persisted = Object.fromEntries(Object.keys(change.changed).map(name => [name, row[name]]));
journalParams.push(runId, schema, table, primaryKey, JSON.stringify(pack(change.pkValue)), snapshot(change.before), snapshot(persisted));
}
const [journal] = await connection.execute(
`INSERT INTO ${qualified(journalSchema, CHANGES)}
(run_id, schema_name, table_name, pk_name, pk_value, before_json, after_json)
VALUES ${active.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')}`, journalParams);
if (journal.affectedRows !== active.length || journal.warningStatus > 0) {
throw new Error('批量回滚日志未完整保存,事务必须回滚');
}
return { changed: active.length, skipped };
}
export async function rollbackChanges(connection, journalSchema, runId) {
const result = { restored: 0, alreadyRestored: 0, conflicts: [] };
let cursor = null;
const journal = qualified(journalSchema, CHANGES);
while (true) {
const [batch] = await connection.execute(
`SELECT CAST(id AS CHAR) AS id FROM ${journal}
WHERE run_id = ? AND rolled_back = 0${cursor === null ? '' : ' AND id < ?'} ORDER BY id DESC LIMIT 100`,
cursor === null ? [runId] : [runId, cursor]);
if (!batch.length) break;
for (const entry of batch) {
cursor = String(entry.id);
await connection.beginTransaction();
try {
const [[row]] = await connection.execute(`SELECT * FROM ${journal} WHERE id = ? AND run_id = ? FOR UPDATE`, [cursor, runId]);
if (!row || Number(row.rolled_back) === 1) { await connection.commit(); continue; }
const before = restoreSnapshot(row.before_json);
const after = restoreSnapshot(row.after_json);
const pkValue = unpack(JSON.parse(row.pk_value));
const metadata = await tableMetadata(connection, row.schema_name, row.table_name);
assertFields(metadata, row.pk_name, before, after);
const target = qualified(row.schema_name, row.table_name);
const expectedAfter = condition({ ...before, ...after });
const [matchesAfter] = await connection.execute(
`SELECT 1 AS matched FROM ${target} WHERE ${quote(row.pk_name)} = ? AND ${expectedAfter.sql} FOR UPDATE`,
[parameter(pkValue), ...expectedAfter.params]);
let alreadyRestored = false;
if (!matchesAfter.length) {
const expectedBefore = condition(before);
const [matchesBefore] = await connection.execute(
`SELECT 1 AS matched FROM ${target} WHERE ${quote(row.pk_name)} = ? AND ${expectedBefore.sql} FOR UPDATE`,
[parameter(pkValue), ...expectedBefore.params]);
if (!matchesBefore.length) {
await connection.rollback();
result.conflicts.push({ id: cursor, schema: row.schema_name, table: row.table_name, pkValue: typeof pkValue === 'bigint' ? pkValue.toString() : pkValue });
continue;
}
alreadyRestored = true;
} else {
const values = Object.fromEntries(Object.keys(after).map(name => [name, before[name]]));
const update = assignments(values, metadata);
const [updated] = await connection.execute(
`UPDATE ${target} SET ${update.sql} WHERE ${quote(row.pk_name)} = ? AND ${expectedAfter.sql}`,
[...update.params, parameter(pkValue), ...expectedAfter.params]);
if (updated.affectedRows !== 1) throw new Error('已锁定的回滚记录发生变化,事务已终止');
if (updated.warningStatus > 0) throw new Error('数据库回滚产生警告,事务已终止');
}
await connection.execute(`UPDATE ${journal} SET rolled_back = 1 WHERE id = ? AND run_id = ?`, [cursor, runId]);
await connection.commit();
if (alreadyRestored) result.alreadyRestored++;
else result.restored++;
} catch (error) {
await connection.rollback();
throw error;
}
}
}
return result;
}