Files
Edu/services/student-bff/src/shared/cache/cache.module.ts
SpecialX f585080e70 feat(student-bff): 完整实现 student-bff 聚合层
包含 src 全部实现、Dockerfile、shared-ts/bff 包等
2026-07-10 19:10:51 +08:00

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;
}
}
}