feat: initialize parent-bff service with full core features

add complete parent-bff implementation including:
- GraphQL endpoint with depth/cost validation
- ChildGuard越权校验 with redis cache and singleflight
- parallel orchestration with partial failure fallback
- three-level cache fallback strategy (Redis + LRU + downstream)
- Kafka consumer for cache invalidation and notification push
- opossum circuit breaker for downstream services
- Prometheus metrics and SLO alerts
- Helm chart for k8s deployment with multi-environment configs
- Grafana dashboard for observability
- complete unit and integration tests
This commit is contained in:
SpecialX
2026-07-10 18:49:06 +08:00
parent a7d8f92227
commit 2229309a1e
88 changed files with 11221 additions and 0 deletions

View File

@@ -0,0 +1,254 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// 使用 vi.hoisted 确保 mockDelCalls 在 mock factory 之前定义
const mockDelCalls = vi.hoisted(() => [] as string[]);
// Mock safeRedis捕获 del 调用以验证失效的缓存 key
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn(
async (
fn: (client: unknown) => Promise<unknown>,
fallback: unknown,
): Promise<unknown> => {
const mockClient = {
del: (key: string): number => {
mockDelCalls.push(key);
return 1;
},
get: (): null => null,
set: (): string => "OK",
ping: (): string => "PONG",
};
try {
return await fn(mockClient);
} catch {
return fallback;
}
},
),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { CacheInvalidationHandler } from "../../src/shared/kafka/handlers/cache-invalidation.handler.js";
import type {
EventHandler,
EventContext,
} from "../../src/shared/kafka/handlers/event-handler.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
interface TeachingEventBody {
studentId?: string;
childId?: string;
classId?: string;
parentId?: string;
}
describe("测试用例 10Kafka 缓存失效Integration", () => {
let handler: EventHandler<TeachingEventBody>;
beforeEach(() => {
vi.clearAllMocks();
mockDelCalls.length = 0;
handler = new CacheInvalidationHandler();
});
describe("edu.teaching.grade.recorded", () => {
it("失效 grades:{childId}:* + dashboard:{parentId}", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-001",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
classId: "class-001",
},
};
await handler.handle(ctx);
// 失效 grades:student-001:1 + dashboard:parent-001
expect(mockDelCalls).toContain("grades:student-001:1");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(2);
});
it("使用 studentId 作为 childId fallback", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-002",
key: "parent-001",
body: {
studentId: "student-002",
parentId: "parent-001",
},
};
await handler.handle(ctx);
// childId 从 studentId fallback
expect(mockDelCalls).toContain("grades:student-002:1");
expect(mockDelCalls).toContain("dashboard:parent-001");
});
it("无 parentId 时只失效 grades 缓存", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-003",
key: "student-001",
body: {
childId: "student-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("grades:student-001:1");
expect(mockDelCalls).not.toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("edu.teaching.homework.graded", () => {
it("失效 homework:{childId}:{classId} + dashboard:{parentId}", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.homework.graded",
eventId: "evt-004",
key: "parent-001",
body: {
childId: "student-001",
classId: "class-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("homework:student-001:class-001");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(2);
});
it("无 classId 时只失效 dashboard", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.homework.graded",
eventId: "evt-005",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
// 无 classId 时 homework 缓存不失效条件childId && classId
expect(mockDelCalls).not.toContain("homework:student-001:undefined");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("edu.teaching.exam.published", () => {
it("失效 exams:{childId}:{classId} + dashboard:{parentId}", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.exam.published",
eventId: "evt-006",
key: "parent-001",
body: {
childId: "student-001",
classId: "class-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("exams:student-001:class-001");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(2);
});
});
describe("edu.notification.read", () => {
it("失效 notifications:{parentId}:1", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.notification.read",
eventId: "evt-007",
key: "parent-001",
body: {
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("notifications:parent-001:1");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("edu.notification.recalled", () => {
it("失效 notifications:{parentId}:1", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.notification.recalled",
eventId: "evt-008",
key: "parent-001",
body: {
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("notifications:parent-001:1");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("未知 topic", () => {
it("不失效任何缓存", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.unknown.event",
eventId: "evt-009",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toHaveLength(0);
});
});
describe("P5 场景:成绩录入后缓存失效全链路", () => {
it("成绩事件同时失效 grades + dashboard前端刷新后重新拉取", async () => {
// 模拟真实场景:教师录入成绩 → Kafka → 缓存失效 → 前端下次查询走下游
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-full-chain",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
classId: "class-001",
},
};
await handler.handle(ctx);
// 验证 safeRedis 被调用了 2 次grades del + dashboard del
expect(mockSafeRedis).toHaveBeenCalledTimes(2);
// 验证失效的 key 正确
expect(mockDelCalls).toContain("grades:student-001:1");
expect(mockDelCalls).toContain("dashboard:parent-001");
});
});
});