修复陶泥号重复生成
认证快照恢复 next_user_id 改为跟随最大陶泥号 新增 user_account 迁移 JSON 陶泥号补救脚本 补充 UUID 用户编号恢复回归测试 (cherry picked from commit 114773c5876d421e9be4d3bc7372c189d70eae86)
This commit is contained in:
@@ -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
|
||||||
|
: '<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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,14 +4,14 @@ use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp};
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
mapper::{
|
mapper::{
|
||||||
AuthUserSnapshot, PersistentAuthStoreSnapshot, RefreshSessionSnapshot,
|
sanitize_identity_component, AuthUserSnapshot, PersistentAuthStoreSnapshot,
|
||||||
StoredPasswordUserSnapshot, StoredRefreshSessionSnapshot, StoredWechatIdentitySnapshot,
|
RefreshSessionSnapshot, StoredPasswordUserSnapshot, StoredRefreshSessionSnapshot,
|
||||||
sanitize_identity_component,
|
StoredWechatIdentitySnapshot,
|
||||||
},
|
},
|
||||||
tables::{
|
tables::{
|
||||||
AuthIdentity, AuthStoreProjectionMeta, AuthStoreSnapshot, RefreshSession, UserAccount,
|
|
||||||
auth_identity, auth_store_projection_meta, auth_store_snapshot, refresh_session,
|
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 phone_to_user_id = std::collections::HashMap::new();
|
||||||
let mut users_by_username = std::collections::HashMap::new();
|
let mut users_by_username = std::collections::HashMap::new();
|
||||||
for user in users {
|
for user in users {
|
||||||
if let Some(numeric_id) = user
|
next_user_id =
|
||||||
.user_id
|
next_user_id.max(next_sequence_from_public_user_code(&user.public_user_code));
|
||||||
.strip_prefix("user_")
|
|
||||||
.and_then(|value| value.parse::<u64>().ok())
|
|
||||||
{
|
|
||||||
next_user_id = next_user_id.max(numeric_id.saturating_add(1));
|
|
||||||
}
|
|
||||||
let phone_number = user
|
let phone_number = user
|
||||||
.phone_number_e164
|
.phone_number_e164
|
||||||
.clone()
|
.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::<u64>().ok())
|
||||||
|
.map(|sequence| sequence.saturating_add(1))
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) {
|
fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) {
|
||||||
let meta_id = AUTH_STORE_PROJECTION_META_ID.to_string();
|
let meta_id = AUTH_STORE_PROJECTION_META_ID.to_string();
|
||||||
if ctx
|
if ctx
|
||||||
@@ -775,4 +779,47 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(!snapshot.phone_to_user_id.contains_key("+8613900009999"));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user