import fs from "node:fs"; import path from "node:path"; import { dbGlobal } from "drizzle-pkg/lib/db"; import { mediaAssets, postMediaRefs } from "drizzle-pkg/lib/schema/content"; import { count, eq } from "drizzle-orm"; import { RELATIVE_ASSETS_DIR } from "#server/constants/media"; function assetsBaseDir(): string { return path.resolve(process.cwd(), RELATIVE_ASSETS_DIR); } function isSafeStorageKey(key: string): boolean { if (!key || key !== path.basename(key)) { return false; } return !key.includes(".."); } function resolvedFilePath(storageKey: string): string | null { if (!isSafeStorageKey(storageKey)) { return null; } const base = assetsBaseDir(); const full = path.resolve(base, storageKey); const prefix = base + path.sep; if (full !== base && !full.startsWith(prefix)) { return null; } return full; } export type StorageAuditMissingRow = { id: number; userId: number; storageKey: string; refCount: number; }; export type StorageAuditResult = { scannedAt: string; dbRowCount: number; diskFileCount: number; missingOnDisk: StorageAuditMissingRow[]; invalidStorageKey: Array<{ id: number; userId: number; storageKey: string; refCount: number }>; onDiskNotInDb: string[]; }; export async function auditMediaStorageVsDb(): Promise { const base = assetsBaseDir(); const rows = await dbGlobal .select({ id: mediaAssets.id, userId: mediaAssets.userId, storageKey: mediaAssets.storageKey, }) .from(mediaAssets); const refAgg = await dbGlobal .select({ assetId: postMediaRefs.assetId, c: count(), }) .from(postMediaRefs) .groupBy(postMediaRefs.assetId); const refMap = new Map(); for (const r of refAgg) { refMap.set(r.assetId, r.c); } const dbKeys = new Set(); const missingOnDisk: StorageAuditMissingRow[] = []; const invalidStorageKey: StorageAuditMissingRow[] = []; for (const r of rows) { const refCount = refMap.get(r.id) ?? 0; if (!isSafeStorageKey(r.storageKey)) { invalidStorageKey.push({ id: r.id, userId: r.userId, storageKey: r.storageKey, refCount, }); continue; } dbKeys.add(r.storageKey); const full = resolvedFilePath(r.storageKey); if (!full) { invalidStorageKey.push({ id: r.id, userId: r.userId, storageKey: r.storageKey, refCount, }); continue; } if (!fs.existsSync(full)) { missingOnDisk.push({ id: r.id, userId: r.userId, storageKey: r.storageKey, refCount, }); } } let diskFileCount = 0; const onDiskNotInDb: string[] = []; if (!fs.existsSync(base)) { return { scannedAt: new Date().toISOString(), dbRowCount: rows.length, diskFileCount: 0, missingOnDisk, invalidStorageKey, onDiskNotInDb: [], }; } const dirents = await fs.promises.readdir(base, { withFileTypes: true }); for (const e of dirents) { if (!e.isFile()) { continue; } if (e.name.startsWith(".")) { continue; } diskFileCount += 1; if (!dbKeys.has(e.name)) { onDiskNotInDb.push(e.name); } } onDiskNotInDb.sort(); return { scannedAt: new Date().toISOString(), dbRowCount: rows.length, diskFileCount, missingOnDisk, invalidStorageKey, onDiskNotInDb, }; } /** * 删除「磁盘上不存在」且「无任何文章引用」的 media_assets 行(不尝试 unlink)。 */ export async function removeUnreferencedDbRowsForMissingFiles(): Promise<{ removed: number; removedIds: number[]; }> { const audit = await auditMediaStorageVsDb(); const candidates = audit.missingOnDisk.filter((m) => m.refCount === 0); if (candidates.length === 0) { return { removed: 0, removedIds: [] }; } const removedIds: number[] = []; for (const c of candidates) { const full = resolvedFilePath(c.storageKey); if (!full) { continue; } if (fs.existsSync(full)) { continue; } const [{ n }] = await dbGlobal .select({ n: count() }) .from(postMediaRefs) .where(eq(postMediaRefs.assetId, c.id)); if (n > 0) { continue; } await dbGlobal.delete(mediaAssets).where(eq(mediaAssets.id, c.id)); removedIds.push(c.id); } return { removed: removedIds.length, removedIds }; }