#!/usr/bin/env node /** * Delete all MindSpace pages for one user (uses the same cascade as the API). * * Usage: * node scripts/purge-user-pages.mjs --username=john --dry-run * node scripts/purge-user-pages.mjs --username=john --yes * node scripts/purge-user-pages.mjs --user-id=... --yes --remove-from-plaza */ import path from 'node:path'; import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { createDbPool } from '../db.mjs'; import { createPageService } from '../mindspace-pages.mjs'; import { resolvePublishDir } from '../user-publish.mjs'; import { loadH5Environment } from './load-env.mjs'; const PUBLIC_HTML_SKIP_DIRS = new Set([ '.tmp-images', 'assets', 'images', 'shared', 'node_modules', ]); const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const root = path.join(scriptDir, '..'); loadH5Environment(scriptDir); function readArg(name) { const prefix = `--${name}=`; const hit = process.argv.find((arg) => arg.startsWith(prefix)); return hit ? hit.slice(prefix.length).trim() : null; } const dryRun = process.argv.includes('--dry-run'); const confirmed = process.argv.includes('--yes'); const removeFromPlaza = process.argv.includes('--remove-from-plaza'); const purgeSources = process.argv.includes('--purge-sources'); const untilEmpty = process.argv.includes('--until-empty'); const username = readArg('username'); const userIdArg = readArg('user-id'); const batchSize = Math.max(1, Number(readArg('batch-size') ?? 100)); const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ? path.resolve(process.env.MINDSPACE_STORAGE_ROOT) : path.join(root, 'data', 'mindspace'); async function resolveUserId(pool) { if (userIdArg) return userIdArg; if (!username) { throw new Error('请指定 --username=... 或 --user-id=...'); } const [rows] = await pool.query( `SELECT id, username, email FROM h5_users WHERE username = ? LIMIT 1`, [username], ); const row = rows[0]; if (!row) throw new Error(`用户不存在: ${username}`); return row.id; } async function countRemainingPages(pool, userId) { const [rows] = await pool.query( `SELECT COUNT(*) AS count FROM h5_page_records WHERE user_id = ? AND status <> 'deleted'`, [userId], ); return Number(rows[0]?.count ?? 0); } async function listWorkspaceHtmlFiles(publishDir) { const files = []; async function walk(currentDir, relativeDir = '') { let entries; try { entries = await fs.readdir(currentDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (entry.isDirectory()) { if (PUBLIC_HTML_SKIP_DIRS.has(entry.name)) continue; await walk(path.join(currentDir, entry.name), path.posix.join(relativeDir, entry.name)); continue; } if (!entry.name.toLowerCase().endsWith('.html')) continue; files.push(path.join(currentDir, entry.name)); } } await walk(publishDir); return files; } async function purgeSyncSources(pool, userId, h5Root) { const now = Date.now(); const publishDir = resolvePublishDir(h5Root, { id: userId }); const htmlFiles = await listWorkspaceHtmlFiles(publishDir); let removedFiles = 0; for (const filePath of htmlFiles) { await fs.unlink(filePath).catch(() => {}); removedFiles += 1; const thumbPath = filePath.replace(/\.html$/i, '.thumbnail.svg'); await fs.unlink(thumbPath).catch(() => {}); } const [assetResult] = await pool.query( `UPDATE h5_assets a JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id SET a.status = 'deleted', a.deleted_at = ?, a.updated_at = ? WHERE a.user_id = ? AND c.category_code = 'public' AND a.mime_type = 'text/html' AND a.status <> 'deleted'`, [now, now, userId], ); const [plazaResult] = await pool.query( `UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE user_id = ? AND status != 'hidden'`, [now, userId], ); return { removedFiles, deletedPublicHtmlAssets: Number(assetResult.affectedRows ?? 0), hiddenPlazaPosts: Number(plazaResult.affectedRows ?? 0), }; } async function listAllPageIds(pool, userId) { const ids = []; let offset = 0; while (true) { const [rows] = await pool.query( `SELECT id FROM h5_page_records WHERE user_id = ? AND status <> 'deleted' ORDER BY created_at ASC LIMIT ? OFFSET ?`, [userId, batchSize, offset], ); if (rows.length === 0) break; ids.push(...rows.map((row) => row.id)); if (rows.length < batchSize) break; offset += rows.length; } return ids; } const pool = createDbPool(); const pageService = createPageService(pool, { h5Root: root, storageRoot }); try { const userId = await resolveUserId(pool); const [userRows] = await pool.query( `SELECT username, email FROM h5_users WHERE id = ? LIMIT 1`, [userId], ); const user = userRows[0]; const pageIds = await listAllPageIds(pool, userId); const [plazaRows] = await pool.query( `SELECT COUNT(*) AS count FROM plaza_posts pp JOIN h5_publish_records pr ON pr.id = pp.publication_id WHERE pr.user_id = ? AND pp.status != 'hidden'`, [userId], ); const plazaPostCount = Number(plazaRows[0]?.count ?? 0); console.log(`用户: ${user?.username ?? userId} (${user?.email ?? 'unknown'})`); console.log(`待删除页面: ${pageIds.length}`); console.log(`广场帖子: ${plazaPostCount}(${removeFromPlaza || purgeSources ? '将一并隐藏' : '保留'})`); if (purgeSources) console.log('将清理工作区 public HTML 与公开 HTML 资产,避免同步复活页面'); if (untilEmpty) console.log('将循环删除直到账户下无剩余页面'); if (pageIds.length === 0) { console.log('没有可删除的页面。'); process.exit(0); } if (dryRun) { if (purgeSources) { const publishDir = resolvePublishDir(root, { id: userId }); const htmlFiles = await listWorkspaceHtmlFiles(publishDir); console.log(`[dry-run] 工作区 HTML 文件: ${htmlFiles.length}`); } console.log('[dry-run] 未执行删除。'); process.exit(0); } if (!confirmed) { console.error('危险操作:请加 --yes 确认删除。可先 --dry-run 查看数量。'); process.exit(1); } let deleted = 0; let failed = 0; let freedBytes = 0; let hiddenPlazaPosts = 0; let removedFiles = 0; let deletedPublicHtmlAssets = 0; const started = Date.now(); const maxRounds = untilEmpty ? 20 : 1; for (let round = 1; round <= maxRounds; round += 1) { if (purgeSources) { const sourceResult = await purgeSyncSources(pool, userId, root); removedFiles += sourceResult.removedFiles; deletedPublicHtmlAssets += sourceResult.deletedPublicHtmlAssets; hiddenPlazaPosts += sourceResult.hiddenPlazaPosts; if (round === 1) { console.log( `已清理同步来源:HTML 文件 ${sourceResult.removedFiles},公开 HTML 资产 ${sourceResult.deletedPublicHtmlAssets},广场帖 ${sourceResult.hiddenPlazaPosts}`, ); } } const roundPageIds = round === 1 ? pageIds : await listAllPageIds(pool, userId); if (roundPageIds.length === 0) break; if (round > 1) { console.log(`第 ${round} 轮:发现 ${roundPageIds.length} 个复活/残留页面,继续删除…`); } for (const pageId of roundPageIds) { try { const result = await pageService.deletePage(userId, pageId, { removeFromPlaza }); deleted += 1; freedBytes += Number(result.freedBytes ?? 0); hiddenPlazaPosts += Number(result.hiddenPlazaPostCount ?? 0); if (deleted % 25 === 0) { const elapsedSec = ((Date.now() - started) / 1000).toFixed(1); console.log( `[${deleted}] 已删除 ${deleted} 页,失败 ${failed},释放 ${(freedBytes / (1024 * 1024)).toFixed(1)} MB,用时 ${elapsedSec}s`, ); } } catch (error) { failed += 1; const message = error instanceof Error ? error.message : String(error); console.error(`删除失败 ${pageId}: ${message}`); } } if (!untilEmpty) break; const remainingAfterRound = await countRemainingPages(pool, userId); if (remainingAfterRound === 0) break; if (round === maxRounds) { console.warn(`已达最大 ${maxRounds} 轮,仍有 ${remainingAfterRound} 页未删除`); } } const remaining = await countRemainingPages(pool, userId); const elapsedSec = ((Date.now() - started) / 1000).toFixed(1); console.log('---'); console.log(`完成:成功 ${deleted},失败 ${failed},剩余 ${remaining} 页`); console.log(`释放空间约 ${(freedBytes / (1024 * 1024)).toFixed(1)} MB`); if (purgeSources) { console.log(`清理 HTML 文件 ${removedFiles},公开 HTML 资产 ${deletedPublicHtmlAssets}`); } if (removeFromPlaza || purgeSources) console.log(`隐藏广场帖 ${hiddenPlazaPosts} 条`); console.log(`总用时 ${elapsedSec}s`); process.exit(failed > 0 || remaining > 0 ? 1 : 0); } finally { await pool.end(); }