feat(cache): TMDB / 豆瓣 / Bangumi 接入统一元数据缓存与策略表

- 策略表「来源 × 资源类型」(metaCachePolicy):搜索 / 识别 7 天(空 1 天)、详情 / 季 / 剧集组 30 天(空 1 天)、
  榜单 / 热门 / 每日放送 6 小时(空 1 小时)、人物 / 图片 90 天;取值来自 storage.cacheTtl,
  追加 tmdbListHours / tmdbListEmptyHours / imageDays / metaDetailDays / metaMissDays,metaSearchDays 缺省改 7 天
- TMDB 客户端共享缓存端口加 fetch(带请求类别与空结果判定),服务端整条读取交给 MetadataCache;
  识别缓存、douban.request / bangumi.request、douban.subject / bangumi.subject、meta.subject_miss、discover.match 都走这一层
- 豆瓣 / bgm 条目只在「不存在」时记查无,网络失败 / 被拦不缓存(本进程冷却 30 分钟)
- 关注中作品(isEntityWatched 口径,含选中的剧集组)的 TMDB 条目写入 pinned、命中续期
- 各进程启动 LISTEN 失效通知并每分钟把命中计数写 cache_stat;GET /cache/regions 给元数据 region 带近 7 天命中统计

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 19:37:34 +08:00
1 parent ad472a1a19
commit 1dca95f883
16 files changed
+687 -137

No files matched your search

+13 -5
View File
@@ -67,7 +67,10 @@ describe.skipIf(!hasPg)("C2-A 缓存(真实 PG)", () => {
await (await new SourcesRuntime(h.ctx).tmdb())!.details("movie", 872585);
const calls = tmdb.calls.length;
const rows = (
await sql<{ key: string; days: number }>`select key, extract(epoch from expires_at - now()) / 86400 as days
await sql<{
key: string;
days: number;
}>`select key, extract(epoch from coalesce(fresh_until, expires_at) - now()) / 86400 as days
from kv_cache where region = 'tmdb.request'`.execute(h.db)
).rows;
const detail = rows.find((x) => x.key.startsWith("/movie/872585?"))!;
@@ -80,7 +83,7 @@ describe.skipIf(!hasPg)("C2-A 缓存(真实 PG)", () => {
const days = async (like: string) =>
Number(
(
await sql<{ d: number }>`select extract(epoch from expires_at - now()) / 86400 as d
await sql<{ d: number }>`select extract(epoch from coalesce(fresh_until, expires_at) - now()) / 86400 as d
from kv_cache where region = 'tmdb.request' and key like ${like}`.execute(h.db)
).rows[0]!.d,
);
@@ -96,7 +99,7 @@ describe.skipIf(!hasPg)("C2-A 缓存(真实 PG)", () => {
expect(empty).toBeLessThanOrEqual(2);
});
it("追踪中的作品:详情缓存命中时续期到 now + 详情 TTL;未追踪的不动", async () => {
it("关注中的作品:详情缓存命中时续期到 now + 详情 TTL 并标 pinned;未关注的不动", async () => {
const ent = (
await sql<{
id: string;
@@ -111,19 +114,24 @@ describe.skipIf(!hasPg)("C2-A 缓存(真实 PG)", () => {
const client = (await new SourcesRuntime(h.ctx).tmdb())!;
await client.details("movie", 872585);
await client.details("movie", 693134);
await sql`update kv_cache set expires_at = now() + interval '1 day'
await sql`update kv_cache set fresh_until = now() + interval '1 day', expires_at = now() + interval '2 days',
pinned = false
where region = 'tmdb.request' and (key like '/movie/872585?%' or key like '/movie/693134?%')`.execute(h.db);
await client.details("movie", 872585);
await client.details("movie", 693134);
const left = async (like: string) =>
Number(
(
await sql<{ d: number }>`select extract(epoch from expires_at - now()) / 86400 as d
await sql<{ d: number }>`select extract(epoch from fresh_until - now()) / 86400 as d
from kv_cache where region = 'tmdb.request' and key like ${like}`.execute(h.db)
).rows[0]!.d,
);
expect(await left("/movie/872585?%")).toBeGreaterThan(19.9);
expect(await left("/movie/693134?%")).toBeLessThanOrEqual(1);
const pinned = await sql<{ k: string; p: boolean }>`select key as k, pinned as p from kv_cache
where region = 'tmdb.request' and (key like '/movie/872585?%' or key like '/movie/693134?%')`.execute(h.db);
expect(pinned.rows.filter((r) => r.p).every((r) => r.k.startsWith("/movie/872585?"))).toBe(true);
expect(pinned.rows.some((r) => r.p)).toBe(true);
});
it("GET /cache/tmdb 列出条目(键 / 过期 / 大小)与上限;POST /cache/tmdb/clear 清空", async () => {