ea6c49f046
支持全部页面分页/多选/删除/分享,删除时可选移除广场帖并清理工作区附件;修复并发同步重复创建页面,并补齐预览下载链接重写与 iframe 下载权限。 Co-authored-by: Cursor <cursoragent@cursor.com>
267 lines
8.9 KiB
JavaScript
267 lines
8.9 KiB
JavaScript
#!/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();
|
||
}
|