feat(teacher-bff): 完整实现 teacher-bff GraphQL 聚合层
包含 clients/graphql/middleware、health probes、shared-ts contracts 等
This commit is contained in:
@@ -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 缺失或无效(401,president §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 场景 A,president §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 场景 B,president §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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
// 健康检查端点(G3 + president §2.4)
|
||||
// - GET /healthz:liveness,仅返回进程存活(不检查下游)
|
||||
// - GET /readyz:readiness,DownstreamHealthCheck 注册表(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 /healthz:liveness,仅返回进程存活。
|
||||
* - GET /readyz:readiness,BFF 不直接访问 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
services/teacher-bff/src/shared/health/iam-grpc.probe.ts
Normal file
27
services/teacher-bff/src/shared/health/iam-grpc.probe.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
113
services/teacher-bff/src/shared/health/readiness.probe.ts
Normal file
113
services/teacher-bff/src/shared/health/readiness.probe.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// /readyz 探针注册表(president §2.4 + G2)
|
||||
// DownstreamHealthCheck 注册表模式:每个下游注册独立探针,按阶段启用
|
||||
// P2: Redis PING + iam gRPC 50052(2 项)
|
||||
// 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();
|
||||
37
services/teacher-bff/src/shared/health/redis.probe.ts
Normal file
37
services/teacher-bff/src/shared/health/redis.probe.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user