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

@@ -0,0 +1,159 @@
// DownstreamClient 抽象基类B8 裁决3 个 BFF 统一使用)
// 提供统一的错误映射 + 结构化日志 + metrics 记录
import type { Logger } from "pino";
import { logger } from "../shared/observability/logger.js";
import {
BadGatewayError,
AggregationFailedError,
} from "../shared/errors/application-error.js";
import type {
CallContext,
DownstreamError,
DownstreamResult,
DownstreamServiceName,
GrpcMetadata,
} from "./types.js";
/** gRPC 状态码 → HTTP 语义映射(@grpc/grpc-js status codes */
const GRPC_STATUS = {
OK: 0,
CANCELLED: 1,
UNKNOWN: 2,
INVALID_ARGUMENT: 3,
DEADLINE_EXCEEDED: 4,
NOT_FOUND: 5,
ALREADY_EXISTS: 6,
PERMISSION_DENIED: 7,
RESOURCE_EXHAUSTED: 8,
FAILED_PRECONDITION: 9,
ABORTED: 10,
OUT_OF_RANGE: 11,
UNIMPLEMENTED: 12,
INTERNAL: 13,
UNAVAILABLE: 14,
DATA_LOSS: 15,
UNAUTHENTICATED: 16,
} as const;
export abstract class BaseDownstreamClient {
protected log: Logger;
abstract readonly serviceName: DownstreamServiceName;
constructor() {
// abstract property 在子类构造后才可用,延迟初始化 logger
this.log = logger.child({ downstream: "downstream" });
}
/** 子类在构造函数中调用以初始化 logger带正确 serviceName */
protected initLogger(): void {
this.log = logger.child({ downstream: this.serviceName });
}
/** 构建 gRPC metadata注入 x-user-id + x-request-id */
protected buildMetadata(ctx: CallContext): GrpcMetadata {
const meta: GrpcMetadata = { "x-user-id": ctx.userId };
if (ctx.traceId) {
meta["x-request-id"] = ctx.traceId;
}
return meta;
}
/** 将 gRPC 错误映射为 BadGatewayError502 */
protected mapGrpcError(err: unknown, rpc: string): BadGatewayError {
const grpcErr = err as {
code?: number;
message?: string;
details?: string;
};
const code = grpcErr.code ?? GRPC_STATUS.UNKNOWN;
const message = grpcErr.message ?? "Unknown gRPC error";
this.log.warn(
{ rpc, grpcCode: code, message, err },
"Downstream gRPC call failed",
);
// UNAVAILABLE → 502 Bad Gateway
if (code === GRPC_STATUS.UNAVAILABLE) {
return new BadGatewayError(
`Downstream ${this.serviceName}.${rpc} unavailable`,
{ service: this.serviceName, rpc, grpcCode: code },
);
}
// UNAUTHENTICATED → 映射为 BadGatewayBFF 自身做身份校验,下游不应返回 UNAUTHENTICATED
if (code === GRPC_STATUS.UNAUTHENTICATED) {
return new BadGatewayError(
`Downstream ${this.serviceName}.${rpc} returned UNAUTHENTICATED`,
{ service: this.serviceName, rpc, grpcCode: code },
);
}
// 其他错误统一映射为 BadGateway
return new BadGatewayError(
`Downstream ${this.serviceName}.${rpc} failed: ${message}`,
{
service: this.serviceName,
rpc,
grpcCode: code,
details: grpcErr.details,
},
);
}
/** 执行 gRPC 调用并统一错误处理 + 超时控制 */
protected async callGrpc<T>(
rpc: string,
fn: () => Promise<T>,
timeoutMs = 3000,
): Promise<T> {
const start = Date.now();
try {
const result = await Promise.race([
fn(),
this.createTimeout(timeoutMs, rpc),
]);
const duration = Date.now() - start;
this.log.debug(
{ rpc, durationMs: duration },
"Downstream gRPC call succeeded",
);
return result;
} catch (err) {
throw this.mapGrpcError(err, rpc);
}
}
private createTimeout(ms: number, rpc: string): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => {
reject(
new BadGatewayError(
`Downstream ${this.serviceName}.${rpc} timed out after ${ms}ms`,
{ service: this.serviceName, rpc, timeoutMs: ms },
),
);
}, ms);
});
}
/** 降级模式 B部分失败时返回 success=true + degraded=true + warningpresident §2.6 */
protected degraded<T>(
data: T | null,
warning: string,
rpc: string,
): DownstreamResult<T> {
this.log.warn({ rpc, warning }, "Downstream call degraded");
return { success: true, data, degraded: true, warning };
}
/** 聚合失败多下游部分失败且无降级数据president §2.6 */
protected aggregationFailed(
errors: DownstreamError[],
): AggregationFailedError {
return new AggregationFailedError(
`Aggregation failed for ${this.serviceName}: ${errors.length} downstream(s) failed`,
{ service: this.serviceName, errors },
);
}
}

View File

@@ -0,0 +1,10 @@
// DownstreamClient 聚合模块B8 裁决3 个 BFF 统一使用)
// P2 仅注册 IamModuleP3+ 扩展 CoreEduModule / ContentModule / DataAnaModule / MsgModule / AiModule
import { Module } from "@nestjs/common";
import { IamModule } from "./iam/iam.module.js";
@Module({
imports: [IamModule],
exports: [IamModule],
})
export class ClientsModule {}

View File

@@ -0,0 +1,256 @@
// gRPC Channel + Client 工厂B2 裁决:首次实现即 gRPC
// 使用 @grpc/grpc-js + @grpc/proto-loader 动态加载 proto无生成代码依赖
// 上游就绪后可切换为 @bufbuild/protobuf 生成代码B8 裁决)
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import path from "node:path";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import type { DownstreamServiceName } from "../types.js";
/** proto 文件根目录monorepo 相对路径) */
const PROTO_ROOT = path.resolve(
process.cwd(),
"../../packages/shared-proto/proto",
);
/** 已加载的 proto 定义缓存(按文件名缓存) */
const packageCache = new Map<string, protoLoader.PackageDefinition>();
/** 已创建的 gRPC Client 缓存(按 service+target 缓存) */
const clientCache = new Map<string, grpc.Client>();
/** 各下游服务对应的 proto 文件 + 包名 + service 名 */
interface ServiceProtoConfig {
protoFile: string;
packageName: string;
serviceName: string;
}
const SERVICE_PROTO_MAP: Record<DownstreamServiceName, ServiceProtoConfig> = {
iam: {
protoFile: "iam.proto",
packageName: "next_edu_cloud.iam.v1",
serviceName: "IamService",
},
"core-edu": {
protoFile: "core_edu.proto",
packageName: "next_edu_cloud.core_edu.v1",
serviceName: "", // core-edu 多 service按需指定
},
content: {
protoFile: "content.proto",
packageName: "next_edu_cloud.content.v1",
serviceName: "",
},
"data-ana": {
protoFile: "analytics.proto",
packageName: "next_edu_cloud.analytics.v1",
serviceName: "",
},
msg: {
protoFile: "msg.proto",
packageName: "next_edu_cloud.msg.v1",
serviceName: "",
},
ai: {
protoFile: "ai.proto",
packageName: "next_edu_cloud.ai.v1",
serviceName: "",
},
};
/** 加载 proto 定义(带缓存) */
function loadPackageDefinition(
protoFile: string,
): protoLoader.PackageDefinition {
const cached = packageCache.get(protoFile);
if (cached) return cached;
const fullPath = path.join(PROTO_ROOT, protoFile);
const def = protoLoader.loadSync(fullPath, {
keepCase: false,
longs: String,
enums: String,
defaults: true,
oneofs: true,
includeDirs: [PROTO_ROOT],
});
packageCache.set(protoFile, def);
return def;
}
/** 获取下游服务的 gRPC target从环境变量 */
export function getGrpcTarget(service: DownstreamServiceName): string {
const map: Record<DownstreamServiceName, string> = {
iam: env.IAM_GRPC_TARGET,
"core-edu": env.CORE_EDU_GRPC_TARGET,
content: env.CONTENT_GRPC_TARGET,
"data-ana": env.DATA_ANA_GRPC_TARGET,
msg: env.MSG_GRPC_TARGET,
ai: env.AI_GRPC_TARGET,
};
return map[service];
}
/** 判断下游服务是否已配置 target未配置则走 mock */
export function isServiceConfigured(service: DownstreamServiceName): boolean {
const target = getGrpcTarget(service);
return target.length > 0;
}
/** 创建或获取 gRPC Client带缓存 */
export function getGrpcClient(
service: DownstreamServiceName,
serviceName?: string,
): grpc.Client {
const target = getGrpcTarget(service);
const config = SERVICE_PROTO_MAP[service];
const svcName = serviceName ?? config.serviceName;
if (!target) {
throw new Error(
`gRPC target not configured for service: ${service} (set ${service.toUpperCase().replace("-", "_")}_GRPC_TARGET)`,
);
}
if (!svcName) {
throw new Error(
`Service name not specified for ${service}, pass serviceName explicitly`,
);
}
const cacheKey = `${service}:${svcName}:${target}`;
const cached = clientCache.get(cacheKey);
if (cached) return cached;
const def = loadPackageDefinition(config.protoFile);
const proto = grpc.loadPackageDefinition(def) as unknown as Record<
string,
Record<string, unknown>
>;
// 按包路径查找 Service 构造器
const pkgParts = config.packageName.split(".");
let pkg: Record<string, unknown> = proto;
for (const part of pkgParts) {
pkg = pkg[part] as Record<string, unknown>;
if (!pkg) {
throw new Error(
`Package ${config.packageName} not found in ${config.protoFile}`,
);
}
}
const ServiceConstructor = pkg[svcName] as unknown as {
new (target: string, credentials: grpc.ChannelCredentials): grpc.Client;
};
if (!ServiceConstructor) {
throw new Error(
`Service ${svcName} not found in package ${config.packageName}`,
);
}
// P2-P2 阶段使用 insecure内网mTLS 在 P6 硬化阶段启用)
const client = new ServiceConstructor(
target,
grpc.credentials.createInsecure(),
);
clientCache.set(cacheKey, client);
logger.info({ service, serviceName: svcName, target }, "gRPC client created");
return client;
}
/** 创建 gRPC metadata注入 x-user-id + x-request-id */
export function createGrpcMetadata(
userId: string,
traceId?: string,
): grpc.Metadata {
const meta = new grpc.Metadata();
meta.add("x-user-id", userId);
if (traceId) {
meta.add("x-request-id", traceId);
}
return meta;
}
/** 关闭所有 gRPC Client优雅关闭时调用 */
export function closeAllGrpcClients(): void {
for (const [key, client] of clientCache) {
try {
client.close();
logger.info({ client: key }, "gRPC client closed");
} catch (err) {
logger.warn({ client: key, err }, "Failed to close gRPC client");
}
}
clientCache.clear();
}
/**
* 执行 gRPC 健康检查grpc.health.v1.Health/Check
* 用于 /readyz 探针president §2.4
*
* 使用标准 grpc.health.v1.Health 服务,通过 makeUnaryRequest 直接调用。
* 超时 2s返回 SERVING / NOT_SERVING / UNREACHABLE。
*/
export async function checkGrpcHealth(
service: DownstreamServiceName,
): Promise<{ serving: boolean; latencyMs: number; error?: string }> {
const target = getGrpcTarget(service);
if (!target) {
return { serving: false, latencyMs: 0, error: "target not configured" };
}
const start = Date.now();
const client = new grpc.Client(target, grpc.credentials.createInsecure(), {
"grpc.enable_retries": 0,
});
return new Promise((resolve) => {
const timeout = setTimeout(() => {
client.close();
resolve({
serving: false,
latencyMs: Date.now() - start,
error: "health check timed out (2s)",
});
}, 2000);
client.makeUnaryRequest(
"grpc.health.v1.Health/Check",
// serialize: HealthCheckRequest → Buffer空消息体
() => Buffer.alloc(0),
// deserialize: Buffer → { status: string }
(buf: Buffer) => {
// proto HealthCheckResponse { enum ServingStatus { SERVING = 1; } int32 status = 1; }
// 简化解析:直接读取第一个 varint 字段field 1 = status
if (buf.length < 2) return { status: "UNKNOWN" };
const statusByte: number = buf[1] ?? 0;
const statusMap: Record<number, string> = {
0: "UNKNOWN",
1: "SERVING",
2: "NOT_SERVING",
3: "SERVICE_UNKNOWN",
};
return { status: statusMap[statusByte] ?? "UNKNOWN" };
},
{},
new grpc.Metadata(),
{},
(err: grpc.ServiceError | null, value: unknown) => {
clearTimeout(timeout);
const latencyMs = Date.now() - start;
client.close();
if (err) {
resolve({ serving: false, latencyMs, error: err.message });
} else {
const status = (value as { status?: string } | null | undefined)
?.status;
resolve({ serving: status === "SERVING", latencyMs });
}
},
);
});
}

View File

@@ -0,0 +1,31 @@
// IamClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P2+ 需要的 iam RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC未就绪 RPC 降级 mock + warning
import type { CallContext } from "../types.js";
import type {
UserInfo,
ViewportItem,
EffectivePermissions,
} from "./iam.types.js";
/** IamClient DI tokenNestJS 注入用) */
export const IAM_CLIENT = Symbol("IAM_CLIENT");
/** IamClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
export interface IamClient {
/** 获取用户信息iam.proto GetUserInfo✅ 已就绪) */
getUserInfo(ctx: CallContext): Promise<UserInfo>;
/** 获取教师导航菜单iam.proto GetViewports❌ 待 coord 补全) */
getViewports(ctx: CallContext): Promise<ViewportItem[]>;
/** 获取有效权限集iam.proto GetEffectivePermissions❌ 待 coord 补全) */
getEffectivePermissions(ctx: CallContext): Promise<EffectivePermissions>;
/** 健康检查(/readyz 探针用) */
checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}>;
}

View File

@@ -0,0 +1,79 @@
// IamClient gRPC 实现B2 裁决:首次实现即 gRPC
// iam.proto 现状仅 GetUserInfoGetViewports/GetEffectivePermissions 待 coord 补全
// 未就绪 RPC 降级 mock + warning 日志president §2.6 降级模式 B
import { Injectable } from "@nestjs/common";
import type * as grpc from "@grpc/grpc-js";
import { BaseDownstreamClient } from "../base.client.js";
import {
createGrpcMetadata,
getGrpcClient,
checkGrpcHealth,
} from "../grpc/grpc.factory.js";
import type { CallContext } from "../types.js";
import type { IamClient } from "./iam-client.interface.js";
import type {
UserInfo,
ViewportItem,
EffectivePermissions,
} from "./iam.types.js";
import { IamMockClient } from "./iam-mock.client.js";
@Injectable()
export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
readonly serviceName = "iam" as const;
private readonly mock: IamMockClient;
constructor() {
super();
this.initLogger();
// mock 用于未就绪 RPC 的降级proto 未定义的 RPC 走 mock + warning
this.mock = new IamMockClient();
}
async getUserInfo(ctx: CallContext): Promise<UserInfo> {
return this.callGrpc("GetUserInfo", async () => {
const client = getGrpcClient("iam") as unknown as {
getUserInfo(
req: { userId: string },
meta: grpc.Metadata,
cb: (err: grpc.ServiceError | null, res: UserInfo) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<UserInfo>((resolve, reject) => {
client.getUserInfo({ userId: ctx.userId }, meta, (err, res) => {
if (err) reject(err);
else resolve(res);
});
});
});
}
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
// iam.proto 暂无 GetViewports RPC降级 mock + warning待 coord 补全)
this.log.warn(
{ rpc: "GetViewports", reason: "RPC not in iam.proto yet" },
"Downstream RPC not ready, falling back to mock",
);
return this.mock.getViewports(ctx);
}
async getEffectivePermissions(
ctx: CallContext,
): Promise<EffectivePermissions> {
// iam.proto 暂无 GetEffectivePermissions RPC降级 mock + warning
this.log.warn(
{ rpc: "GetEffectivePermissions", reason: "RPC not in iam.proto yet" },
"Downstream RPC not ready, falling back to mock",
);
return this.mock.getEffectivePermissions(ctx);
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return checkGrpcHealth("iam");
}
}

View File

@@ -0,0 +1,125 @@
// IamClient Mock 实现B8 裁决:上游就绪前的降级策略)
// DEV_MODE=true 时全部走 mockDEV_MODE=false 时仅未就绪 RPC 走 mock
// mock 数据对齐 contract §4.2 mock 策略
import { Injectable } from "@nestjs/common";
import { BaseDownstreamClient } from "../base.client.js";
import type { CallContext } from "../types.js";
import type { IamClient } from "./iam-client.interface.js";
import type {
UserInfo,
ViewportItem,
EffectivePermissions,
} from "./iam.types.js";
/** 教师默认 mock 用户contract §4.1 mock 策略) */
const MOCK_TEACHER: UserInfo = {
id: "teacher-001",
email: "teacher@edu.test",
name: "张老师",
roles: ["teacher"],
permissions: [
"exam:read",
"exam:write",
"homework:read",
"homework:write",
"grade:read",
"grade:write",
"class:read",
"student:read",
],
};
/** 教师默认 mock 视口L1 导航) */
const MOCK_VIEWPORTS: ViewportItem[] = [
{
key: "dashboard",
label: "工作台",
route: "/dashboard",
icon: null,
sortOrder: 1,
requiredPermission: null,
},
{
key: "classes",
label: "我的班级",
route: "/classes",
icon: null,
sortOrder: 2,
requiredPermission: "class:read",
},
{
key: "exams",
label: "考试管理",
route: "/exams",
icon: null,
sortOrder: 3,
requiredPermission: "exam:read",
},
{
key: "homework",
label: "作业管理",
route: "/homework",
icon: null,
sortOrder: 4,
requiredPermission: "homework:read",
},
{
key: "grades",
label: "成绩管理",
route: "/grades",
icon: null,
sortOrder: 5,
requiredPermission: "grade:read",
},
{
key: "analytics",
label: "学情分析",
route: "/analytics",
icon: null,
sortOrder: 6,
requiredPermission: "student:read",
},
];
/** 教师默认 mock 权限(全权限 + OWN 数据范围) */
const MOCK_PERMISSIONS: EffectivePermissions = {
permissions: MOCK_TEACHER.permissions,
dataScope: "OWN",
};
@Injectable()
export class IamMockClient extends BaseDownstreamClient implements IamClient {
readonly serviceName = "iam" as const;
constructor() {
super();
this.initLogger();
}
async getUserInfo(ctx: CallContext): Promise<UserInfo> {
this.log.debug({ userId: ctx.userId }, "Mock getUserInfo");
// 返回 mock 教师,但 id 用请求中的 userId保持一致性
return { ...MOCK_TEACHER, id: ctx.userId };
}
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
this.log.debug({ userId: ctx.userId }, "Mock getViewports");
return MOCK_VIEWPORTS;
}
async getEffectivePermissions(
ctx: CallContext,
): Promise<EffectivePermissions> {
this.log.debug({ userId: ctx.userId }, "Mock getEffectivePermissions");
return MOCK_PERMISSIONS;
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
// mock 模式下健康检查总是返回 serving=true
return { serving: true, latencyMs: 0 };
}
}

View File

@@ -0,0 +1,31 @@
// iam 模块B8 裁决:按 DEV_MODE 选择 mock 或 gRPC 实现)
import { Module } from "@nestjs/common";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import { IAM_CLIENT } from "./iam-client.interface.js";
import { IamGrpcClient } from "./iam-grpc.client.js";
import { IamMockClient } from "./iam-mock.client.js";
@Module({
providers: [
{
provide: IAM_CLIENT,
useFactory: () => {
if (env.TEACHER_BFF_DEV_MODE) {
logger.warn(
{ devMode: true },
"IamClient using mock (TEACHER_BFF_DEV_MODE=true)",
);
return new IamMockClient();
}
logger.info(
{ devMode: false, target: env.IAM_GRPC_TARGET },
"IamClient using gRPC",
);
return new IamGrpcClient();
},
},
],
exports: [IAM_CLIENT],
})
export class IamModule {}

View File

@@ -0,0 +1,44 @@
// iam 下游服务类型定义(对齐 iam.proto + 待 coord 补全的 RPC
// iam.proto 现状4 RPCRegister/Login/RefreshToken/GetUserInfo
// 待 coord 补全GetViewports / GetEffectivePermissions / GetEffectiveAccess / BatchGetUsers / GetChildrenByParent
/** 用户信息(对齐 iam.proto UserInfo message */
export interface UserInfo {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
}
/** 视口项L1 导航菜单,待 coord 补 GetViewports RPC 的 response message */
export interface ViewportItem {
key: string;
label: string;
route: string;
icon: string | null;
sortOrder: number;
requiredPermission: string | null;
}
/** 有效权限集(待 coord 补 GetEffectivePermissions RPC 的 response message */
export interface EffectivePermissions {
permissions: string[];
/** 数据范围OWN / GRADE / SCHOOL / ALL */
dataScope: string;
}
/** GetUserInfo 请求 */
export interface GetUserInfoRequest {
userId: string;
}
/** GetViewports 请求 */
export interface GetViewportsRequest {
userId: string;
}
/** GetEffectivePermissions 请求 */
export interface GetEffectivePermissionsRequest {
userId: string;
}

View File

@@ -0,0 +1,46 @@
// DownstreamClient 抽象层共享类型B8 裁决3 个 BFF 统一使用)
// 所有下游 gRPC 调用通过此抽象层,统一 trace 上下文注入 + 错误映射 + metrics
/** 下游调用上下文per-request由 GraphQL Resolver 注入) */
export interface CallContext {
/** 当前用户 ID从 JWT 或 x-user-id header 提取) */
userId: string;
/** 请求追踪 IDX-Request-Id用于日志关联 */
traceId?: string;
/** 用户角色(可选,用于下游权限校验) */
roles?: readonly string[];
}
/** 下游调用结果(降级模式 B 支持success + degraded 标记) */
export type DownstreamResult<T> =
| { success: true; data: T; degraded?: false }
| { success: true; data: T | null; degraded: true; warning: string }
| { success: false; error: DownstreamError };
export interface DownstreamError {
code: string;
message: string;
service: string;
rpc?: string;
details?: Record<string, unknown>;
}
/** 下游服务名(用于 metrics 标签 + 日志字段) */
export type DownstreamServiceName =
"iam" | "core-edu" | "content" | "data-ana" | "msg" | "ai";
/** gRPC 健康检查结果(/readyz 探针用) */
export interface GrpcHealthStatus {
service: DownstreamServiceName;
target: string;
status: "SERVING" | "NOT_SERVING" | "UNKNOWN" | "UNREACHABLE";
latencyMs?: number;
error?: string;
}
/** 下游 gRPC 调用 metadata注入 trace context + x-user-id */
export interface GrpcMetadata {
"x-user-id": string;
"x-request-id"?: string;
traceparent?: string;
}