feat(mindspace): 全部页面管理、级联删除与页面同步去重
支持全部页面分页/多选/删除/分享,删除时可选移除广场帖并清理工作区附件;修复并发同步重复创建页面,并补齐预览下载链接重写与 iframe 下载权限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
#!/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();
|
||||
}
|
||||
Reference in New Issue
Block a user