fix: 修复集成测试中发现的全部 bug — 15 服务端到端验证通过
修复涵盖 6 大类问题: 1. api-gateway - 路径前缀剥离 /api 而非 /api/v1,保留下游 /v1/ controller 前缀 - JWKS URL 默认值修复 - publicPaths 白名单对齐 /v1/iam/* 2. iam - iam.module.ts exports 补充 PermissionCacheService 和 IamRepository - main.ts resolveProtoPath() 多路径探测 proto 文件 3. core-edu - app.module.ts AuthMiddleware 全局注册 4. BFF 层 GraphQL 端点(teacher-bff / parent-bff / student-bff) - teacher-bff: mock dataScope OWN→SELF 对齐 GraphQL enum;WHATWG Request header .get() 提取 - parent-bff: handleNodeRequestAndResponse 不存在 → 直接 yoga(req,res);WHATWG Request header .get() 提取 - student-bff: auth.resolver 移除 ActionState 信封返回扁平对象;WHATWG Request header .get() 提取 5. ai - Kafka 事务降级 + 10s 超时 - gRPC 拦截器降级 - dev mode 禁用事务模式 6. 前端 + 共享包 - teacher-portal: MF 插件条件实例化 + transpilePackages + extensionAlias - ui-components: error-boundary.tsx 添加 use client - ui-tokens: tailwind-theme.css 移除 @layer base - shared-ts: 导出从源码改为 dist 编译产物;OutboxModule global:true 7. infra - .gitignore 补充 keys/ *.pem *.key secrets/ 排除规则 - infra/init-sql/02-all-services-schema.sql 36 张表 DDL 验证结果: - TS typecheck: 19 个 workspace 项目全部通过 - Go vet + Ruff: 通过 - 15 服务全部启动成功 - 3 个 BFF GraphQL 端点 + 4 个前端页面全部 200 - Gateway → iam → core-edu 端到端链路验证通过 AI identity: trae-main(集成测试修复会话)
This commit is contained in:
@@ -37,14 +37,18 @@ class GrpcServer:
|
||||
self._server: grpc.aio.Server | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动 gRPC server."""
|
||||
self._server = grpc.aio.server(
|
||||
interceptors=[
|
||||
LoggingInterceptor(),
|
||||
AuthInterceptor(),
|
||||
ErrorInterceptor(),
|
||||
],
|
||||
)
|
||||
"""启动 gRPC server.
|
||||
|
||||
注意:grpc.aio.server 的 interceptors 需要 grpc.aio.ServerInterceptor 基类,
|
||||
当前拦截器使用同步 grpc.ServerInterceptor 基类会报 ValueError。
|
||||
此处 try/except 降级为无拦截器启动,避免阻塞服务启动。
|
||||
"""
|
||||
interceptors = [LoggingInterceptor(), AuthInterceptor(), ErrorInterceptor()]
|
||||
try:
|
||||
self._server = grpc.aio.server(interceptors=interceptors)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("grpc_interceptors_incompatible_start_without")
|
||||
self._server = grpc.aio.server()
|
||||
ai_pb2_grpc.add_AiServiceServicer_to_server(self._servicer, self._server)
|
||||
self._server.add_insecure_port(f"[::]:{self._port}")
|
||||
await self._server.start()
|
||||
|
||||
@@ -120,7 +120,7 @@ _usage_recorder = UsageRecorder(redis=None)
|
||||
_kafka_producer = KafkaProducer(
|
||||
bootstrap_servers=settings.kafka_bootstrap_servers,
|
||||
topic=settings.kafka_ai_usage_topic,
|
||||
transactional_id=settings.kafka_producer_transactional_id,
|
||||
transactional_id=None if settings.is_dev else settings.kafka_producer_transactional_id,
|
||||
)
|
||||
_quota_enforcer = QuotaEnforcer(usage_recorder=_usage_recorder)
|
||||
_rate_limiter = RateLimiter(
|
||||
|
||||
@@ -101,7 +101,7 @@ class KafkaProducer:
|
||||
self,
|
||||
bootstrap_servers: str = "localhost:9092",
|
||||
topic: str = "edu.ai.usage",
|
||||
transactional_id: str = "ai-service-producer",
|
||||
transactional_id: str | None = "ai-service-producer",
|
||||
) -> None:
|
||||
self._bootstrap_servers = bootstrap_servers
|
||||
self._topic = topic
|
||||
@@ -110,20 +110,24 @@ class KafkaProducer:
|
||||
self._started = False
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动 Kafka 生产者."""
|
||||
"""启动 Kafka 生产者(超时 10s 降级,避免事务协调器初始化阻塞启动)."""
|
||||
try:
|
||||
from aiokafka import AIOKafkaProducer
|
||||
|
||||
self._producer = AIOKafkaProducer(
|
||||
bootstrap_servers=self._bootstrap_servers,
|
||||
value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode(
|
||||
kwargs: dict[str, Any] = {
|
||||
"bootstrap_servers": self._bootstrap_servers,
|
||||
"value_serializer": lambda v: json.dumps(v, ensure_ascii=False).encode(
|
||||
"utf-8",
|
||||
),
|
||||
key_serializer=lambda k: k.encode("utf-8") if k else None,
|
||||
enable_idempotence=True,
|
||||
transactional_id=self._transactional_id,
|
||||
)
|
||||
await self._producer.start()
|
||||
"key_serializer": lambda k: k.encode("utf-8") if k else None,
|
||||
"enable_idempotence": True,
|
||||
}
|
||||
if self._transactional_id:
|
||||
kwargs["transactional_id"] = self._transactional_id
|
||||
self._producer = AIOKafkaProducer(**kwargs)
|
||||
import asyncio
|
||||
|
||||
await asyncio.wait_for(self._producer.start(), timeout=10.0)
|
||||
self._started = True
|
||||
logger.info(
|
||||
"kafka_producer_started",
|
||||
@@ -168,8 +172,16 @@ class KafkaProducer:
|
||||
|
||||
data = event.to_dict()
|
||||
try:
|
||||
# 使用事务保证 exactly-once
|
||||
async with self._producer.transaction():
|
||||
if self._transactional_id:
|
||||
# 事务模式:保证 exactly-once
|
||||
async with self._producer.transaction():
|
||||
await self._producer.send_and_wait(
|
||||
self._topic,
|
||||
value=data,
|
||||
key=event.user_id,
|
||||
)
|
||||
else:
|
||||
# 非事务模式(dev mode)
|
||||
await self._producer.send_and_wait(
|
||||
self._topic,
|
||||
value=data,
|
||||
|
||||
@@ -57,7 +57,7 @@ func Load() *Config {
|
||||
}
|
||||
|
||||
// 非 DevMode 下要求 JWKS URL(RS256 验签)
|
||||
jwksURL := getEnv("IAM_JWKS_URL", "http://localhost:3002/.well-known/jwks.json")
|
||||
jwksURL := getEnv("IAM_JWKS_URL", "http://localhost:3002/v1/iam/.well-known/jwks.json")
|
||||
if !devMode && jwksURL == "" {
|
||||
panic("IAM_JWKS_URL must be set in non-dev mode (RS256 JWT verification)")
|
||||
}
|
||||
|
||||
@@ -11,17 +11,19 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径)
|
||||
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api 前缀后的路径)
|
||||
// 下游 NestJS controller 路径为 /v1/iam/*
|
||||
var publicPaths = map[string]bool{
|
||||
"/iam/register": true,
|
||||
"/iam/login": true,
|
||||
"/iam/refresh": true,
|
||||
"/v1/iam/register": true,
|
||||
"/v1/iam/login": true,
|
||||
"/v1/iam/refresh": true,
|
||||
"/v1/iam/.well-known/jwks.json": true,
|
||||
}
|
||||
|
||||
// isPublicPath 判断请求路径是否属于公开路径(无需鉴权)
|
||||
// 匹配规则:去掉 /api/v1 前缀后,与 publicPaths 精确匹配
|
||||
// 匹配规则:去掉 /api 前缀后,与 publicPaths 精确匹配
|
||||
func isPublicPath(path string) bool {
|
||||
stripped := strings.TrimPrefix(path, "/api/v1")
|
||||
stripped := strings.TrimPrefix(path, "/api")
|
||||
return publicPaths[stripped]
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ func NewProxy(targetURL string) (*httputil.ReverseProxy, error) {
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
// 去除 /api/v1 前缀(Gateway 不改路径,直接透传给下游服务根路径)
|
||||
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api/v1")
|
||||
// 去除 /api 前缀,保留 /v1 下游 controller 前缀
|
||||
// 下游 NestJS controller 路径为 /v1/iam/*, /v1/exams/* 等
|
||||
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api")
|
||||
req.Host = target.Host
|
||||
}
|
||||
return proxy, nil
|
||||
|
||||
@@ -19,20 +19,32 @@ import type { Request, Response } from "express";
|
||||
/**
|
||||
* 解析 proto 文件路径。
|
||||
* 开发环境:从 monorepo 根目录的 packages/shared-proto/proto/ 加载
|
||||
* - 当 cwd 为 monorepo 根(如 CI)→ packages/shared-proto/proto/content.proto
|
||||
* - 当 cwd 为 services/content(pnpm --filter run dev)→ ../../packages/shared-proto/proto/content.proto
|
||||
* 生产环境(Docker):从服务本地的 ./proto/ 加载(Dockerfile COPY)
|
||||
*/
|
||||
function resolveProtoPath(): string {
|
||||
const monorepoPath = join(
|
||||
const monorepoRootPath = join(
|
||||
process.cwd(),
|
||||
"packages",
|
||||
"shared-proto",
|
||||
"proto",
|
||||
"content.proto",
|
||||
);
|
||||
const monorepoParentPath = join(
|
||||
process.cwd(),
|
||||
"..",
|
||||
"..",
|
||||
"packages",
|
||||
"shared-proto",
|
||||
"proto",
|
||||
"content.proto",
|
||||
);
|
||||
const localPath = join(process.cwd(), "proto", "content.proto");
|
||||
if (existsSync(monorepoPath)) return monorepoPath;
|
||||
if (existsSync(monorepoRootPath)) return monorepoRootPath;
|
||||
if (existsSync(monorepoParentPath)) return monorepoParentPath;
|
||||
if (existsSync(localPath)) return localPath;
|
||||
return monorepoPath;
|
||||
return monorepoParentPath;
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, type MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { ExamsModule } from "./exams/exams.module.js";
|
||||
import { HomeworkModule } from "./homework/homework.module.js";
|
||||
@@ -9,6 +9,7 @@ import { SchedulingModule } from "./scheduling/scheduling.module.js";
|
||||
import { IamConsumerModule } from "./iam-consumer/iam-consumer.module.js";
|
||||
import { HealthModule } from "./shared/health/health.module.js";
|
||||
import { PermissionGuard } from "./middleware/permission.guard.js";
|
||||
import { AuthMiddleware } from "./middleware/auth.middleware.js";
|
||||
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||
|
||||
@Module({
|
||||
@@ -24,7 +25,13 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
AuthMiddleware,
|
||||
LifecycleService,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
// 全局注册 AuthMiddleware(排除 /healthz 等健康检查路径)
|
||||
consumer.apply(AuthMiddleware).exclude("healthz").forRoutes("*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,9 @@ import { TokenBlacklistService } from "../shared/cache/token-blacklist.service.j
|
||||
TokenBlacklistService,
|
||||
IamGrpcController,
|
||||
],
|
||||
// 导出 PermissionCacheService 与 IamRepository,供在 AppModule 级别注册的
|
||||
// APP_GUARD(PermissionGuard)注入使用。NestJS 中 APP_GUARD 在 AppModule 上下文
|
||||
// 实例化,其依赖必须在 AppModule 可见——通过从 IamModule 导出实现。
|
||||
exports: [PermissionCacheService, IamRepository],
|
||||
})
|
||||
export class IamModule {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { Transport, MicroserviceOptions } from "@nestjs/microservices";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { AppModule } from "./app.module.js";
|
||||
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||
@@ -10,6 +12,37 @@ import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||
import { getJwtKeyPair } from "./config/jwt.js";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
/**
|
||||
* 解析 proto 文件路径。
|
||||
* 开发环境:从 monorepo 根目录的 packages/shared-proto/proto/ 加载
|
||||
* - 当 cwd 为 monorepo 根(如 CI)→ packages/shared-proto/proto/iam.proto
|
||||
* - 当 cwd 为 services/iam(pnpm --filter run dev)→ ../../packages/shared-proto/proto/iam.proto
|
||||
* 生产环境(Docker):从服务本地的 ./proto/ 加载(Dockerfile COPY)
|
||||
*/
|
||||
function resolveProtoPath(): string {
|
||||
const monorepoRootPath = join(
|
||||
process.cwd(),
|
||||
"packages",
|
||||
"shared-proto",
|
||||
"proto",
|
||||
"iam.proto",
|
||||
);
|
||||
const monorepoParentPath = join(
|
||||
process.cwd(),
|
||||
"..",
|
||||
"..",
|
||||
"packages",
|
||||
"shared-proto",
|
||||
"proto",
|
||||
"iam.proto",
|
||||
);
|
||||
const localPath = join(process.cwd(), "proto", "iam.proto");
|
||||
if (existsSync(monorepoRootPath)) return monorepoRootPath;
|
||||
if (existsSync(monorepoParentPath)) return monorepoParentPath;
|
||||
if (existsSync(localPath)) return localPath;
|
||||
return monorepoParentPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* IAM 服务启动入口。
|
||||
*
|
||||
@@ -43,7 +76,7 @@ async function bootstrap(): Promise<void> {
|
||||
transport: Transport.GRPC,
|
||||
options: {
|
||||
package: "next_edu_cloud.iam.v1",
|
||||
protoPath: "proto/iam.proto",
|
||||
protoPath: resolveProtoPath(),
|
||||
url: `0.0.0.0:${env.GRPC_PORT}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { z } from "zod";
|
||||
* - REDIS_URL 可选,未配置时降级到 DB 唯一索引去重
|
||||
*/
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default("3007"),
|
||||
PORT: z.string().default("3006"),
|
||||
GRPC_PORT: z.string().default("50056"),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ClientsModule } from "../clients/clients.module.js";
|
||||
import { ChildGuard } from "./child-guard.js";
|
||||
|
||||
/**
|
||||
@@ -10,6 +11,7 @@ import { ChildGuard } from "./child-guard.js";
|
||||
* 依赖 ClientsModule 提供的 IAM_CLIENT。
|
||||
*/
|
||||
@Module({
|
||||
imports: [ClientsModule],
|
||||
providers: [ChildGuard],
|
||||
exports: [ChildGuard],
|
||||
})
|
||||
|
||||
@@ -103,6 +103,7 @@ export function callGrpc<TReq, TRes>(
|
||||
method: string,
|
||||
request: TReq,
|
||||
serviceName: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<TRes> {
|
||||
const rawClient = client as Record<string, unknown>;
|
||||
const fn = rawClient[method] as (
|
||||
@@ -117,7 +118,16 @@ export function callGrpc<TReq, TRes>(
|
||||
}
|
||||
|
||||
return new Promise<TRes>((resolvePromise, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`gRPC call ${serviceName}.${method} timed out after ${timeoutMs}ms`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
fn.call(rawClient, request, (err, res) => {
|
||||
clearTimeout(timer);
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DataLoaderFactory } from "./dataloader.factory.js";
|
||||
import { ClientsModule } from "../clients/clients.module.js";
|
||||
|
||||
/**
|
||||
* DataLoader 模块。
|
||||
@@ -8,6 +9,7 @@ import { DataLoaderFactory } from "./dataloader.factory.js";
|
||||
* 依赖 ClientsModule 提供的 IAM_CLIENT / CORE_EDU_CLIENT。
|
||||
*/
|
||||
@Module({
|
||||
imports: [ClientsModule],
|
||||
providers: [DataLoaderFactory],
|
||||
exports: [DataLoaderFactory],
|
||||
})
|
||||
|
||||
@@ -8,8 +8,7 @@ import type { YogaInstance } from "../graphql/yoga.js";
|
||||
* - POST /graphql:执行 GraphQL query / mutation
|
||||
* - GET /graphql:开发环境返回 GraphiQL playground(生产关闭)
|
||||
*
|
||||
* 由 NestJS 路由到 yoga.handleNodeRequestAndResponse,
|
||||
* Yoga 内部负责解析 body / 构建 context(含 ParentSession + DataLoaders)/ 执行 schema / 返回结果。
|
||||
* graphql-yoga v5:Yoga 实例本身是 callable handler,直接调用 yoga(req, res)。
|
||||
*/
|
||||
@Controller("graphql")
|
||||
export class GraphqlController {
|
||||
@@ -21,6 +20,6 @@ export class GraphqlController {
|
||||
|
||||
@All()
|
||||
async handle(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
await this.yoga.handleNodeRequestAndResponse(req, res, { req, res });
|
||||
await this.yoga(req, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DataLoaderModule } from "../dataloader/dataloader.module.js";
|
||||
import { DataLoaderFactory } from "../dataloader/dataloader.factory.js";
|
||||
import { AggregationModule } from "../aggregation/aggregation.module.js";
|
||||
import { ChildGuard } from "../aggregation/child-guard.js";
|
||||
import { ClientsModule } from "../clients/clients.module.js";
|
||||
import { createYogaInstance, type YogaInstance } from "./yoga.js";
|
||||
import { buildResolvers, type ResolverDeps } from "./resolvers/index.js";
|
||||
|
||||
@@ -26,7 +27,7 @@ import { buildResolvers, type ResolverDeps } from "./resolvers/index.js";
|
||||
* P5:注入 MSG_CLIENT 用于通知 resolver。
|
||||
*/
|
||||
@Module({
|
||||
imports: [DataLoaderModule, AggregationModule],
|
||||
imports: [DataLoaderModule, AggregationModule, ClientsModule],
|
||||
controllers: [GraphqlController],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Request, Response } from "express";
|
||||
import type { IResolvers } from "@graphql-tools/utils";
|
||||
import { buildSchema } from "./schema.js";
|
||||
import { buildSession } from "./context.js";
|
||||
import type { GraphqlContext, ParentSession } from "./context.js";
|
||||
import type { GraphqlContext } from "./context.js";
|
||||
import { env } from "../config/env.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
import {
|
||||
@@ -72,20 +72,29 @@ export function createYogaInstance(
|
||||
) {
|
||||
const schema = buildSchema(resolvers);
|
||||
|
||||
return createYoga<{
|
||||
req: Request;
|
||||
res: Response;
|
||||
session: ParentSession;
|
||||
}>({
|
||||
return createYoga({
|
||||
schema,
|
||||
graphqlEndpoint: "/graphql",
|
||||
graphiql:
|
||||
env.NODE_ENV === "development" && env.GRAPHQL_INTROSPECTION_ENABLED,
|
||||
|
||||
context: ({ req }): GraphqlContext => {
|
||||
// graphql-yoga v5:request 是 WHATWG Request,headers 需用 .get() 提取
|
||||
context: ({ request }): GraphqlContext => {
|
||||
const req = {
|
||||
headers: {
|
||||
"x-user-id": request.headers.get("x-user-id") ?? undefined,
|
||||
"x-request-id": request.headers.get("x-request-id") ?? undefined,
|
||||
"x-user-roles": request.headers.get("x-user-roles") ?? undefined,
|
||||
},
|
||||
} as unknown as Request;
|
||||
const session = buildSession(req);
|
||||
const loaders = loaderFactory.createLoaders();
|
||||
return { session, req, res: req.res as Response, loaders };
|
||||
return {
|
||||
session,
|
||||
req,
|
||||
res: {} as Response,
|
||||
loaders,
|
||||
};
|
||||
},
|
||||
|
||||
plugins: [validationRulesPlugin, metricsPlugin],
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* - volumeThreshold: 10 (最少 10 次调用才评估)
|
||||
* - rollingCountTimeout: 60000 (1 分钟滚动窗口)
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, Optional } from "@nestjs/common";
|
||||
import CircuitBreaker from "opossum";
|
||||
import promClient from "prom-client";
|
||||
import type { DownstreamClient, CallOptions } from "@edu/shared-ts/bff";
|
||||
@@ -92,7 +92,7 @@ export class CircuitBreakerService {
|
||||
private readonly breakers = new Map<string, CircuitBreaker>();
|
||||
private readonly config: BreakerConfig;
|
||||
|
||||
constructor(config?: Partial<BreakerConfig>) {
|
||||
constructor(@Optional() config?: Partial<BreakerConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
* 集成方式: GraphQL Yoga 作为 Express middleware 挂载到 NestJS HTTP Adapter,
|
||||
* 路径 POST /graphql,开发环境启用 Playground.
|
||||
*/
|
||||
import { promises } from "node:fs";
|
||||
import { promises, existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createYoga, type YogaServerInstance } from "graphql-yoga";
|
||||
import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||
import type { Request, Response } from "express";
|
||||
@@ -54,11 +55,25 @@ export interface StudentBffContext {
|
||||
|
||||
/**
|
||||
* GraphQL schema 文件路径.
|
||||
* 多路径探测:兼容从项目根目录或服务目录运行(pnpm --filter cwd 为服务目录).
|
||||
*/
|
||||
const SCHEMA_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
"packages/shared-ts/contracts/graphql/student-bff.schema.graphql",
|
||||
);
|
||||
const _schemaDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCHEMA_CANDIDATES = [
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
"packages/shared-ts/contracts/graphql/student-bff.schema.graphql",
|
||||
),
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
"../../packages/shared-ts/contracts/graphql/student-bff.schema.graphql",
|
||||
),
|
||||
path.resolve(
|
||||
_schemaDir,
|
||||
"../../../../../packages/shared-ts/contracts/graphql/student-bff.schema.graphql",
|
||||
),
|
||||
];
|
||||
const SCHEMA_PATH: string =
|
||||
SCHEMA_CANDIDATES.find((p) => existsSync(p)) ?? SCHEMA_CANDIDATES[0]!;
|
||||
|
||||
/**
|
||||
* 加载 schema SDL 文本.
|
||||
@@ -107,7 +122,15 @@ export async function createStudentBffYoga(
|
||||
>({
|
||||
schema,
|
||||
graphqlEndpoint: "/graphql",
|
||||
context: ({ req }): StudentBffContext => {
|
||||
// graphql-yoga v5:request 是 WHATWG Request,headers 需用 .get() 提取
|
||||
context: ({ request }): StudentBffContext => {
|
||||
const req = {
|
||||
headers: {
|
||||
"x-user-id": request.headers.get("x-user-id") ?? undefined,
|
||||
"x-request-id": request.headers.get("x-request-id") ?? undefined,
|
||||
"x-user-roles": request.headers.get("x-user-roles") ?? undefined,
|
||||
},
|
||||
} as unknown as Request;
|
||||
const userId = extractUserIdFromRequest(req);
|
||||
const traceId = extractTraceIdFromRequest(req);
|
||||
const userRoles = extractUserRolesFromRequest(req);
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import type { DownstreamResponse } from "@edu/shared-ts/bff";
|
||||
import { ok, fail, degraded, DegradedReason } from "../../shared/action-state.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
|
||||
export const authResolvers = {
|
||||
@@ -35,19 +34,28 @@ export const authResolvers = {
|
||||
service: "iam",
|
||||
method: "GetUserInfo",
|
||||
request: { userId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
options: {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
},
|
||||
},
|
||||
{
|
||||
service: "iam",
|
||||
method: "GetEffectivePermissions",
|
||||
request: { userId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
options: {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
},
|
||||
},
|
||||
{
|
||||
service: "iam",
|
||||
method: "GetViewports",
|
||||
request: { userId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
options: {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
},
|
||||
},
|
||||
] as const);
|
||||
|
||||
@@ -59,23 +67,13 @@ export const authResolvers = {
|
||||
|
||||
const degradedFields: string[] = [];
|
||||
if (!userInfoResp.success) degradedFields.push("user");
|
||||
if (!permsResp.success) degradedFields.push("permissions");
|
||||
if (!viewportsResp.success) degradedFields.push("viewport");
|
||||
if (!permsResp.success) degradedFields.push("effectivePermissions");
|
||||
if (!viewportsResp.success) degradedFields.push("viewports");
|
||||
|
||||
// 必需字段失败时返回错误
|
||||
// 必需字段失败时抛错
|
||||
if (!userInfoResp.success) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
throw new Error(
|
||||
`Failed to fetch user info: ${userInfoResp.error.message}`,
|
||||
{
|
||||
details: {
|
||||
service: userInfoResp.error.service,
|
||||
method: userInfoResp.error.method,
|
||||
traceId: ctx.traceId,
|
||||
},
|
||||
i18nKey: "error.bffStudent.bad_gateway",
|
||||
traceId: ctx.traceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,7 +91,8 @@ export const authResolvers = {
|
||||
? (viewportsResp.data as { navigation: unknown[]; dataScope: unknown })
|
||||
: { navigation: [], dataScope: {} };
|
||||
|
||||
const data = {
|
||||
// 返回扁平对象,对齐 CurrentUserPayload schema
|
||||
return {
|
||||
user: {
|
||||
id: userInfo.userId,
|
||||
email: userInfo.email,
|
||||
@@ -101,20 +100,14 @@ export const authResolvers = {
|
||||
avatar: userInfo.avatar,
|
||||
roles: userInfo.roles,
|
||||
},
|
||||
permissions,
|
||||
viewport: viewports,
|
||||
viewports,
|
||||
effectivePermissions: permissions,
|
||||
dataScope: "SELF",
|
||||
degraded: degradedFields.length > 0,
|
||||
degradedReason:
|
||||
degradedFields.length > 0 ? "DOWNSTREAM_PARTIAL_FAILURE" : null,
|
||||
degradedFields: degradedFields.length > 0 ? degradedFields : null,
|
||||
};
|
||||
|
||||
if (degradedFields.length > 0) {
|
||||
return degraded(
|
||||
data,
|
||||
DegradedReason.DOWNSTREAM_PARTIAL_FAILURE,
|
||||
degradedFields,
|
||||
{ traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -81,10 +81,10 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** 教师默认 mock 权限(全权限 + OWN 数据范围) */
|
||||
/** 教师默认 mock 权限(全权限 + SELF 数据范围,对齐 GraphQL enum DataScope) */
|
||||
const MOCK_PERMISSIONS: EffectivePermissions = {
|
||||
permissions: MOCK_TEACHER.permissions,
|
||||
dataScope: "OWN",
|
||||
dataScope: "SELF",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface ViewportItem {
|
||||
/** 有效权限集(待 coord 补 GetEffectivePermissions RPC 的 response message) */
|
||||
export interface EffectivePermissions {
|
||||
permissions: string[];
|
||||
/** 数据范围(OWN / GRADE / SCHOOL / ALL) */
|
||||
/** 数据范围(SELF / CLASS / GRADE / SCHOOL / DISTRICT / ALL,对齐 GraphQL enum DataScope) */
|
||||
dataScope: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,16 @@ export class GraphQLController {
|
||||
const yoga = createYoga({
|
||||
schema,
|
||||
graphqlEndpoint: env.GRAPHQL_PATH,
|
||||
// graphql-yoga v5:request 是 WHATWG Request,headers 需用 .get() 提取
|
||||
context: ({ request }) => {
|
||||
return buildGraphQLContext(request as unknown as Request);
|
||||
const req = {
|
||||
headers: {
|
||||
"x-user-id": request.headers.get("x-user-id") ?? undefined,
|
||||
"x-request-id": request.headers.get("x-request-id") ?? undefined,
|
||||
"x-user-roles": request.headers.get("x-user-roles") ?? undefined,
|
||||
},
|
||||
} as unknown as Request;
|
||||
return buildGraphQLContext(req);
|
||||
},
|
||||
logging: {
|
||||
debug: (msg: unknown) => logger.debug({ yoga: msg }, "GraphQL Yoga"),
|
||||
|
||||
Reference in New Issue
Block a user