feat(teacher-bff): 完整实现 teacher-bff GraphQL 聚合层

包含 clients/graphql/middleware、health probes、shared-ts contracts 等
This commit is contained in:
SpecialX
2026-07-10 19:10:07 +08:00
parent b82593aac2
commit 99155a5ea1
37 changed files with 2862 additions and 324 deletions

View File

@@ -1,12 +1,20 @@
// BFF_TEACHER_ 错误码B5 + G14 裁决,统一 BFF_ 前缀)
// 越权防御 3 类错误码president §2.7 裁决):
// - BFF_TEACHER_UNAUTHORIZED (401): x-user-id 缺失或无效
// - BFF_TEACHER_FORBIDDEN_RESOURCE (403): teacherId 与资源无归属关系(场景 A
// - BFF_TEACHER_IDENTITY_MISMATCH (403): JWT teacherId 与 body teacherId 不一致(场景 B
// 其他聚合层错误码contract §1.5
export type ErrorType =
| "validation"
| "not_found"
| "permission_denied"
| "unauthorized"
| "forbidden_resource"
| "identity_mismatch"
| "conflict"
| "business"
| "database"
| "bad_gateway"
| "aggregation_failed"
| "internal";
export interface ErrorDetails {
@@ -40,79 +48,95 @@ export abstract class ApplicationError extends Error {
}
}
/** 输入参数校验失败400 */
export class ValidationError extends ApplicationError {
readonly type = "validation" as const;
readonly statusCode = 400;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_VALIDATION_ERROR", details);
super(message, "BFF_TEACHER_VALIDATION_FAILED", details);
}
}
/** 资源不存在404 */
export class NotFoundError extends ApplicationError {
readonly type = "not_found" as const;
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, "TEACHER_BFF_NOT_FOUND", {
super(`${resource} not found: ${id}`, "BFF_TEACHER_NOT_FOUND", {
resource,
id,
});
}
}
export class PermissionDeniedError extends ApplicationError {
readonly type = "permission_denied" as const;
readonly statusCode = 403;
constructor(permission: string) {
super(`Permission denied: ${permission}`, "TEACHER_BFF_PERMISSION_DENIED", {
permission,
});
}
}
/** x-user-id 缺失或无效401president §2.7 */
export class UnauthorizedError extends ApplicationError {
readonly type = "unauthorized" as const;
readonly statusCode = 401;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_UNAUTHORIZED", details);
super(message, "BFF_TEACHER_UNAUTHORIZED", details);
}
}
/** teacherId 与资源无归属关系403 场景 Apresident §2.7 */
export class ForbiddenResourceError extends ApplicationError {
readonly type = "forbidden_resource" as const;
readonly statusCode = 403;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_TEACHER_FORBIDDEN_RESOURCE", details);
}
}
/** JWT teacherId 与请求 body teacherId 不一致403 场景 Bpresident §2.7 */
export class IdentityMismatchError extends ApplicationError {
readonly type = "identity_mismatch" as const;
readonly statusCode = 403;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_TEACHER_IDENTITY_MISMATCH", details);
}
}
/** 并发冲突409 */
export class ConflictError extends ApplicationError {
readonly type = "conflict" as const;
readonly statusCode = 409;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_CONFLICT", details);
super(message, "BFF_TEACHER_CONFLICT", details);
}
}
/** 业务规则违反422 */
export class BusinessError extends ApplicationError {
readonly type = "business" as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_BUSINESS_ERROR", details);
}
}
export class DatabaseError extends ApplicationError {
readonly type = "database" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_DATABASE_ERROR", details);
super(message, "BFF_TEACHER_BUSINESS_ERROR", details);
}
}
/** 下游 gRPC 不可达502 */
export class BadGatewayError extends ApplicationError {
readonly type = "bad_gateway" as const;
readonly statusCode = 502;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_BAD_GATEWAY", details);
super(message, "BFF_TEACHER_UPSTREAM_UNAVAILABLE", details);
}
}
/** 聚合多下游时部分失败且无降级数据500 */
export class AggregationFailedError extends ApplicationError {
readonly type = "aggregation_failed" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_TEACHER_AGGREGATION_FAILED", details);
}
}
/** 兜底内部错误500 */
export class InternalError extends ApplicationError {
readonly type = "internal" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_INTERNAL_ERROR", details);
super(message, "BFF_TEACHER_INTERNAL_ERROR", details);
}
}

View File

@@ -9,6 +9,14 @@ import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
/**
* GlobalErrorFilter — 统一错误兜底G8 裁决:首次实现即注册 + ActionState 信封)。
*
* REST 端点:响应 ActionState 信封 { success: false, error: { code, message, details, traceId } }
* GraphQL 端点:由 Yoga 自身错误处理,错误 extensions.code = BFF_TEACHER_*(见 graphql/error-formatter
*
* 错误码统一 BFF_TEACHER_ 前缀B5 + G14 裁决)。
*/
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
@@ -34,7 +42,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: "TEACHER_BFF_VALIDATION_ERROR",
code: "BFF_TEACHER_VALIDATION_FAILED",
message: "Validation failed",
details: exception.flatten(),
traceId,
@@ -47,7 +55,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: "HTTP_ERROR",
code: "BFF_TEACHER_INTERNAL_ERROR",
message,
traceId,
},
@@ -60,7 +68,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: "INTERNAL_ERROR",
code: "BFF_TEACHER_INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},

View File

@@ -1,15 +1,17 @@
import { Controller, Get } from "@nestjs/common";
// 健康检查端点G3 + president §2.4
// - GET /healthzliveness仅返回进程存活不检查下游
// - GET /readyzreadinessDownstreamHealthCheck 注册表P2: Redis + iam gRPC
//
// 软失败规则contract §3.3
// - status=ok → 200
// - status=degraded → 200可选依赖失败告警但不阻断
// - status=unavailable → 503必需依赖失败
import { Controller, Get, Res, HttpStatus } from "@nestjs/common";
import type { Response } from "express";
import { healthRegistry } from "./readiness.probe.js";
const SERVICE_NAME = "teacher-bff";
/**
* 健康检查端点。
*
* - GET /healthzliveness仅返回进程存活。
* - GET /readyzreadinessBFF 不直接访问 DB可直接返回 ok。
*
* 不需要鉴权,必须在路由白名单中放行。
*/
@Controller()
export class HealthController {
@Get("healthz")
@@ -22,11 +24,12 @@ export class HealthController {
}
@Get("readyz")
readiness(): { status: string; service: string; timestamp: string } {
return {
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
async readiness(@Res() res: Response): Promise<void> {
const report = await healthRegistry.runAll();
const statusCode =
report.status === "unavailable"
? HttpStatus.SERVICE_UNAVAILABLE
: HttpStatus.OK;
res.status(statusCode).json(report);
}
}

View File

@@ -1,7 +1,49 @@
import { Module } from "@nestjs/common";
// 健康检查模块G3 + president §2.4
// P2 注册 2 项探针Redis + iam gRPC
// P3+ 扩展core-edu / content / data-ana / ai / msg按阶段新增
import { Module, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { Redis } from "ioredis";
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
import { HealthController } from "./health.controller.js";
import { healthRegistry } from "./readiness.probe.js";
import { RedisHealthProbe } from "./redis.probe.js";
import { IamGrpcHealthProbe } from "./iam-grpc.probe.js";
import { ClientsModule } from "../../clients/clients.module.js";
@Module({
imports: [ClientsModule],
controllers: [HealthController],
providers: [IamGrpcHealthProbe],
})
export class HealthModule {}
export class HealthModule implements OnModuleInit, OnModuleDestroy {
private redis: Redis | null = null;
constructor(private readonly iamProbe: IamGrpcHealthProbe) {}
async onModuleInit(): Promise<void> {
// 注册 Redis 探针(必需依赖)
this.redis = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 3,
lazyConnect: false,
connectTimeout: 5000,
});
const redisProbe = new RedisHealthProbe(this.redis);
healthRegistry.register(redisProbe);
logger.info({ probe: "redis", required: true }, "Health probe registered");
// 注册 iam gRPC 探针(必需依赖)
healthRegistry.register(this.iamProbe);
logger.info(
{ probe: "iam-grpc", required: true, target: env.IAM_GRPC_TARGET },
"Health probe registered",
);
}
async onModuleDestroy(): Promise<void> {
if (this.redis) {
await this.redis.quit();
logger.info("Redis connection closed");
}
}
}

View File

@@ -0,0 +1,27 @@
// iam gRPC 健康探针(/readyz P2 必需依赖)
// 调用 grpc.health.v1.Health/Check 检查 iam:50052 是否 SERVING
import { Injectable, Inject } from "@nestjs/common";
import { IAM_CLIENT } from "../../clients/iam/iam-client.interface.js";
import type { IamClient } from "../../clients/iam/iam-client.interface.js";
import type { HealthProbe } from "./readiness.probe.js";
@Injectable()
export class IamGrpcHealthProbe implements HealthProbe {
readonly name = "iam-grpc";
readonly required = true;
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const result = await this.iam.checkHealth();
return {
status: result.serving ? "up" : "down",
latencyMs: result.latencyMs,
error: result.error,
};
}
}

View File

@@ -0,0 +1,113 @@
// /readyz 探针注册表president §2.4 + G2
// DownstreamHealthCheck 注册表模式:每个下游注册独立探针,按阶段启用
// P2: Redis PING + iam gRPC 500522 项)
// P3+: + core-edu / content / data-ana / ai / msg按阶段扩展
//
// 软失败规则contract §3.3
// - 必需依赖Redis / 已启用 gRPC 下游)失败返 503
// - 可选依赖(未启用 gRPC 下游)失败仅告警返 200 + degraded: true
/** 单个健康检查结果 */
export interface HealthCheckResult {
name: string;
status: "up" | "down" | "degraded";
latencyMs: number;
required: boolean;
error?: string;
}
/** /readyz 聚合结果 */
export interface ReadinessReport {
status: "ok" | "degraded" | "unavailable";
service: string;
timestamp: string;
checks: HealthCheckResult[];
degraded?: boolean;
}
/** 健康检查探针接口(每个下游注册一个) */
export interface HealthProbe {
/** 探针名称(如 "redis" / "iam-grpc" */
readonly name: string;
/** 是否为必需依赖(必需失败返 503可选失败返 200 + degraded */
readonly required: boolean;
/** 执行健康检查 */
check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}>;
}
/**
* 健康检查注册表DownstreamHealthCheck 模式)
* 按阶段注册探针,/readyz 调用时遍历所有已注册探针
*/
export class HealthCheckRegistry {
private readonly probes = new Map<string, HealthProbe>();
register(probe: HealthProbe): void {
this.probes.set(probe.name, probe);
}
unregister(name: string): void {
this.probes.delete(name);
}
async runAll(): Promise<ReadinessReport> {
const checks: HealthCheckResult[] = [];
let hasRequiredDown = false;
let hasOptionalDown = false;
for (const probe of this.probes.values()) {
try {
const result = await probe.check();
checks.push({
name: probe.name,
status: result.status,
latencyMs: result.latencyMs,
required: probe.required,
error: result.error,
});
if (result.status === "down" && probe.required) {
hasRequiredDown = true;
} else if (result.status === "down" && !probe.required) {
hasOptionalDown = true;
}
} catch (err) {
checks.push({
name: probe.name,
status: "down",
latencyMs: 0,
required: probe.required,
error: err instanceof Error ? err.message : String(err),
});
if (probe.required) {
hasRequiredDown = true;
} else {
hasOptionalDown = true;
}
}
}
let status: ReadinessReport["status"];
if (hasRequiredDown) {
status = "unavailable";
} else if (hasOptionalDown) {
status = "degraded";
} else {
status = "ok";
}
return {
status,
service: "teacher-bff",
timestamp: new Date().toISOString(),
checks,
degraded: hasOptionalDown && !hasRequiredDown,
};
}
}
/** 全局注册表实例(单例) */
export const healthRegistry = new HealthCheckRegistry();

View File

@@ -0,0 +1,37 @@
// Redis 健康探针(/readyz P2 必需依赖)
// PING 命令检查 Redis 连通性,超时 2s
import type { Redis } from "ioredis";
import type { HealthProbe } from "./readiness.probe.js";
export class RedisHealthProbe implements HealthProbe {
readonly name = "redis";
readonly required = true;
constructor(private readonly redis: Redis) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const start = Date.now();
try {
const result = await this.redis.ping();
const latencyMs = Date.now() - start;
if (result === "PONG") {
return { status: "up", latencyMs };
}
return {
status: "down",
latencyMs,
error: `Unexpected PING response: ${result}`,
};
} catch (err) {
return {
status: "down",
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}
}

View File

@@ -9,7 +9,7 @@ export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
sdk = new NodeSDK({
serviceName: "teacher-bff",
serviceName: env.OTEL_SERVICE_NAME,
traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}),
@@ -17,11 +17,12 @@ export function initTracer(): void {
});
sdk.start();
console.log("Tracer initialized with auto-instrumentations");
console.log(`Tracer initialized (service: ${env.OTEL_SERVICE_NAME})`);
}
export async function shutdownTracer(): Promise<void> {
if (sdk) {
await sdk.shutdown();
sdk = null;
}
}