1fab3e79fa
认证快照恢复 next_user_id 改为跟随最大陶泥号 新增 user_account 迁移 JSON 陶泥号补救脚本 补充 UUID 用户编号恢复回归测试 (cherry picked from commit 114773c5876d421e9be4d3bc7372c189d70eae86)
263 lines
7.1 KiB
JavaScript
263 lines
7.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
if (isCliEntry()) {
|
|
runCli(process.argv.slice(2)).catch((error) => {
|
|
console.error(
|
|
`[repair-auth-public-user-codes] ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
export function repairAuthPublicUserCodesInMigration(migration) {
|
|
if (!migration || !Array.isArray(migration.tables)) {
|
|
throw new Error('迁移 JSON 必须包含 tables 数组。');
|
|
}
|
|
|
|
const userTable = migration.tables.find(
|
|
(table) => table?.name === 'user_account',
|
|
);
|
|
if (!userTable || !Array.isArray(userTable.rows)) {
|
|
throw new Error('迁移 JSON 缺少 user_account 表。');
|
|
}
|
|
|
|
const usedCodes = new Set();
|
|
const usedUsernames = new Set();
|
|
let maxPublicSequence = 0;
|
|
let maxPhoneUsernameSequence = 0;
|
|
|
|
for (const row of userTable.rows) {
|
|
const sequence = parsePublicUserCode(row?.public_user_code);
|
|
if (sequence !== null) {
|
|
maxPublicSequence = Math.max(maxPublicSequence, sequence);
|
|
}
|
|
const phoneUsernameSequence = parsePhoneUsername(row?.username);
|
|
if (phoneUsernameSequence !== null) {
|
|
maxPhoneUsernameSequence = Math.max(
|
|
maxPhoneUsernameSequence,
|
|
phoneUsernameSequence,
|
|
);
|
|
}
|
|
}
|
|
|
|
const repairedRows = [];
|
|
for (const row of userTable.rows) {
|
|
if (!row || typeof row !== 'object') {
|
|
continue;
|
|
}
|
|
|
|
const originalCode =
|
|
typeof row.public_user_code === 'string' ? row.public_user_code : '';
|
|
const publicSequence = parsePublicUserCode(originalCode);
|
|
const normalizedCode =
|
|
publicSequence === null ? '' : formatPublicUserCode(publicSequence);
|
|
if (publicSequence === null || usedCodes.has(normalizedCode)) {
|
|
const nextSequence = allocatePublicSequence();
|
|
row.public_user_code = formatPublicUserCode(nextSequence);
|
|
repairedRows.push({
|
|
userId: readUserId(row),
|
|
field: 'public_user_code',
|
|
from: originalCode,
|
|
to: row.public_user_code,
|
|
});
|
|
} else {
|
|
row.public_user_code = normalizedCode;
|
|
usedCodes.add(normalizedCode);
|
|
}
|
|
|
|
const originalUsername =
|
|
typeof row.username === 'string' ? row.username.trim() : '';
|
|
if (!originalUsername || usedUsernames.has(originalUsername)) {
|
|
const nextUsername = allocateUsername(row, originalUsername);
|
|
row.username = nextUsername;
|
|
repairedRows.push({
|
|
userId: readUserId(row),
|
|
field: 'username',
|
|
from: originalUsername,
|
|
to: nextUsername,
|
|
});
|
|
} else {
|
|
row.username = originalUsername;
|
|
usedUsernames.add(originalUsername);
|
|
}
|
|
}
|
|
|
|
return {
|
|
migration: {
|
|
schema_version: migration.schema_version,
|
|
exported_at_micros: migration.exported_at_micros,
|
|
tables: [
|
|
{
|
|
name: 'user_account',
|
|
rows: userTable.rows,
|
|
},
|
|
],
|
|
},
|
|
repairedRows,
|
|
nextPublicSequence: maxPublicSequence + 1,
|
|
};
|
|
|
|
function allocatePublicSequence() {
|
|
let sequence = maxPublicSequence + 1;
|
|
while (usedCodes.has(formatPublicUserCode(sequence))) {
|
|
sequence += 1;
|
|
}
|
|
maxPublicSequence = sequence;
|
|
usedCodes.add(formatPublicUserCode(sequence));
|
|
return sequence;
|
|
}
|
|
|
|
function allocateUsername(row, originalUsername) {
|
|
let username;
|
|
if (shouldUsePhoneUsername(row, originalUsername)) {
|
|
do {
|
|
maxPhoneUsernameSequence += 1;
|
|
username = `phone_${String(maxPhoneUsernameSequence).padStart(8, '0')}`;
|
|
} while (usedUsernames.has(username));
|
|
} else {
|
|
const base = sanitizeUsernameBase(
|
|
originalUsername || row.display_name || 'user',
|
|
);
|
|
let suffix =
|
|
parsePublicUserCode(row.public_user_code) ?? maxPublicSequence;
|
|
do {
|
|
username = `${base}_${String(suffix).padStart(8, '0')}`;
|
|
suffix += 1;
|
|
} while (usedUsernames.has(username));
|
|
}
|
|
usedUsernames.add(username);
|
|
return username;
|
|
}
|
|
}
|
|
|
|
function parsePublicUserCode(value) {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const match = value
|
|
.trim()
|
|
.toUpperCase()
|
|
.match(/^SY-?([0-9]{1,8})$/u);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const sequence = Number.parseInt(match[1], 10);
|
|
return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : null;
|
|
}
|
|
|
|
function formatPublicUserCode(sequence) {
|
|
return `SY-${String(sequence).padStart(8, '0')}`;
|
|
}
|
|
|
|
function parsePhoneUsername(value) {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const match = value.trim().match(/^phone_([0-9]{1,8})$/u);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const sequence = Number.parseInt(match[1], 10);
|
|
return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : null;
|
|
}
|
|
|
|
function shouldUsePhoneUsername(row, originalUsername) {
|
|
const loginMethod =
|
|
typeof row.login_method === 'string' ? row.login_method.trim() : '';
|
|
return (
|
|
/^phone_[0-9]{1,8}$/u.test(originalUsername) ||
|
|
loginMethod === 'phone' ||
|
|
loginMethod === 'password'
|
|
);
|
|
}
|
|
|
|
function sanitizeUsernameBase(value) {
|
|
const sanitized = String(value)
|
|
.trim()
|
|
.replace(/[^A-Za-z0-9_]+/gu, '_')
|
|
.replace(/^_+|_+$/gu, '');
|
|
return sanitized || 'user';
|
|
}
|
|
|
|
function readUserId(row) {
|
|
return typeof row.user_id === 'string' && row.user_id.trim()
|
|
? row.user_id
|
|
: '<unknown>';
|
|
}
|
|
|
|
async function runCli(argv) {
|
|
const options = parseCliArgs(argv);
|
|
const inputPath = path.resolve(options.in);
|
|
const outputPath = path.resolve(options.out);
|
|
const migration = JSON.parse(await readFile(inputPath, 'utf8'));
|
|
const result = repairAuthPublicUserCodesInMigration(migration);
|
|
|
|
if (!options.dryRun) {
|
|
await writeFile(
|
|
outputPath,
|
|
`${JSON.stringify(result.migration, null, 2)}\n`,
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`[repair-auth-public-user-codes] ${options.dryRun ? 'dry-run' : `已写入 ${outputPath}`},修复 ${result.repairedRows.length} 项`,
|
|
);
|
|
if (result.repairedRows.length > 0) {
|
|
console.table(result.repairedRows.slice(0, 50));
|
|
if (result.repairedRows.length > 50) {
|
|
console.log(
|
|
`[repair-auth-public-user-codes] 仅展示前 50 项,共 ${result.repairedRows.length} 项。`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseCliArgs(argv) {
|
|
const options = {
|
|
in: '',
|
|
out: '',
|
|
dryRun: false,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
const readValue = (name) => {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error(`${name} 缺少参数值。`);
|
|
}
|
|
index += 1;
|
|
return value;
|
|
};
|
|
|
|
if (arg === '--in') {
|
|
options.in = readValue(arg);
|
|
} else if (arg === '--out') {
|
|
options.out = readValue(arg);
|
|
} else if (arg === '--dry-run') {
|
|
options.dryRun = true;
|
|
} else {
|
|
throw new Error(`未知参数: ${arg}`);
|
|
}
|
|
}
|
|
|
|
if (!options.in) {
|
|
throw new Error('必须传入 --in。');
|
|
}
|
|
if (!options.out && !options.dryRun) {
|
|
throw new Error('非 dry-run 必须传入 --out。');
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function isCliEntry() {
|
|
const entry = process.argv[1];
|
|
return entry
|
|
? import.meta.url === `file://${entry.replace(/\\/gu, '/')}`
|
|
: false;
|
|
}
|