5 changed files with 523 additions and 1 deletions
@ -0,0 +1,305 @@ |
|||
<script setup lang="ts"> |
|||
import { request, unwrapApiBody, type ApiResponse } from '../../../utils/http/factory' |
|||
import { useAuthSession } from '../../../composables/useAuthSession' |
|||
|
|||
definePageMeta({ title: '媒体存储校验' }) |
|||
|
|||
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 }> |
|||
onDiskNotInDb: string[] |
|||
} |
|||
|
|||
const { user, refresh } = useAuthSession() |
|||
const toast = useToast() |
|||
|
|||
const loading = ref(false) |
|||
const cleaning = ref(false) |
|||
const report = ref<AuditReport | null>(null) |
|||
const cleanupOpen = ref(false) |
|||
|
|||
async function ensureAdmin() { |
|||
await refresh(true) |
|||
if (user.value?.role !== 'admin') { |
|||
await navigateTo('/me') |
|||
} |
|||
} |
|||
|
|||
function extractError(e: unknown): string { |
|||
if (e && typeof e === 'object') { |
|||
const fe = e as { statusMessage?: string; message?: string; data?: { message?: string } } |
|||
if (typeof fe.statusMessage === 'string' && fe.statusMessage.length) { |
|||
return fe.statusMessage |
|||
} |
|||
if (typeof fe.data?.message === 'string' && fe.data.message.length) { |
|||
return fe.data.message |
|||
} |
|||
if (typeof fe.message === 'string' && fe.message.length) { |
|||
return fe.message |
|||
} |
|||
} |
|||
return '操作失败' |
|||
} |
|||
|
|||
async function runAudit() { |
|||
loading.value = true |
|||
try { |
|||
const res = await request<ApiResponse<AuditReport>>('/api/admin/media/storage-audit') |
|||
report.value = unwrapApiBody(res) |
|||
toast.add({ title: '校验完成', color: 'success' }) |
|||
} catch (e: unknown) { |
|||
toast.add({ title: extractError(e), color: 'error' }) |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
async function runCleanup() { |
|||
cleaning.value = true |
|||
try { |
|||
const res = await request<ApiResponse<{ removed: number; removedIds: number[] }>>( |
|||
'/api/admin/media/storage-audit-cleanup', |
|||
{ method: 'POST', body: {} }, |
|||
) |
|||
const { removed } = unwrapApiBody(res) |
|||
toast.add({ title: `已移除 ${removed} 条失效记录(无引用且磁盘无文件)`, color: 'success' }) |
|||
cleanupOpen.value = false |
|||
await runAudit() |
|||
} catch (e: unknown) { |
|||
toast.add({ title: extractError(e), color: 'error' }) |
|||
} finally { |
|||
cleaning.value = false |
|||
} |
|||
} |
|||
|
|||
const safeToCleanupCount = computed(() => { |
|||
if (!report.value) { |
|||
return 0 |
|||
} |
|||
return report.value.missingOnDisk.filter((m) => m.refCount === 0).length |
|||
}) |
|||
|
|||
const riskyMissingCount = computed(() => { |
|||
if (!report.value) { |
|||
return 0 |
|||
} |
|||
return report.value.missingOnDisk.filter((m) => m.refCount > 0).length |
|||
}) |
|||
|
|||
const cleanupDescription = computed(() => { |
|||
const n = safeToCleanupCount.value |
|||
return `将删除 ${n} 条 media_assets 记录:磁盘上无对应文件,且当前无任何文章引用。此操作不可撤销。` |
|||
}) |
|||
|
|||
onMounted(async () => { |
|||
await ensureAdmin() |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<UContainer class="py-8 space-y-6 w-full max-w-[min(100%,88rem)] px-4 sm:px-6"> |
|||
<div class="flex flex-wrap justify-between items-start gap-3"> |
|||
<div> |
|||
<h1 class="text-2xl font-semibold"> |
|||
媒体存储校验 |
|||
</h1> |
|||
<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>: |
|||
库中有记录但文件缺失、非法 storageKey、以及磁盘上未登记的文件。 |
|||
「一键清理」仅删除<strong>无文章引用</strong>且<strong>磁盘上确实没有文件</strong>的库记录,不会删磁盘文件。 |
|||
</p> |
|||
</div> |
|||
<UButton to="/me" variant="ghost" size="sm"> |
|||
返回控制台 |
|||
</UButton> |
|||
</div> |
|||
|
|||
<div class="flex flex-wrap items-center gap-3"> |
|||
<UButton :loading="loading" @click="runAudit"> |
|||
运行校验 |
|||
</UButton> |
|||
<UButton |
|||
v-if="report && safeToCleanupCount > 0" |
|||
color="warning" |
|||
variant="outline" |
|||
:disabled="loading" |
|||
@click="cleanupOpen = true" |
|||
> |
|||
清理失效库记录({{ safeToCleanupCount }}) |
|||
</UButton> |
|||
</div> |
|||
|
|||
<div v-if="report" class="space-y-6"> |
|||
<div class="text-sm text-muted flex flex-wrap gap-x-4 gap-y-1"> |
|||
<span>扫描时间:{{ new Date(report.scannedAt).toLocaleString('zh-CN') }}</span> |
|||
<span>库行数:{{ report.dbRowCount }}</span> |
|||
<span>磁盘文件数:{{ report.diskFileCount }}</span> |
|||
</div> |
|||
|
|||
<UCard v-if="riskyMissingCount > 0"> |
|||
<template #header> |
|||
<span class="text-warning">需人工处理:文件缺失但仍被文章引用({{ riskyMissingCount }})</span> |
|||
</template> |
|||
<p class="text-sm text-muted mb-3"> |
|||
请先修改相关文章正文或封面中的图片地址,再处理库记录;勿使用「清理失效库记录」删除这些行(当前实现不会删除有引用的行)。 |
|||
</p> |
|||
<div class="overflow-x-auto rounded border border-default"> |
|||
<table class="w-full text-sm"> |
|||
<thead> |
|||
<tr class="text-left text-muted border-b border-default bg-elevated/40"> |
|||
<th class="p-2"> |
|||
id |
|||
</th> |
|||
<th class="p-2"> |
|||
userId |
|||
</th> |
|||
<th class="p-2"> |
|||
storageKey |
|||
</th> |
|||
<th class="p-2"> |
|||
引用数 |
|||
</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-for="row in report.missingOnDisk.filter((m) => m.refCount > 0)" :key="row.id" class="border-b border-default/60"> |
|||
<td class="p-2 tabular-nums"> |
|||
{{ row.id }} |
|||
</td> |
|||
<td class="p-2 tabular-nums"> |
|||
{{ row.userId }} |
|||
</td> |
|||
<td class="p-2 font-mono text-xs"> |
|||
{{ row.storageKey }} |
|||
</td> |
|||
<td class="p-2 tabular-nums"> |
|||
{{ row.refCount }} |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</UCard> |
|||
|
|||
<UCard> |
|||
<template #header> |
|||
库有记录但磁盘无文件(且无引用时可清理):{{ report.missingOnDisk.length }} |
|||
</template> |
|||
<UEmpty v-if="!report.missingOnDisk.length" title="无" /> |
|||
<div v-else class="overflow-x-auto rounded border border-default"> |
|||
<table class="w-full text-sm"> |
|||
<thead> |
|||
<tr class="text-left text-muted border-b border-default bg-elevated/40"> |
|||
<th class="p-2"> |
|||
id |
|||
</th> |
|||
<th class="p-2"> |
|||
userId |
|||
</th> |
|||
<th class="p-2"> |
|||
storageKey |
|||
</th> |
|||
<th class="p-2"> |
|||
引用数 |
|||
</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-for="row in report.missingOnDisk" :key="row.id" class="border-b border-default/60"> |
|||
<td class="p-2 tabular-nums"> |
|||
{{ row.id }} |
|||
</td> |
|||
<td class="p-2 tabular-nums"> |
|||
{{ row.userId }} |
|||
</td> |
|||
<td class="p-2 font-mono text-xs"> |
|||
{{ row.storageKey }} |
|||
</td> |
|||
<td class="p-2 tabular-nums"> |
|||
{{ row.refCount }} |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</UCard> |
|||
|
|||
<UCard v-if="report.invalidStorageKey.length"> |
|||
<template #header> |
|||
非法或可疑的 storageKey:{{ report.invalidStorageKey.length }} |
|||
</template> |
|||
<div class="overflow-x-auto rounded border border-default"> |
|||
<table class="w-full text-sm"> |
|||
<thead> |
|||
<tr class="text-left text-muted border-b border-default bg-elevated/40"> |
|||
<th class="p-2"> |
|||
id |
|||
</th> |
|||
<th class="p-2"> |
|||
userId |
|||
</th> |
|||
<th class="p-2"> |
|||
storageKey |
|||
</th> |
|||
<th class="p-2"> |
|||
引用数 |
|||
</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-for="row in report.invalidStorageKey" :key="row.id" class="border-b border-default/60"> |
|||
<td class="p-2 tabular-nums"> |
|||
{{ row.id }} |
|||
</td> |
|||
<td class="p-2 tabular-nums"> |
|||
{{ row.userId }} |
|||
</td> |
|||
<td class="p-2 font-mono text-xs break-all"> |
|||
{{ row.storageKey }} |
|||
</td> |
|||
<td class="p-2 tabular-nums"> |
|||
{{ row.refCount }} |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</UCard> |
|||
|
|||
<UCard> |
|||
<template #header> |
|||
磁盘有文件但未在 media_assets 登记:{{ report.onDiskNotInDb.length }} |
|||
</template> |
|||
<p class="text-sm text-muted mb-3"> |
|||
可能是历史遗留或手动拷贝;需自行决定是否删除磁盘文件(本页不自动删文件)。 |
|||
</p> |
|||
<UEmpty v-if="!report.onDiskNotInDb.length" title="无" /> |
|||
<ul v-else class="font-mono text-xs max-h-64 overflow-y-auto space-y-1 border border-default rounded p-3 bg-elevated/30"> |
|||
<li v-for="name in report.onDiskNotInDb" :key="name"> |
|||
{{ name }} |
|||
</li> |
|||
</ul> |
|||
</UCard> |
|||
</div> |
|||
|
|||
<UModal |
|||
v-model:open="cleanupOpen" |
|||
title="确认清理失效库记录" |
|||
:description="cleanupDescription" |
|||
> |
|||
<template #footer> |
|||
<div class="flex justify-end gap-2"> |
|||
<UButton variant="outline" color="neutral" :disabled="cleaning" @click="cleanupOpen = false"> |
|||
取消 |
|||
</UButton> |
|||
<UButton color="warning" :loading="cleaning" @click="runCleanup"> |
|||
确认清理 |
|||
</UButton> |
|||
</div> |
|||
</template> |
|||
</UModal> |
|||
</UContainer> |
|||
</template> |
|||
@ -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); |
|||
}); |
|||
@ -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); |
|||
}); |
|||
@ -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<StorageAuditResult> { |
|||
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<number, number>(); |
|||
for (const r of refAgg) { |
|||
refMap.set(r.assetId, r.c); |
|||
} |
|||
|
|||
const dbKeys = new Set<string>(); |
|||
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 }; |
|||
} |
|||
Loading…
Reference in new issue