212 lines
6.3 KiB
TypeScript
212 lines
6.3 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
// G9 优雅关闭: 由 main.ts SIGTERM handler 统一调用 disconnectAll
|
|
logger.info("CacheModule destroyed (Redis disconnect handled by main.ts)");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 缓存服务 - 封装 Redis get/set + 降级模式.
|
|
*
|
|
* 使用方式:
|
|
* const cached = await cacheService.get<StudentDashboard>("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<T>(pattern: string, ...keyParts: (string | number)[]): Promise<T | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void>((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<void> {
|
|
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<void>((resolve) => {
|
|
stream.on("end", () => {
|
|
pipeline.exec().finally(() => resolve());
|
|
});
|
|
});
|
|
} catch (err) {
|
|
logger.warn({ err, prefix }, "Cache invalidateByPrefix failed");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 健康检查 (供 /readyz 探针使用, §2.4).
|
|
*/
|
|
async ping(): Promise<boolean> {
|
|
try {
|
|
const result = await this.redis.ping();
|
|
return result === "PONG";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|