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

+40 -12
View File
@@ -14,7 +14,16 @@ import { dataPaths } from "../../config.ts";
import { externalFetch } from "../../egress.ts";
import { requireScope } from "../../http/auth.ts";
import { errorBody } from "../../http/errors.ts";
import { CACHE_REGIONS, sharedCache, storageSettings, tmdbCachePolicy } from "../../shared-cache.ts";
import {
CACHE_REGIONS,
METADATA_REGIONS,
metadataCache,
type RegionHitStats,
regionHitStats,
sharedCache,
storageSettings,
tmdbCachePolicy,
} from "../../shared-cache.ts";
import { registerScheduledJob } from "../schedules/registry.ts";
import type { ModuleApp, ServerContext, ServerModule } from "../types.ts";
import { ImageCache, ImageProxyError } from "./images.ts";
@@ -72,6 +81,13 @@ function route<D extends EndpointDef>(
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
/** 命中统计 → 接口形状;命中率 = (L1 + L2 + 旧值) / (L1 + L2 + 旧值 + 未命中) */
function hitView(h: RegionHitStats | undefined) {
const v = h ?? { l1Hits: 0, l2Hits: 0, staleHits: 0, misses: 0, failures: 0, refreshes: 0 };
const hit = v.l1Hits + v.l2Hits + v.staleHits;
return { ...v, hitRate: hit + v.misses > 0 ? hit / (hit + v.misses) : null };
}
export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule & {
images(ctx: ServerContext): ImageCache;
} {
@@ -166,7 +182,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
return out;
});
route(app, Api.cache.tmdbClear, async () => {
const cache = sharedCache(ctx);
const cache = metadataCache(ctx);
const deleted =
(await cache.clear(CACHE_REGIONS.tmdbRecognize)) + (await cache.clear(CACHE_REGIONS.tmdbRequest));
ctx.logger.info({ deleted }, "TMDB 缓存已清空");
@@ -194,18 +210,30 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
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,
})),
// 统一元数据缓存的 region 带最近 7 天命中统计(口径见 contracts CacheHitStats)
const hits = await regionHitStats(ctx).catch(() => new Map<string, RegionHitStats>());
const withHits = (region: string) => {
const h = hits.get(region);
if (!h) return METADATA_REGIONS.includes(region) ? { hits: hitView(undefined) } : {};
return { hits: hitView(h) };
};
const 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,
...withHits(x.region),
}));
// 条目已清空、但最近有读取统计的元数据 region 也列出来
for (const region of hits.keys())
if (!items.some((i) => i.region === region))
items.push({ region, entries: 0, expired: 0, bytes: 0, lastWriteAt: null, ...withHits(region) });
items.sort((a, b) => a.region.localeCompare(b.region));
return { items };
});
route(app, Api.cache.regionClear, async ({ params }) => {
const deleted = await sharedCache(ctx).clear(params.region);
const deleted = await metadataCache(ctx).clear(params.region);
ctx.logger.info({ region: params.region, deleted }, "缓存区已清空");
return { deleted };
});
@@ -217,7 +245,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
lane: "maintenance",
defaults: {
name: "缓存清理",
description: "删除过期的 TMDB 与识别缓存",
description: "删除过期的元数据与其它缓存",
group: "maintain",
schedule: { type: "interval", everyMs: 10 * 60_000 },
firstRun: "after_interval",