|
|
@@ -1,6 +1,7 @@
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
import { randomBytes } from 'node:crypto';
|
|
|
+import { buildPlan, isReadingImport, isCanonical, text } from './lib/reading-account-policy.mjs';
|
|
|
|
|
|
const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
|
|
|
const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
|
|
|
@@ -56,74 +57,6 @@ async function allRows(path, where, keys) {
|
|
|
return rows;
|
|
|
}
|
|
|
|
|
|
-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)); }
|
|
|
-function isCanonical(row) { return text(row.sourceKey).startsWith('legacy-sync:user:') && !row.isDeleted; }
|
|
|
-function isReadingImport(row) {
|
|
|
- return !text(row.sourceKey) && !legacyId(row) && row.isAdmin !== true && !row.isDeleted;
|
|
|
-}
|
|
|
-
|
|
|
-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 = [];
|
|
|
- let nameResolved = 0;
|
|
|
- let ambiguous = 0;
|
|
|
- let unmatched = 0;
|
|
|
- for (const source of sources) {
|
|
|
- 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 {
|
|
|
- promote.push(source.objectId);
|
|
|
- if (possible.length > 1) ambiguous += 1;
|
|
|
- else unmatched += 1;
|
|
|
- }
|
|
|
- }
|
|
|
- return { targets, sources, merge, promote, nameResolved, ambiguous, unmatched };
|
|
|
-}
|
|
|
-
|
|
|
async function ownerStats() {
|
|
|
const result = {};
|
|
|
for (const className of ['SurveyItem', 'SurveyLog']) {
|
|
|
@@ -160,16 +93,15 @@ async function executePlan(plan) {
|
|
|
})).sessionToken;
|
|
|
|
|
|
const embeddedMerge = JSON.stringify(plan.merge).replace(/</g, '\\u003c');
|
|
|
- const embeddedPromote = JSON.stringify(plan.promote).replace(/</g, '\\u003c');
|
|
|
const code = String.raw`
|
|
|
async function handler(request,response){
|
|
|
try{
|
|
|
const current=request.user||(typeof user!=='undefined'?user:null);if(!current)return response.status(401).json({success:false,message:'需要超级管理员会话'});await current.fetch({useMasterKey:true});
|
|
|
const roles=Array.isArray(current.get('roles'))?current.get('roles').map(String):[];if(current.get('adminRoleKey')!=='super-admin'&&!roles.includes('super-admin'))return response.status(403).json({success:false,message:'仅超级管理员可执行'});
|
|
|
- const companyId=${JSON.stringify(COMPANY_ID)},merge=${embeddedMerge},promote=${embeddedPromote};
|
|
|
+ const companyId=${JSON.stringify(COMPANY_ID)},merge=${embeddedMerge};
|
|
|
await Psql.none('ALTER TABLE "SurveyLog" ALTER COLUMN "user" TYPE text USING CAST("user" AS text)');
|
|
|
const merged=merge.length?await Psql.one('WITH input AS MATERIALIZED (SELECT x.source,x.target FROM jsonb_to_recordset($2::jsonb) AS x(source text,target text)),valid AS MATERIALIZED (SELECT i.source,i.target FROM input i JOIN "_User" s ON s."objectId"=i.source AND s."company"=$1 JOIN "_User" t ON t."objectId"=i.target AND t."company"=$1 WHERE COALESCE(s."isDeleted",FALSE)=FALSE AND COALESCE(t."isDeleted",FALSE)=FALSE),items AS (UPDATE "SurveyItem" r SET "user"=v.target,"updatedAt"=NOW() FROM valid v WHERE CAST(r."user" AS text) IN (v.source,\'_User$\'||v.source) RETURNING r."objectId"),logs AS (UPDATE "SurveyLog" r SET "user"=v.target,"updatedAt"=NOW() FROM valid v WHERE CAST(r."user" AS text) IN (v.source,\'_User$\'||v.source) RETURNING r."objectId"),profiles AS (UPDATE "_User" t SET "level"=COALESCE(NULLIF(t."level",\'\'),NULLIF(s."level",\'\')),"updatedAt"=NOW() FROM valid v JOIN "_User" s ON s."objectId"=v.source WHERE t."objectId"=v.target RETURNING t."objectId"),sessions AS (DELETE FROM "_Session" x USING valid v WHERE CAST(x."user" AS text) IN (v.source,\'_User$\'||v.source) RETURNING x."objectId"),sources AS (UPDATE "_User" s SET "isDisabled"=TRUE,"isDeleted"=TRUE,"identityType"=\'merged\',"sourceKey"=\'reading-merged:user:\'||s."objectId","legacyUserData"=COALESCE(s."legacyUserData",\'{}\'::jsonb)||jsonb_build_object(\'ReadingMergedIntoObjectId\',v.target,\'ReadingMergedAt\',NOW()),"updatedAt"=NOW() FROM valid v WHERE s."objectId"=v.source RETURNING s."objectId") SELECT (SELECT COUNT(*)::int FROM sources) users,(SELECT COUNT(*)::int FROM items) items,(SELECT COUNT(*)::int FROM logs) logs,(SELECT COUNT(*)::int FROM sessions) sessions',[companyId,JSON.stringify(merge)]):{users:0,items:0,logs:0,sessions:0};
|
|
|
- const promoted=promote.length?await Psql.one('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-legacy-user-id\'))),input AS MATERIALIZED (SELECT value#>>\'{}\' object_id FROM jsonb_array_elements($2::jsonb)),maximum AS MATERIALIZED (SELECT COALESCE(MAX(CASE WHEN COALESCE("legacyUserId",0)>0 THEN "legacyUserId" ELSE 0 END),0)::bigint max_id FROM "_User",lock_row WHERE "company"=$1),candidates AS MATERIALIZED (SELECT u."objectId",(SELECT max_id FROM maximum)+ROW_NUMBER() OVER(ORDER BY u."objectId") legacy_id FROM "_User" u JOIN input i ON i.object_id=u."objectId" WHERE u."company"=$1 AND COALESCE(u."isDeleted",FALSE)=FALSE AND COALESCE(u."legacyUserId",0)=0),updated AS (UPDATE "_User" u SET "legacyUserId"=c.legacy_id,"legacyGroupId"=1,"identityType"=\'member\',"sourceKey"=\'reading-unified:user:\'||u."objectId","isDisabled"=FALSE,"isDeleted"=FALSE,"registeredAt"=COALESCE(u."registeredAt",u."createdAt"),"legacyUserData"=COALESCE(u."legacyUserData",\'{}\'::jsonb)||jsonb_build_object(\'UserID\',c.legacy_id,\'UserName\',COALESCE(NULLIF(u."username",\'\'),\'reading_\'||u."objectId"),\'HoneyName\',COALESCE(NULLIF(u."nickname",\'\'),NULLIF(u."realName",\'\'),NULLIF(u."username",\'\'),\'阅读会员\'),\'Mobile\',COALESCE(NULLIF(u."mobile",\'\'),NULLIF(u."phone",\'\'),CASE WHEN u."username"~\'^1[0-9]{10}$\' THEN u."username" ELSE \'\' END),\'GroupID\',1,\'ParentUserID\',0,\'RegTime\',COALESCE(u."registeredAt",u."createdAt"),\'State\',1,\'ReadingUnifiedAt\',NOW()),"updatedAt"=NOW() FROM candidates c WHERE u."objectId"=c."objectId" RETURNING u."objectId") SELECT COUNT(*)::int users FROM updated',[companyId,JSON.stringify(promote)]):{users:0};
|
|
|
+ const promoted={users:0};
|
|
|
response.json({success:true,data:{merged,promoted}});
|
|
|
}catch(error){response.status(Number(error.status)||500).json({success:false,message:String(error.message||error)});}
|
|
|
}`;
|
|
|
@@ -221,8 +153,9 @@ const summary = {
|
|
|
maxMergeSourceIdLength: Math.max(0, ...plan.merge.map((item) => item.source.length)),
|
|
|
maxMergeTargetIdLength: Math.max(0, ...plan.merge.map((item) => item.target.length)),
|
|
|
nameResolved: plan.nameResolved,
|
|
|
- ambiguousPromoted: plan.ambiguous,
|
|
|
- unmatchedPromoted: plan.unmatched,
|
|
|
+ heldForVerification: plan.held,
|
|
|
+ ambiguousHeld: plan.ambiguous,
|
|
|
+ unmatchedHeld: plan.unmatched,
|
|
|
surveyItemsToMove: plan.merge.reduce((total, item) => total + (ownersBefore.SurveyItem.counts.get(item.source) || 0), 0),
|
|
|
surveyLogsToMove: plan.merge.reduce((total, item) => total + (ownersBefore.SurveyLog.counts.get(item.source) || 0), 0),
|
|
|
};
|
|
|
@@ -248,7 +181,10 @@ try {
|
|
|
surveyItems: ownersAfter.SurveyItem.records,
|
|
|
surveyLogs: ownersAfter.SurveyLog.records,
|
|
|
};
|
|
|
- if (remaining.length || invalidOwners.size) throw new Error(`合并后验证失败:未归一账号 ${remaining.length},无效阅读数据归属 ${invalidOwners.size}`);
|
|
|
+ // Unverified reading owners remain untouched, instead of receiving invented legacy IDs.
|
|
|
+ const remainingPlan = buildPlan(usersAfter);
|
|
|
+ if (remainingPlan.merge.length) throw new Error('合并后仍有已核实待合并账号');
|
|
|
+ if (ownersAfter.SurveyItem.records !== ownersBefore.SurveyItem.records || ownersAfter.SurveyLog.records !== ownersBefore.SurveyLog.records) throw new Error('阅读记录数量变化');
|
|
|
}
|
|
|
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
|
} finally {
|