#!/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 : ''; } 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; }