feat(teacher-bff): 完整实现 teacher-bff GraphQL 聚合层
包含 clients/graphql/middleware、health probes、shared-ts contracts 等
This commit is contained in:
159
services/teacher-bff/src/clients/base.client.ts
Normal file
159
services/teacher-bff/src/clients/base.client.ts
Normal 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 错误映射为 BadGatewayError(502) */
|
||||
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 → 映射为 BadGateway(BFF 自身做身份校验,下游不应返回 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 + warning(president §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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
10
services/teacher-bff/src/clients/clients.module.ts
Normal file
10
services/teacher-bff/src/clients/clients.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// DownstreamClient 聚合模块(B8 裁决:3 个 BFF 统一使用)
|
||||
// P2 仅注册 IamModule,P3+ 扩展 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 {}
|
||||
256
services/teacher-bff/src/clients/grpc/grpc.factory.ts
Normal file
256
services/teacher-bff/src/clients/grpc/grpc.factory.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
31
services/teacher-bff/src/clients/iam/iam-client.interface.ts
Normal file
31
services/teacher-bff/src/clients/iam/iam-client.interface.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// IamClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||
// 接口定义所有 P2+ 需要的 iam RPC,实现分 gRPC + mock 两种
|
||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC(未就绪 RPC 降级 mock + warning)
|
||||
import type { CallContext } from "../types.js";
|
||||
import type {
|
||||
UserInfo,
|
||||
ViewportItem,
|
||||
EffectivePermissions,
|
||||
} from "./iam.types.js";
|
||||
|
||||
/** IamClient DI token(NestJS 注入用) */
|
||||
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;
|
||||
}>;
|
||||
}
|
||||
79
services/teacher-bff/src/clients/iam/iam-grpc.client.ts
Normal file
79
services/teacher-bff/src/clients/iam/iam-grpc.client.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// IamClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||
// iam.proto 现状仅 GetUserInfo,GetViewports/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");
|
||||
}
|
||||
}
|
||||
125
services/teacher-bff/src/clients/iam/iam-mock.client.ts
Normal file
125
services/teacher-bff/src/clients/iam/iam-mock.client.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// IamClient Mock 实现(B8 裁决:上游就绪前的降级策略)
|
||||
// DEV_MODE=true 时全部走 mock;DEV_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 };
|
||||
}
|
||||
}
|
||||
31
services/teacher-bff/src/clients/iam/iam.module.ts
Normal file
31
services/teacher-bff/src/clients/iam/iam.module.ts
Normal 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 {}
|
||||
44
services/teacher-bff/src/clients/iam/iam.types.ts
Normal file
44
services/teacher-bff/src/clients/iam/iam.types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// iam 下游服务类型定义(对齐 iam.proto + 待 coord 补全的 RPC)
|
||||
// iam.proto 现状:4 RPC(Register/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;
|
||||
}
|
||||
46
services/teacher-bff/src/clients/types.ts
Normal file
46
services/teacher-bff/src/clients/types.ts
Normal 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;
|
||||
/** 请求追踪 ID(X-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;
|
||||
}
|
||||
Reference in New Issue
Block a user