feat(cache): 通用缓存按区统计与清空接口

GET /cache/regions 列出 kv_cache 各区的未过期条数、已过期条数、占用字节、最近写入;
POST /cache/regions/{region}/clear 清空一个区(admin)。contracts 加区名中文映射
cacheRegionLabel,web 加对应 hooks。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:01:19 +08:00
1 parent ad3bf8926b
commit b7460d5d0a
5 files changed
+190 -6

No files matched your search

+33 -2
View File
@@ -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> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
function route<D extends EndpointDef>(
app: ModuleApp,
def: D,
handler: (i: { query: Infer<D["query"]> }) => Promise<unknown>,
handler: (i: { query: Infer<D["query"]>; params: Infer<D["params"]> }) => Promise<unknown>,
) {
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<D extends EndpointDef>(
});
// 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) {
+61
View File
@@ -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<typeof bearer>;
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);
});
});