diff --git a/apps/api/src/routes/admin/conf.ts b/apps/api/src/routes/admin/conf.ts index 872be03..9aafe04 100644 --- a/apps/api/src/routes/admin/conf.ts +++ b/apps/api/src/routes/admin/conf.ts @@ -4,6 +4,7 @@ import { type DashboardInfo, type JudgeServer, type JudgeServerList, + type OrphanImage, type OrphanTestCase, type UploadImageResponse, type WebsiteConfig, @@ -184,6 +185,112 @@ adminConfRoutes.delete("/orphan-test-cases", requireSuperAdmin, async (c) => { return success(c, { deleted: targets.length }) }) +// ---------------------------------------------------------------- 没人用的图片 + +/** 只认编辑器传上来的那几种。目录里别的东西(手工放进去的 mp4 之类)一律不碰 */ +const UPLOAD_IMAGE_RE = /^[\w-]+\.(?:png|jpe?g|gif|bmp)$/i +const IMAGE_NAME_PATTERN = "([A-Za-z0-9_-]+\\.(?:png|jpe?g|gif|bmp))" +/** 刚传的图可能还在编辑器里、题目还没保存,一天以内的不算 */ +const ORPHAN_IMAGE_GRACE_MS = 24 * 60 * 60 * 1000 + +/** + * 库里提到过的图片文件名(小写)。不去列「哪些列放富文本」—— 题面、提示、公告、教程里都有, + * 提交的代码里也有,以后还会有新表 —— 而是把每张表的每个文本列都扫一遍,并且只认文件名、 + * 不认路径:老域名的绝对地址、markdown 的 `![](…)` 都算用着。宁可多留,不能错删 + */ +async function referencedImageNames() { + const columns = await db.execute<{ table_name: string; column_name: string }>(sql` + select c.table_name, c.column_name + from information_schema.columns c + join information_schema.tables t + on t.table_schema = c.table_schema and t.table_name = c.table_name + where c.table_schema = current_schema() and t.table_type = 'BASE TABLE' + and c.udt_name in ('text', 'varchar', 'bpchar', 'json', 'jsonb', '_text', '_varchar') + `) + const tables = new Map() + for (const { table_name, column_name } of columns) { + tables.set(table_name, [...(tables.get(table_name) ?? []), column_name]) + } + const names = new Set() + // 一张表一条、挨个来:要扫全部提交,别和上课的请求抢那两个核 + for (const [table, columnNames] of tables) { + const text = sql.join( + columnNames.map((column) => sql`coalesce(${sql.identifier(column)}::text, '')`), + sql` || ' ' || `, + ) + const rows = await db.execute<{ name: string }>(sql` + select distinct lower(m[1]) as name + from (select ${text} as t from ${sql.identifier(table)}) s, + regexp_matches(s.t, ${IMAGE_NAME_PATTERN}, 'gi') m + `) + for (const row of rows) names.add(row.name) + } + return names +} + +/** 编辑器插图、头像各一个目录;头像换了、删了,旧文件同样留在盘上 */ +const IMAGE_FOLDERS = { + upload: { directory: config.uploadDirectory, prefix: config.uploadUriPrefix }, + avatar: { directory: config.avatarDirectory, prefix: config.avatarUriPrefix }, +} as const + +type ImageFolder = keyof typeof IMAGE_FOLDERS + +async function orphanImages() { + const onDisk = await Promise.all( + (Object.keys(IMAGE_FOLDERS) as ImageFolder[]).map(async (folder) => + (await readdir(IMAGE_FOLDERS[folder].directory).catch(() => [] as string[])) + .filter((name) => UPLOAD_IMAGE_RE.test(name)) + .map((name) => ({ folder, name })), + ), + ).then((lists) => lists.flat()) + if (onDisk.length === 0) return [] + const referenced = await referencedImageNames() + // 一张都没提到,多半是连错了库(DATA_DIR 没设、起了一套空库),这时候全目录都会被当成没人用 + if (referenced.size === 0) return [] + const cutoff = Date.now() - ORPHAN_IMAGE_GRACE_MS + const rows = await Promise.all( + onDisk + .filter(({ name }) => !referenced.has(name.toLowerCase())) + .map(async ({ folder, name }) => { + const { directory, prefix } = IMAGE_FOLDERS[folder] + const info = await stat(resolve(directory, name)).catch(() => null) + if (!info?.isFile() || info.mtimeMs > cutoff) return null + return { + folder, + name, + url: `${prefix}/${name}`, + size: info.size, + createTime: info.mtimeMs / 1000, + } satisfies OrphanImage + }), + ) + return rows + .filter((row): row is OrphanImage => row !== null) + .sort((a, b) => b.createTime - a.createTime) +} + +adminConfRoutes.get("/orphan-images", requireSuperAdmin, async (c) => { + return success(c, await orphanImages()) +}) + +adminConfRoutes.delete("/orphan-images", requireSuperAdmin, async (c) => { + const folder = c.req.query("folder") + const name = c.req.query("name") + // 删之前重新扫一遍:列表是刚才看的,这期间可能有人把图插回了题面、把头像换了回去 + const orphans = await orphanImages() + const targets = orphans.filter( + (image) => (!folder || image.folder === folder) && (!name || image.name === name), + ) + if (name && targets.length === 0) { + return failure(c, 404, "not-an-orphan", "这张图片不存在或还有地方在用,未删除") + } + for (const image of targets) { + await rm(resolve(IMAGE_FOLDERS[image.folder].directory, image.name), { force: true }) + } + return success(c, { deleted: targets.length }) +}) + // ---------------------------------------------------------------- 概览 / 随机点名 adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => { diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 56c3efe..0e35a52 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -60,6 +60,7 @@ import type { AdminUserList, DashboardInfo, JudgeServerList, + OrphanImage, OrphanTestCase, AdminProblem, AdminProblemList, @@ -475,6 +476,15 @@ export function pruneInvalidTestcases(id?: string) { return api.delete("admin/orphan-test-cases", { params: { id } }) } +export function listOrphanImages() { + return api.get("admin/orphan-images") +} + +/** 不带参数是全删;带 folder 只删那个目录的,再带 name 只删一张 */ +export function pruneOrphanImages(target?: Pick & { name?: string }) { + return api.delete<{ deleted: number }>("admin/orphan-images", { params: target }) +} + export function getJudgeServer() { return api.get("admin/judge-servers") } diff --git a/apps/web/src/admin/setting/config.vue b/apps/web/src/admin/setting/config.vue index d75e70e..5d36df7 100644 --- a/apps/web/src/admin/setting/config.vue +++ b/apps/web/src/admin/setting/config.vue @@ -5,7 +5,7 @@ import { classLabel } from "utils/classes" import { errorMessage } from "utils/api" import { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MIN_DIGITS, CLASS_NAME_RE } from "utils/constants" import { copyToClipboard, parseTime } from "utils/functions" -import type { OrphanTestCase, Server, WebsiteConfig } from "utils/types" +import type { OrphanImage, OrphanTestCase, Server, WebsiteConfig } from "utils/types" import { useConfigStore } from "shared/store/config" import { deleteJudgeServer, @@ -13,7 +13,9 @@ import { getJudgeServer, getWebsite, listInvalidTestcases, + listOrphanImages, pruneInvalidTestcases, + pruneOrphanImages, } from "../api" /** @@ -179,6 +181,54 @@ async function deleteTestcase(id?: string) { getTestcases() } +// ---------------------------------------------------------------- 没人用的图片 + +/** + * 要扫遍整个库(包括十几万条提交),所以不在进页面时自动查,点了才查。 + * null = 还没查过 + */ +const orphanImages = ref(null) +const scanningImages = ref(false) + +const IMAGE_GROUPS = [ + { folder: "upload", label: "编辑器里删掉的图" }, + { folder: "avatar", label: "换下来的头像" }, +] as const + +const imageGroups = computed(() => + IMAGE_GROUPS.map((group) => { + const images = (orphanImages.value ?? []).filter((image) => image.folder === group.folder) + const bytes = images.reduce((sum, image) => sum + image.size, 0) + return { ...group, images, bytes } + }).filter((group) => group.images.length), +) + +async function scanImages() { + scanningImages.value = true + try { + orphanImages.value = await listOrphanImages() + } catch (error) { + message.error(errorMessage(error)) + } finally { + scanningImages.value = false + } +} + +async function deleteImages(target: Parameters[0]) { + try { + const { deleted } = await pruneOrphanImages(target) + message.success(`删掉了 ${deleted} 张`) + } catch (error) { + message.error(errorMessage(error)) + } + scanImages() +} + +function formatBytes(bytes: number) { + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB` + return `${(bytes / 1024 / 1024).toFixed(1)}MB` +} + onMounted(() => { getWebsiteConfig() getTestcases() @@ -295,6 +345,49 @@ onMounted(() => { +
+

+ 没人用的图片 + + {{ orphanImages ? "再查一遍" : "查一下" }} + +

+ + 都有地方在用 + +
+
+ {{ group.label }} + {{ group.images.length }} 张 · {{ formatBytes(group.bytes) }} + + + 删掉这 {{ group.images.length }} 张,删了找不回来,确定吗? + +
+
+
+ + + + +
+
+
+
+

没有题目用的测试数据 {{ testcases.length }} 份 @@ -372,6 +465,57 @@ h3 { gap: 8px; } +.image-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.image-group-head { + display: flex; + align-items: center; + font-size: 14px; +} + +.thumbs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.thumb { + position: relative; + width: 72px; + height: 72px; + border-radius: 6px; + overflow: hidden; + border: 1px solid v-bind("theme.dividerColor"); + background: v-bind("theme.actionColor"); +} + +.thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.thumb-delete { + position: absolute; + top: 2px; + right: 2px; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 14px; + line-height: 20px; + cursor: pointer; +} + .toggle { display: inline-flex; align-items: center; diff --git a/apps/web/src/utils/types.ts b/apps/web/src/utils/types.ts index f1305d4..ef76bad 100644 --- a/apps/web/src/utils/types.ts +++ b/apps/web/src/utils/types.ts @@ -313,6 +313,7 @@ export type { JudgeServerList, DashboardInfo, OrphanTestCase, + OrphanImage, AdminUser, AdminUserList, AdminContestList, diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 32e66a0..9e07960 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -361,6 +361,16 @@ export const orphanTestCaseSchema = z.object({ createTime: z.number(), }) +/** 上传目录里、库里哪儿都没提到的图片:编辑器插的图(upload)和换下来的头像(avatar) */ +export const orphanImageSchema = z.object({ + folder: z.enum(["upload", "avatar"]), + name: z.string(), + url: z.string(), + /** 字节 */ + size: z.number(), + createTime: z.number(), +}) + /** * 后台的用户排名列表。行的形状与 oj 侧榜单相同,但**没有 100 名的上限**: * 这里不是榜单,是老师按班级前缀翻全班学生的管理视图,要能一直翻到底。 @@ -1130,6 +1140,7 @@ export type JudgeServer = z.infer export type DashboardInfo = z.infer export type JudgeServerList = z.infer export type OrphanTestCase = z.infer +export type OrphanImage = z.infer export type AdminUserRank = z.infer export type AdminAiReportListItem = z.infer