Files
Genarrative/scripts/repair-auth-public-user-codes.mjs
T
kdletters 1fab3e79fa 修复陶泥号重复生成
认证快照恢复 next_user_id 改为跟随最大陶泥号

新增 user_account 迁移 JSON 陶泥号补救脚本

补充 UUID 用户编号恢复回归测试

(cherry picked from commit 114773c5876d421e9be4d3bc7372c189d70eae86)
2026-06-30 17:12:13 +08:00

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