diff --git a/apps/server/src/modules/cache/index.ts b/apps/server/src/modules/cache/index.ts index 9bf7cda5..097133d9 100644 --- a/apps/server/src/modules/cache/index.ts +++ b/apps/server/src/modules/cache/index.ts @@ -8,6 +8,7 @@ import { type TmdbCacheItem, type TmdbCacheList, } from "@umt/contracts"; +import { sql } from "@umt/db"; import type { z } from "zod"; import { dataPaths } from "../../config.ts"; import { externalFetch } from "../../egress.ts"; @@ -40,10 +41,11 @@ type Infer = T extends z.ZodType ? z.infer : Record; function route( app: ModuleApp, def: D, - handler: (i: { query: Infer }) => Promise, + handler: (i: { query: Infer; params: Infer }) => Promise, ) { const request: RouteConfig["request"] = {}; if (def.query) request.query = def.query as never; + if (def.params) request.params = def.params as never; const r = createRoute({ method: def.method, path: def.path, @@ -57,7 +59,12 @@ function route( }); // biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验 app.openapi(r, (async (c: any) => { - const data = def.response.parse(await handler({ query: def.query ? c.req.valid("query") : {} } as never)); + const data = def.response.parse( + await handler({ + query: def.query ? c.req.valid("query") : {}, + params: def.params ? c.req.valid("param") : {}, + } as never), + ); return c.json({ ok: true, data }, 200); // biome-ignore lint/suspicious/noExplicitAny: 同上 }) as any); @@ -178,6 +185,30 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule & }; }); route(app, Api.cache.imagesClear, async () => ({ deleted: await images(ctx).clear() })); + // 通用缓存按区统计与清空(设置 → 缓存);图片缓存在磁盘上,走上面两个接口 + route(app, Api.cache.regions, async () => { + const r = await sql<{ region: string; entries: number; expired: number; bytes: string; last: Date | null }>` + select region, + count(*) filter (where expires_at > now())::int as entries, + count(*) filter (where expires_at <= now())::int as expired, + coalesce(sum(pg_column_size(value) + pg_column_size(key)), 0)::text as bytes, + max(updated_at) as last + from kv_cache group by region order by region`.execute(ctx.db); + return { + items: r.rows.map((x) => ({ + region: x.region, + entries: Number(x.entries), + expired: Number(x.expired), + bytes: Number(x.bytes), + lastWriteAt: x.last ? new Date(x.last).toISOString() : null, + })), + }; + }); + route(app, Api.cache.regionClear, async ({ params }) => { + const deleted = await sharedCache(ctx).clear(params.region); + ctx.logger.info({ region: params.region, deleted }, "缓存区已清空"); + return { deleted }; + }); }, registerTasks() {}, start(ctx: ServerContext) { diff --git a/apps/server/test/cache/region-admin.test.ts b/apps/server/test/cache/region-admin.test.ts new file mode 100644 index 00000000..be5a5efe --- /dev/null +++ b/apps/server/test/cache/region-admin.test.ts @@ -0,0 +1,61 @@ +import { PgCache } from "@umt/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createCacheModule } from "../../src/modules/cache/index.ts"; +import { bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts"; +import { DEV_KEY, json } from "../helpers.ts"; + +// 设置 → 缓存:通用缓存(kv_cache)按区统计与单区清空(GET /cache/regions、POST /cache/regions/{region}/clear) + +const hasPg = await pgAvailable(); + +describe.skipIf(!hasPg)("通用缓存按区管理(真实 PG)", () => { + let h: Harness; + let admin: ReturnType; + + beforeAll(async () => { + h = await createHarness({ extraModules: [createCacheModule()] }); + admin = bearer(h.app, DEV_KEY); + }); + afterAll(async () => { + await h?.close(); + }); + + it("按区列出未过期条数、已过期条数、占用字节、最近写入;清空一个区不影响其它区", async () => { + const cache = new PgCache(h.db); + await cache.set("douban.subject", "1", { title: "a" }); + await cache.set("douban.subject", "2", { title: "b" }); + await cache.set("site.detail", "x", { body: "y".repeat(200) }); + // 写入后立刻过期的一条(ttl 1 秒,等它过期) + await cache.set("site.detail", "old", { v: 1 }, { ttl: 1 }); + await new Promise((r) => setTimeout(r, 1100)); + + const res = await admin("GET", "/cache/regions"); + expect(res.status).toBe(200); + const items = (await json(res)).data.items as { + region: string; + entries: number; + expired: number; + bytes: number; + lastWriteAt: string | null; + }[]; + const douban = items.find((x) => x.region === "douban.subject")!; + const site = items.find((x) => x.region === "site.detail")!; + expect(douban).toMatchObject({ entries: 2, expired: 0 }); + expect(douban.bytes).toBeGreaterThan(0); + expect(Date.parse(douban.lastWriteAt!)).toBeGreaterThan(Date.now() - 60_000); + expect(site).toMatchObject({ entries: 1, expired: 1 }); + expect(site.bytes).toBeGreaterThan(200); + + const cleared = await json(await admin("POST", "/cache/regions/site.detail/clear")); + expect(cleared.data.deleted).toBe(2); + const after = (await json(await admin("GET", "/cache/regions"))).data.items as { region: string }[]; + expect(after.map((x) => x.region)).toContain("douban.subject"); + expect(after.map((x) => x.region)).not.toContain("site.detail"); + expect(await cache.count("douban.subject")).toBe(2); + }); + + it("区名不合法时拒绝(422),不执行删除", async () => { + const res = await admin("POST", "/cache/regions/BAD%20NAME/clear"); + expect(res.status).toBe(422); + }); +}); diff --git a/apps/web/app/api/cache.ts b/apps/web/app/api/cache.ts index d7784407..240ea083 100644 --- a/apps/web/app/api/cache.ts +++ b/apps/web/app/api/cache.ts @@ -1,10 +1,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { CacheClearResult, ImageCacheStatus, TmdbCacheList } from "@umt/contracts"; +import type { CacheClearResult, CacheRegionList, ImageCacheStatus, TmdbCacheList } from "@umt/contracts"; import { apiData } from "~/lib/api.ts"; -// C2-A 缓存管理:TMDB 缓存(条数 / 上限 / TTL / 清空)、图片代理缓存(用量 / 清空) +// C2-A 缓存管理:TMDB 缓存(条数 / 上限 / TTL / 清空)、图片代理缓存(用量 / 清空)、通用缓存按区统计与清空 -const keys = { tmdb: ["cache", "tmdb"] as const, images: ["cache", "images"] as const }; +const keys = { + tmdb: ["cache", "tmdb"] as const, + images: ["cache", "images"] as const, + regions: ["cache", "regions"] as const, +}; export function useTmdbCache() { return useQuery({ @@ -17,7 +21,7 @@ export function useClearTmdbCache() { const qc = useQueryClient(); return useMutation({ mutationFn: () => apiData("/cache/tmdb/clear", { method: "POST" }), - onSuccess: () => qc.invalidateQueries({ queryKey: keys.tmdb }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["cache"] }), }); } @@ -35,3 +39,20 @@ export function useClearImageCache() { onSuccess: () => qc.invalidateQueries({ queryKey: keys.images }), }); } + +export function useCacheRegions() { + return useQuery({ + queryKey: keys.regions, + queryFn: ({ signal }) => apiData("/cache/regions", { signal }), + }); +} + +/** 清空一个区;TMDB 两个区也在这里清,顺带刷新 TMDB 缓存计数 */ +export function useClearCacheRegion() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (region: string) => + apiData(`/cache/regions/${encodeURIComponent(region)}/clear`, { method: "POST" }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["cache"] }), + }); +} diff --git a/packages/contracts/src/api/platform.ts b/packages/contracts/src/api/platform.ts index f2305f5f..30527e0b 100644 --- a/packages/contracts/src/api/platform.ts +++ b/packages/contracts/src/api/platform.ts @@ -95,6 +95,21 @@ export const platformApi = { summary: "清空图片代理缓存", response: cache.CacheClearResult, }), + regions: ep({ + method: "get", + path: "/cache/regions", + scope: "admin", + summary: "通用缓存(kv_cache)按区统计:条目数、已过期数、占用字节、最近写入", + response: cache.CacheRegionList, + }), + regionClear: ep({ + method: "post", + path: "/cache/regions/{region}/clear", + scope: "admin", + summary: "清空通用缓存的一个区", + params: cache.CacheRegionParam, + response: cache.CacheClearResult, + }), }, // ── 网页搜索(WEB-1,docs/runbook/websearch.md) ── diff --git a/packages/contracts/src/cache.ts b/packages/contracts/src/cache.ts index 0e81f501..2b748e57 100644 --- a/packages/contracts/src/cache.ts +++ b/packages/contracts/src/cache.ts @@ -75,3 +75,59 @@ export function tmdbImageUrl(path: string | null | undefined, size: TmdbImageSiz if (!/^[A-Za-z0-9_-]+\.(jpg|jpeg|png|webp)$/.test(p)) return null; return `/api/v1/img/tmdb/${size}/${p}`; } + +// ── 通用缓存(kv_cache)按 region 管理(设置 → 缓存) ── + +/** region 名:小写字母、数字、`.`、`_`、`-` */ +export const CacheRegionName = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/, "缓存区名不合法"); +export const CacheRegionParam = z.object({ region: CacheRegionName }); + +export const CacheRegionStat = z.object({ + region: z.string(), + /** 未过期条目数 */ + entries: NonNegInt, + /** 已过期、等清理任务删除的条目数 */ + expired: NonNegInt, + /** 键与值占用字节(pg_column_size 之和,含已过期) */ + bytes: NonNegInt, + /** 最近一次写入 */ + lastWriteAt: Timestamp.nullable(), +}); +export type CacheRegionStat = z.infer; + +export const CacheRegionList = z.object({ items: z.array(CacheRegionStat) }); +export type CacheRegionList = z.infer; + +/** region → 界面名;精确匹配优先,其次按前缀(`bangumi.*`);都不中返回 null(界面原样显示 region) */ +const CACHE_REGION_LABELS: Record = { + "tmdb.request": "TMDB 请求", + "tmdb.recognize": "TMDB 识别", + "douban.request": "豆瓣请求", + "douban.subject": "豆瓣条目", + "bangumi.request": "Bangumi 请求", + "bangumi.subject": "Bangumi 条目", + "meta.subject_miss": "豆瓣 / Bangumi 查无结果", + "discover.list": "发现列表", + "site.detail": "站点详情页", + "identity.detail_ids": "识别抓详情页计数", + enrich: "元数据补全", + "enrich.robots": "元数据补全 robots.txt", + websearch: "网页搜索", + "websearch.allow": "可读取的网页", + images: "图片", +}; +const CACHE_REGION_PREFIX_LABELS: [string, string][] = [ + ["tmdb.", "TMDB"], + ["douban.", "豆瓣"], + ["bangumi.", "Bangumi"], + ["discover.", "发现"], + ["site.", "站点"], + ["enrich.", "元数据补全"], + ["websearch.", "网页搜索"], +]; +export function cacheRegionLabel(region: string): string | null { + const exact = CACHE_REGION_LABELS[region]; + if (exact) return exact; + const hit = CACHE_REGION_PREFIX_LABELS.find(([p]) => region.startsWith(p)); + return hit ? `${hit[1]}(${region.slice(hit[0].length)})` : null; +}