import fs from "node:fs"; import path from "node:path"; import { dbGlobal } from "drizzle-pkg/lib/db"; import { mediaAssets, mediaRefs } from "drizzle-pkg/lib/schema/content"; import { 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 StorageAuditRefDetail = { ownerType: string; ownerId: number; }; export type StorageAuditMissingRow = { id: number; userId: number; storageKey: string; refCount: number; refs: StorageAuditRefDetail[]; }; export type StorageAuditResult = { scannedAt: string; dbRowCount: number; diskFileCount: number; missingOnDisk: StorageAuditMissingRow[]; invalidStorageKey: StorageAuditMissingRow[]; 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 refRows = await dbGlobal .select({ assetId: mediaRefs.assetId, ownerType: mediaRefs.ownerType, ownerId: mediaRefs.ownerId, }) .from(mediaRefs); const refsByAsset = new Map(); for (const row of refRows) { const list = refsByAsset.get(row.assetId) ?? []; list.push({ ownerType: row.ownerType, ownerId: row.ownerId }); refsByAsset.set(row.assetId, list); } for (const list of refsByAsset.values()) { list.sort( (a, b) => a.ownerType.localeCompare(b.ownerType) || a.ownerId - b.ownerId, ); } function refsForAsset(assetId: number): StorageAuditRefDetail[] { return refsByAsset.get(assetId) ?? []; } function refCountForAsset(assetId: number): number { return refsForAsset(assetId).length; } const dbKeys = new Set(); const missingOnDisk: StorageAuditMissingRow[] = []; const invalidStorageKey: StorageAuditMissingRow[] = []; for (const r of rows) { const refCount = refCountForAsset(r.id); const refs = refsForAsset(r.id); if (!isSafeStorageKey(r.storageKey)) { invalidStorageKey.push({ id: r.id, userId: r.userId, storageKey: r.storageKey, refCount, refs, }); continue; } dbKeys.add(r.storageKey); const full = resolvedFilePath(r.storageKey); if (!full) { invalidStorageKey.push({ id: r.id, userId: r.userId, storageKey: r.storageKey, refCount, refs, }); continue; } if (!fs.existsSync(full)) { missingOnDisk.push({ id: r.id, userId: r.userId, storageKey: r.storageKey, refCount, refs, }); } } 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 stillRefs = await dbGlobal .select({ one: mediaRefs.assetId }) .from(mediaRefs) .where(eq(mediaRefs.assetId, c.id)) .limit(1); if (stillRefs.length > 0) { continue; } await dbGlobal.delete(mediaAssets).where(eq(mediaAssets.id, c.id)); removedIds.push(c.id); } return { removed: removedIds.length, removedIds }; }