import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; const WORKSPACE_TEMP_SKIP = new Set([ '.tkmindhints', '.goosehints', '.tkmind-profile.json', '.agents', '.goose', ]); function candidateId(kind, key) { return `${kind}:${crypto.createHash('sha256').update(key).digest('hex').slice(0, 24)}`; } async function fileSize(targetPath) { try { const stat = await fs.stat(targetPath); return stat.isFile() ? stat.size : 0; } catch { return 0; } } async function walkFiles(rootDir, onFile) { let entries; try { entries = await fs.readdir(rootDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = path.join(rootDir, entry.name); if (entry.isDirectory()) { if (WORKSPACE_TEMP_SKIP.has(entry.name)) continue; await walkFiles(fullPath, onFile); continue; } if (!entry.isFile()) continue; await onFile(fullPath); } } export function createCleanupService(pool, options = {}) { const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace')); const h5Root = path.resolve(options.h5Root ?? process.cwd()); const absoluteStoragePath = (storageKey) => { const resolved = path.resolve(storageRoot, storageKey); if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) { throw new Error('存储路径越界'); } return resolved; }; const listCandidates = async (userId, username) => { const candidates = []; const [uploads] = await pool.query( `SELECT id, filename, reserved_bytes, temporary_storage_key, status, expires_at, created_at FROM h5_upload_sessions WHERE user_id = ? AND status IN ('reserved', 'uploaded', 'expired', 'failed') ORDER BY created_at DESC LIMIT 100`, [userId], ); for (const upload of uploads) { const storagePath = upload.temporary_storage_key ? absoluteStoragePath(upload.temporary_storage_key) : null; const exists = storagePath ? await fileSize(storagePath) : 0; if (!exists && upload.status === 'expired') continue; candidates.push({ id: candidateId('upload', upload.id), kind: 'stale_upload', label: upload.filename, path: upload.temporary_storage_key ?? '', sizeBytes: exists || Number(upload.reserved_bytes ?? 0), createdAt: Number(upload.created_at), detail: upload.status === 'reserved' ? '未完成的上传预留' : upload.status === 'uploaded' ? '已上传但未完成入库' : '已失效的上传临时文件', refId: upload.id, }); } const tmpDir = path.join(storageRoot, 'tmp', userId); await walkFiles(tmpDir, async (fullPath) => { const key = path.relative(storageRoot, fullPath).split(path.sep).join('/'); const active = uploads.some( (upload) => upload.temporary_storage_key === key && upload.status === 'reserved', ); if (active) return; const sizeBytes = await fileSize(fullPath); if (!sizeBytes) return; candidates.push({ id: candidateId('tmp', key), kind: 'orphan_tmp', label: path.basename(fullPath), path: key, sizeBytes, createdAt: null, detail: '孤立的临时上传文件', refId: key, }); }); const workspaceTempDir = path.join(h5Root, 'temp', username); await walkFiles(workspaceTempDir, async (fullPath) => { const rel = path.relative(workspaceTempDir, fullPath).split(path.sep).join('/'); const sizeBytes = await fileSize(fullPath); if (!sizeBytes) return; candidates.push({ id: candidateId('workspace', rel), kind: 'workspace_temp', label: rel, path: `temp/${username}/${rel}`, sizeBytes, createdAt: null, detail: 'Agent 工作区临时文件', refId: rel, }); }); const [staleVersions] = await pool.query( `SELECT pv.id AS version_id, pv.page_id, pv.version_no, pv.content_asset_id, pv.bundle_asset_id, p.title, a.size_bytes, a.display_name, pv.created_at, COALESCE(bundle.size_bytes, 0) AS bundle_size_bytes FROM h5_page_versions pv JOIN h5_page_records p ON p.id = pv.page_id AND p.user_id = ? AND p.status <> 'deleted' JOIN h5_assets a ON a.id = pv.content_asset_id AND a.user_id = ? AND a.status <> 'deleted' LEFT JOIN h5_assets bundle ON bundle.id = pv.bundle_asset_id AND bundle.user_id = ? AND bundle.status <> 'deleted' WHERE pv.id <> p.current_version_id AND NOT EXISTS ( SELECT 1 FROM h5_publish_records pr WHERE pr.page_version_id = pv.id AND pr.user_id = ? ) ORDER BY pv.created_at ASC LIMIT 500`, [userId, userId, userId, userId], ); for (const row of staleVersions) { const contentBytes = Number(row.size_bytes ?? 0); const bundleBytes = Number(row.bundle_size_bytes ?? 0); candidates.push({ id: candidateId('page_version', row.version_id), kind: 'stale_page_version', label: `${row.title} · v${row.version_no}`, path: `page/${row.page_id}/version/${row.version_no}`, sizeBytes: contentBytes + bundleBytes, createdAt: Number(row.created_at), detail: '页面历史版本(非当前版本)', refId: row.version_id, }); } return candidates; }; const runCleanup = async (userId, username, itemIds) => { const selected = new Set(itemIds ?? []); const candidates = await listCandidates(userId, username); const targets = candidates.filter((item) => selected.has(item.id)); let freedBytes = 0; let removedCount = 0; for (const item of targets) { if (item.kind === 'stale_upload') { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( `SELECT id, space_id, reserved_bytes, temporary_storage_key, status FROM h5_upload_sessions WHERE id = ? AND user_id = ? AND status IN ('reserved', 'uploaded', 'expired', 'failed') LIMIT 1 FOR UPDATE`, [item.refId, userId], ); const upload = rows[0]; if (upload) { await conn.query( `UPDATE h5_user_spaces SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ? WHERE id = ? AND user_id = ?`, [upload.reserved_bytes, Date.now(), upload.space_id, userId], ); await conn.query( `UPDATE h5_upload_sessions SET status = 'expired' WHERE id = ? AND user_id = ?`, [upload.id, userId], ); await conn.commit(); if (upload.temporary_storage_key) { const target = absoluteStoragePath(upload.temporary_storage_key); const sizeBytes = await fileSize(target); await fs.rm(target, { force: true }); freedBytes += sizeBytes; } removedCount += 1; } else { await conn.rollback(); } } catch { await conn.rollback(); } finally { conn.release(); } continue; } if (item.kind === 'orphan_tmp') { const target = absoluteStoragePath(item.refId); const sizeBytes = await fileSize(target); await fs.rm(target, { force: true }); freedBytes += sizeBytes; removedCount += 1; continue; } if (item.kind === 'workspace_temp') { const target = path.join(h5Root, 'temp', username, item.refId); const resolvedRoot = path.resolve(path.join(h5Root, 'temp', username)); const resolved = path.resolve(target); if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) continue; const sizeBytes = await fileSize(resolved); await fs.rm(resolved, { force: true }); freedBytes += sizeBytes; removedCount += 1; continue; } if (item.kind === 'stale_page_version') { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( `SELECT pv.id, pv.page_id, pv.content_asset_id, pv.bundle_asset_id, p.current_version_id, p.space_id FROM h5_page_versions pv JOIN h5_page_records p ON p.id = pv.page_id WHERE pv.id = ? AND p.user_id = ? LIMIT 1 FOR UPDATE`, [item.refId, userId], ); const version = rows[0]; if (!version || version.id === version.current_version_id) { await conn.rollback(); continue; } const [pubRows] = await conn.query( `SELECT id FROM h5_publish_records WHERE page_version_id = ? AND user_id = ? LIMIT 1`, [version.id, userId], ); if (pubRows.length) { await conn.rollback(); continue; } const now = Date.now(); let freed = 0; const assetsToDelete = [version.content_asset_id].filter(Boolean); if (version.bundle_asset_id) { const [bundleUse] = await conn.query( `SELECT COUNT(*) AS cnt FROM h5_page_versions WHERE bundle_asset_id = ? AND id <> ?`, [version.bundle_asset_id, version.id], ); if (Number(bundleUse[0]?.cnt ?? 0) === 0) { assetsToDelete.push(version.bundle_asset_id); } } for (const assetId of assetsToDelete) { const [assetRows] = await conn.query( `SELECT id, size_bytes FROM h5_assets WHERE id = ? AND user_id = ? AND status <> 'deleted' LIMIT 1 FOR UPDATE`, [assetId, userId], ); const asset = assetRows[0]; if (!asset) continue; await conn.query( `UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?`, [now, now, assetId, userId], ); freed += Number(asset.size_bytes ?? 0); } await conn.query(`DELETE FROM h5_page_versions WHERE id = ?`, [version.id]); if (freed > 0) { await conn.query( `UPDATE h5_user_spaces SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ? WHERE id = ? AND user_id = ?`, [freed, now, version.space_id, userId], ); } await conn.commit(); freedBytes += freed; removedCount += 1; } catch { await conn.rollback(); } finally { conn.release(); } } } return { removedCount, freedBytes }; }; return { listCandidates, runCleanup }; }