| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- import fs from 'node:fs';
- import path from 'node:path';
- const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions';
- const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
- const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
- const CONFIRM_TEXT = 'SOFT_DELETE_IP_OPERATOR_CANDIDATES';
- const CONFIRM = readArg('--confirm') || process.env.IP_CLEANUP_CONFIRM || '';
- const INPUT = readArg('--input') || process.env.IP_CLEANUP_REPORT || '';
- const CONCURRENCY = clamp(Number(process.env.IP_CLEANUP_CONCURRENCY || readArg('--concurrency') || 3), 1, 5);
- const OUTPUT_DIR = process.env.IP_CLEANUP_OUTPUT_DIR
- ? path.resolve(process.env.IP_CLEANUP_OUTPUT_DIR)
- : path.join(process.cwd(), 'tmp');
- const allowedEntityTypes = new Set([
- 'ipOperator.accountSnapshot',
- 'ipOperator.accountSnapshotWork',
- 'ipOperator.accountSnapshotComment',
- 'ipOperator.planEvidenceItem',
- 'ipOperator.planCommentPainInsight',
- ]);
- const protectedEntityTypes = new Set([
- 'ipOperator.account',
- 'ipOperator.profile',
- 'ipOperator.plan',
- ]);
- if (!SESSION_TOKEN) {
- console.error('Missing SMOKE_SESSION_TOKEN. Refusing to modify cloud data.');
- process.exit(1);
- }
- if (!INPUT) {
- console.error('Missing --input <dry-run-report.json> or IP_CLEANUP_REPORT.');
- process.exit(1);
- }
- const inputPath = path.resolve(INPUT);
- const report = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
- const candidates = Array.isArray(report.candidates) ? report.candidates : [];
- const activeTargets = [];
- const skipped = [];
- for (const row of candidates) {
- const entityType = String(row.entityType || '');
- const entityId = String(row.entityId || '');
- const status = String(row.status || 'active');
- if (!entityType || !entityId) {
- skipped.push({ ...row, skippedReason: 'missing entityType/entityId' });
- continue;
- }
- if (protectedEntityTypes.has(entityType)) {
- skipped.push({ ...row, skippedReason: 'protected entity type' });
- continue;
- }
- if (!allowedEntityTypes.has(entityType)) {
- skipped.push({ ...row, skippedReason: 'not in cleanup allowlist' });
- continue;
- }
- if (status !== 'active') {
- skipped.push({ ...row, skippedReason: `status is ${status}; soft delete not needed` });
- continue;
- }
- activeTargets.push({
- accountId: row.accountId || '',
- entityType,
- entityId,
- reason: row.reason || '',
- status,
- });
- }
- console.log(`IP operator soft cleanup input=${inputPath}`);
- console.log(`dryRunGeneratedAt=${report.generatedAt || '(unknown)'}`);
- console.log(`candidateRows=${candidates.length}`);
- console.log(`activeTargets=${activeTargets.length}`);
- console.log(`skipped=${skipped.length}`);
- console.log(`concurrency=${CONCURRENCY}`);
- console.log('');
- if (CONFIRM !== CONFIRM_TEXT) {
- console.log('No cleanup executed.');
- console.log(`To execute soft delete, rerun with: --confirm ${CONFIRM_TEXT}`);
- console.log('This will call systemStorage.delete for active allowlisted candidate rows only.');
- process.exit(2);
- }
- const cloudFunctionsPath = path.join(process.cwd(), 'src', 'app', 'services', 'cloud-functions.ts');
- const fn = readCloudFunctionIds(cloudFunctionsPath);
- if (!fn.systemStorage) {
- console.error('CLOUD_FN.systemStorage is empty. Check src/app/services/cloud-functions.ts.');
- process.exit(1);
- }
- const startedAt = new Date().toISOString();
- const results = [];
- let index = 0;
- await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
- while (index < activeTargets.length) {
- const currentIndex = index;
- index += 1;
- const target = activeTargets[currentIndex];
- const result = await softDelete(target);
- results[currentIndex] = result;
- if ((currentIndex + 1) % 50 === 0 || currentIndex + 1 === activeTargets.length) {
- console.log(`progress ${currentIndex + 1}/${activeTargets.length}`);
- }
- }
- }));
- const completedAt = new Date().toISOString();
- const failures = results.filter((item) => !item.ok);
- const successCount = results.length - failures.length;
- const output = {
- startedAt,
- completedAt,
- inputPath,
- dryRunGeneratedAt: report.generatedAt || '',
- activeTargets: activeTargets.length,
- successCount,
- failureCount: failures.length,
- skippedCount: skipped.length,
- skipped,
- failures,
- results,
- };
- fs.mkdirSync(OUTPUT_DIR, { recursive: true });
- const outputPath = path.join(OUTPUT_DIR, `ip-operator-cleanup-soft-delete-${safeTimestamp(completedAt)}.json`);
- fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
- console.log('');
- console.log(`Soft cleanup complete. success=${successCount} failures=${failures.length} skipped=${skipped.length}`);
- console.log(`Result report: ${outputPath}`);
- if (failures.length) process.exit(1);
- async function softDelete(target) {
- const result = await call(fn.systemStorage, {
- action: 'delete',
- entityType: target.entityType,
- entityId: target.entityId,
- });
- const ok = Number(result?.code || 0) === 200 && result?.success !== false;
- return {
- ...target,
- ok,
- responseCode: result?.code,
- error: ok ? '' : result?.error || result?.message || JSON.stringify(result).slice(0, 500),
- };
- }
- async function call(id, body) {
- let lastResult = null;
- let lastError = null;
- for (let attempt = 1; attempt <= 4; attempt += 1) {
- try {
- const response = await fetch(FN_URL, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-Parse-Application-Id': APP_ID,
- },
- body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken: SESSION_TOKEN, ...body }),
- });
- const text = await response.text();
- let result;
- try {
- result = JSON.parse(text);
- } catch {
- result = { code: response.status, success: false, error: text };
- }
- lastResult = result;
- if (attempt < 4 && isRetryableResult(result)) {
- await sleep(600 * attempt);
- continue;
- }
- return result;
- } catch (error) {
- lastError = error;
- const message = `${error?.message || ''} ${error?.cause?.code || ''}`;
- if (attempt < 4 && isRetryableMessage(message)) {
- await sleep(600 * attempt);
- continue;
- }
- return { code: 500, success: false, error: error?.message || 'fetch failed' };
- }
- }
- if (lastResult) return lastResult;
- return { code: 500, success: false, error: lastError?.message || 'fetch failed' };
- }
- function readArg(name) {
- const index = process.argv.indexOf(name);
- return index >= 0 ? process.argv[index + 1] || '' : '';
- }
- function readCloudFunctionIds(filePath) {
- const text = fs.readFileSync(filePath, 'utf8');
- const ids = {};
- const re = /(\w+):\s*'([^']*)'/g;
- let match;
- while ((match = re.exec(text))) ids[match[1]] = match[2];
- return ids;
- }
- function isRetryableResult(result) {
- const message = `${result?.error || ''} ${result?.message || ''}`;
- return Number(result?.code || 0) >= 500 || isRetryableMessage(message);
- }
- function isRetryableMessage(message) {
- return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || '');
- }
- function sleep(ms) {
- return new Promise((resolve) => setTimeout(resolve, ms));
- }
- function clamp(value, min, max) {
- if (!Number.isFinite(value)) return min;
- return Math.max(min, Math.min(max, Math.floor(value)));
- }
- function safeTimestamp(value) {
- return String(value).replace(/[:.]/g, '-');
- }
|