Files
memind/scripts/purge-user-pages.mjs
T
john ea6c49f046 feat(mindspace): 全部页面管理、级联删除与页面同步去重
支持全部页面分页/多选/删除/分享,删除时可选移除广场帖并清理工作区附件;修复并发同步重复创建页面,并补齐预览下载链接重写与 iframe 下载权限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:08:06 +08:00

267 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();
}