diff --git a/apps/server/src/modules/auth-settings/provided.ts b/apps/server/src/modules/auth-settings/provided.ts index 743c407c..430be9d9 100644 --- a/apps/server/src/modules/auth-settings/provided.ts +++ b/apps/server/src/modules/auth-settings/provided.ts @@ -1,5 +1,11 @@ import { createCipheriv, createDecipheriv, createHmac, randomBytes, randomUUID } from "node:crypto"; -import { DEFAULT_SETTINGS, GlobalSettings, type SecretPurpose, type SettingsKey } from "@umt/contracts"; +import { + DEFAULT_SETTINGS, + GlobalSettings, + type SecretPurpose, + type SettingsKey, + withStorageDefaults, +} from "@umt/contracts"; import type { DB, Kysely } from "@umt/db"; import type { Logger } from "pino"; import type { EventBusPort } from "../../http/sse.ts"; @@ -335,7 +341,13 @@ export function createSettingsReader(deps: { for (const key of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) { const def = DEFAULT_SETTINGS[key]; const v = stored.get(key); - const merged = v && typeof v === "object" && !Array.isArray(v) ? { ...def, ...(v as object) } : def; + // storage 是两层结构:逐层补缺省,旧库里缺子字段也能整体通过校验 + const merged = + key === "storage" + ? withStorageDefaults(v) + : v && typeof v === "object" && !Array.isArray(v) + ? { ...def, ...(v as object) } + : def; const parsed = GlobalSettings.shape[key].safeParse(merged); if (!parsed.success) deps.logger.warn({ key }, "settings 值不合法,回退缺省"); out[key] = parsed.success ? parsed.data : def; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4b459a7b..ff40acbd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -24,11 +24,14 @@ const CacheShape = z.object({ checkIntervalSec: z.number().int().min(10).max(3600), /** 缓存清理的目标使用率(低水位) */ cleanupTargetPercent: Percent, - /** TMDB 缓存(请求响应 + 识别结果)保留小时数(对照 MP META_CACHE_EXPIRE,缺省 24) */ + /** + * @deprecated CACHE-2 起由 storage.cacheTtl(tmdbSearchDays / tmdbDetailDays)取代,服务端不再读取; + * 保留在 schema 里只为兼容已存的 JSON 与旧设置页。 + */ tmdbTtlHours: z.number().int().min(1).max(720), /** TMDB 缓存每类条数上限,超出删最旧(对照 MP CONF.tmdb,缺省 256;大内存模式 1024) */ tmdbMaxSize: z.number().int().min(16).max(100_000), - /** 空结果 / 未识别的短缓存秒数(对照 MP EMPTY_RESULT_CACHE_TTL,缺省 1800) */ + /** @deprecated CACHE-2 起由 storage.cacheTtl.tmdbMissDays 取代,服务端不再读取 */ emptyResultTtlSec: z.number().int().min(0).max(86_400), /** 图片代理落盘缓存(对照 MP GLOBAL_IMAGE_CACHE,缺省关闭;关闭时仍代理,只是不落盘) */ globalImageCache: z.boolean(), @@ -410,7 +413,7 @@ export const DEFAULT_ENRICH_DOMAINS: string[] = [ export const IdentitySettings = z.object({ eagerTmdbLookup: z.boolean(), - /** 待识别池里 unmatched 条目多少天没有新证据就归档(仍可按状态筛出、可手动指定) */ + /** @deprecated CACHE-2 起由 storage.retentionDays.identityPoolArchive 取代,服务端不再读取 */ poolArchiveDays: z.number().int().min(1).max(3650), /** * META-1:订阅别名 / 新作品收录规则命中、TMDB 却查不到时自动尝试一次元数据补全(只出建议,不建作品)。 @@ -432,6 +435,98 @@ export const IdentitySettings = z.object({ }); export type IdentitySettings = z.infer; +// ── 存储预算与保留(CACHE-2,docs/runbook/storage.md)── + +const Gb = z.number().min(0.01).max(10_000); +const Days = z.number().int().min(1).max(3650); + +export const StorageBudgets = z + .object({ + /** PG 数据目录预算(GB):超过 pgDataWarnGb 告警,超过本值 critical */ + pgDataGb: Gb, + pgDataWarnGb: Gb, + /** 图片代理缓存容量(GB):超出按最久未访问删除(LRU) */ + imagesGb: Gb, + /** DATA_DIR/backups 占用预算(GB) */ + backupsGb: Gb, + /** Agent 会话 + 视觉证据(DATA_DIR/agent-sessions、tmp/evidence)预算(GB) */ + sessionsEvidenceGb: Gb, + }) + .refine((v) => v.pgDataWarnGb <= v.pgDataGb, { message: "PG 告警线不得高于预算" }); +export type StorageBudgets = z.infer; + +/** 各类流水数据保留期(到期由 storage.retention.* 定时任务分批删除) */ +export const StorageRetentionDays = z.object({ + /** observation(Feed 观察) */ + observation: Days, + /** sse_event(SSE 补读) */ + sseEvent: Days, + /** process_heartbeat_sample(小时) */ + heartbeatHours: z + .number() + .int() + .min(1) + .max(24 * 90), + /** 已结束的业务任务 task 与引擎 run */ + taskRun: Days, + /** 已结束 Agent 会话的 agent_run_event / agent_run_message */ + agentEvent: Days, + /** audit_event(能力调用审计除外) */ + audit: Days, + /** 能力调用统计:audit_event(target_type=capability)与已结束的 capability_invocation */ + capabilityStats: Days, + /** 待识别池 unmatched 条目无新证据多少天后归档 */ + identityPoolArchive: Days, +}); +export type StorageRetentionDays = z.infer; + +/** 缓存有效期(天):只改 TTL 的取值来源,缓存语义仍对照 MP(docs/porting/moviepilot-tmdb-cache.md) */ +export const StorageCacheTtl = z.object({ + /** TMDB 搜索 / find 响应与识别结果 */ + tmdbSearchDays: Days, + /** TMDB 详情(/movie/{id}、/tv/{id}、季);追踪中的作品命中时续期 */ + tmdbDetailDays: Days, + /** TMDB 空结果与未识别负缓存(MP EMPTY_RESULT_CACHE_TTL) */ + tmdbMissDays: Days, + /** 站点详情页扩展内容(kv_cache site.detail) */ + siteDetailDays: Days, + /** 网页搜索结果 */ + webSearchDays: Days, +}); +export type StorageCacheTtl = z.infer; + +export const StorageSettings = z.object({ + budgets: StorageBudgets, + retentionDays: StorageRetentionDays, + cacheTtl: StorageCacheTtl, +}); +export type StorageSettings = z.infer; + +export const DEFAULT_STORAGE_SETTINGS: StorageSettings = { + budgets: { pgDataGb: 25, pgDataWarnGb: 20, imagesGb: 20, backupsGb: 20, sessionsEvidenceGb: 10 }, + retentionDays: { + observation: 90, + sseEvent: 7, + heartbeatHours: 24, + taskRun: 60, + agentEvent: 90, + audit: 180, + capabilityStats: 90, + identityPoolArchive: 30, + }, + cacheTtl: { tmdbSearchDays: 7, tmdbDetailDays: 30, tmdbMissDays: 1, siteDetailDays: 7, webSearchDays: 1 }, +}; + +/** 存储设置与缺省逐层合并(旧库里缺子字段时补齐) */ +export function withStorageDefaults(v: unknown): StorageSettings { + const o = (v && typeof v === "object" ? v : {}) as Partial>; + return { + budgets: { ...DEFAULT_STORAGE_SETTINGS.budgets, ...(o.budgets ?? {}) }, + retentionDays: { ...DEFAULT_STORAGE_SETTINGS.retentionDays, ...(o.retentionDays ?? {}) }, + cacheTtl: { ...DEFAULT_STORAGE_SETTINGS.cacheTtl, ...(o.cacheTtl ?? {}) }, + } as StorageSettings; +} + export const GlobalSettings = z.object({ automation: AutomationSettings, cache: CacheSettings, @@ -445,6 +540,7 @@ export const GlobalSettings = z.object({ naming: NamingSettings, identity: IdentitySettings, search: SearchSettings, + storage: StorageSettings, }); export type GlobalSettings = z.infer; @@ -526,6 +622,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = { enrichDomains: DEFAULT_ENRICH_DOMAINS, }, search: { defaultSiteMode: "default", siteIds: [] }, + storage: DEFAULT_STORAGE_SETTINGS, }; /** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */ @@ -576,6 +673,13 @@ export const GlobalSettingsUpdate = z.object({ naming: NamingSettings.partial().optional(), identity: IdentitySettings.partial().optional(), search: SearchSettings.partial().optional(), + storage: z + .object({ + budgets: z.object(StorageBudgets.shape).partial().optional(), + retentionDays: StorageRetentionDays.partial().optional(), + cacheTtl: StorageCacheTtl.partial().optional(), + }) + .optional(), }); export type GlobalSettingsUpdate = z.infer; @@ -602,6 +706,16 @@ export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpda naming: { ...(current.naming ?? DEFAULT_SETTINGS.naming), ...patch.naming }, identity: { ...(current.identity ?? DEFAULT_SETTINGS.identity), ...patch.identity }, search: { ...(current.search ?? DEFAULT_SETTINGS.search), ...patch.search }, + storage: mergeStorage(current.storage, patch.storage), + }; +} + +function mergeStorage(current: StorageSettings | undefined, patch: GlobalSettingsUpdate["storage"]): StorageSettings { + const base = withStorageDefaults(current); + return { + budgets: { ...base.budgets, ...patch?.budgets }, + retentionDays: { ...base.retentionDays, ...patch?.retentionDays }, + cacheTtl: { ...base.cacheTtl, ...patch?.cacheTtl }, }; } @@ -638,6 +752,10 @@ export const DEPRECATED_SETTINGS_FIELDS = [ "pi.model", "pi.thinkingLevel", "pi.apiKeySecretRef", + // CACHE-2:由 storage.cacheTtl / storage.retentionDays 取代 + "cache.tmdbTtlHours", + "cache.emptyResultTtlSec", + "identity.poolArchiveDays", ] as const; // ── 设置页分区(docs/design/console-ia.md §3.4) ── @@ -682,6 +800,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ { id: "cache", label: "缓存水位" }, { id: "budget", label: "每日预算" }, { id: "retention", label: "保留" }, + { id: "storage", label: "存储与保留" }, ], fields: [ "automation.enabled", @@ -695,6 +814,9 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ "budget.maxConcurrentDownloads", "retention.seedRetentionDays", "retention.logRetentionDays", + "storage.budgets", + "storage.retentionDays", + "storage.cacheTtl", ], resources: [], }, @@ -774,11 +896,8 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ fields: [ "metadata.tmdbApiKeySecretRef", "metadata.tmdbBaseUrl", - "cache.tmdbTtlHours", "cache.tmdbMaxSize", - "cache.emptyResultTtlSec", "identity.eagerTmdbLookup", - "identity.poolArchiveDays", "identity.autoEnrich", "identity.enrichDailyBudget", "identity.enrichDomains",