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,251 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IamClient } from "../../src/clients/iam.client.js";
import type { MsgClient } from "../../src/clients/msg.client.js";
import type { DataAnaClient } from "../../src/clients/data-ana.client.js";
import type { CoreEduClient } from "../../src/clients/core-edu.client.js";
import type {
ChildDto,
UserInfoDto,
ViewportDto,
} from "../../src/clients/dtos.js";
// Mock safeRedis 以控制 ChildGuard 缓存行为
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue(null),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { buildSelectChildMutationResolver } from "../../src/graphql/resolvers/select-child.resolver.js";
import type { ResolverDeps } from "../../src/graphql/resolvers/index.js";
import type { GraphqlContext } from "../../src/graphql/context.js";
import { ChildGuard } from "../../src/aggregation/child-guard.js";
import { ChildNotBoundError } from "../../src/shared/errors/application-error.js";
import { logger } from "../../src/shared/observability/logger.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
const MOCK_CHILDREN: ChildDto[] = [
{
id: "student-001",
name: "李同学",
grade: "三年级",
classId: "class-001",
className: "三年级1班",
gradeId: "grade-003",
},
{
id: "student-002",
name: "李妹妹",
grade: "一年级",
classId: "class-002",
className: "一年级2班",
gradeId: "grade-001",
},
];
class StubIamClient implements IamClient {
async getUserInfo(userId: string): Promise<UserInfoDto> {
return {
id: userId,
email: "",
name: "",
roles: ["parent"],
permissions: [],
};
}
async getChildrenByParent(_parentId: string): Promise<ChildDto[]> {
return MOCK_CHILDREN.map((c) => ({ ...c }));
}
async getViewports(_userId: string): Promise<ViewportDto[]> {
return [];
}
async getEffectivePermissions(_userId: string): Promise<string[]> {
return [];
}
}
function buildMockContext(parentId: string, traceId: string): GraphqlContext {
return {
session: {
parentId,
roles: ["parent"],
dataScope: "CHILDREN",
traceId,
},
req: {} as never,
res: {} as never,
loaders: {} as never,
};
}
describe("测试用例 8selectChild 审计日志Integration", () => {
let iamClient: StubIamClient;
let childGuard: ChildGuard;
let loggerInfoSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue(null);
iamClient = new StubIamClient();
childGuard = new ChildGuard(iamClient);
loggerInfoSpy = vi.spyOn(logger, "info").mockImplementation(() => logger);
});
it("合法 childId记录审计日志traceId + parentId + childId + timestamp", async () => {
// safeRedis: miss → set okChildGuard.getBoundChildren
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-001");
const result = await resolver(null, { childId: "student-001" }, ctx);
// 返回值正确
expect(result.childId).toBe("student-001");
expect(result.audited).toBe(true);
expect(result.selectedAt).toBeTruthy();
// selectedAt 是合法 ISO 时间
const selectedAtDate = new Date(result.selectedAt);
expect(Number.isNaN(selectedAtDate.getTime())).toBe(false);
// 审计日志被调用
expect(loggerInfoSpy).toHaveBeenCalled();
// 查找 "Child selected (audit log)" 的调用
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeDefined();
// 审计日志包含 traceId + parentId + childId + selectedAt
const logPayload = auditCall![0] as Record<string, unknown>;
expect(logPayload.parentId).toBe("parent-001");
expect(logPayload.childId).toBe("student-001");
expect(logPayload.traceId).toBe("trace-audit-001");
expect(logPayload.selectedAt).toBe(result.selectedAt);
});
it("越权 childId先抛 ChildNotBoundError不记录审计日志", async () => {
// safeRedis: miss → set okChildGuard.getBoundChildren 第一次调用)
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-002");
// 越权 childId 抛 ChildNotBoundError
await expect(
resolver(null, { childId: "student-999" }, ctx),
).rejects.toThrow(ChildNotBoundError);
// 不应记录审计日志(只可能有 ChildGuard 的 warn 日志)
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeUndefined();
});
it("审计日志的 selectedAt 与返回值一致", async () => {
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-003");
const result = await resolver(null, { childId: "student-002" }, ctx);
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeDefined();
const logPayload = auditCall![0] as Record<string, unknown>;
// 审计日志的 selectedAt 与返回值完全一致
expect(logPayload.selectedAt).toBe(result.selectedAt);
// childId 也一致
expect(logPayload.childId).toBe(result.childId);
});
it("不同 traceId 生成不同审计日志", async () => {
// 第一次调用
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
// 第一次traceId-A
const result1 = await resolver(
null,
{ childId: "student-001" },
buildMockContext("parent-001", "traceId-A"),
);
// 等待 2ms 确保 selectedAt 时间戳不同
await new Promise((r) => setTimeout(r, 2));
// 第二次traceId-B缓存已写入直接命中或重新调
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const result2 = await resolver(
null,
{ childId: "student-002" },
buildMockContext("parent-002", "traceId-B"),
);
// 两次审计日志的 traceId 不同
const auditCalls = loggerInfoSpy.mock.calls.filter(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCalls).toHaveLength(2);
const payload1 = auditCalls[0]![0] as Record<string, unknown>;
const payload2 = auditCalls[1]![0] as Record<string, unknown>;
expect(payload1.traceId).toBe("traceId-A");
expect(payload2.traceId).toBe("traceId-B");
expect(payload1.childId).toBe("student-001");
expect(payload2.childId).toBe("student-002");
expect(result1.selectedAt).not.toBe(result2.selectedAt);
});
});