ip-operator-cleanup-soft-delete.mjs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions';
  4. const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
  5. const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
  6. const CONFIRM_TEXT = 'SOFT_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 CONCURRENCY = clamp(Number(process.env.IP_CLEANUP_CONCURRENCY || readArg('--concurrency') || 3), 1, 5);
  10. const OUTPUT_DIR = process.env.IP_CLEANUP_OUTPUT_DIR
  11. ? path.resolve(process.env.IP_CLEANUP_OUTPUT_DIR)
  12. : path.join(process.cwd(), 'tmp');
  13. const allowedEntityTypes = new Set([
  14. 'ipOperator.accountSnapshot',
  15. 'ipOperator.accountSnapshotWork',
  16. 'ipOperator.accountSnapshotComment',
  17. 'ipOperator.planEvidenceItem',
  18. 'ipOperator.planCommentPainInsight',
  19. ]);
  20. const protectedEntityTypes = new Set([
  21. 'ipOperator.account',
  22. 'ipOperator.profile',
  23. 'ipOperator.plan',
  24. ]);
  25. if (!SESSION_TOKEN) {
  26. console.error('Missing SMOKE_SESSION_TOKEN. Refusing to modify cloud data.');
  27. process.exit(1);
  28. }
  29. if (!INPUT) {
  30. console.error('Missing --input <dry-run-report.json> or IP_CLEANUP_REPORT.');
  31. process.exit(1);
  32. }
  33. const inputPath = path.resolve(INPUT);
  34. const report = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
  35. const candidates = Array.isArray(report.candidates) ? report.candidates : [];
  36. const activeTargets = [];
  37. const skipped = [];
  38. for (const row of candidates) {
  39. const entityType = String(row.entityType || '');
  40. const entityId = String(row.entityId || '');
  41. const status = String(row.status || 'active');
  42. if (!entityType || !entityId) {
  43. skipped.push({ ...row, skippedReason: 'missing entityType/entityId' });
  44. continue;
  45. }
  46. if (protectedEntityTypes.has(entityType)) {
  47. skipped.push({ ...row, skippedReason: 'protected entity type' });
  48. continue;
  49. }
  50. if (!allowedEntityTypes.has(entityType)) {
  51. skipped.push({ ...row, skippedReason: 'not in cleanup allowlist' });
  52. continue;
  53. }
  54. if (status !== 'active') {
  55. skipped.push({ ...row, skippedReason: `status is ${status}; soft delete not needed` });
  56. continue;
  57. }
  58. activeTargets.push({
  59. accountId: row.accountId || '',
  60. entityType,
  61. entityId,
  62. reason: row.reason || '',
  63. status,
  64. });
  65. }
  66. console.log(`IP operator soft cleanup input=${inputPath}`);
  67. console.log(`dryRunGeneratedAt=${report.generatedAt || '(unknown)'}`);
  68. console.log(`candidateRows=${candidates.length}`);
  69. console.log(`activeTargets=${activeTargets.length}`);
  70. console.log(`skipped=${skipped.length}`);
  71. console.log(`concurrency=${CONCURRENCY}`);
  72. console.log('');
  73. if (CONFIRM !== CONFIRM_TEXT) {
  74. console.log('No cleanup executed.');
  75. console.log(`To execute soft delete, rerun with: --confirm ${CONFIRM_TEXT}`);
  76. console.log('This will call systemStorage.delete for active allowlisted candidate rows only.');
  77. process.exit(2);
  78. }
  79. const cloudFunctionsPath = path.join(process.cwd(), 'src', 'app', 'services', 'cloud-functions.ts');
  80. const fn = readCloudFunctionIds(cloudFunctionsPath);
  81. if (!fn.systemStorage) {
  82. console.error('CLOUD_FN.systemStorage is empty. Check src/app/services/cloud-functions.ts.');
  83. process.exit(1);
  84. }
  85. const startedAt = new Date().toISOString();
  86. const results = [];
  87. let index = 0;
  88. await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
  89. while (index < activeTargets.length) {
  90. const currentIndex = index;
  91. index += 1;
  92. const target = activeTargets[currentIndex];
  93. const result = await softDelete(target);
  94. results[currentIndex] = result;
  95. if ((currentIndex + 1) % 50 === 0 || currentIndex + 1 === activeTargets.length) {
  96. console.log(`progress ${currentIndex + 1}/${activeTargets.length}`);
  97. }
  98. }
  99. }));
  100. const completedAt = new Date().toISOString();
  101. const failures = results.filter((item) => !item.ok);
  102. const successCount = results.length - failures.length;
  103. const output = {
  104. startedAt,
  105. completedAt,
  106. inputPath,
  107. dryRunGeneratedAt: report.generatedAt || '',
  108. activeTargets: activeTargets.length,
  109. successCount,
  110. failureCount: failures.length,
  111. skippedCount: skipped.length,
  112. skipped,
  113. failures,
  114. results,
  115. };
  116. fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  117. const outputPath = path.join(OUTPUT_DIR, `ip-operator-cleanup-soft-delete-${safeTimestamp(completedAt)}.json`);
  118. fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
  119. console.log('');
  120. console.log(`Soft cleanup complete. success=${successCount} failures=${failures.length} skipped=${skipped.length}`);
  121. console.log(`Result report: ${outputPath}`);
  122. if (failures.length) process.exit(1);
  123. async function softDelete(target) {
  124. const result = await call(fn.systemStorage, {
  125. action: 'delete',
  126. entityType: target.entityType,
  127. entityId: target.entityId,
  128. });
  129. const ok = Number(result?.code || 0) === 200 && result?.success !== false;
  130. return {
  131. ...target,
  132. ok,
  133. responseCode: result?.code,
  134. error: ok ? '' : result?.error || result?.message || JSON.stringify(result).slice(0, 500),
  135. };
  136. }
  137. async function call(id, body) {
  138. let lastResult = null;
  139. let lastError = null;
  140. for (let attempt = 1; attempt <= 4; attempt += 1) {
  141. try {
  142. const response = await fetch(FN_URL, {
  143. method: 'POST',
  144. headers: {
  145. 'Content-Type': 'application/json',
  146. 'X-Parse-Application-Id': APP_ID,
  147. },
  148. body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken: SESSION_TOKEN, ...body }),
  149. });
  150. const text = await response.text();
  151. let result;
  152. try {
  153. result = JSON.parse(text);
  154. } catch {
  155. result = { code: response.status, success: false, error: text };
  156. }
  157. lastResult = result;
  158. if (attempt < 4 && isRetryableResult(result)) {
  159. await sleep(600 * attempt);
  160. continue;
  161. }
  162. return result;
  163. } catch (error) {
  164. lastError = error;
  165. const message = `${error?.message || ''} ${error?.cause?.code || ''}`;
  166. if (attempt < 4 && isRetryableMessage(message)) {
  167. await sleep(600 * attempt);
  168. continue;
  169. }
  170. return { code: 500, success: false, error: error?.message || 'fetch failed' };
  171. }
  172. }
  173. if (lastResult) return lastResult;
  174. return { code: 500, success: false, error: lastError?.message || 'fetch failed' };
  175. }
  176. function readArg(name) {
  177. const index = process.argv.indexOf(name);
  178. return index >= 0 ? process.argv[index + 1] || '' : '';
  179. }
  180. function readCloudFunctionIds(filePath) {
  181. const text = fs.readFileSync(filePath, 'utf8');
  182. const ids = {};
  183. const re = /(\w+):\s*'([^']*)'/g;
  184. let match;
  185. while ((match = re.exec(text))) ids[match[1]] = match[2];
  186. return ids;
  187. }
  188. function isRetryableResult(result) {
  189. const message = `${result?.error || ''} ${result?.message || ''}`;
  190. return Number(result?.code || 0) >= 500 || isRetryableMessage(message);
  191. }
  192. function isRetryableMessage(message) {
  193. return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || '');
  194. }
  195. function sleep(ms) {
  196. return new Promise((resolve) => setTimeout(resolve, ms));
  197. }
  198. function clamp(value, min, max) {
  199. if (!Number.isFinite(value)) return min;
  200. return Math.max(min, Math.min(max, Math.floor(value)));
  201. }
  202. function safeTimestamp(value) {
  203. return String(value).replace(/[:.]/g, '-');
  204. }