Files
memind/mindspace-cleanup.mjs
T

319 lines
11 KiB
JavaScript
Raw Normal View History

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 };
}