Browse Source

feat(admin): storage vs media_assets audit and safe DB cleanup

Made-with: Cursor
main
npmrun 10 hours ago
parent
commit
4e4d3b1c92
  1. 305
      app/pages/me/admin/media-storage.vue
  2. 13
      app/pages/me/index.vue
  3. 13
      server/api/admin/media/storage-audit-cleanup.post.ts
  4. 13
      server/api/admin/media/storage-audit.get.ts
  5. 180
      server/service/media/storage-audit.ts

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

@ -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>

13
app/pages/me/index.vue

@ -69,7 +69,7 @@ onMounted(async () => {
</UCard> </UCard>
<UCard> <UCard>
<div class="font-medium"> <div class="font-medium">
媒体清理 文章媒体清理
</div> </div>
<p class="text-sm text-muted mt-1"> <p class="text-sm text-muted mt-1">
孤儿图片审查与清理 孤儿图片审查与清理
@ -113,6 +113,17 @@ onMounted(async () => {
打开 打开
</UButton> </UButton>
</UCard> </UCard>
<UCard v-if="user?.role === 'admin'">
<div class="font-medium">
媒体存储校验
</div>
<p class="text-sm text-muted mt-1">
磁盘与 media_assets 一致性
</p>
<UButton to="/me/admin/media-storage" class="mt-3" size="sm">
打开
</UButton>
</UCard>
</div> </div>
</UContainer> </UContainer>
</template> </template>

13
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);
});

13
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);
});

180
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<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…
Cancel
Save