Files
Friendship-and-chat-api/script/migrate-db-files-to-oss/lib/source-batch.mjs
T
dzc f55561c98d feat:上传到阿里云oss
feat:把以前的图片迁移到阿里云oss
2026-11-05 15:22:55 +08:00

88 lines
4.5 KiB
JavaScript

import { id, qualified } from './core.mjs';
function sourceChanged() {
return Object.assign(new Error('源图片在任务建立后发生变化或已删除,请检查该批文件'), {
code: 'MIGRATION_SOURCE_CHANGED'
});
}
/**
* Validate a bounded batch immediately after the caller begins an InnoDB
* REPEATABLE READ transaction, BEFORE any consistent read or explicitly created
* consistent snapshot. The plain metadata read must establish its snapshot
* only after the primary-key range has been locked.
*
* The first locking read scans from the minimum expected ID through the upper
* key range, including deleted rows and potential newer versions. This retains
* range/gap protection until commit/rollback, including against undeleting or
* changing another row into a newer matching version. Do not restrict this
* range lock to only expected IDs, active rows, or matching paths.
*
* The caller owns transaction boundaries, rollback on failure and batch size.
*/
export async function verifySourceBatch(db, config, items) {
if (!Array.isArray(items) || items.length === 0) throw new TypeError('源文件校验批次不能为空');
if (!config?.mysql?.database) throw new TypeError('源文件校验需要数据库名称');
const expected = new Map();
for (const item of items) {
const contentId = id(item?.contentId);
const configId = id(item?.configId);
if (expected.has(contentId)) throw new TypeError('源文件校验批次不能包含重复的内容编号');
if (typeof item.path !== 'string' || !item.path.length) throw new TypeError('源文件路径必须为非空字符串');
if (!Number.isSafeInteger(item.size) || item.size < 0 || typeof item.sha256 !== 'string' || !item.sha256.length) {
throw new TypeError('源文件校验需要有效的预期大小和 SHA-256');
}
expected.set(contentId, { ...item, contentId, configId });
}
const entries = [...expected.values()];
const minimumId = entries.reduce((minimum, item) => BigInt(item.contentId) < BigInt(minimum) ? item.contentId : minimum, entries[0].contentId);
const sourceTable = qualified(config.mysql.database, 'infra_file_content');
const cases = entries.map(() => 'WHEN config_id=? AND path=? THEN ?').join(' ');
const predicates = entries.map(() => '(config_id=? AND path=? AND id>=?)').join(' OR ');
const caseParams = entries.flatMap(item => [item.configId, item.path, item.contentId]);
const predicateParams = entries.flatMap(item => [item.configId, item.path, item.contentId]);
// A primary-key-only locking scan avoids the large-row access path triggered
// by locking reads that evaluate config/path predicates. Do not filter out
// deleted rows: their undelete could otherwise create a new active version.
await db.execute(
`SELECT id FROM ${sourceTable} WHERE id>=? ORDER BY id FOR UPDATE`,
[minimumId]
);
// CASE is evaluated by MySQL using the path column's collation. The returned
// expected ID is the only grouping key used in JavaScript. This consistent
// read establishes a fresh snapshot while the whole relevant range is held.
const [latestRows] = await db.execute(
`SELECT id,CASE ${cases} END AS expected_content_id FROM ${sourceTable}
WHERE deleted=0 AND id>=? AND (${predicates}) ORDER BY id DESC`,
[...caseParams, minimumId, ...predicateParams]
);
const latest = new Map();
for (const row of latestRows) {
const expectedId = String(row.expected_content_id);
if (!expected.has(expectedId)) throw sourceChanged();
const actualId = id(row.id);
if (!latest.has(expectedId) || BigInt(actualId) > BigInt(latest.get(expectedId))) latest.set(expectedId, actualId);
}
for (const contentId of expected.keys()) {
if (latest.get(contentId) !== contentId) throw sourceChanged();
}
// The range is already locked and each config/path version is validated.
// Digest work now touches only the selected primary keys, not a sorted scan.
const [contentRows] = await db.execute(
`SELECT id,config_id,path,OCTET_LENGTH(content) AS size,LOWER(SHA2(content,256)) AS sha256
FROM ${sourceTable} WHERE deleted=0 AND id IN (${entries.map(() => '?').join(',')}) FOR UPDATE`,
entries.map(item => item.contentId)
);
const verified = new Set();
for (const row of contentRows) {
const contentId = id(row.id);
const item = expected.get(contentId);
if (!item || verified.has(contentId) || Number(row.size) !== item.size || row.sha256 !== item.sha256) throw sourceChanged();
verified.add(contentId);
}
if (verified.size !== expected.size) throw sourceChanged();
}