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 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, '-'); }