From 4e4d3b1c92cb57d19b678a019102742f719c5210 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sat, 18 Apr 2026 21:02:56 +0800 Subject: [PATCH] feat(admin): storage vs media_assets audit and safe DB cleanup Made-with: Cursor --- app/pages/me/admin/media-storage.vue | 305 +++++++++++++++++++++ app/pages/me/index.vue | 13 +- .../api/admin/media/storage-audit-cleanup.post.ts | 13 + server/api/admin/media/storage-audit.get.ts | 13 + server/service/media/storage-audit.ts | 180 ++++++++++++ 5 files changed, 523 insertions(+), 1 deletion(-) create mode 100644 app/pages/me/admin/media-storage.vue create mode 100644 server/api/admin/media/storage-audit-cleanup.post.ts create mode 100644 server/api/admin/media/storage-audit.get.ts create mode 100644 server/service/media/storage-audit.ts diff --git a/app/pages/me/admin/media-storage.vue b/app/pages/me/admin/media-storage.vue new file mode 100644 index 0000000..0f2b4d2 --- /dev/null +++ b/app/pages/me/admin/media-storage.vue @@ -0,0 +1,305 @@ + + + diff --git a/app/pages/me/index.vue b/app/pages/me/index.vue index f641f59..db0507c 100644 --- a/app/pages/me/index.vue +++ b/app/pages/me/index.vue @@ -69,7 +69,7 @@ onMounted(async () => {
- 媒体清理 + 文章媒体清理

孤儿图片审查与清理 @@ -113,6 +113,17 @@ onMounted(async () => { 打开 + +

+ 媒体存储校验 +
+

+ 磁盘与 media_assets 一致性 +

+ + 打开 + +
diff --git a/server/api/admin/media/storage-audit-cleanup.post.ts b/server/api/admin/media/storage-audit-cleanup.post.ts new file mode 100644 index 0000000..f0c24b5 --- /dev/null +++ b/server/api/admin/media/storage-audit-cleanup.post.ts @@ -0,0 +1,13 @@ +import { removeUnreferencedDbRowsForMissingFiles } from "#server/service/media/storage-audit"; +import { requireAdmin } from "#server/utils/admin-guard"; +import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; +import { getRequestIP } from "h3"; + +export default defineWrappedResponseHandler(async (event) => { + await requireAdmin(event); + const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; + assertUnderRateLimit(`admin-media-storage-audit-cleanup:${ip}`, 10, 60_000); + + const result = await removeUnreferencedDbRowsForMissingFiles(); + return R.success(result); +}); diff --git a/server/api/admin/media/storage-audit.get.ts b/server/api/admin/media/storage-audit.get.ts new file mode 100644 index 0000000..47dfd03 --- /dev/null +++ b/server/api/admin/media/storage-audit.get.ts @@ -0,0 +1,13 @@ +import { auditMediaStorageVsDb } from "#server/service/media/storage-audit"; +import { requireAdmin } from "#server/utils/admin-guard"; +import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; +import { getRequestIP } from "h3"; + +export default defineWrappedResponseHandler(async (event) => { + await requireAdmin(event); + const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; + assertUnderRateLimit(`admin-media-storage-audit:${ip}`, 30, 60_000); + + const report = await auditMediaStorageVsDb(); + return R.success(report); +}); diff --git a/server/service/media/storage-audit.ts b/server/service/media/storage-audit.ts new file mode 100644 index 0000000..25adbcc --- /dev/null +++ b/server/service/media/storage-audit.ts @@ -0,0 +1,180 @@ +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 }; +}