export function text(value) { return String(value ?? '').trim(); } function number(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } function legacyId(row) { return number(row.legacyUserId || row.legacyUserData?.UserID); } function normalizePhone(value) { const digits = text(value).replace(/\D/g, ''); if (/^86\d{11}$/.test(digits)) return digits.slice(2); return /^1\d{10}$/.test(digits) ? digits : ''; } function phones(row) { return new Set([ row.mobile, row.phone, row.mobilePhoneNumber, row.username, row.legacyUserData?.Mobile, row.legacyUserData?.UserName, ].map(normalizePhone).filter(Boolean)); } function normalizeName(value) { return text(value).toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ''); } function names(row) { return new Set([ row.nickname, row.nickName, row.realName, row.name, row.legacyUserData?.HoneyName, row.legacyUserData?.TrueName, ].map(normalizeName).filter((value) => value.length >= 2)); } function intersects(left, right) { return [...left].some((value) => right.has(value)); } export function isCanonical(row) { return text(row.sourceKey).startsWith('legacy-sync:user:') && !row.isDeleted; } export function isReadingImport(row) { return !text(row.sourceKey) && !legacyId(row) && row.isAdmin !== true && !row.isDeleted; } export function buildPlan(users) { const targets = users.filter(isCanonical); const sources = users.filter(isReadingImport); const targetsByPhone = new Map(); for (const target of targets) { for (const phone of phones(target)) { const values = targetsByPhone.get(phone) || []; values.push(target); targetsByPhone.set(phone, values); } } const merge = []; const promote = []; // Never allocate old-system IDs for reading accounts. const held = []; let nameResolved = 0; let ambiguous = 0; let unmatched = 0; for (const source of sources) { if (source.legacyUserData?.ReadingSourceVerified !== 'xiaoshu') { held.push({ objectId: source.objectId, reason: 'unverified-source' }); continue; } const candidates = new Map(); for (const phone of phones(source)) { for (const candidate of targetsByPhone.get(phone) || []) candidates.set(candidate.objectId, candidate); } let possible = [...candidates.values()]; if (possible.length > 1) { const sourceNames = names(source); const nameMatches = possible.filter((candidate) => intersects(sourceNames, names(candidate))); if (nameMatches.length === 1) { possible = nameMatches; nameResolved += 1; } } if (possible.length === 1) { merge.push({ source: source.objectId, target: possible[0].objectId }); } else { held.push({ objectId: source.objectId, reason: possible.length > 1 ? 'ambiguous-identity' : 'unmatched-identity' }); if (possible.length > 1) ambiguous += 1; else unmatched += 1; } } return { targets, sources, merge, promote, held, nameResolved, ambiguous, unmatched }; }