Browse Source

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
main
npmrun 15 hours ago
parent
commit
f822086861
  1. 58
      app/pages/me/admin/media-storage.vue
  2. BIN
      packages/drizzle-pkg/db.sqlite
  3. 54
      server/service/media/storage-audit.ts

58
app/pages/me/admin/media-storage.vue

@ -3,15 +3,39 @@ import { useAuthSession } from '../../../composables/useAuthSession'
definePageMeta({ title: '媒体存储校验' }) definePageMeta({ title: '媒体存储校验' })
type RefDetail = { ownerType: string; ownerId: number }
type AuditRow = {
id: number
userId: number
storageKey: string
refCount: number
refs: RefDetail[]
}
type AuditReport = { type AuditReport = {
scannedAt: string scannedAt: string
dbRowCount: number dbRowCount: number
diskFileCount: number diskFileCount: number
missingOnDisk: Array<{ id: number; userId: number; storageKey: string; refCount: number }> missingOnDisk: AuditRow[]
invalidStorageKey: Array<{ id: number; userId: number; storageKey: string; refCount: number }> invalidStorageKey: AuditRow[]
onDiskNotInDb: string[] 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 { user, refresh } = useAuthSession()
const { fetchData } = useClientApi() const { fetchData } = useClientApi()
const toast = useToast() const toast = useToast()
@ -45,7 +69,7 @@ async function runCleanup() {
'/api/admin/media/storage-audit-cleanup', '/api/admin/media/storage-audit-cleanup',
{ method: 'POST', body: {} }, { method: 'POST', body: {} },
) )
toast.add({ title: `已移除 ${removed} 条失效记录(无引用且磁盘无文件)`, color: 'success' }) toast.add({ title: `已移除 ${removed} 条失效记录(无 media_refs 且磁盘无文件)`, color: 'success' })
cleanupOpen.value = false cleanupOpen.value = false
await runAudit() await runAudit()
} finally { } finally {
@ -69,7 +93,7 @@ const riskyMissingCount = computed(() => {
const cleanupDescription = computed(() => { const cleanupDescription = computed(() => {
const n = safeToCleanupCount.value const n = safeToCleanupCount.value
return `将删除 ${n} 条 media_assets 记录:磁盘上无对应文件,且当前无任何文章引用。此操作不可撤销。` return `将删除 ${n} 条 media_assets 记录:磁盘上无对应文件,且当前无任何 media_refs 引用(文章/个人资料等)。此操作不可撤销。`
}) })
onMounted(async () => { onMounted(async () => {
@ -87,7 +111,7 @@ onMounted(async () => {
<p class="text-sm text-muted mt-1 max-w-3xl"> <p class="text-sm text-muted mt-1 max-w-3xl">
比对 <code class="text-xs">public/assets</code> 与表 <code class="text-xs">media_assets</code> 比对 <code class="text-xs">public/assets</code> 与表 <code class="text-xs">media_assets</code>
库中有记录但文件缺失非法 storageKey以及磁盘上未登记的文件 库中有记录但文件缺失非法 storageKey以及磁盘上未登记的文件
一键清理仅删除<strong>文章引用</strong><strong>磁盘上确实没有文件</strong>的库记录不会删磁盘文件 一键清理仅删除<strong> media_refs 引用</strong><strong>磁盘上确实没有文件</strong>的库记录不会删磁盘文件
</p> </p>
</div> </div>
<UButton to="/me" variant="ghost" size="sm"> <UButton to="/me" variant="ghost" size="sm">
@ -119,10 +143,10 @@ onMounted(async () => {
<UCard v-if="riskyMissingCount > 0"> <UCard v-if="riskyMissingCount > 0">
<template #header> <template #header>
<span class="text-warning">需人工处理文件缺失但仍被文章引用{{ riskyMissingCount }}</span> <span class="text-warning">需人工处理文件缺失但仍被引用{{ riskyMissingCount }}</span>
</template> </template>
<p class="text-sm text-muted mb-3"> <p class="text-sm text-muted mb-3">
请先修改相关文章正文或封面中的图片地址再处理库记录勿使用清理失效库记录删除这些行当前实现不会删除有引用的行 引用来源列出 media_refs owner_type / owner_id如文章 id用户资料 user id请先修改对应正文封面或个人资料中的图片再处理库记录勿使用清理失效库记录删除这些行
</p> </p>
<div class="overflow-x-auto rounded border border-default"> <div class="overflow-x-auto rounded border border-default">
<table class="w-full text-sm"> <table class="w-full text-sm">
@ -140,6 +164,9 @@ onMounted(async () => {
<th class="p-2"> <th class="p-2">
引用数 引用数
</th> </th>
<th class="p-2 min-w-[12rem]">
引用来源owner_type
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -156,6 +183,9 @@ onMounted(async () => {
<td class="p-2 tabular-nums"> <td class="p-2 tabular-nums">
{{ row.refCount }} {{ row.refCount }}
</td> </td>
<td class="p-2 text-xs align-top">
{{ formatRefSources(row.refs) }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -183,6 +213,9 @@ onMounted(async () => {
<th class="p-2"> <th class="p-2">
引用数 引用数
</th> </th>
<th class="p-2 min-w-[12rem]">
引用来源owner_type
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -199,6 +232,10 @@ onMounted(async () => {
<td class="p-2 tabular-nums"> <td class="p-2 tabular-nums">
{{ row.refCount }} {{ row.refCount }}
</td> </td>
<td class="p-2 text-xs align-top">
<span v-if="row.refCount === 0" class="text-muted"></span>
<span v-else>{{ formatRefSources(row.refs) }}</span>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -225,6 +262,9 @@ onMounted(async () => {
<th class="p-2"> <th class="p-2">
引用数 引用数
</th> </th>
<th class="p-2 min-w-[12rem]">
引用来源owner_type
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -241,6 +281,10 @@ onMounted(async () => {
<td class="p-2 tabular-nums"> <td class="p-2 tabular-nums">
{{ row.refCount }} {{ row.refCount }}
</td> </td>
<td class="p-2 text-xs align-top">
<span v-if="row.refCount === 0" class="text-muted"></span>
<span v-else>{{ formatRefSources(row.refs) }}</span>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

54
server/service/media/storage-audit.ts

@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { dbGlobal } from "drizzle-pkg/lib/db"; import { dbGlobal } from "drizzle-pkg/lib/db";
import { mediaAssets, mediaRefs } from "drizzle-pkg/lib/schema/content"; 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"; import { RELATIVE_ASSETS_DIR } from "#server/constants/media";
function assetsBaseDir(): string { function assetsBaseDir(): string {
@ -29,11 +29,17 @@ function resolvedFilePath(storageKey: string): string | null {
return full; return full;
} }
export type StorageAuditRefDetail = {
ownerType: string;
ownerId: number;
};
export type StorageAuditMissingRow = { export type StorageAuditMissingRow = {
id: number; id: number;
userId: number; userId: number;
storageKey: string; storageKey: string;
refCount: number; refCount: number;
refs: StorageAuditRefDetail[];
}; };
export type StorageAuditResult = { export type StorageAuditResult = {
@ -41,7 +47,7 @@ export type StorageAuditResult = {
dbRowCount: number; dbRowCount: number;
diskFileCount: number; diskFileCount: number;
missingOnDisk: StorageAuditMissingRow[]; missingOnDisk: StorageAuditMissingRow[];
invalidStorageKey: Array<{ id: number; userId: number; storageKey: string; refCount: number }>; invalidStorageKey: StorageAuditMissingRow[];
onDiskNotInDb: string[]; onDiskNotInDb: string[];
}; };
@ -55,17 +61,32 @@ export async function auditMediaStorageVsDb(): Promise<StorageAuditResult> {
}) })
.from(mediaAssets); .from(mediaAssets);
const refAgg = await dbGlobal const refRows = await dbGlobal
.select({ .select({
assetId: mediaRefs.assetId, assetId: mediaRefs.assetId,
c: count(), ownerType: mediaRefs.ownerType,
ownerId: mediaRefs.ownerId,
}) })
.from(mediaRefs) .from(mediaRefs);
.groupBy(mediaRefs.assetId);
const refsByAsset = new Map<number, StorageAuditRefDetail[]>();
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<number, number>(); function refCountForAsset(assetId: number): number {
for (const r of refAgg) { return refsForAsset(assetId).length;
refMap.set(r.assetId, r.c);
} }
const dbKeys = new Set<string>(); const dbKeys = new Set<string>();
@ -73,13 +94,15 @@ export async function auditMediaStorageVsDb(): Promise<StorageAuditResult> {
const invalidStorageKey: StorageAuditMissingRow[] = []; const invalidStorageKey: StorageAuditMissingRow[] = [];
for (const r of rows) { 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)) { if (!isSafeStorageKey(r.storageKey)) {
invalidStorageKey.push({ invalidStorageKey.push({
id: r.id, id: r.id,
userId: r.userId, userId: r.userId,
storageKey: r.storageKey, storageKey: r.storageKey,
refCount, refCount,
refs,
}); });
continue; continue;
} }
@ -91,6 +114,7 @@ export async function auditMediaStorageVsDb(): Promise<StorageAuditResult> {
userId: r.userId, userId: r.userId,
storageKey: r.storageKey, storageKey: r.storageKey,
refCount, refCount,
refs,
}); });
continue; continue;
} }
@ -100,6 +124,7 @@ export async function auditMediaStorageVsDb(): Promise<StorageAuditResult> {
userId: r.userId, userId: r.userId,
storageKey: r.storageKey, storageKey: r.storageKey,
refCount, refCount,
refs,
}); });
} }
} }
@ -165,11 +190,12 @@ export async function removeUnreferencedDbRowsForMissingFiles(): Promise<{
if (fs.existsSync(full)) { if (fs.existsSync(full)) {
continue; continue;
} }
const [{ n }] = await dbGlobal const stillRefs = await dbGlobal
.select({ n: count() }) .select({ one: mediaRefs.assetId })
.from(mediaRefs) .from(mediaRefs)
.where(eq(mediaRefs.assetId, c.id)); .where(eq(mediaRefs.assetId, c.id))
if (n > 0) { .limit(1);
if (stillRefs.length > 0) {
continue; continue;
} }
await dbGlobal.delete(mediaAssets).where(eq(mediaAssets.id, c.id)); await dbGlobal.delete(mediaAssets).where(eq(mediaAssets.id, c.id));

Loading…
Cancel
Save