diff --git a/app/pages/me/admin/media-storage.vue b/app/pages/me/admin/media-storage.vue index 6129047..773d396 100644 --- a/app/pages/me/admin/media-storage.vue +++ b/app/pages/me/admin/media-storage.vue @@ -3,15 +3,39 @@ import { useAuthSession } from '../../../composables/useAuthSession' definePageMeta({ title: '媒体存储校验' }) +type RefDetail = { ownerType: string; ownerId: number } + +type AuditRow = { + id: number + userId: number + storageKey: string + refCount: number + refs: RefDetail[] +} + type AuditReport = { scannedAt: string dbRowCount: number diskFileCount: number - missingOnDisk: Array<{ id: number; userId: number; storageKey: string; refCount: number }> - invalidStorageKey: Array<{ id: number; userId: number; storageKey: string; refCount: number }> + missingOnDisk: AuditRow[] + invalidStorageKey: AuditRow[] onDiskNotInDb: string[] } +function refSourceLabel(ownerType: string, ownerId: number): string { + if (ownerType === 'post') { + return `文章 #${ownerId}` + } + if (ownerType === 'profile') { + return `个人资料 #${ownerId}` + } + return `${ownerType} #${ownerId}` +} + +function formatRefSources(refs: RefDetail[]): string { + return refs.map((r) => refSourceLabel(r.ownerType, r.ownerId)).join(' · ') +} + const { user, refresh } = useAuthSession() const { fetchData } = useClientApi() const toast = useToast() @@ -45,7 +69,7 @@ async function runCleanup() { '/api/admin/media/storage-audit-cleanup', { method: 'POST', body: {} }, ) - toast.add({ title: `已移除 ${removed} 条失效记录(无引用且磁盘无文件)`, color: 'success' }) + toast.add({ title: `已移除 ${removed} 条失效记录(无 media_refs 且磁盘无文件)`, color: 'success' }) cleanupOpen.value = false await runAudit() } finally { @@ -69,7 +93,7 @@ const riskyMissingCount = computed(() => { const cleanupDescription = computed(() => { const n = safeToCleanupCount.value - return `将删除 ${n} 条 media_assets 记录:磁盘上无对应文件,且当前无任何文章引用。此操作不可撤销。` + return `将删除 ${n} 条 media_assets 记录:磁盘上无对应文件,且当前无任何 media_refs 引用(文章/个人资料等)。此操作不可撤销。` }) onMounted(async () => { @@ -87,7 +111,7 @@ onMounted(async () => {

比对 public/assets 与表 media_assets: 库中有记录但文件缺失、非法 storageKey、以及磁盘上未登记的文件。 - 「一键清理」仅删除无文章引用磁盘上确实没有文件的库记录,不会删磁盘文件。 + 「一键清理」仅删除无 media_refs 引用磁盘上确实没有文件的库记录,不会删磁盘文件。

@@ -119,10 +143,10 @@ onMounted(async () => {

- 请先修改相关文章正文或封面中的图片地址,再处理库记录;勿使用「清理失效库记录」删除这些行(当前实现不会删除有引用的行)。 + 「引用来源」列出 media_refs 的 owner_type / owner_id(如文章 id、用户资料 user id)。请先修改对应正文、封面或个人资料中的图片,再处理库记录;勿使用「清理失效库记录」删除这些行。

@@ -140,6 +164,9 @@ onMounted(async () => { + @@ -156,6 +183,9 @@ onMounted(async () => { +
引用数 + 引用来源(owner_type) +
{{ row.refCount }} + {{ formatRefSources(row.refs) }} +
@@ -183,6 +213,9 @@ onMounted(async () => { 引用数 + + 引用来源(owner_type) + @@ -199,6 +232,10 @@ onMounted(async () => { {{ row.refCount }} + + + {{ formatRefSources(row.refs) }} + @@ -225,6 +262,9 @@ onMounted(async () => { 引用数 + + 引用来源(owner_type) + @@ -241,6 +281,10 @@ onMounted(async () => { {{ row.refCount }} + + + {{ formatRefSources(row.refs) }} + diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index 5bbb0ad..5f7e7c0 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ diff --git a/server/service/media/storage-audit.ts b/server/service/media/storage-audit.ts index 403eaa9..213b00f 100644 --- a/server/service/media/storage-audit.ts +++ b/server/service/media/storage-audit.ts @@ -2,7 +2,7 @@ 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 { count, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { RELATIVE_ASSETS_DIR } from "#server/constants/media"; function assetsBaseDir(): string { @@ -29,11 +29,17 @@ function resolvedFilePath(storageKey: string): string | 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 = { @@ -41,7 +47,7 @@ export type StorageAuditResult = { dbRowCount: number; diskFileCount: number; missingOnDisk: StorageAuditMissingRow[]; - invalidStorageKey: Array<{ id: number; userId: number; storageKey: string; refCount: number }>; + invalidStorageKey: StorageAuditMissingRow[]; onDiskNotInDb: string[]; }; @@ -55,17 +61,32 @@ export async function auditMediaStorageVsDb(): Promise { }) .from(mediaAssets); - const refAgg = await dbGlobal + const refRows = await dbGlobal .select({ assetId: mediaRefs.assetId, - c: count(), + ownerType: mediaRefs.ownerType, + ownerId: mediaRefs.ownerId, }) - .from(mediaRefs) - .groupBy(mediaRefs.assetId); + .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) ?? []; + } - const refMap = new Map(); - for (const r of refAgg) { - refMap.set(r.assetId, r.c); + function refCountForAsset(assetId: number): number { + return refsForAsset(assetId).length; } const dbKeys = new Set(); @@ -73,13 +94,15 @@ export async function auditMediaStorageVsDb(): Promise { const invalidStorageKey: StorageAuditMissingRow[] = []; for (const r of rows) { - const refCount = refMap.get(r.id) ?? 0; + 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; } @@ -91,6 +114,7 @@ export async function auditMediaStorageVsDb(): Promise { userId: r.userId, storageKey: r.storageKey, refCount, + refs, }); continue; } @@ -100,6 +124,7 @@ export async function auditMediaStorageVsDb(): Promise { userId: r.userId, storageKey: r.storageKey, refCount, + refs, }); } } @@ -165,11 +190,12 @@ export async function removeUnreferencedDbRowsForMissingFiles(): Promise<{ if (fs.existsSync(full)) { continue; } - const [{ n }] = await dbGlobal - .select({ n: count() }) + const stillRefs = await dbGlobal + .select({ one: mediaRefs.assetId }) .from(mediaRefs) - .where(eq(mediaRefs.assetId, c.id)); - if (n > 0) { + .where(eq(mediaRefs.assetId, c.id)) + .limit(1); + if (stillRefs.length > 0) { continue; } await dbGlobal.delete(mediaAssets).where(eq(mediaAssets.id, c.id));