From f822086861f6f01b5d55568e05c5daccb4be628c Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 19 Apr 2026 02:14:46 +0800 Subject: [PATCH] feat(media): enhance media storage audit with detailed reference tracking - Introduced new types for audit rows and reference details to improve data structure. - Updated the media storage audit logic to include references for each asset, allowing for better tracking of media usage. - Enhanced UI to display reference sources for media assets, clarifying the relationship between assets and their references. - Improved cleanup descriptions and toast messages for better user understanding of actions taken. Made-with: Cursor --- app/pages/me/admin/media-storage.vue | 58 ++++++++++++++++++++++++++++++---- packages/drizzle-pkg/db.sqlite | Bin 147456 -> 147456 bytes server/service/media/storage-audit.ts | 54 +++++++++++++++++++++++-------- 3 files changed, 91 insertions(+), 21 deletions(-) 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 5bbb0add419387eb916b87a4df7d6cd37559a17f..5f7e7c01a6e4aa8344d6195e1c14cc845963d9a0 100644 GIT binary patch delta 996 zcma)4TSydP6#mc5&d$!dGiL`eDFa=o*syg+*S+~-Mf4KkC6Z9AMjbCyC~7w-T?r(e1U!3h_n-Wk`h?{r~QRk2Pn`&v(w8IdjhUO(LNr63Uqz zwTlq4ig`A3@vJ`zT_d}NQX|%AHZ&L^E6^Azj0cKi;bV%cKDwT$)U2UgcGojU|-Vxstdah01`#ldvbK5 zOUsEQne6h_)cS*M`|I|$)CbLu4y&^xSQw2)!lB}5q{s-z0`YipJQOhuBha$XY_bXt zT6Jw}-IEtiv3sh&f3olL?4?1sY1Y)TY422wqQ18W)FMm9hMDouKiA-)svRetN~@3c&Xy#4V;Hv5 zl|$g!r7PV&){$v1F+#V<9->sF5>hJh4~A1*2RYZu)ppBlIGiH%E34L)7N_p?^ zR3C#9j&9MiG1$z}9$GyNg%YPNOh*`8=Eeyk;5fk<9A7e~>QgLmhziK~g-A}0xJH@0 ziN-}qQSaa)3OG-20q3~{v*?sW09mjX-h;=hh%o5c=K3MMW9&D9eefIf$l4B3CyLV4 zqnf?>z4_Gq4TinA33M?d^-nXxLdRlu0xbO|_3a1N*0KyPuiA~>=XXlw20ls(|c jVX(cmv1P9%rRSI;IE8bp^bLA4BvzR&73{g7pn3lSFe?n| delta 878 zcma)4T}V@582;Y#H^1%dd^;^MQ&7oBu;p}Hn??-5f~as(cA=&ooytI*xPD36R-kre z_V-;02HpfYe|B;1GWsFtNAs?mu&^ScB84=VjlSc==q?WL!~1>beO{jT`QBIz$71+; zp0x)dRLmlW1&ow_!Md9_{1^F2GLS4Pyg*7y_N$27n1pFNQy?kBh`b=T$x&ie=9OXQ?IlI@3vmEyoq#Gj_W=CDK|TsporNml zh!D@FwX<+o@bF=v4U5X8}3MC36XWOMpno-@|i5L*O@1K1RjHAfM|eV z0B-=t^B5a6; + 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));