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,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 });
}
},
);
});
}