feat(teacher-bff): 完整实现 teacher-bff GraphQL 聚合层
包含 clients/graphql/middleware、health probes、shared-ts contracts 等
This commit is contained in:
37
services/teacher-bff/src/graphql/context.ts
Normal file
37
services/teacher-bff/src/graphql/context.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// GraphQL 上下文构建(B1 裁决:P2 起直接 GraphQL)
|
||||
// 从 Express 请求提取 userId / traceId,注入下游 CallContext
|
||||
import type { Request } from "express";
|
||||
import type { CallContext } from "../clients/types.js";
|
||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||
|
||||
export interface GraphQLOperationContext {
|
||||
/** 当前请求的 CallContext(注入下游 gRPC 调用) */
|
||||
callCtx: CallContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Express 请求构建 GraphQL 上下文
|
||||
* P2: 从 x-user-id header 提取 userId(api-gateway 注入)
|
||||
* P3+: 从 JWT 提取(api-gateway 校验后注入 x-user-id + x-user-roles)
|
||||
*/
|
||||
export function buildGraphQLContext(req: Request): GraphQLOperationContext {
|
||||
const userIdHeader = req.headers["x-user-id"];
|
||||
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
|
||||
const requestIdHeader = req.headers["x-request-id"];
|
||||
const traceId =
|
||||
typeof requestIdHeader === "string" ? requestIdHeader : undefined;
|
||||
|
||||
const rolesHeader = req.headers["x-user-roles"];
|
||||
const roles =
|
||||
typeof rolesHeader === "string"
|
||||
? rolesHeader.split(",").filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
callCtx: { userId, traceId, roles },
|
||||
};
|
||||
}
|
||||
37
services/teacher-bff/src/graphql/error-formatter.ts
Normal file
37
services/teacher-bff/src/graphql/error-formatter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// GraphQL 错误格式化(G8 裁决:ActionState 信封 + BFF_TEACHER_ 错误码)
|
||||
// Yoga 自身错误处理,extensions.code = BFF_TEACHER_* + extensions.traceId
|
||||
import { ApplicationError } from "../shared/errors/application-error.js";
|
||||
|
||||
/**
|
||||
* 格式化 GraphQL 错误(president §2.6 降级模式 B + G8 ActionState 信封)
|
||||
* 错误 extensions 包含:code(BFF_TEACHER_*)、traceId、details
|
||||
*/
|
||||
export function formatGraphQLError(
|
||||
error: unknown,
|
||||
traceId?: string,
|
||||
): { message: string; extensions: Record<string, unknown> } {
|
||||
// ApplicationError → 映射 code + details
|
||||
if (error instanceof ApplicationError) {
|
||||
return {
|
||||
message: error.message,
|
||||
extensions: {
|
||||
code: error.code,
|
||||
traceId: traceId ?? error.traceId ?? "unknown",
|
||||
details: error.details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 原生 GraphQL Error(语法错误、校验错误等)
|
||||
const err = error as {
|
||||
message?: string;
|
||||
extensions?: Record<string, unknown>;
|
||||
};
|
||||
return {
|
||||
message: err.message ?? "Unknown GraphQL error",
|
||||
extensions: {
|
||||
code: (err.extensions?.code as string) ?? "BFF_TEACHER_INTERNAL_ERROR",
|
||||
traceId: traceId ?? "unknown",
|
||||
},
|
||||
};
|
||||
}
|
||||
65
services/teacher-bff/src/graphql/graphql.controller.ts
Normal file
65
services/teacher-bff/src/graphql/graphql.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// GraphQL Yoga endpoint(B1 裁决:P2 起直接 GraphQL)
|
||||
// POST /graphql — GraphQL query/mutation endpoint
|
||||
// GET /graphql — GraphQL Playground(开发环境)/ schema introspection
|
||||
import { Controller, Post, Get, Req, Res, Inject } from "@nestjs/common";
|
||||
import { createYoga } from "graphql-yoga";
|
||||
import type { Request, Response } from "express";
|
||||
import { env } from "../config/env.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
import { loadSchema } from "./schema-loader.js";
|
||||
import { buildGraphQLContext } from "./context.js";
|
||||
import { TeacherService } from "../teacher/teacher.service.js";
|
||||
import { buildResolvers } from "../teacher/teacher.resolver.js";
|
||||
|
||||
@Controller("graphql")
|
||||
export class GraphQLController {
|
||||
// Yoga 实例类型复杂,用内联类型避免泛型不匹配
|
||||
private readonly yoga: (req: Request, res: Response) => Promise<void>;
|
||||
|
||||
constructor(@Inject(TeacherService) service: TeacherService) {
|
||||
const resolvers = buildResolvers(service);
|
||||
const schema = loadSchema(resolvers);
|
||||
|
||||
const yoga = createYoga({
|
||||
schema,
|
||||
graphqlEndpoint: env.GRAPHQL_PATH,
|
||||
context: ({ request }) => {
|
||||
return buildGraphQLContext(request as unknown as Request);
|
||||
},
|
||||
logging: {
|
||||
debug: (msg: unknown) => logger.debug({ yoga: msg }, "GraphQL Yoga"),
|
||||
info: (msg: unknown) => logger.info({ yoga: msg }, "GraphQL Yoga"),
|
||||
warn: (msg: unknown) => logger.warn({ yoga: msg }, "GraphQL Yoga"),
|
||||
error: (msg: unknown) => logger.error({ yoga: msg }, "GraphQL Yoga"),
|
||||
},
|
||||
// GraphQL Playground(开发环境启用)
|
||||
graphiql: env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
this.yoga = yoga as unknown as (
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => Promise<void>;
|
||||
|
||||
logger.info(
|
||||
{ graphqlEndpoint: env.GRAPHQL_PATH },
|
||||
"GraphQL Yoga endpoint initialized",
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async handleGraphQL(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
await this.yoga(req, res);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async handleGraphiQL(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
await this.yoga(req, res);
|
||||
}
|
||||
}
|
||||
10
services/teacher-bff/src/graphql/graphql.module.ts
Normal file
10
services/teacher-bff/src/graphql/graphql.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// GraphQL 模块(B1 裁决:P2 起直接 GraphQL Yoga)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { GraphQLController } from "./graphql.controller.js";
|
||||
import { TeacherModule } from "../teacher/teacher.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [TeacherModule],
|
||||
controllers: [GraphQLController],
|
||||
})
|
||||
export class GraphQLModule {}
|
||||
22
services/teacher-bff/src/graphql/schema-loader.ts
Normal file
22
services/teacher-bff/src/graphql/schema-loader.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// GraphQL Schema 加载器(从 .graphql 文件加载 SDL,B1 裁决)
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||
import type { IResolvers } from "@graphql-tools/utils";
|
||||
|
||||
/** schema 文件路径(monorepo 相对路径) */
|
||||
const SCHEMA_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
"../../packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql",
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载 GraphQL schema(从 SDL 文件 + resolvers 构建可执行 schema)
|
||||
* schema 文件位于 packages/shared-ts/contracts/graphql/(ARB-001 仲裁产物)
|
||||
*/
|
||||
export function loadSchema(
|
||||
resolvers: IResolvers,
|
||||
): ReturnType<typeof makeExecutableSchema> {
|
||||
const typeDefs = readFileSync(SCHEMA_PATH, "utf-8");
|
||||
return makeExecutableSchema({ typeDefs, resolvers });
|
||||
}
|
||||
Reference in New Issue
Block a user