| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464 |
- import fs from 'node:fs';
- import path from 'node:path';
- import { fileURLToPath } from 'node:url';
- 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 ACCOUNT_FILTER = String(process.env.IP_CLEANUP_ACCOUNT_ID || '').trim();
- const INCLUDE_ALL_ACTIVE = /^(1|true|yes)$/i.test(String(process.env.IP_CLEANUP_INCLUDE_ALL_ACTIVE || ''));
- const PRINT_CANDIDATES = /^(1|true|yes)$/i.test(String(process.env.IP_CLEANUP_PRINT_CANDIDATES || ''));
- const OUTPUT_DIR = process.env.IP_CLEANUP_OUTPUT_DIR
- ? path.resolve(process.env.IP_CLEANUP_OUTPUT_DIR)
- : path.join(process.cwd(), 'tmp');
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
- const rootDir = path.resolve(__dirname, '..', '..');
- const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts');
- const fn = readCloudFunctionIds(cloudFunctionsPath);
- const entityTypes = [
- 'ipOperator.account',
- 'ipOperator.profile',
- 'ipOperator.plan',
- 'ipOperator.accountSnapshot',
- 'ipOperator.accountSnapshotWork',
- 'ipOperator.accountSnapshotComment',
- 'ipOperator.accountDiagnosis',
- 'ipOperator.positioningProposal',
- 'ipOperator.positioningVersion',
- 'ipOperator.contentDirection',
- 'ipOperator.operationTask',
- 'ipOperator.publishBinding',
- 'ipOperator.planEvidenceItem',
- 'ipOperator.planCommentPainInsight',
- 'ipOperator.planContentCalendarItem',
- 'ipOperator.planPublishPackage',
- 'ipOperator.planScriptBody',
- 'ipOperator.planGenerationPreview',
- ];
- if (!SESSION_TOKEN) {
- console.error('Missing SMOKE_SESSION_TOKEN. This dry-run only reads the current Parse user namespace.');
- process.exit(1);
- }
- 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 rowsByType = new Map();
- const allRows = [];
- for (const entityType of entityTypes) {
- const rows = await listRows(entityType);
- rowsByType.set(entityType, rows);
- allRows.push(...rows);
- }
- const stats = await call(fn.systemStorage, { action: 'stats' }).catch(() => null);
- const accounts = rowsByType.get('ipOperator.account') || [];
- const plans = rowsByType.get('ipOperator.plan') || [];
- const snapshots = rowsByType.get('ipOperator.accountSnapshot') || [];
- const works = rowsByType.get('ipOperator.accountSnapshotWork') || [];
- const comments = rowsByType.get('ipOperator.accountSnapshotComment') || [];
- const evidenceRows = rowsByType.get('ipOperator.planEvidenceItem') || [];
- const painRows = rowsByType.get('ipOperator.planCommentPainInsight') || [];
- const accountIds = new Set();
- for (const account of accounts) accountIds.add(account.entityId);
- for (const row of [...snapshots, ...works, ...comments, ...evidenceRows, ...painRows]) {
- const accountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.data?.snapshotId || row.entityId);
- if (accountId) accountIds.add(accountId);
- }
- const targetAccountIds = [...accountIds].filter((id) => !ACCOUNT_FILTER || id === ACCOUNT_FILTER);
- const candidates = [];
- for (const accountId of targetAccountIds) {
- collectAccountCandidates(accountId, candidates);
- }
- const typeStatus = summarizeByTypeAndStatus(allRows);
- const candidateSummary = summarizeByTypeAndStatus(candidates);
- const reportBase = `ip-operator-cleanup-dry-run-${safeTimestamp(startedAt)}`;
- const jsonPath = path.join(OUTPUT_DIR, `${reportBase}.json`);
- const mdPath = path.join(OUTPUT_DIR, `${reportBase}.md`);
- const report = buildReport({
- generatedAt: startedAt,
- accountFilter: ACCOUNT_FILTER || '',
- stats: stats?.success ? stats.data : null,
- fetchedSummary: summaryToObject(typeStatus),
- candidateSummary: summaryToObject(candidateSummary),
- fetchedRowCount: allRows.length,
- candidateCount: candidates.length,
- candidates,
- });
- fs.mkdirSync(OUTPUT_DIR, { recursive: true });
- fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
- fs.writeFileSync(mdPath, renderMarkdownReport(report), 'utf8');
- console.log(`IP operator cleanup dry-run generatedAt=${startedAt}`);
- console.log(`scope=current Parse user; accountFilter=${ACCOUNT_FILTER || '(all detected accounts)'}`);
- console.log('');
- console.log('Current fetched rows by entityType/status:');
- printSummary(typeStatus);
- if (stats?.success && stats.data?.totalEntities !== undefined) {
- console.log('');
- console.log(`systemStorage.stats totalEntities=${stats.data.totalEntities}`);
- }
- console.log('');
- console.log('Cleanup candidates by entityType/status:');
- printSummary(candidateSummary);
- console.log('');
- console.log(`Cleanup candidate rows: ${candidates.length}`);
- console.log('');
- console.log(`Full JSON report: ${jsonPath}`);
- console.log(`Full Markdown report: ${mdPath}`);
- if (PRINT_CANDIDATES) {
- console.log('');
- printCandidates(candidates);
- }
- console.log('');
- console.log('DRY-RUN ONLY: no delete/purge/physical removal was executed.');
- console.log('Next step: review candidate reasons. Only after confirmation should a separate execution script be used.');
- async function listRows(entityType) {
- const statuses = INCLUDE_ALL_ACTIVE
- ? ['active', 'deleted', 'purged', 'archived']
- : ['active', 'deleted', 'purged'];
- const merged = [];
- const seen = new Set();
- for (const status of statuses) {
- const result = await call(fn.systemStorage, { action: 'list', entityType, status, limit: 1000 });
- if (!result?.success) {
- console.warn(`WARN list failed entityType=${entityType} status=${status}: ${result?.error || JSON.stringify(result)}`);
- continue;
- }
- for (const row of result.data || []) {
- const key = row.objectId || `${row.entityType}:${row.entityId}:${row.status}`;
- if (seen.has(key)) continue;
- seen.add(key);
- merged.push(normalizeRow(row));
- }
- }
- return merged;
- }
- function collectAccountCandidates(accountId, output) {
- const currentSnapshotId = `snapshot_${accountId}_current`;
- const currentPlanId = `ip_account_plan_${accountId}`;
- const plan = plans.find((row) => row.entityId === currentPlanId || row.data?.id === currentPlanId);
- const currentRefs = new Set([
- ...toArray(plan?.data?.externalizedCollections?.evidenceItems),
- ...toArray(plan?.data?.evidenceItems).map((item) => item?.id).filter(Boolean),
- ]);
- const currentPainRefs = new Set([
- ...toArray(plan?.data?.externalizedCollections?.commentPainInsights),
- ...toArray(plan?.data?.commentPainInsights).map((item) => item?.id).filter(Boolean),
- ]);
- const staleSnapshotIds = new Set();
- for (const row of snapshots) {
- const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.entityId);
- if (rowAccountId !== accountId) continue;
- if (row.entityId === currentSnapshotId) continue;
- if (/^snapshot_.+_(current)$/.test(row.entityId)) continue;
- staleSnapshotIds.add(row.entityId);
- pushCandidate(output, row, accountId, `old account snapshot; keep ${currentSnapshotId}`);
- }
- for (const row of works) {
- const snapshotId = row.data?.snapshotId || parseSnapshotIdFromChildId(row.entityId);
- const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(snapshotId);
- if (rowAccountId !== accountId) continue;
- if (staleSnapshotIds.has(snapshotId)) {
- pushCandidate(output, row, accountId, `work belongs to old snapshot ${snapshotId}`);
- }
- }
- for (const row of comments) {
- const snapshotId = row.data?.snapshotId || parseSnapshotIdFromChildId(row.entityId);
- const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(snapshotId);
- if (rowAccountId !== accountId) continue;
- if (staleSnapshotIds.has(snapshotId)) {
- pushCandidate(output, row, accountId, `comment belongs to old snapshot ${snapshotId}`);
- }
- }
- for (const row of evidenceRows) {
- const data = row.data || {};
- if (data.accountId !== accountId) continue;
- const evidenceId = data.id || row.entityId.split('__').pop();
- const snapshotId = data.snapshotId || '';
- if (staleSnapshotIds.has(snapshotId)) {
- pushCandidate(output, row, accountId, `evidence belongs to old snapshot ${snapshotId}`);
- continue;
- }
- if (plan && currentRefs.size && !currentRefs.has(evidenceId)) {
- pushCandidate(output, row, accountId, `evidence is not referenced by current plan ${currentPlanId}`);
- }
- }
- for (const row of painRows) {
- const data = row.data || {};
- const planId = data.planId || row.entityId.split('__')[0];
- if (planId !== currentPlanId) continue;
- const painId = data.id || row.entityId.split('__').pop();
- const evidenceIds = toArray(data.evidenceItemIds);
- const referencesStaleEvidence = evidenceIds.some((id) => candidatesEvidenceIdsForAccount(output, accountId).has(id));
- if (referencesStaleEvidence) {
- pushCandidate(output, row, accountId, 'pain insight references stale evidence');
- continue;
- }
- if (plan && currentPainRefs.size && !currentPainRefs.has(painId)) {
- pushCandidate(output, row, accountId, `pain insight is not referenced by current plan ${currentPlanId}`);
- }
- }
- for (const row of allRows) {
- if ((row.status === 'deleted' || row.status === 'purged') && row.entityType.startsWith('ipOperator.')) {
- const rowAccountId = row.data?.accountId || parseAccountIdFromSnapshotId(row.data?.snapshotId || row.entityId);
- if (rowAccountId === accountId) pushCandidate(output, row, accountId, `already ${row.status}; physical cleanup candidate only after review`);
- }
- }
- }
- function candidatesEvidenceIdsForAccount(output, accountId) {
- return new Set(output
- .filter((row) => row.accountId === accountId && row.entityType === 'ipOperator.planEvidenceItem')
- .map((row) => row.data?.id || row.entityId.split('__').pop())
- .filter(Boolean));
- }
- function pushCandidate(output, row, accountId, reason) {
- const key = row.objectId || `${row.entityType}:${row.entityId}`;
- if (output.some((item) => (item.objectId || `${item.entityType}:${item.entityId}`) === key)) return;
- output.push({ ...row, accountId, reason });
- }
- 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 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 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 normalizeRow(row) {
- return {
- objectId: row.objectId || '',
- entityType: row.entityType || row.type || '',
- entityId: row.entityId || row.id || '',
- status: row.status || 'active',
- createdAt: row.createdAt || '',
- updatedAt: row.updatedAt || '',
- data: row.data && typeof row.data === 'object' ? row.data : {},
- };
- }
- function summarizeByTypeAndStatus(rows) {
- const summary = new Map();
- for (const row of rows) {
- const type = row.entityType || '(unknown)';
- const status = row.status || '(unknown)';
- if (!summary.has(type)) summary.set(type, {});
- summary.get(type)[status] = (summary.get(type)[status] || 0) + 1;
- }
- return summary;
- }
- function printSummary(summary) {
- const types = [...summary.keys()].sort();
- if (!types.length) {
- console.log(' (none)');
- return;
- }
- for (const type of types) {
- const statuses = Object.entries(summary.get(type))
- .sort(([a], [b]) => a.localeCompare(b))
- .map(([status, count]) => `${status}:${count}`)
- .join(', ');
- console.log(` ${type} -> ${statuses}`);
- }
- }
- function printCandidates(rows) {
- if (!rows.length) {
- console.log(' (none)');
- return;
- }
- const sorted = [...rows].sort((a, b) =>
- a.accountId.localeCompare(b.accountId)
- || a.entityType.localeCompare(b.entityType)
- || a.entityId.localeCompare(b.entityId));
- for (const row of sorted) {
- console.log([
- ` account=${row.accountId}`,
- `type=${row.entityType}`,
- `status=${row.status}`,
- `entityId=${row.entityId}`,
- row.objectId ? `objectId=${row.objectId}` : '',
- `reason=${row.reason}`,
- ].filter(Boolean).join(' | '));
- }
- }
- function buildReport(input) {
- return {
- generatedAt: input.generatedAt,
- scope: {
- parseUser: 'current sessionToken user',
- accountFilter: input.accountFilter,
- includeArchived: INCLUDE_ALL_ACTIVE,
- },
- stats: input.stats,
- fetchedRowCount: input.fetchedRowCount,
- fetchedSummary: input.fetchedSummary,
- candidateCount: input.candidateCount,
- candidateSummary: input.candidateSummary,
- candidates: input.candidates.map((row) => ({
- accountId: row.accountId,
- objectId: row.objectId,
- entityType: row.entityType,
- entityId: row.entityId,
- status: row.status,
- createdAt: row.createdAt,
- updatedAt: row.updatedAt,
- reason: row.reason,
- dataId: row.data?.id || '',
- snapshotId: row.data?.snapshotId || '',
- planId: row.data?.planId || '',
- workId: row.data?.workId || '',
- })),
- };
- }
- function renderMarkdownReport(report) {
- const lines = [
- '# IP Operator Cleanup Dry Run',
- '',
- `- generatedAt: ${report.generatedAt}`,
- `- scope: ${report.scope.parseUser}`,
- `- accountFilter: ${report.scope.accountFilter || '(all detected accounts)'}`,
- `- fetchedRowCount: ${report.fetchedRowCount}`,
- `- candidateCount: ${report.candidateCount}`,
- ];
- if (report.stats?.totalEntities !== undefined) {
- lines.push(`- systemStorage.stats totalEntities: ${report.stats.totalEntities}`);
- }
- lines.push('', '## Current fetched rows by entityType/status', '');
- lines.push(...summaryMarkdownLines(report.fetchedSummary));
- lines.push('', '## Cleanup candidates by entityType/status', '');
- lines.push(...summaryMarkdownLines(report.candidateSummary));
- lines.push('', '## Candidate rows', '');
- if (!report.candidates.length) {
- lines.push('(none)');
- } else {
- lines.push('| accountId | entityType | status | entityId | objectId | reason |');
- lines.push('|---|---|---|---|---|---|');
- for (const row of report.candidates) {
- lines.push(`| ${escapeMd(row.accountId)} | ${escapeMd(row.entityType)} | ${escapeMd(row.status)} | ${escapeMd(row.entityId)} | ${escapeMd(row.objectId)} | ${escapeMd(row.reason)} |`);
- }
- }
- lines.push('', '> DRY-RUN ONLY: no delete/purge/physical removal was executed.', '');
- return `${lines.join('\n')}\n`;
- }
- function summaryMarkdownLines(summary) {
- const types = Object.keys(summary).sort();
- if (!types.length) return ['(none)'];
- return types.map((type) => {
- const statuses = Object.entries(summary[type])
- .sort(([a], [b]) => a.localeCompare(b))
- .map(([status, count]) => `${status}:${count}`)
- .join(', ');
- return `- ${type}: ${statuses}`;
- });
- }
- function summaryToObject(summary) {
- const result = {};
- for (const [type, statuses] of summary.entries()) {
- result[type] = { ...statuses };
- }
- return result;
- }
- function safeTimestamp(value) {
- return String(value).replace(/[:.]/g, '-');
- }
- function escapeMd(value) {
- return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
- }
- function parseSnapshotIdFromChildId(value) {
- const text = String(value || '');
- const index = text.indexOf('__');
- return index >= 0 ? text.slice(0, index) : '';
- }
- function parseAccountIdFromSnapshotId(value) {
- const text = String(value || '');
- const match = text.match(/^snapshot_(.+?)_(?:current|\d{10,}.*)$/);
- return match?.[1] || '';
- }
- function toArray(value) {
- return Array.isArray(value) ? value : [];
- }
|