diff --git a/scripts/repair-auth-public-user-codes.mjs b/scripts/repair-auth-public-user-codes.mjs new file mode 100644 index 000000000..6ae461110 --- /dev/null +++ b/scripts/repair-auth-public-user-codes.mjs @@ -0,0 +1,262 @@ +#!/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; +} diff --git a/scripts/repair-auth-public-user-codes.test.ts b/scripts/repair-auth-public-user-codes.test.ts new file mode 100644 index 000000000..6273e77d6 --- /dev/null +++ b/scripts/repair-auth-public-user-codes.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { repairAuthPublicUserCodesInMigration } from './repair-auth-public-user-codes.mjs'; + +function table(name: string, rows: unknown[]) { + return { name, rows }; +} + +describe('repairAuthPublicUserCodesInMigration', () => { + it('keeps the first public code and reassigns duplicate codes and usernames', () => { + const migration = { + schema_version: 7, + exported_at_micros: 1, + tables: [ + table('user_account', [ + { + user_id: 'user_a', + public_user_code: 'SY-00000007', + username: 'phone_00000008', + login_method: 'phone', + }, + { + user_id: 'user_b', + public_user_code: 'SY-00000007', + username: 'phone_00000008', + login_method: 'phone', + }, + { + user_id: 'user_c', + public_user_code: 'SY00000009', + username: 'phone_00000010', + login_method: 'phone', + }, + ]), + table('auth_identity', [{ identity_id: 'authi_phone_1' }]), + ], + }; + + const result = repairAuthPublicUserCodesInMigration(migration); + + expect(result.migration.tables).toHaveLength(1); + expect(result.migration.tables[0].name).toBe('user_account'); + expect(result.migration.tables[0].rows).toMatchObject([ + { + user_id: 'user_a', + public_user_code: 'SY-00000007', + username: 'phone_00000008', + }, + { + user_id: 'user_b', + public_user_code: 'SY-00000010', + username: 'phone_00000011', + }, + { + user_id: 'user_c', + public_user_code: 'SY-00000009', + username: 'phone_00000010', + }, + ]); + expect(result.repairedRows).toEqual([ + { + userId: 'user_b', + field: 'public_user_code', + from: 'SY-00000007', + to: 'SY-00000010', + }, + { + userId: 'user_b', + field: 'username', + from: 'phone_00000008', + to: 'phone_00000011', + }, + ]); + }); + + it('assigns missing public codes without colliding with existing codes', () => { + const migration = { + schema_version: 7, + exported_at_micros: 1, + tables: [ + table('user_account', [ + { + user_id: 'user_a', + public_user_code: '', + username: '', + login_method: 'password', + }, + { + user_id: 'user_b', + public_user_code: 'SY-00000002', + username: 'phone_00000003', + login_method: 'phone', + }, + ]), + ], + }; + + const result = repairAuthPublicUserCodesInMigration(migration); + + expect(result.migration.tables[0].rows).toMatchObject([ + { + user_id: 'user_a', + public_user_code: 'SY-00000003', + username: 'phone_00000004', + }, + { + user_id: 'user_b', + public_user_code: 'SY-00000002', + username: 'phone_00000003', + }, + ]); + }); +}); diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index b11acc80d..5beef1efb 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -4,14 +4,14 @@ use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; use super::{ mapper::{ - AuthUserSnapshot, PersistentAuthStoreSnapshot, RefreshSessionSnapshot, - StoredPasswordUserSnapshot, StoredRefreshSessionSnapshot, StoredWechatIdentitySnapshot, - sanitize_identity_component, + sanitize_identity_component, AuthUserSnapshot, PersistentAuthStoreSnapshot, + RefreshSessionSnapshot, StoredPasswordUserSnapshot, StoredRefreshSessionSnapshot, + StoredWechatIdentitySnapshot, }, tables::{ - AuthIdentity, AuthStoreProjectionMeta, AuthStoreSnapshot, RefreshSession, UserAccount, auth_identity, auth_store_projection_meta, auth_store_snapshot, refresh_session, - user_account, + user_account, AuthIdentity, AuthStoreProjectionMeta, AuthStoreSnapshot, RefreshSession, + UserAccount, }, }; @@ -516,13 +516,8 @@ fn build_auth_store_snapshot_from_rows( let mut phone_to_user_id = std::collections::HashMap::new(); let mut users_by_username = std::collections::HashMap::new(); for user in users { - if let Some(numeric_id) = user - .user_id - .strip_prefix("user_") - .and_then(|value| value.parse::().ok()) - { - next_user_id = next_user_id.max(numeric_id.saturating_add(1)); - } + next_user_id = + next_user_id.max(next_sequence_from_public_user_code(&user.public_user_code)); let phone_number = user .phone_number_e164 .clone() @@ -598,6 +593,15 @@ fn build_auth_store_snapshot_from_rows( }) } +fn next_sequence_from_public_user_code(public_user_code: &str) -> u64 { + public_user_code + .trim() + .strip_prefix("SY-") + .and_then(|value| value.parse::().ok()) + .map(|sequence| sequence.saturating_add(1)) + .unwrap_or(1) +} + fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) { let meta_id = AUTH_STORE_PROJECTION_META_ID.to_string(); if ctx @@ -775,4 +779,47 @@ mod tests { ); assert!(!snapshot.phone_to_user_id.contains_key("+8613900009999")); } + + #[test] + fn auth_export_next_user_id_follows_public_user_code_for_uuid_user_ids() { + let users = vec![ + UserAccount { + user_id: "user_5c3a59c4ff4044f2a2f43d55c7f445ac".to_string(), + public_user_code: "SY-00000042".to_string(), + username: "phone_00000043".to_string(), + display_name: "测试玩家".to_string(), + avatar_url: None, + phone_number_masked: Some("138****8000".to_string()), + phone_number_e164: Some("+8613800008000".to_string()), + login_method: "phone".to_string(), + binding_status: "active".to_string(), + wechat_bound: false, + password_hash: "hash-live".to_string(), + password_login_enabled: true, + token_version: 1, + user_tags: Some(vec![]), + }, + UserAccount { + user_id: "user_6d4a59c4ff4044f2a2f43d55c7f445ad".to_string(), + public_user_code: "SY-00000107".to_string(), + username: "phone_00000108".to_string(), + display_name: "测试玩家2".to_string(), + avatar_url: None, + phone_number_masked: Some("139****8000".to_string()), + phone_number_e164: Some("+8613900008000".to_string()), + login_method: "phone".to_string(), + binding_status: "active".to_string(), + wechat_bound: false, + password_hash: "hash-live-2".to_string(), + password_login_enabled: true, + token_version: 1, + user_tags: Some(vec![]), + }, + ]; + + let snapshot = build_auth_store_snapshot_from_rows(users, vec![], vec![]) + .expect("auth rows should export"); + + assert_eq!(snapshot.next_user_id, 108); + } }