/** * student-bff Redis 缓存模块. * * 仲裁依据: * - coord-final-decisions §2 B6 (Redis 5-30s 短缓存) * - coord-final-decisions §1 G9 (优雅关闭 Redis 连接) * - president-final-rulings §2.6 (降级模式方案 B: degraded=true + degradedReason) * - 02-architecture-design.md §3.1 (Redis 缓存 Schema) * * 缓存 Key 规范: * student:dashboard:{userId} TTL 15s * student:exams:{userId}:{classId} TTL 30s * student:homework:{userId}:{classId} TTL 30s * student:grades:{userId}:{page} TTL 60s * student:notifications:{userId}:{page} TTL 15s * student:textbooks:{gradeId}:{subjectId} TTL 300s * student:chapters:{textbookId} TTL 300s * student:analytics:weakness:{userId} TTL 300s * student:analytics:trend:{userId}:{range} TTL 600s * student:viewports:{userId} TTL 300s */ import { Global, Module, OnModuleDestroy } from "@nestjs/common"; import Redis from "ioredis"; import { env } from "../../config/env.js"; import { logger } from "../observability/logger.js"; import { recordCacheAccess } from "../observability/metrics.js"; export const REDIS_CLIENT = Symbol("REDIS_CLIENT"); /** * 缓存 TTL 预设 (秒). */ export const CacheTTL = { DASHBOARD: 15, EXAMS: 30, HOMEWORK: 30, GRADES: 60, NOTIFICATIONS: 15, TEXTBOOKS: 300, CHAPTERS: 300, ANALYTICS_WEAKNESS: 300, ANALYTICS_TREND: 600, VIEWPORTS: 300, } as const; /** * 缓存 Key 构建器 (统一前缀 + 规范化). */ export function buildCacheKey(pattern: string, ...parts: (string | number)[]): string { const suffix = parts.map(String).join(":"); return `${env.REDIS_KEY_PREFIX}${pattern}:${suffix}`; } @Global() @Module({ providers: [ { provide: REDIS_CLIENT, useFactory: (): Redis => { const client = new Redis(env.REDIS_URL, { lazyConnect: false, maxRetriesPerRequest: 3, enableReadyCheck: true, retryStrategy: (times) => Math.min(times * 100, 2000), }); client.on("error", (err) => { logger.error({ err }, "Redis client error"); }); client.on("connect", () => { logger.info({ url: env.REDIS_URL }, "Redis connected"); }); return client; }, }, ], exports: [REDIS_CLIENT], }) export class CacheModule implements OnModuleDestroy { constructor() {} async onModuleDestroy(): Promise { // G9 优雅关闭: 由 main.ts SIGTERM handler 统一调用 disconnectAll logger.info("CacheModule destroyed (Redis disconnect handled by main.ts)"); } } /** * 缓存服务 - 封装 Redis get/set + 降级模式. * * 使用方式: * const cached = await cacheService.get("dashboard", userId); * if (cached) return cached; * const fresh = await aggregateDashboard(userId); * await cacheService.set("dashboard", userId, fresh, CacheTTL.DASHBOARD); */ export class CacheService { constructor(private readonly redis: Redis) {} /** * 读取缓存, 自动 JSON 反序列化. * 失败时返回 null (不抛异常, 上层走降级模式). */ async get(pattern: string, ...keyParts: (string | number)[]): Promise { const key = buildCacheKey(pattern, ...keyParts); try { const raw = await this.redis.get(key); if (raw) { recordCacheAccess(pattern, true); return JSON.parse(raw) as T; } recordCacheAccess(pattern, false); return null; } catch (err) { logger.warn({ err, key, pattern }, "Cache get failed, returning null"); recordCacheAccess(pattern, false); return null; } } /** * 写入缓存, 自动 JSON 序列化. * 失败时仅记录日志, 不影响主流程 (降级模式). */ async set( pattern: string, value: unknown, ttlSeconds: number, ...keyParts: (string | number)[] ): Promise { const key = buildCacheKey(pattern, ...keyParts); try { const serialized = JSON.stringify(value); // TTL 加 ±20% 随机抖动, 避免缓存雪崩 (02 §8.1) const jitter = Math.floor(ttlSeconds * 0.2 * (Math.random() * 2 - 1)); const ttl = Math.max(1, ttlSeconds + jitter); await this.redis.set(key, serialized, "EX", ttl); } catch (err) { logger.warn({ err, key, pattern }, "Cache set failed, skipping"); } } /** * 失效缓存 (按 pattern 通配符删除). * 用于写操作后主动失效相关缓存. */ async invalidate(pattern: string, ...keyParts: (string | number)[]): Promise { const key = buildCacheKey(pattern, ...keyParts); try { // 如果 keyParts 含通配符, 用 SCAN 删除 if (key.includes("*")) { const stream = this.redis.scanStream({ match: key, count: 100, }); const pipeline = this.redis.pipeline(); stream.on("data", (keys: string[]) => { if (keys.length > 0) { keys.forEach((k) => pipeline.del(k)); } }); await new Promise((resolve) => { stream.on("end", () => { pipeline.exec().finally(() => resolve()); }); }); } else { await this.redis.del(key); } } catch (err) { logger.warn({ err, key, pattern }, "Cache invalidate failed"); } } /** * 批量失效 (如成绩发布后失效学生所有成绩缓存). */ async invalidateByPrefix(prefix: string): Promise { const pattern = `${env.REDIS_KEY_PREFIX}${prefix}*`; try { const stream = this.redis.scanStream({ match: pattern, count: 100, }); const pipeline = this.redis.pipeline(); stream.on("data", (keys: string[]) => { if (keys.length > 0) { keys.forEach((k) => pipeline.del(k)); } }); await new Promise((resolve) => { stream.on("end", () => { pipeline.exec().finally(() => resolve()); }); }); } catch (err) { logger.warn({ err, prefix }, "Cache invalidateByPrefix failed"); } } /** * 健康检查 (供 /readyz 探针使用, §2.4). */ async ping(): Promise { try { const result = await this.redis.ping(); return result === "PONG"; } catch { return false; } } }