reading-account-policy.mjs 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  1. export function text(value) { return String(value ?? '').trim(); }
  2. function number(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; }
  3. function legacyId(row) { return number(row.legacyUserId || row.legacyUserData?.UserID); }
  4. function normalizePhone(value) {
  5. const digits = text(value).replace(/\D/g, '');
  6. if (/^86\d{11}$/.test(digits)) return digits.slice(2);
  7. return /^1\d{10}$/.test(digits) ? digits : '';
  8. }
  9. function phones(row) {
  10. return new Set([
  11. row.mobile, row.phone, row.mobilePhoneNumber, row.username,
  12. row.legacyUserData?.Mobile, row.legacyUserData?.UserName,
  13. ].map(normalizePhone).filter(Boolean));
  14. }
  15. function normalizeName(value) {
  16. return text(value).toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '');
  17. }
  18. function names(row) {
  19. return new Set([
  20. row.nickname, row.nickName, row.realName, row.name,
  21. row.legacyUserData?.HoneyName, row.legacyUserData?.TrueName,
  22. ].map(normalizeName).filter((value) => value.length >= 2));
  23. }
  24. function intersects(left, right) { return [...left].some((value) => right.has(value)); }
  25. export function isCanonical(row) { return text(row.sourceKey).startsWith('legacy-sync:user:') && !row.isDeleted; }
  26. export function isReadingImport(row) {
  27. return !text(row.sourceKey) && !legacyId(row) && row.isAdmin !== true && !row.isDeleted;
  28. }
  29. export function buildPlan(users) {
  30. const targets = users.filter(isCanonical);
  31. const sources = users.filter(isReadingImport);
  32. const targetsByPhone = new Map();
  33. for (const target of targets) {
  34. for (const phone of phones(target)) {
  35. const values = targetsByPhone.get(phone) || [];
  36. values.push(target);
  37. targetsByPhone.set(phone, values);
  38. }
  39. }
  40. const merge = [];
  41. const promote = []; // Never allocate old-system IDs for reading accounts.
  42. const held = [];
  43. let nameResolved = 0;
  44. let ambiguous = 0;
  45. let unmatched = 0;
  46. for (const source of sources) {
  47. if (source.legacyUserData?.ReadingSourceVerified !== 'xiaoshu') {
  48. held.push({ objectId: source.objectId, reason: 'unverified-source' });
  49. continue;
  50. }
  51. const candidates = new Map();
  52. for (const phone of phones(source)) {
  53. for (const candidate of targetsByPhone.get(phone) || []) candidates.set(candidate.objectId, candidate);
  54. }
  55. let possible = [...candidates.values()];
  56. if (possible.length > 1) {
  57. const sourceNames = names(source);
  58. const nameMatches = possible.filter((candidate) => intersects(sourceNames, names(candidate)));
  59. if (nameMatches.length === 1) { possible = nameMatches; nameResolved += 1; }
  60. }
  61. if (possible.length === 1) {
  62. merge.push({ source: source.objectId, target: possible[0].objectId });
  63. } else {
  64. held.push({ objectId: source.objectId, reason: possible.length > 1 ? 'ambiguous-identity' : 'unmatched-identity' });
  65. if (possible.length > 1) ambiguous += 1;
  66. else unmatched += 1;
  67. }
  68. }
  69. return { targets, sources, merge, promote, held, nameResolved, ambiguous, unmatched };
  70. }