merge-reading-users.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. #!/usr/bin/env node
  2. import { randomBytes } from 'node:crypto';
  3. import { buildPlan, isReadingImport, isCanonical, text } from './lib/reading-account-policy.mjs';
  4. const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
  5. const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
  6. const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
  7. const FUNCTION_URL = PARSE_URL.replace(/\/parse$/, '/api/functions');
  8. const COMPANY_ID = process.env.XIAOSHU_COMPANY_ID || '7pIbDBJmKx';
  9. const commit = process.argv.includes('--commit');
  10. if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
  11. const headers = {
  12. 'X-Parse-Application-Id': APP_ID,
  13. 'X-Parse-Master-Key': MASTER_KEY,
  14. 'Content-Type': 'application/json',
  15. };
  16. const company = { __type: 'Pointer', className: 'Company', objectId: COMPANY_ID };
  17. const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
  18. async function parse(path, init = {}, retries = 4) {
  19. try {
  20. const response = await fetch(`${PARSE_URL}${path}`, {
  21. ...init,
  22. headers: { ...headers, ...(init.headers || {}) },
  23. });
  24. const payload = await response.json().catch(() => ({}));
  25. if (!response.ok || payload.error) {
  26. const error = new Error(typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status }));
  27. error.status = response.status;
  28. throw error;
  29. }
  30. return payload;
  31. } catch (error) {
  32. if (!retries || (error.status && error.status < 500 && error.status !== 429)) throw error;
  33. await sleep((5 - retries) * 800);
  34. return parse(path, init, retries - 1);
  35. }
  36. }
  37. async function allRows(path, where, keys) {
  38. const rows = [];
  39. for (let skip = 0; ; skip += 1000) {
  40. const query = new URLSearchParams({
  41. where: JSON.stringify(where),
  42. limit: '1000',
  43. skip: String(skip),
  44. order: 'objectId',
  45. keys,
  46. });
  47. const page = (await parse(`${path}?${query}`)).results || [];
  48. rows.push(...page);
  49. if (page.length < 1000) break;
  50. }
  51. return rows;
  52. }
  53. async function ownerStats() {
  54. const result = {};
  55. for (const className of ['SurveyItem', 'SurveyLog']) {
  56. const rows = await allRows(`/classes/${className}`, { user: { $exists: true } }, 'objectId,user');
  57. const counts = new Map();
  58. for (const row of rows) {
  59. const id = text(row.user?.objectId || row.user).replace(/^_User\$/, '');
  60. if (id) counts.set(id, (counts.get(id) || 0) + 1);
  61. }
  62. result[className] = { records: rows.length, owners: counts.size, counts };
  63. }
  64. return result;
  65. }
  66. let temporaryUserId = '';
  67. let temporaryFunctionId = '';
  68. async function executePlan(plan) {
  69. const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`;
  70. const username = `reading_merge_${suffix}`;
  71. const password = `${randomBytes(24).toString('base64url')}Aa9!`;
  72. const path = `xiaoshu/system/merge-reading-users-${suffix}`;
  73. const created = await parse('/users', {
  74. method: 'POST',
  75. body: JSON.stringify({
  76. username, password, company, isAdmin: true, role: 'admin',
  77. roles: ['admin', 'super-admin'], adminRoleKey: 'super-admin',
  78. realName: '阅读账号合并临时管理员', testCreatedBy: 'merge-reading-users',
  79. }),
  80. });
  81. temporaryUserId = created.objectId;
  82. const token = created.sessionToken || (await parse('/login', {
  83. method: 'POST', body: JSON.stringify({ username, password }),
  84. })).sessionToken;
  85. const embeddedMerge = JSON.stringify(plan.merge).replace(/</g, '\\u003c');
  86. const code = String.raw`
  87. async function handler(request,response){
  88. try{
  89. const current=request.user||(typeof user!=='undefined'?user:null);if(!current)return response.status(401).json({success:false,message:'需要超级管理员会话'});await current.fetch({useMasterKey:true});
  90. 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:'仅超级管理员可执行'});
  91. const companyId=${JSON.stringify(COMPANY_ID)},merge=${embeddedMerge};
  92. await Psql.none('ALTER TABLE "SurveyLog" ALTER COLUMN "user" TYPE text USING CAST("user" AS text)');
  93. 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};
  94. const promoted={users:0};
  95. response.json({success:true,data:{merged,promoted}});
  96. }catch(error){response.status(Number(error.status)||500).json({success:false,message:String(error.message||error)});}
  97. }`;
  98. const fn = await parse('/classes/Function', {
  99. method: 'POST',
  100. body: JSON.stringify({
  101. name: path, desc: '一次性合并阅读账号与原会员', type: 'standalone', path, code,
  102. params: [], paramList: [], respType: 'json', respJson: { success: true }, enabled: true,
  103. }),
  104. });
  105. temporaryFunctionId = fn.objectId;
  106. const response = await fetch(`${FUNCTION_URL}/${path}`, {
  107. method: 'POST',
  108. headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
  109. body: JSON.stringify({ token, params: {} }),
  110. });
  111. const payload = await response.json().catch(() => ({}));
  112. if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `账号合并失败:${response.status}`);
  113. return payload.data;
  114. }
  115. async function cleanup() {
  116. if (temporaryFunctionId) await parse(`/classes/Function/${temporaryFunctionId}`, { method: 'DELETE' }).catch(() => undefined);
  117. if (temporaryUserId) {
  118. const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: temporaryUserId } }));
  119. const sessions = await parse(`/classes/_Session?where=${where}&limit=1000&keys=objectId`).catch(() => ({ results: [] }));
  120. for (const session of sessions.results || []) await parse(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined);
  121. await parse(`/users/${temporaryUserId}`, { method: 'DELETE' }).catch(() => undefined);
  122. }
  123. }
  124. const userKeys = 'objectId,username,nickname,nickName,realName,name,mobile,phone,mobilePhoneNumber,level,company,sourceKey,legacyUserId,legacyGroupId,legacyUserData,identityType,isAdmin,isDisabled,isDeleted,createdAt,registeredAt';
  125. const usersBefore = await allRows('/users', { company }, userKeys);
  126. const ownersBefore = await ownerStats();
  127. const plan = buildPlan(usersBefore);
  128. const sourceIds = new Set(plan.sources.map((row) => row.objectId));
  129. const readingSources = new Set();
  130. for (const value of Object.values(ownersBefore)) {
  131. for (const owner of value.counts.keys()) if (sourceIds.has(owner)) readingSources.add(owner);
  132. }
  133. const summary = {
  134. mode: commit ? 'commit' : 'dry-run',
  135. usersBefore: usersBefore.length,
  136. canonicalUsersBefore: plan.targets.length,
  137. readingImports: plan.sources.length,
  138. readingImportsWithRecords: readingSources.size,
  139. mergeUsers: plan.merge.length,
  140. promoteUsers: plan.promote.length,
  141. maxMergeSourceIdLength: Math.max(0, ...plan.merge.map((item) => item.source.length)),
  142. maxMergeTargetIdLength: Math.max(0, ...plan.merge.map((item) => item.target.length)),
  143. nameResolved: plan.nameResolved,
  144. heldForVerification: plan.held,
  145. ambiguousHeld: plan.ambiguous,
  146. unmatchedHeld: plan.unmatched,
  147. surveyItemsToMove: plan.merge.reduce((total, item) => total + (ownersBefore.SurveyItem.counts.get(item.source) || 0), 0),
  148. surveyLogsToMove: plan.merge.reduce((total, item) => total + (ownersBefore.SurveyLog.counts.get(item.source) || 0), 0),
  149. };
  150. try {
  151. if (commit && (plan.merge.length || plan.promote.length)) summary.executed = await executePlan(plan);
  152. if (commit) {
  153. const usersAfter = await allRows('/users', { company }, userKeys);
  154. const ownersAfter = await ownerStats();
  155. const remaining = usersAfter.filter(isReadingImport);
  156. const activeById = new Map(usersAfter.filter((row) => !row.isDeleted).map((row) => [row.objectId, row]));
  157. const invalidOwners = new Set();
  158. for (const value of Object.values(ownersAfter)) {
  159. for (const owner of value.counts.keys()) {
  160. const row = activeById.get(owner);
  161. if (!row || (!isCanonical(row) && !text(row.sourceKey).startsWith('reading-unified:user:'))) invalidOwners.add(owner);
  162. }
  163. }
  164. summary.verification = {
  165. usersAfter: usersAfter.length,
  166. remainingUnnormalizedUsers: remaining.length,
  167. invalidReadingOwners: invalidOwners.size,
  168. surveyItems: ownersAfter.SurveyItem.records,
  169. surveyLogs: ownersAfter.SurveyLog.records,
  170. };
  171. // Unverified reading owners remain untouched, instead of receiving invented legacy IDs.
  172. const remainingPlan = buildPlan(usersAfter);
  173. if (remainingPlan.merge.length) throw new Error('合并后仍有已核实待合并账号');
  174. if (ownersAfter.SurveyItem.records !== ownersBefore.SurveyItem.records || ownersAfter.SurveyLog.records !== ownersBefore.SurveyLog.records) throw new Error('阅读记录数量变化');
  175. }
  176. process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
  177. } finally {
  178. await cleanup();
  179. }