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:
1 parent
ad3bf8926b
commit
b7460d5d0a
5 files changed
+190
-6
No files matched your search
+33
-2
@@ -8,6 +8,7 @@ import {
|
|||||||
type TmdbCacheItem,
|
type TmdbCacheItem,
|
||||||
type TmdbCacheList,
|
type TmdbCacheList,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
|
import { sql } from "@umt/db";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { dataPaths } from "../../config.ts";
|
import { dataPaths } from "../../config.ts";
|
||||||
import { externalFetch } from "../../egress.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>(
|
function route<D extends EndpointDef>(
|
||||||
app: ModuleApp,
|
app: ModuleApp,
|
||||||
def: D,
|
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"] = {};
|
const request: RouteConfig["request"] = {};
|
||||||
if (def.query) request.query = def.query as never;
|
if (def.query) request.query = def.query as never;
|
||||||
|
if (def.params) request.params = def.params as never;
|
||||||
const r = createRoute({
|
const r = createRoute({
|
||||||
method: def.method,
|
method: def.method,
|
||||||
path: def.path,
|
path: def.path,
|
||||||
@@ -57,7 +59,12 @@ function route<D extends EndpointDef>(
|
|||||||
});
|
});
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
|
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
|
||||||
app.openapi(r, (async (c: any) => {
|
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);
|
return c.json({ ok: true, data }, 200);
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||||
}) as any);
|
}) 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.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() {},
|
registerTasks() {},
|
||||||
start(ctx: ServerContext) {
|
start(ctx: ServerContext) {
|
||||||
|
|||||||
+61
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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";
|
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() {
|
export function useTmdbCache() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -17,7 +21,7 @@ export function useClearTmdbCache() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => apiData<CacheClearResult>("/cache/tmdb/clear", { method: "POST" }),
|
mutationFn: () => apiData<CacheClearResult>("/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 }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: keys.images }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCacheRegions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: keys.regions,
|
||||||
|
queryFn: ({ signal }) => apiData<CacheRegionList>("/cache/regions", { signal }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空一个区;TMDB 两个区也在这里清,顺带刷新 TMDB 缓存计数 */
|
||||||
|
export function useClearCacheRegion() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (region: string) =>
|
||||||
|
apiData<CacheClearResult>(`/cache/regions/${encodeURIComponent(region)}/clear`, { method: "POST" }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["cache"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -95,6 +95,21 @@ export const platformApi = {
|
|||||||
summary: "清空图片代理缓存",
|
summary: "清空图片代理缓存",
|
||||||
response: cache.CacheClearResult,
|
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) ──
|
// ── 网页搜索(WEB-1,docs/runbook/websearch.md) ──
|
||||||
|
|||||||
@@ -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;
|
if (!/^[A-Za-z0-9_-]+\.(jpg|jpeg|png|webp)$/.test(p)) return null;
|
||||||
return `/api/v1/img/tmdb/${size}/${p}`;
|
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<typeof CacheRegionStat>;
|
||||||
|
|
||||||
|
export const CacheRegionList = z.object({ items: z.array(CacheRegionStat) });
|
||||||
|
export type CacheRegionList = z.infer<typeof CacheRegionList>;
|
||||||
|
|
||||||
|
/** region → 界面名;精确匹配优先,其次按前缀(`bangumi.*`);都不中返回 null(界面原样显示 region) */
|
||||||
|
const CACHE_REGION_LABELS: Record<string, string> = {
|
||||||
|
"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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user