| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- 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 <cleanup-report.json> 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, '-');
- }
|