ip-operator-cleanup-physical-delete.mjs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. const PARSE_HOST = normalizeParseHost(process.env.SMOKE_PARSE_HOST || 'https://server.fmode.cn');
  4. const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
  5. const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
  6. const CONFIRM_TEXT = 'PHYSICAL_DELETE_IP_OPERATOR_CANDIDATES';
  7. const CONFIRM = readArg('--confirm') || process.env.IP_CLEANUP_CONFIRM || '';
  8. const INPUT = readArg('--input') || process.env.IP_CLEANUP_REPORT || '';
  9. const EXECUTE = CONFIRM === CONFIRM_TEXT;
  10. const CONCURRENCY = clamp(Number(process.env.IP_CLEANUP_CONCURRENCY || readArg('--concurrency') || 2), 1, 4);
  11. const OUTPUT_DIR = process.env.IP_CLEANUP_OUTPUT_DIR
  12. ? path.resolve(process.env.IP_CLEANUP_OUTPUT_DIR)
  13. : path.join(process.cwd(), 'tmp');
  14. const PROJECT_KEY = 'video-workflow';
  15. const CLASS_NAME = 'VideoWorkflowEntity';
  16. const allowedEntityTypes = new Set([
  17. 'ipOperator.accountSnapshot',
  18. 'ipOperator.accountSnapshotWork',
  19. 'ipOperator.accountSnapshotComment',
  20. 'ipOperator.planEvidenceItem',
  21. 'ipOperator.planCommentPainInsight',
  22. ]);
  23. const protectedEntityTypes = new Set([
  24. 'ipOperator.account',
  25. 'ipOperator.profile',
  26. 'ipOperator.plan',
  27. ]);
  28. if (!SESSION_TOKEN) {
  29. console.error('Missing SMOKE_SESSION_TOKEN. Refusing to access Parse.');
  30. process.exit(1);
  31. }
  32. if (!INPUT) {
  33. console.error('Missing --input <cleanup-report.json> or IP_CLEANUP_REPORT.');
  34. process.exit(1);
  35. }
  36. const inputPath = path.resolve(INPUT);
  37. const report = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
  38. const sourceCandidates = Array.isArray(report.candidates)
  39. ? report.candidates
  40. : [
  41. ...toArray(report.results).map((item) => ({ ...item, status: item.status || 'deleted' })),
  42. ...toArray(report.skipped).map((item) => ({ ...item, status: item.status || 'deleted' })),
  43. ];
  44. const candidates = normalizeCandidates(sourceCandidates);
  45. console.log(`IP operator physical cleanup input=${inputPath}`);
  46. console.log(`mode=${EXECUTE ? 'EXECUTE_PHYSICAL_DELETE' : 'RESOLVE_ONLY_DRY_RUN'}`);
  47. console.log(`candidateRows=${sourceCandidates.length}`);
  48. console.log(`allowlistedTargets=${candidates.length}`);
  49. console.log(`concurrency=${CONCURRENCY}`);
  50. console.log('');
  51. const me = await parseRequest('GET', '/users/me');
  52. const ownerId = me?.objectId || '';
  53. if (!ownerId) {
  54. console.error('Could not resolve current Parse user from SMOKE_SESSION_TOKEN.');
  55. process.exit(1);
  56. }
  57. const startedAt = new Date().toISOString();
  58. const resolved = [];
  59. let resolveIndex = 0;
  60. await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
  61. while (resolveIndex < candidates.length) {
  62. const currentIndex = resolveIndex;
  63. resolveIndex += 1;
  64. const target = candidates[currentIndex];
  65. resolved[currentIndex] = await resolveTarget(target, ownerId);
  66. if ((currentIndex + 1) % 100 === 0 || currentIndex + 1 === candidates.length) {
  67. console.log(`resolve progress ${currentIndex + 1}/${candidates.length}`);
  68. }
  69. }
  70. }));
  71. const resolvable = resolved.filter((item) => item.objectId);
  72. const unresolved = resolved.filter((item) => !item.objectId);
  73. console.log('');
  74. console.log(`resolvedObjectIds=${resolvable.length}`);
  75. console.log(`unresolved=${unresolved.length}`);
  76. let deleteResults = [];
  77. if (!EXECUTE) {
  78. console.log('');
  79. console.log('No physical delete executed.');
  80. console.log(`To execute irreversible physical delete, rerun with: --confirm ${CONFIRM_TEXT}`);
  81. } else {
  82. deleteResults = [];
  83. let deleteIndex = 0;
  84. await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
  85. while (deleteIndex < resolvable.length) {
  86. const currentIndex = deleteIndex;
  87. deleteIndex += 1;
  88. const target = resolvable[currentIndex];
  89. deleteResults[currentIndex] = await deleteTarget(target);
  90. if ((currentIndex + 1) % 100 === 0 || currentIndex + 1 === resolvable.length) {
  91. console.log(`delete progress ${currentIndex + 1}/${resolvable.length}`);
  92. }
  93. }
  94. }));
  95. }
  96. const completedAt = new Date().toISOString();
  97. const deleteFailures = deleteResults.filter((item) => !item.ok);
  98. const output = {
  99. startedAt,
  100. completedAt,
  101. mode: EXECUTE ? 'execute' : 'dry-run',
  102. inputPath,
  103. ownerId,
  104. sourceCandidateRows: sourceCandidates.length,
  105. allowlistedTargets: candidates.length,
  106. resolvedObjectIds: resolvable.length,
  107. unresolvedCount: unresolved.length,
  108. deleteAttempted: deleteResults.length,
  109. deleteSuccess: deleteResults.filter((item) => item.ok).length,
  110. deleteFailures: deleteFailures.length,
  111. resolved,
  112. unresolved,
  113. deleteResults,
  114. };
  115. fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  116. const outputPath = path.join(
  117. OUTPUT_DIR,
  118. `ip-operator-cleanup-physical-${EXECUTE ? 'delete' : 'dry-run'}-${safeTimestamp(completedAt)}.json`,
  119. );
  120. fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
  121. console.log('');
  122. console.log(`Physical cleanup ${EXECUTE ? 'execute' : 'dry-run'} complete.`);
  123. console.log(`Result report: ${outputPath}`);
  124. if (deleteFailures.length) process.exit(1);
  125. function normalizeCandidates(rows) {
  126. const seen = new Set();
  127. const output = [];
  128. for (const row of rows) {
  129. const entityType = String(row.entityType || '');
  130. const entityId = String(row.entityId || '');
  131. if (!entityType || !entityId) continue;
  132. if (protectedEntityTypes.has(entityType)) continue;
  133. if (!allowedEntityTypes.has(entityType)) continue;
  134. const key = `${entityType}:${entityId}`;
  135. if (seen.has(key)) continue;
  136. seen.add(key);
  137. output.push({
  138. accountId: row.accountId || '',
  139. entityType,
  140. entityId,
  141. status: row.status || '',
  142. reason: row.reason || '',
  143. });
  144. }
  145. return output;
  146. }
  147. async function resolveTarget(target, ownerId) {
  148. const where = {
  149. projectKey: PROJECT_KEY,
  150. owner: { __type: 'Pointer', className: '_User', objectId: ownerId },
  151. entityType: target.entityType,
  152. entityId: target.entityId,
  153. };
  154. const query = new URLSearchParams();
  155. query.set('where', JSON.stringify(where));
  156. query.set('limit', '2');
  157. query.set('keys', 'objectId,entityType,entityId,status,ownerId,projectKey,createdAt,updatedAt');
  158. const result = await parseRequest('GET', `/classes/${encodeURIComponent(CLASS_NAME)}?${query.toString()}`);
  159. const rows = Array.isArray(result.results) ? result.results : [];
  160. return {
  161. ...target,
  162. objectId: rows.length === 1 ? rows[0].objectId : '',
  163. matchedRows: rows.length,
  164. currentStatus: rows[0]?.status || '',
  165. createdAt: rows[0]?.createdAt || '',
  166. updatedAt: rows[0]?.updatedAt || '',
  167. resolveError: rows.length > 1 ? 'multiple rows matched; refusing to delete' : rows.length === 0 ? 'not found' : '',
  168. };
  169. }
  170. async function deleteTarget(target) {
  171. try {
  172. await parseRequest('DELETE', `/classes/${encodeURIComponent(CLASS_NAME)}/${encodeURIComponent(target.objectId)}`);
  173. return { ...target, ok: true, error: '' };
  174. } catch (error) {
  175. return { ...target, ok: false, error: error?.message || 'delete failed' };
  176. }
  177. }
  178. async function parseRequest(method, parsePath) {
  179. const headers = {
  180. Accept: 'application/json',
  181. 'X-Parse-Application-Id': APP_ID,
  182. 'X-Parse-Session-Token': SESSION_TOKEN,
  183. };
  184. let lastError = null;
  185. for (let attempt = 1; attempt <= 4; attempt += 1) {
  186. try {
  187. const response = await fetch(`${PARSE_HOST}/parse${parsePath}`, { method, headers });
  188. const data = await response.json().catch(() => ({}));
  189. if (!response.ok || data.error) {
  190. const error = new Error(data.error || data.message || `Parse ${method} ${parsePath} failed`);
  191. error.status = response.status || 500;
  192. error.detail = data;
  193. if (attempt < 4 && isRetryableError(error)) {
  194. await sleep(500 * attempt);
  195. continue;
  196. }
  197. throw error;
  198. }
  199. return data;
  200. } catch (error) {
  201. lastError = error;
  202. if (attempt < 4 && isRetryableError(error)) {
  203. await sleep(500 * attempt);
  204. continue;
  205. }
  206. throw error;
  207. }
  208. }
  209. throw lastError || new Error(`Parse ${method} ${parsePath} failed`);
  210. }
  211. function isRetryableError(error) {
  212. const status = Number(error?.status || 0);
  213. const message = `${error?.message || ''} ${error?.cause?.code || ''} ${error?.detail?.error || ''}`;
  214. return status >= 500 || /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message);
  215. }
  216. function readArg(name) {
  217. const index = process.argv.indexOf(name);
  218. return index >= 0 ? process.argv[index + 1] || '' : '';
  219. }
  220. function toArray(value) {
  221. return Array.isArray(value) ? value : [];
  222. }
  223. function normalizeParseHost(value) {
  224. return String(value || 'https://server.fmode.cn').replace(/\/+$/, '').replace(/\/parse$/i, '');
  225. }
  226. function clamp(value, min, max) {
  227. if (!Number.isFinite(value)) return min;
  228. return Math.max(min, Math.min(max, Math.floor(value)));
  229. }
  230. function sleep(ms) {
  231. return new Promise((resolve) => setTimeout(resolve, ms));
  232. }
  233. function safeTimestamp(value) {
  234. return String(value).replace(/[:.]/g, '-');
  235. }