feat(mindspace): 全部页面管理、级联删除与页面同步去重

支持全部页面分页/多选/删除/分享,删除时可选移除广场帖并清理工作区附件;修复并发同步重复创建页面,并补齐预览下载链接重写与 iframe 下载权限。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-30 01:22:38 +08:00
parent 6e6d00568a
commit ea6c49f046
30 changed files with 2562 additions and 351 deletions
+266
View File
@@ -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();
}