import fs from 'node:fs'; import path from 'node:path'; const PARSE_HOST = normalizeParseHost(process.env.SMOKE_PARSE_HOST || 'https://server.fmode.cn'); const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster'; const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || ''; const CONFIRM_TEXT = 'PHYSICAL_DELETE_IP_OPERATOR_CANDIDATES'; const CONFIRM = readArg('--confirm') || process.env.IP_CLEANUP_CONFIRM || ''; const INPUT = readArg('--input') || process.env.IP_CLEANUP_REPORT || ''; const EXECUTE = CONFIRM === CONFIRM_TEXT; const CONCURRENCY = clamp(Number(process.env.IP_CLEANUP_CONCURRENCY || readArg('--concurrency') || 2), 1, 4); const OUTPUT_DIR = process.env.IP_CLEANUP_OUTPUT_DIR ? path.resolve(process.env.IP_CLEANUP_OUTPUT_DIR) : path.join(process.cwd(), 'tmp'); const PROJECT_KEY = 'video-workflow'; const CLASS_NAME = 'VideoWorkflowEntity'; 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 access Parse.'); 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 sourceCandidates = Array.isArray(report.candidates) ? report.candidates : [ ...toArray(report.results).map((item) => ({ ...item, status: item.status || 'deleted' })), ...toArray(report.skipped).map((item) => ({ ...item, status: item.status || 'deleted' })), ]; const candidates = normalizeCandidates(sourceCandidates); console.log(`IP operator physical cleanup input=${inputPath}`); console.log(`mode=${EXECUTE ? 'EXECUTE_PHYSICAL_DELETE' : 'RESOLVE_ONLY_DRY_RUN'}`); console.log(`candidateRows=${sourceCandidates.length}`); console.log(`allowlistedTargets=${candidates.length}`); console.log(`concurrency=${CONCURRENCY}`); console.log(''); const me = await parseRequest('GET', '/users/me'); const ownerId = me?.objectId || ''; if (!ownerId) { console.error('Could not resolve current Parse user from SMOKE_SESSION_TOKEN.'); process.exit(1); } const startedAt = new Date().toISOString(); const resolved = []; let resolveIndex = 0; await Promise.all(Array.from({ length: CONCURRENCY }, async () => { while (resolveIndex < candidates.length) { const currentIndex = resolveIndex; resolveIndex += 1; const target = candidates[currentIndex]; resolved[currentIndex] = await resolveTarget(target, ownerId); if ((currentIndex + 1) % 100 === 0 || currentIndex + 1 === candidates.length) { console.log(`resolve progress ${currentIndex + 1}/${candidates.length}`); } } })); const resolvable = resolved.filter((item) => item.objectId); const unresolved = resolved.filter((item) => !item.objectId); console.log(''); console.log(`resolvedObjectIds=${resolvable.length}`); console.log(`unresolved=${unresolved.length}`); let deleteResults = []; if (!EXECUTE) { console.log(''); console.log('No physical delete executed.'); console.log(`To execute irreversible physical delete, rerun with: --confirm ${CONFIRM_TEXT}`); } else { deleteResults = []; let deleteIndex = 0; await Promise.all(Array.from({ length: CONCURRENCY }, async () => { while (deleteIndex < resolvable.length) { const currentIndex = deleteIndex; deleteIndex += 1; const target = resolvable[currentIndex]; deleteResults[currentIndex] = await deleteTarget(target); if ((currentIndex + 1) % 100 === 0 || currentIndex + 1 === resolvable.length) { console.log(`delete progress ${currentIndex + 1}/${resolvable.length}`); } } })); } const completedAt = new Date().toISOString(); const deleteFailures = deleteResults.filter((item) => !item.ok); const output = { startedAt, completedAt, mode: EXECUTE ? 'execute' : 'dry-run', inputPath, ownerId, sourceCandidateRows: sourceCandidates.length, allowlistedTargets: candidates.length, resolvedObjectIds: resolvable.length, unresolvedCount: unresolved.length, deleteAttempted: deleteResults.length, deleteSuccess: deleteResults.filter((item) => item.ok).length, deleteFailures: deleteFailures.length, resolved, unresolved, deleteResults, }; fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const outputPath = path.join( OUTPUT_DIR, `ip-operator-cleanup-physical-${EXECUTE ? 'delete' : 'dry-run'}-${safeTimestamp(completedAt)}.json`, ); fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8'); console.log(''); console.log(`Physical cleanup ${EXECUTE ? 'execute' : 'dry-run'} complete.`); console.log(`Result report: ${outputPath}`); if (deleteFailures.length) process.exit(1); function normalizeCandidates(rows) { const seen = new Set(); const output = []; for (const row of rows) { const entityType = String(row.entityType || ''); const entityId = String(row.entityId || ''); if (!entityType || !entityId) continue; if (protectedEntityTypes.has(entityType)) continue; if (!allowedEntityTypes.has(entityType)) continue; const key = `${entityType}:${entityId}`; if (seen.has(key)) continue; seen.add(key); output.push({ accountId: row.accountId || '', entityType, entityId, status: row.status || '', reason: row.reason || '', }); } return output; } async function resolveTarget(target, ownerId) { const where = { projectKey: PROJECT_KEY, owner: { __type: 'Pointer', className: '_User', objectId: ownerId }, entityType: target.entityType, entityId: target.entityId, }; const query = new URLSearchParams(); query.set('where', JSON.stringify(where)); query.set('limit', '2'); query.set('keys', 'objectId,entityType,entityId,status,ownerId,projectKey,createdAt,updatedAt'); const result = await parseRequest('GET', `/classes/${encodeURIComponent(CLASS_NAME)}?${query.toString()}`); const rows = Array.isArray(result.results) ? result.results : []; return { ...target, objectId: rows.length === 1 ? rows[0].objectId : '', matchedRows: rows.length, currentStatus: rows[0]?.status || '', createdAt: rows[0]?.createdAt || '', updatedAt: rows[0]?.updatedAt || '', resolveError: rows.length > 1 ? 'multiple rows matched; refusing to delete' : rows.length === 0 ? 'not found' : '', }; } async function deleteTarget(target) { try { await parseRequest('DELETE', `/classes/${encodeURIComponent(CLASS_NAME)}/${encodeURIComponent(target.objectId)}`); return { ...target, ok: true, error: '' }; } catch (error) { return { ...target, ok: false, error: error?.message || 'delete failed' }; } } async function parseRequest(method, parsePath) { const headers = { Accept: 'application/json', 'X-Parse-Application-Id': APP_ID, 'X-Parse-Session-Token': SESSION_TOKEN, }; let lastError = null; for (let attempt = 1; attempt <= 4; attempt += 1) { try { const response = await fetch(`${PARSE_HOST}/parse${parsePath}`, { method, headers }); const data = await response.json().catch(() => ({})); if (!response.ok || data.error) { const error = new Error(data.error || data.message || `Parse ${method} ${parsePath} failed`); error.status = response.status || 500; error.detail = data; if (attempt < 4 && isRetryableError(error)) { await sleep(500 * attempt); continue; } throw error; } return data; } catch (error) { lastError = error; if (attempt < 4 && isRetryableError(error)) { await sleep(500 * attempt); continue; } throw error; } } throw lastError || new Error(`Parse ${method} ${parsePath} failed`); } function isRetryableError(error) { const status = Number(error?.status || 0); const message = `${error?.message || ''} ${error?.cause?.code || ''} ${error?.detail?.error || ''}`; return status >= 500 || /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message); } function readArg(name) { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] || '' : ''; } function toArray(value) { return Array.isArray(value) ? value : []; } function normalizeParseHost(value) { return String(value || 'https://server.fmode.cn').replace(/\/+$/, '').replace(/\/parse$/i, ''); } function clamp(value, min, max) { if (!Number.isFinite(value)) return min; return Math.max(min, Math.min(max, Math.floor(value))); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function safeTimestamp(value) { return String(value).replace(/[:.]/g, '-'); }