import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; const WORKSPACE_TEMP_SKIP = new Set(['.tkmindhints', '.goosehints', '.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, }); }); 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; } } return { removedCount, freedBytes }; }; return { listCandidates, runCleanup }; }