import fs from 'node:fs'; import crypto from 'node:crypto'; import assert from 'node:assert/strict'; import { login, call } from './role-permissions-live.mjs'; // One-time cleanup of the explicitly audited local inventory. New projects are never included. const dir = 'reports/bridge-content-cleanup-20260925'; const plan = JSON.parse(fs.readFileSync(dir + '/plan.json', 'utf8')); const protectedIds = new Set([...plan.keep, ...plan.blockers].map(p => p.id)); for (let changed = true; changed;) { changed = false; for (const project of plan.all.filter(p => protectedIds.has(p.id))) { for (const id of project.dependencies) if (!protectedIds.has(id)) { protectedIds.add(id); changed = true; } } } const pending = plan.candidates.filter(p => !protectedIds.has(p.id)); const order = []; while (pending.length) { const index = pending.findIndex(p => !pending.some(other => other.id !== p.id && other.dependencies.includes(p.id))); assert.ok(index >= 0, 'Dependency cycle: stop rather than deleting referenced content'); order.push(...pending.splice(index, 1)); } console.log(JSON.stringify({ removable: order.map(p => ({ id: p.id, type: p.type, name: p.name })), protectedCount: protectedIds.size }, null, 2)); if (process.argv.includes('--apply-unreferenced')) { const session = await login('root'); const request = (path, body, method) => call('tran/v1/' + path, session.accessToken, body, method); const resultPath = dir + '/deleted-unreferenced.json'; const result = fs.existsSync(resultPath) ? JSON.parse(fs.readFileSync(resultPath, 'utf8')) : { startedAt: new Date().toISOString(), deleted: [] }; const save = () => fs.writeFileSync(resultPath, JSON.stringify(result, null, 2)); try { for (const project of order) { assert.ok(['MODEL', 'SCENE', 'TRAINING'].includes(project.type)); assert.ok(!protectedIds.has(project.id)); const backup = fs.readFileSync(plan.backupDirectory + '/project-' + project.id + '.json'); assert.equal(crypto.createHash('sha256').update(backup).digest('hex'), project.backupSha256, 'Backup must match audited snapshot'); const response = await request('content/projects/' + project.id); if (response.status === 404 && result.deleted.some(p => p.id === project.id)) continue; assert.equal(response.status, 200, 'Read project ' + project.id); let current = response.data.project; assert.equal(current.type, project.type); assert.equal(current.name, project.name); assert.equal(current.version, project.version, 'Concurrent change to project ' + project.id); if (current.status === 'PUBLISHED') { const revoked = await request('content/projects/' + project.id + '/review', { version: current.version, action: 'REVOKE', comment: '按用户要求清理非005~008的历史内容;已备份并核对无保留工程或教学任务引用。', }); assert.equal(revoked.status, 200, 'Revoke project ' + project.id + ': ' + revoked.message); current = revoked.data.project; } const deleted = await request('content/projects/' + project.id + '?version=' + current.version, undefined, 'DELETE'); assert.equal(deleted.status, 200, 'Delete project ' + project.id + ': ' + deleted.message); result.deleted.push({ id: project.id, name: project.name, type: project.type, deletedAt: new Date().toISOString(), backupSha256: project.backupSha256 }); save(); } result.completedAt = new Date().toISOString(); result.pass = true; save(); console.log('Deleted unreferenced projects:', result.deleted.length); } catch (error) { result.pass = false; result.error = error.message; save(); throw error; } }