chore(teacher-bff): merge teacher-bff full implementation into main

This commit is contained in:
SpecialX
2026-07-10 19:18:16 +08:00
37 changed files with 2588 additions and 467 deletions

View File

@@ -2,18 +2,46 @@
FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json nest-cli.json ./
COPY src ./src
# 复制 workspace 配置
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
# 复制 shared-protogRPC proto-loader 运行时加载)
COPY packages/shared-proto ./packages/shared-proto
# 复制 shared-tsGraphQL schema 文件)
COPY packages/shared-ts ./packages/shared-ts
# 复制 teacher-bff 源码
COPY services/teacher-bff ./services/teacher-bff
# 安装依赖(仅 teacher-bff + shared-ts
RUN cd services/teacher-bff && pnpm install --frozen-lockfile
# 构建
WORKDIR /app/services/teacher-bff
RUN pnpm build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
# 复制 workspace 配置
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
# 复制 shared-proto + shared-ts运行时需要
COPY packages/shared-proto ./packages/shared-proto
COPY packages/shared-ts ./packages/shared-ts
# 复制 teacher-bff
COPY services/teacher-bff ./services/teacher-bff
WORKDIR /app/services/teacher-bff
RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/dist ./dist
# 复制构建产物
COPY --from=builder /app/services/teacher-bff/dist ./dist
EXPOSE 3003
CMD ["node", "dist/main.js"]

View File

@@ -189,16 +189,16 @@ graph LR
**目标**:在不改变对外 REST 契约的前提下,补齐黄金模板差距,为后续 gRPC/GraphQL 切换铺路。
| 工作项 | 详情 | 优先级 |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| 引入 Client 抽象层 | 新增 `src/clients/` 目录,定义 `IamClient`/`ClassesClient`/`CoreEduClient` 接口,当前实现为 REST fetch adapterP3 替换为 gRPC adapter | P0 |
| 错误码迁移 | `TEACHER_BFF_*``BFF_TEACHER_*`全量替换 [application-error.ts](../src/shared/errors/application-error.ts) | P0 |
| Zod 输入验证 | Controller 层引入 Zod schema 解析 path/bodyclassId/examId 等) | P0 |
| `/readyz` 下游探针 | 并行 ping iam + classes + core-edu 的 `/healthz`,全部 ok 才返回 ok | P0 |
| Vitest 测试框架 | 引入 Vitest覆盖 Service 聚合逻辑 + Controller + 错误处理,目标 ≥ 80% | P1 |
| env 重构 | `IamServiceUrl``IamServiceTarget`(兼容 REST URL 和 gRPC target为 P3 切换铺路 | P1 |
| DataLoader 骨架 | 新增 `src/dataloader/` 目录,定义 DataLoader 注册器骨架P2 不启用P4 接入 GraphQL | P2 |
| Redis 客户端引入 | 新增 `src/shared/cache/redis.client.ts`P2 仅用于会话/权限缓存P3 接入聚合缓存 | P2 |
| 工作项 | 详情 | 优先级 |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| 引入 Client 抽象层 | 新增 `src/clients/` 目录,定义 `IamClient`/`ClassesClient`/`CoreEduClient` 接口,当前实现为 REST fetch adapterP3 替换为 gRPC adapter | P0 |
| 错误码迁移 | `TEACHER_BFF_*``BFF_TEACHER_*`已完成,见 [application-error.ts](../src/shared/errors/application-error.ts)+ 3 类越权错误码president §2.7 | P0 |
| Zod 输入验证 | Controller 层引入 Zod schema 解析 path/bodyclassId/examId 等) | P0 |
| `/readyz` 下游探针 | 并行 ping iam + classes + core-edu 的 `/healthz`,全部 ok 才返回 ok | P0 |
| Vitest 测试框架 | 引入 Vitest覆盖 Service 聚合逻辑 + Controller + 错误处理,目标 ≥ 80% | P1 |
| env 重构 | `IamServiceUrl``IamServiceTarget`(兼容 REST URL 和 gRPC target为 P3 切换铺路 | P1 |
| DataLoader 骨架 | 新增 `src/dataloader/` 目录,定义 DataLoader 注册器骨架P2 不启用P4 接入 GraphQL | P2 |
| Redis 客户端引入 | 新增 `src/shared/cache/redis.client.ts`P2 仅用于会话/权限缓存P3 接入聚合缓存 | P2 |
**P2 退出标准**lint + typecheck 零错误;测试覆盖率 ≥ 80%`/readyz` 真实探针;错误码全部 `BFF_TEACHER_*`Zod 校验全 Controller 覆盖。
@@ -228,7 +228,7 @@ graph LR
| Schema 设计 | 按教学场景域组织 Query/Mutation见 §5 |
| DataLoader 接入 | Resolver 内调用 DataLoader覆盖全部 N+1 风险点(见 §6 |
| ContentClient gRPC adapter | 调用 content:50054KnowledgeGraphService + ChapterService/QuestionService 待 ai09 补) |
| DataAnaClient gRPC adapter | 调用 data-ana:50055AnalyticsService + GetTeacherDashboardStats 待 ai11 补) |
| DataAnaClient gRPC adapter | 调用 data-ana:50055AnalyticsService + GetTeacherDashboard 待 ai11 补) |
| 双轨读支持 | data-ana 学情查询支持实时查主库 + 聚合查 ClickHouse 宽表 |
| REST 兼容期 | REST 端点保留deprecation header前端逐步迁移到 GraphQL |
| 查询复杂度限制 | GraphQL depth limit + query cost analysis防恶意查询 |
@@ -469,13 +469,13 @@ services/teacher-bff/src/
### 4.3 P3 REST 扩展
| method | path | 说明 |
| ------ | ------------------------------------ | ----------------------------------------------------------------- |
| POST | /teacher/exams | 创建考试(透传 core-edu CreateExam |
| POST | /teacher/homework | 布置作业(透传 core-edu AssignHomework |
| POST | /teacher/grades | 录入成绩(透传 core-edu RecordGrade |
| GET | /teacher/classes/:classId/attendance | 出勤列表core-edu AttendanceService待 ai08 补 proto |
| GET | /teacher/dashboard/stats | 仪表盘统计data-ana GetTeacherDashboardStats,待 ai11 补 proto |
| method | path | 说明 |
| ------ | ------------------------------------ | ------------------------------------------------------------ |
| POST | /teacher/exams | 创建考试(透传 core-edu CreateExam |
| POST | /teacher/homework | 布置作业(透传 core-edu AssignHomework |
| POST | /teacher/grades | 录入成绩(透传 core-edu RecordGrade |
| GET | /teacher/classes/:classId/attendance | 出勤列表core-edu AttendanceService待 ai08 补 proto |
| GET | /teacher/dashboard/stats | 仪表盘统计data-ana GetTeacherDashboard待 ai11 补 proto |
### 4.4 P4 GraphQL API目标态
@@ -1168,18 +1168,22 @@ BFF 是聚合层,**不发布领域事件**无业务状态变更。BFF
| /graphqlP4 | 校验 x-user-id + 注入 context | 同上 |
| /ai/chat/streamP5 | 校验 x-user-id | 同上 |
### 15.2 错误码清单(迁移为 `BFF_TEACHER_*`
### 15.2 错误码清单(`BFF_TEACHER_*` 前缀B5 + G14 裁决
| 错误码 | HTTP | 触发条件 | 当前状态 |
| ------------------------------- | ---- | ------------------------ | --------------------------------------------- |
| `BFF_TEACHER_VALIDATION_ERROR` | 400 | Zod 校验失败 | 需迁移(当前 `TEACHER_BFF_VALIDATION_ERROR` |
| `BFF_TEACHER_UNAUTHORIZED` | 401 | 缺 x-user-id | 需迁移 |
| `BFF_TEACHER_PERMISSION_DENIED` | 403 | 保留BFF 一般不触发) | 需迁移 |
| `BFF_TEACHER_NOT_FOUND` | 404 | 资源不存在 | 需迁移 |
| `BFF_TEACHER_CONFLICT` | 409 | 并发冲突 | 需迁移 |
| `BFF_TEACHER_BUSINESS_ERROR` | 422 | 业务规则违反 | 需迁移 |
| `BFF_TEACHER_BAD_GATEWAY` | 502 | 下游不可达 | 需迁移 |
| `BFF_TEACHER_INTERNAL_ERROR` | 500 | 未知异常 | 需迁移 |
> 越权防御 3 类错误码president §2.7UNAUTHORIZED / FORBIDDEN_RESOURCE / IDENTITY_MISMATCH
| 错误码 | HTTP | 触发条件 | 状态 |
| ---------------------------------- | ---- | ------------------------------------------------------ | --------- |
| `BFF_TEACHER_VALIDATION_FAILED` | 400 | Zod 校验失败 | ✅ 已实现 |
| `BFF_TEACHER_UNAUTHORIZED` | 401 | x-user-id 缺失或无效president §2.7 | ✅ 已实现 |
| `BFF_TEACHER_FORBIDDEN_RESOURCE` | 403 | teacherId 与资源无归属关系(场景 A§2.7 | ✅ 已实现 |
| `BFF_TEACHER_IDENTITY_MISMATCH` | 403 | JWT teacherId 与 body teacherId 不一致(场景 B§2.7 | ✅ 已实现 |
| `BFF_TEACHER_NOT_FOUND` | 404 | 资源不存在 | ✅ 已实现 |
| `BFF_TEACHER_CONFLICT` | 409 | 并发冲突 | ✅ 已实现 |
| `BFF_TEACHER_BUSINESS_ERROR` | 422 | 业务规则违反(含 P2 未就绪字段) | ✅ 已实现 |
| `BFF_TEACHER_UPSTREAM_UNAVAILABLE` | 502 | 下游 gRPC 不可达 | ✅ 已实现 |
| `BFF_TEACHER_AGGREGATION_FAILED` | 500 | 聚合多下游部分失败且无降级数据president §2.6 | ✅ 已实现 |
| `BFF_TEACHER_INTERNAL_ERROR` | 500 | 未知异常 | ✅ 已实现 |
**下游业务错误透传**iam 抛 `IAM_*`、core-edu 抛 `CORE_EDU_*`、classes 抛 `CLASSES_*`BFF 不改写错误码,直接透传给前端。
@@ -1269,7 +1273,7 @@ async readiness() {
| 调用 | content | gRPC 50054P4+ | KnowledgeGraphService.GetPrerequisites / GetLearningPath | 知识图谱 | P4 | ✅ 已有 |
| 调用 | content | gRPC 50054P4+ | _*ChapterService.* / QuestionService._** | 章节/题库 | P4 | ❌ 待 ai09 补 |
| 调用 | data-ana | gRPC 50055P4+ | AnalyticsService.GetClassPerformance / GetStudentWeakness / GetLearningTrend | 学情诊断 | P4 | ✅ 已有 |
| 调用 | data-ana | gRPC 50055P4+ | **GetTeacherDashboardStats**(建议新增) | 仪表盘统计 | P4 | ❌ 待 coord 仲裁 |
| 调用 | data-ana | gRPC 50055P4+ | **GetTeacherDashboard**(建议新增) | 仪表盘统计 | P4 | ❌ 待 coord 仲裁 |
| 调用 | ai | gRPC 50057P5+ | AiService.GenerateQuestion / Chat / OptimizeExpression | AI 辅助 | P5 | ✅ 已有 |
| 调用 | ai | gRPC 50057 streamingP5+ | **AiService.StreamChat** | SSE 流式对话 | P5 | ✅ 已有 |
| 调用 | msg | gRPC 50056P5+ | NotificationService.ListNotifications / SearchNotifications / MarkAsRead | 通知聚合 | P5 | ✅ 已有 |
@@ -1463,7 +1467,7 @@ Redis 单实例 256MB 足够,无需集群。
| 3 | **iam 补 4 个 RPC** | GetViewports / GetEffectivePermissions / Logout / GetPublicKey | 强烈建议 P2 补全teacher-bff 直接依赖) | P2 |
| 4 | **core-edu 补 AttendanceService** | service 级新增 | 建议 P3 补全 | P3 |
| 5 | **core-edu 补 GetClassesByTeacher RPC** | 按教师拉班级 | 建议新增(当前 ListClasses 仅按 gradeId | P3 |
| 6 | **data-ana 补 GetTeacherDashboardStats RPC** | 仪表盘统计聚合 | 建议新增 | P4 |
| 6 | **data-ana 补 GetTeacherDashboard RPC** | 仪表盘统计聚合 | 建议新增 | P4 |
| 7 | **iam 补 BatchGetUsers RPC** | DataLoader 批量化 | 建议新增(否则 DataLoader 退化为一对一) | P4 |
| 8 | **core-edu 补 BatchGetClasses RPC** | DataLoader 批量化 | 建议新增 | P4 |
| 9 | **Kafka consumer 引入时机** | A. P5 引入精确失效 / B. 全程短 TTL 兜底 | **P5 评估**(取决于权限变更频率) | P5 |

View File

@@ -12,15 +12,22 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@grpc/grpc-js": "^1.12.0",
"@grpc/proto-loader": "^0.7.13",
"@graphql-tools/schema": "^10.0.6",
"@graphql-tools/utils": "^10.5.6",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"pino": "^9.4.0",
"prom-client": "^15.1.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"@opentelemetry/sdk-node": "^0.53.0",
"graphql": "^16.9.0",
"graphql-yoga": "^5.7.0",
"ioredis": "^5.4.1",
"pino": "^9.4.0",
"prom-client": "^15.1.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"zod": "^3.23.0"

View File

@@ -1,8 +1,10 @@
// 根模块B1 裁决P2 起 GraphQL Yoga
import { Module } from "@nestjs/common";
import { TeacherModule } from "./teacher/teacher.module.js";
import { GraphQLModule } from "./graphql/graphql.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { MiddlewareModule } from "./middleware/middleware.module.js";
@Module({
imports: [TeacherModule, HealthModule],
imports: [GraphQLModule, HealthModule, MiddlewareModule],
})
export class AppModule {}

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

View File

@@ -1,26 +1,45 @@
import { z } from "zod";
// 环境变量定义B2 裁决gRPC 下游通信B8 裁决DownstreamClient 抽象)
// 端口分配见 docs/architecture/port-allocation.md §5
// iam 50052 / core-edu 50053 / content 50054 / data-ana 50055 / msg 50056 / ai 50058
// president §2.9TEACHER_BFF_DEV_MODE 控制越权防御 GuardP2 放行 / 生产拒绝)
const envSchema = z.object({
PORT: z.string().default("3003"),
IamServiceUrl: z.string().url().default("http://localhost:3002"),
ClassesServiceUrl: z.string().url().default("http://localhost:3001"),
CoreEduServiceUrl: z.string().url().default("http://localhost:3004"),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
LOG_LEVEL: z.string().default("info"),
NODE_ENV: z.string().default("development"),
PORT: z.string().default("3003"),
LOG_LEVEL: z.string().default("info"),
// GraphQL endpoint 路径B1 裁决P2 起直接 GraphQL
GRAPHQL_PATH: z.string().default("/graphql"),
// 越权防御开关president §2.9P2 DEV_MODE 放行 + 日志告警,生产拒绝)
// 生产环境必须显式设为 false
TEACHER_BFF_DEV_MODE: z
.string()
.default("true")
.transform((v) => v === "true")
.pipe(z.boolean()),
// Redis/readyz 探针 + 后续 DataLoader 缓存)
REDIS_URL: z.string().default("redis://localhost:6379"),
// 下游 gRPC targets按阶段启用未启用者可留空字符串
IAM_GRPC_TARGET: z.string().default("localhost:50052"),
CORE_EDU_GRPC_TARGET: z.string().default(""),
CONTENT_GRPC_TARGET: z.string().default(""),
DATA_ANA_GRPC_TARGET: z.string().default(""),
MSG_GRPC_TARGET: z.string().default(""),
AI_GRPC_TARGET: z.string().default(""),
// 可观测性OTEL_EXPORTER_OTLP_ENDPOINT 缺省则不启用 tracer
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
OTEL_SERVICE_NAME: z.string().default("teacher-bff"),
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
const result = envSchema.safeParse({
...process.env,
IamServiceUrl: process.env.IAM_SERVICE_URL || "http://localhost:3002",
ClassesServiceUrl:
process.env.CLASSES_SERVICE_URL || "http://localhost:3001",
CoreEduServiceUrl:
process.env.CORE_EDU_SERVICE_URL || "http://localhost:3004",
});
const result = envSchema.safeParse(process.env);
if (!result.success) {
throw new Error("Invalid env: " + JSON.stringify(result.error.flatten()));
}

View 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 提取 userIdapi-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 },
};
}

View 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 包含codeBFF_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",
},
};
}

View File

@@ -0,0 +1,65 @@
// GraphQL Yoga endpointB1 裁决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);
}
}

View 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 {}

View File

@@ -0,0 +1,22 @@
// GraphQL Schema 加载器(从 .graphql 文件加载 SDLB1 裁决)
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 });
}

View File

@@ -1,36 +1,62 @@
// teacher-bff 入口B1 裁决P2 起 GraphQL Yoga
// 优雅关闭顺序G9HTTP → Redis → gRPC → Tracer
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import type { Application, Request, Response } from "express";
import { AppModule } from "./app.module.js";
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { logger } from "./shared/observability/logger.js";
import { env } from "./config/env.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import type { Request, Response } from "express";
import { closeAllGrpcClients } from "./clients/grpc/grpc.factory.js";
async function bootstrap(): Promise<void> {
initTracer();
const app = await NestFactory.create(AppModule, {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ["log", "error", "warn"],
});
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取
// 直接获取 Express 实例注册路由,避免 HttpAdapter 泛型类型不兼容
const expressApp = app.getHttpAdapter().getInstance() as Application;
expressApp.get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});
await app.listen(env.PORT);
logger.info({ port: env.PORT }, "Teacher BFF started");
logger.info(
{
port: env.PORT,
graphql: env.GRAPHQL_PATH,
devMode: env.TEACHER_BFF_DEV_MODE,
iamTarget: env.IAM_GRPC_TARGET,
},
"Teacher BFF started (GraphQL + gRPC downstream)",
);
process.on("SIGTERM", async () => {
await app.close();
await shutdownTracer();
});
// 优雅关闭G9HTTP → Redis → gRPC → Tracer
const shutdown = async (signal: string) => {
logger.info({ signal }, "Shutting down Teacher BFF");
try {
await app.close(); // 触发 OnModuleDestroyRedis quit
closeAllGrpcClients(); // 关闭所有 gRPC channel
await shutdownTracer();
logger.info("Teacher BFF shutdown complete");
} catch (err) {
logger.error({ err }, "Error during shutdown");
process.exit(1);
}
};
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
}
bootstrap().catch((err: unknown) => {

View File

@@ -0,0 +1,123 @@
// AuthorizationGuard 越权防御B4 + president §2.7/§2.9
// P2 实现DEV_MODE 放行 + 日志告警;生产拒绝(保守策略)
// P3+ 替换为真实 gRPC 校验core-edu.GetClassesByTeacher 比对 teacherId
//
// 3 类越权错误码president §2.7
// - BFF_TEACHER_UNAUTHORIZED (401): x-user-id 缺失或无效
// - BFF_TEACHER_FORBIDDEN_RESOURCE (403): teacherId 与资源无归属关系(场景 A
// - BFF_TEACHER_IDENTITY_MISMATCH (403): JWT teacherId 与 body teacherId 不一致(场景 B
import { Injectable, Inject } from "@nestjs/common";
import { env } from "../config/env.js";
import { logger } from "../shared/observability/logger.js";
import {
ForbiddenResourceError,
IdentityMismatchError,
} from "../shared/errors/application-error.js";
import { IAM_CLIENT } from "../clients/iam/iam-client.interface.js";
import type { IamClient } from "../clients/iam/iam-client.interface.js";
/**
* AuthorizationGuard 接口president §2.9
* P2: DEV_MODE 放行P3+: 真实 gRPC 校验
*/
export interface AuthorizationGuard {
/** 校验教师是否有权访问指定班级(场景 AteacherId 与资源归属) */
canAccessClass(userId: string, classId: string): Promise<boolean>;
/** 校验教师是否有权访问指定考试 */
canAccessExam(userId: string, examId: string): Promise<boolean>;
/** 校验教师是否有权访问指定作业 */
canAccessHomework(userId: string, homeworkId: string): Promise<boolean>;
/** 校验身份一致性(场景 BJWT teacherId 与 body teacherId */
assertIdentityMatch(jwtTeacherId: string, bodyTeacherId: string): void;
}
export const AUTHORIZATION_GUARD = Symbol("AUTHORIZATION_GUARD");
/**
* P2 实现DEV_MODE 放行 + 日志告警
* 生产环境DEV_MODE=false对所有资源校验返回 false保守拒绝
* P3+ 替换为 AuthorizationGuardGrpcImpl接入 core-edu gRPC 真实校验)
*/
@Injectable()
export class AuthorizationGuardImpl implements AuthorizationGuard {
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {}
async canAccessClass(userId: string, classId: string): Promise<boolean> {
if (env.TEACHER_BFF_DEV_MODE) {
logger.warn(
{ userId, classId, devMode: true },
"AuthorizationGuard DEV_MODE: access allowed without real check",
);
return true;
}
// P2 生产模式保守拒绝P3+ 接入 core-edu.GetClassesByTeacher 真实校验)
logger.error(
{ userId, classId, devMode: false },
"AuthorizationGuard production mode not implemented yet (P3+)",
);
throw new ForbiddenResourceError(
`User ${userId} has no access to class ${classId}`,
{ userId, classId, reason: "production_check_not_implemented" },
);
}
async canAccessExam(userId: string, examId: string): Promise<boolean> {
if (env.TEACHER_BFF_DEV_MODE) {
logger.warn(
{ userId, examId, devMode: true },
"AuthorizationGuard DEV_MODE: access allowed without real check",
);
return true;
}
logger.error(
{ userId, examId, devMode: false },
"AuthorizationGuard production mode not implemented yet (P3+)",
);
throw new ForbiddenResourceError(
`User ${userId} has no access to exam ${examId}`,
{ userId, examId, reason: "production_check_not_implemented" },
);
}
async canAccessHomework(
userId: string,
homeworkId: string,
): Promise<boolean> {
if (env.TEACHER_BFF_DEV_MODE) {
logger.warn(
{ userId, homeworkId, devMode: true },
"AuthorizationGuard DEV_MODE: access allowed without real check",
);
return true;
}
logger.error(
{ userId, homeworkId, devMode: false },
"AuthorizationGuard production mode not implemented yet (P3+)",
);
throw new ForbiddenResourceError(
`User ${userId} has no access to homework ${homeworkId}`,
{ userId, homeworkId, reason: "production_check_not_implemented" },
);
}
/**
* 校验身份一致性(场景 Bpresident §2.7
* JWT 中的 teacherId 必须与请求 body 中的 teacherId 一致
* 此校验在所有模式DEV_MODE / 生产)下都强制执行
*/
assertIdentityMatch(jwtTeacherId: string, bodyTeacherId: string): void {
if (jwtTeacherId !== bodyTeacherId) {
logger.error(
{ jwtTeacherId, bodyTeacherId },
"Identity mismatch: JWT teacherId != body teacherId",
);
throw new IdentityMismatchError(
`JWT teacherId (${jwtTeacherId}) does not match body teacherId (${bodyTeacherId})`,
{ jwtTeacherId, bodyTeacherId },
);
}
}
}

View File

@@ -0,0 +1,19 @@
// 中间件模块AuthorizationGuard 越权防御B4 + president §2.9
import { Module } from "@nestjs/common";
import { ClientsModule } from "../clients/clients.module.js";
import {
AUTHORIZATION_GUARD,
AuthorizationGuardImpl,
} from "./authorization.guard.js";
@Module({
imports: [ClientsModule],
providers: [
{
provide: AUTHORIZATION_GUARD,
useClass: AuthorizationGuardImpl,
},
],
exports: [AUTHORIZATION_GUARD],
})
export class MiddlewareModule {}

View File

@@ -1,12 +1,20 @@
// BFF_TEACHER_ 错误码B5 + G14 裁决,统一 BFF_ 前缀)
// 越权防御 3 类错误码president §2.7 裁决):
// - BFF_TEACHER_UNAUTHORIZED (401): x-user-id 缺失或无效
// - BFF_TEACHER_FORBIDDEN_RESOURCE (403): teacherId 与资源无归属关系(场景 A
// - BFF_TEACHER_IDENTITY_MISMATCH (403): JWT teacherId 与 body teacherId 不一致(场景 B
// 其他聚合层错误码contract §1.5
export type ErrorType =
| "validation"
| "not_found"
| "permission_denied"
| "unauthorized"
| "forbidden_resource"
| "identity_mismatch"
| "conflict"
| "business"
| "database"
| "bad_gateway"
| "aggregation_failed"
| "internal";
export interface ErrorDetails {
@@ -40,79 +48,95 @@ export abstract class ApplicationError extends Error {
}
}
/** 输入参数校验失败400 */
export class ValidationError extends ApplicationError {
readonly type = "validation" as const;
readonly statusCode = 400;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_VALIDATION_ERROR", details);
super(message, "BFF_TEACHER_VALIDATION_FAILED", details);
}
}
/** 资源不存在404 */
export class NotFoundError extends ApplicationError {
readonly type = "not_found" as const;
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, "TEACHER_BFF_NOT_FOUND", {
super(`${resource} not found: ${id}`, "BFF_TEACHER_NOT_FOUND", {
resource,
id,
});
}
}
export class PermissionDeniedError extends ApplicationError {
readonly type = "permission_denied" as const;
readonly statusCode = 403;
constructor(permission: string) {
super(`Permission denied: ${permission}`, "TEACHER_BFF_PERMISSION_DENIED", {
permission,
});
}
}
/** x-user-id 缺失或无效401president §2.7 */
export class UnauthorizedError extends ApplicationError {
readonly type = "unauthorized" as const;
readonly statusCode = 401;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_UNAUTHORIZED", details);
super(message, "BFF_TEACHER_UNAUTHORIZED", details);
}
}
/** teacherId 与资源无归属关系403 场景 Apresident §2.7 */
export class ForbiddenResourceError extends ApplicationError {
readonly type = "forbidden_resource" as const;
readonly statusCode = 403;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_TEACHER_FORBIDDEN_RESOURCE", details);
}
}
/** JWT teacherId 与请求 body teacherId 不一致403 场景 Bpresident §2.7 */
export class IdentityMismatchError extends ApplicationError {
readonly type = "identity_mismatch" as const;
readonly statusCode = 403;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_TEACHER_IDENTITY_MISMATCH", details);
}
}
/** 并发冲突409 */
export class ConflictError extends ApplicationError {
readonly type = "conflict" as const;
readonly statusCode = 409;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_CONFLICT", details);
super(message, "BFF_TEACHER_CONFLICT", details);
}
}
/** 业务规则违反422 */
export class BusinessError extends ApplicationError {
readonly type = "business" as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_BUSINESS_ERROR", details);
}
}
export class DatabaseError extends ApplicationError {
readonly type = "database" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_DATABASE_ERROR", details);
super(message, "BFF_TEACHER_BUSINESS_ERROR", details);
}
}
/** 下游 gRPC 不可达502 */
export class BadGatewayError extends ApplicationError {
readonly type = "bad_gateway" as const;
readonly statusCode = 502;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_BAD_GATEWAY", details);
super(message, "BFF_TEACHER_UPSTREAM_UNAVAILABLE", details);
}
}
/** 聚合多下游时部分失败且无降级数据500 */
export class AggregationFailedError extends ApplicationError {
readonly type = "aggregation_failed" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_TEACHER_AGGREGATION_FAILED", details);
}
}
/** 兜底内部错误500 */
export class InternalError extends ApplicationError {
readonly type = "internal" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "TEACHER_BFF_INTERNAL_ERROR", details);
super(message, "BFF_TEACHER_INTERNAL_ERROR", details);
}
}

View File

@@ -9,6 +9,14 @@ import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
/**
* GlobalErrorFilter — 统一错误兜底G8 裁决:首次实现即注册 + ActionState 信封)。
*
* REST 端点:响应 ActionState 信封 { success: false, error: { code, message, details, traceId } }
* GraphQL 端点:由 Yoga 自身错误处理,错误 extensions.code = BFF_TEACHER_*(见 graphql/error-formatter
*
* 错误码统一 BFF_TEACHER_ 前缀B5 + G14 裁决)。
*/
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
@@ -34,7 +42,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: "TEACHER_BFF_VALIDATION_ERROR",
code: "BFF_TEACHER_VALIDATION_FAILED",
message: "Validation failed",
details: exception.flatten(),
traceId,
@@ -47,7 +55,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: "HTTP_ERROR",
code: "BFF_TEACHER_INTERNAL_ERROR",
message,
traceId,
},
@@ -60,7 +68,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = {
success: false,
error: {
code: "INTERNAL_ERROR",
code: "BFF_TEACHER_INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},

View File

@@ -1,15 +1,17 @@
import { Controller, Get } from "@nestjs/common";
// 健康检查端点G3 + president §2.4
// - GET /healthzliveness仅返回进程存活不检查下游
// - GET /readyzreadinessDownstreamHealthCheck 注册表P2: Redis + iam gRPC
//
// 软失败规则contract §3.3
// - status=ok → 200
// - status=degraded → 200可选依赖失败告警但不阻断
// - status=unavailable → 503必需依赖失败
import { Controller, Get, Res, HttpStatus } from "@nestjs/common";
import type { Response } from "express";
import { healthRegistry } from "./readiness.probe.js";
const SERVICE_NAME = "teacher-bff";
/**
* 健康检查端点。
*
* - GET /healthzliveness仅返回进程存活。
* - GET /readyzreadinessBFF 不直接访问 DB可直接返回 ok。
*
* 不需要鉴权,必须在路由白名单中放行。
*/
@Controller()
export class HealthController {
@Get("healthz")
@@ -22,11 +24,12 @@ export class HealthController {
}
@Get("readyz")
readiness(): { status: string; service: string; timestamp: string } {
return {
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
async readiness(@Res() res: Response): Promise<void> {
const report = await healthRegistry.runAll();
const statusCode =
report.status === "unavailable"
? HttpStatus.SERVICE_UNAVAILABLE
: HttpStatus.OK;
res.status(statusCode).json(report);
}
}

View File

@@ -1,7 +1,49 @@
import { Module } from "@nestjs/common";
// 健康检查模块G3 + president §2.4
// P2 注册 2 项探针Redis + iam gRPC
// P3+ 扩展core-edu / content / data-ana / ai / msg按阶段新增
import { Module, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { Redis } from "ioredis";
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
import { HealthController } from "./health.controller.js";
import { healthRegistry } from "./readiness.probe.js";
import { RedisHealthProbe } from "./redis.probe.js";
import { IamGrpcHealthProbe } from "./iam-grpc.probe.js";
import { ClientsModule } from "../../clients/clients.module.js";
@Module({
imports: [ClientsModule],
controllers: [HealthController],
providers: [IamGrpcHealthProbe],
})
export class HealthModule {}
export class HealthModule implements OnModuleInit, OnModuleDestroy {
private redis: Redis | null = null;
constructor(private readonly iamProbe: IamGrpcHealthProbe) {}
async onModuleInit(): Promise<void> {
// 注册 Redis 探针(必需依赖)
this.redis = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 3,
lazyConnect: false,
connectTimeout: 5000,
});
const redisProbe = new RedisHealthProbe(this.redis);
healthRegistry.register(redisProbe);
logger.info({ probe: "redis", required: true }, "Health probe registered");
// 注册 iam gRPC 探针(必需依赖)
healthRegistry.register(this.iamProbe);
logger.info(
{ probe: "iam-grpc", required: true, target: env.IAM_GRPC_TARGET },
"Health probe registered",
);
}
async onModuleDestroy(): Promise<void> {
if (this.redis) {
await this.redis.quit();
logger.info("Redis connection closed");
}
}
}

View File

@@ -0,0 +1,27 @@
// iam gRPC 健康探针(/readyz P2 必需依赖)
// 调用 grpc.health.v1.Health/Check 检查 iam:50052 是否 SERVING
import { Injectable, Inject } from "@nestjs/common";
import { IAM_CLIENT } from "../../clients/iam/iam-client.interface.js";
import type { IamClient } from "../../clients/iam/iam-client.interface.js";
import type { HealthProbe } from "./readiness.probe.js";
@Injectable()
export class IamGrpcHealthProbe implements HealthProbe {
readonly name = "iam-grpc";
readonly required = true;
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const result = await this.iam.checkHealth();
return {
status: result.serving ? "up" : "down",
latencyMs: result.latencyMs,
error: result.error,
};
}
}

View File

@@ -0,0 +1,113 @@
// /readyz 探针注册表president §2.4 + G2
// DownstreamHealthCheck 注册表模式:每个下游注册独立探针,按阶段启用
// P2: Redis PING + iam gRPC 500522 项)
// P3+: + core-edu / content / data-ana / ai / msg按阶段扩展
//
// 软失败规则contract §3.3
// - 必需依赖Redis / 已启用 gRPC 下游)失败返 503
// - 可选依赖(未启用 gRPC 下游)失败仅告警返 200 + degraded: true
/** 单个健康检查结果 */
export interface HealthCheckResult {
name: string;
status: "up" | "down" | "degraded";
latencyMs: number;
required: boolean;
error?: string;
}
/** /readyz 聚合结果 */
export interface ReadinessReport {
status: "ok" | "degraded" | "unavailable";
service: string;
timestamp: string;
checks: HealthCheckResult[];
degraded?: boolean;
}
/** 健康检查探针接口(每个下游注册一个) */
export interface HealthProbe {
/** 探针名称(如 "redis" / "iam-grpc" */
readonly name: string;
/** 是否为必需依赖(必需失败返 503可选失败返 200 + degraded */
readonly required: boolean;
/** 执行健康检查 */
check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}>;
}
/**
* 健康检查注册表DownstreamHealthCheck 模式)
* 按阶段注册探针,/readyz 调用时遍历所有已注册探针
*/
export class HealthCheckRegistry {
private readonly probes = new Map<string, HealthProbe>();
register(probe: HealthProbe): void {
this.probes.set(probe.name, probe);
}
unregister(name: string): void {
this.probes.delete(name);
}
async runAll(): Promise<ReadinessReport> {
const checks: HealthCheckResult[] = [];
let hasRequiredDown = false;
let hasOptionalDown = false;
for (const probe of this.probes.values()) {
try {
const result = await probe.check();
checks.push({
name: probe.name,
status: result.status,
latencyMs: result.latencyMs,
required: probe.required,
error: result.error,
});
if (result.status === "down" && probe.required) {
hasRequiredDown = true;
} else if (result.status === "down" && !probe.required) {
hasOptionalDown = true;
}
} catch (err) {
checks.push({
name: probe.name,
status: "down",
latencyMs: 0,
required: probe.required,
error: err instanceof Error ? err.message : String(err),
});
if (probe.required) {
hasRequiredDown = true;
} else {
hasOptionalDown = true;
}
}
}
let status: ReadinessReport["status"];
if (hasRequiredDown) {
status = "unavailable";
} else if (hasOptionalDown) {
status = "degraded";
} else {
status = "ok";
}
return {
status,
service: "teacher-bff",
timestamp: new Date().toISOString(),
checks,
degraded: hasOptionalDown && !hasRequiredDown,
};
}
}
/** 全局注册表实例(单例) */
export const healthRegistry = new HealthCheckRegistry();

View File

@@ -0,0 +1,37 @@
// Redis 健康探针(/readyz P2 必需依赖)
// PING 命令检查 Redis 连通性,超时 2s
import type { Redis } from "ioredis";
import type { HealthProbe } from "./readiness.probe.js";
export class RedisHealthProbe implements HealthProbe {
readonly name = "redis";
readonly required = true;
constructor(private readonly redis: Redis) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const start = Date.now();
try {
const result = await this.redis.ping();
const latencyMs = Date.now() - start;
if (result === "PONG") {
return { status: "up", latencyMs };
}
return {
status: "down",
latencyMs,
error: `Unexpected PING response: ${result}`,
};
} catch (err) {
return {
status: "down",
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
};
}
}
}

View File

@@ -9,7 +9,7 @@ export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
sdk = new NodeSDK({
serviceName: "teacher-bff",
serviceName: env.OTEL_SERVICE_NAME,
traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}),
@@ -17,11 +17,12 @@ export function initTracer(): void {
});
sdk.start();
console.log("Tracer initialized with auto-instrumentations");
console.log(`Tracer initialized (service: ${env.OTEL_SERVICE_NAME})`);
}
export async function shutdownTracer(): Promise<void> {
if (sdk) {
await sdk.shutdown();
sdk = null;
}
}

View File

@@ -1,70 +0,0 @@
import { Controller, Get, Param, Req } from "@nestjs/common";
import type { Request } from "express";
import { TeacherService } from "./teacher.service.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
interface SuccessResponse<T> {
success: true;
data: T;
}
@Controller("teacher")
export class TeacherController {
constructor(private readonly service: TeacherService) {}
@Get("dashboard")
async dashboard(@Req() req: Request): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.getDashboard(userId);
return { success: true as const, data };
}
@Get("viewports")
async viewports(@Req() req: Request): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.getViewports(userId);
return { success: true as const, data };
}
// 聚合班级下的考试列表core-edu
@Get("classes/:classId/exams")
async listExamsByClass(
@Req() req: Request,
@Param("classId") classId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listExamsByClass(userId, classId);
return { success: true as const, data };
}
// 聚合班级下的作业列表core-edu
@Get("classes/:classId/homework")
async listHomeworkByClass(
@Req() req: Request,
@Param("classId") classId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listHomeworkByClass(userId, classId);
return { success: true as const, data };
}
// 聚合考试下的成绩列表core-edu
@Get("exams/:examId/grades")
async listGradesByExam(
@Req() req: Request,
@Param("examId") examId: string,
): Promise<SuccessResponse<unknown>> {
const userId = this.extractUserId(req);
const data = await this.service.listGradesByExam(userId, examId);
return { success: true as const, data };
}
private extractUserId(req: Request): string {
const header = req.headers["x-user-id"];
const userId = typeof header === "string" ? header : undefined;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
return userId;
}
}

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { TeacherController } from './teacher.controller.js';
import { TeacherService } from './teacher.service.js';
// Teacher 模块B1 裁决P2 起 GraphQL不再使用 REST Controller
import { Module } from "@nestjs/common";
import { TeacherService } from "./teacher.service.js";
import { ClientsModule } from "../clients/clients.module.js";
@Module({
controllers: [TeacherController],
imports: [ClientsModule],
providers: [TeacherService],
exports: [TeacherService],
})
export class TeacherModule {}

View File

@@ -0,0 +1,362 @@
// GraphQL ResolversB1 裁决P2 起 GraphQL
// P2 核心 5 Querydashboard / viewports / me / classes / class
// P3+ Query/Mutation占位返回 null/空 + extensions.warning
// admin 命名空间:占位返回 null/空P6 实现)
import type { IResolvers } from "@graphql-tools/utils";
import { TeacherService } from "./teacher.service.js";
import type { GraphQLOperationContext } from "../graphql/context.js";
import { BusinessError } from "../shared/errors/application-error.js";
/**
* 构建 GraphQL Resolvers
* 依赖 TeacherService通过闭包注入避免 NestJS DI 与 Yoga 的集成复杂度)
*/
export function buildResolvers(service: TeacherService): IResolvers {
return {
Query: {
// ===== P2 核心 5 Query =====
dashboard: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getDashboard(ctx.callCtx);
},
viewports: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getViewports(ctx.callCtx);
},
me: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getCurrentUser(ctx.callCtx);
},
classes: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getClasses(ctx.callCtx);
},
class: async (
_parent: unknown,
args: { id: string },
ctx: GraphQLOperationContext,
) => {
return service.getClass(ctx.callCtx, args.id);
},
// ===== P3+ Query 占位(返回空 + warning跨阶段扩展例外=====
exams: async (
_parent: unknown,
_args: { classId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"exams query not available in P2 (core-edu gRPC not ready, P3+)",
{ phase: "P2", field: "exams", warning: "field_unavailable_in_p2" },
);
},
homework: async (
_parent: unknown,
_args: { classId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"homework query not available in P2 (core-edu gRPC not ready, P3+)",
{
phase: "P2",
field: "homework",
warning: "field_unavailable_in_p2",
},
);
},
grades: async (
_parent: unknown,
_args: { examId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"grades query not available in P2 (core-edu gRPC not ready, P3+)",
{ phase: "P2", field: "grades", warning: "field_unavailable_in_p2" },
);
},
// ===== P4+ Query 占位 =====
knowledgePath: async (
_parent: unknown,
_args: { knowledgePointId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"knowledgePath query not available in P2 (content gRPC not ready, P4+)",
{
phase: "P2",
field: "knowledgePath",
warning: "field_unavailable_in_p2",
},
);
},
classPerformance: async (
_parent: unknown,
_args: { classId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"classPerformance query not available in P2 (data-ana gRPC not ready, P4+)",
{
phase: "P2",
field: "classPerformance",
warning: "field_unavailable_in_p2",
},
);
},
studentWeakness: async (
_parent: unknown,
_args: { studentId: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"studentWeakness query not available in P2 (data-ana gRPC not ready, P4+)",
{
phase: "P2",
field: "studentWeakness",
warning: "field_unavailable_in_p2",
},
);
},
learningTrend: async (
_parent: unknown,
_args: { studentId: string; dateRange?: unknown },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"learningTrend query not available in P2 (data-ana gRPC not ready, P4+)",
{
phase: "P2",
field: "learningTrend",
warning: "field_unavailable_in_p2",
},
);
},
// ===== P5+ Query 占位 =====
notifications: async (
_parent: unknown,
_args: { unreadOnly?: boolean },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"notifications query not available in P2 (msg gRPC not ready, P5+)",
{
phase: "P2",
field: "notifications",
warning: "field_unavailable_in_p2",
},
);
},
// ===== admin 命名空间president §5.1P2 预留P6 实现)=====
admin: () => {
// 返回空对象admin 子字段 resolver 返回 null/空
return {};
},
},
Mutation: {
// ===== P3+ Mutation 占位 =====
createExam: async (
_parent: unknown,
_args: unknown,
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"createExam mutation not available in P2 (core-edu gRPC not ready, P3+)",
{
phase: "P2",
field: "createExam",
warning: "field_unavailable_in_p2",
},
);
},
assignHomework: async (
_parent: unknown,
_args: unknown,
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"assignHomework mutation not available in P2 (core-edu gRPC not ready, P3+)",
{
phase: "P2",
field: "assignHomework",
warning: "field_unavailable_in_p2",
},
);
},
recordGrade: async (
_parent: unknown,
_args: unknown,
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"recordGrade mutation not available in P2 (core-edu gRPC not ready, P3+)",
{
phase: "P2",
field: "recordGrade",
warning: "field_unavailable_in_p2",
},
);
},
// ===== P5+ Mutation 占位 =====
generateQuestion: async (
_parent: unknown,
_args: unknown,
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"generateQuestion mutation not available in P2 (ai gRPC not ready, P5+)",
{
phase: "P2",
field: "generateQuestion",
warning: "field_unavailable_in_p2",
},
);
},
markNotificationAsRead: async (
_parent: unknown,
_args: { id: string },
_ctx: GraphQLOperationContext,
) => {
throw new BusinessError(
"markNotificationAsRead mutation not available in P2 (msg gRPC not ready, P5+)",
{
phase: "P2",
field: "markNotificationAsRead",
warning: "field_unavailable_in_p2",
},
);
},
// ===== admin 命名空间 MutationP2 预留P6 实现)=====
admin: () => {
return {};
},
},
// ===== admin 命名空间子字段 ResolverP2 全部返回 null/空P6 实现)=====
AdminQuery: {
users: () => null,
user: () => null,
roles: () => [],
role: () => null,
school: () => null,
organizations: () => [],
auditLogs: () => null,
},
AdminMutation: {
createUser: () => {
throw new BusinessError(
"admin.createUser not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.createUser" },
);
},
updateUser: () => {
throw new BusinessError(
"admin.updateUser not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.updateUser" },
);
},
deleteUser: () => {
throw new BusinessError(
"admin.deleteUser not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.deleteUser" },
);
},
createRole: () => {
throw new BusinessError(
"admin.createRole not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.createRole" },
);
},
updateRole: () => {
throw new BusinessError(
"admin.updateRole not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.updateRole" },
);
},
deleteRole: () => {
throw new BusinessError(
"admin.deleteRole not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.deleteRole" },
);
},
updateSchool: () => {
throw new BusinessError(
"admin.updateSchool not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.updateSchool" },
);
},
createOrganization: () => {
throw new BusinessError(
"admin.createOrganization not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.createOrganization" },
);
},
updateOrganization: () => {
throw new BusinessError(
"admin.updateOrganization not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.updateOrganization" },
);
},
deleteOrganization: () => {
throw new BusinessError(
"admin.deleteOrganization not available in P2 (P6 implementation)",
{ phase: "P2", field: "admin.deleteOrganization" },
);
},
},
// ===== 类型字段 ResolverClass.exams / Class.homework / Exam.grades 延迟加载)=====
Class: {
exams: () => [], // P3+ core-edu gRPC
homework: () => [], // P3+ core-edu gRPC
},
Exam: {
grades: () => [], // P3+ core-edu gRPC
},
Homework: {
submissions: () => [], // P3+ core-edu gRPC
},
};
}

View File

@@ -1,158 +1,122 @@
import { Injectable } from "@nestjs/common";
import { env } from "../config/env.js";
// TeacherService — GraphQL Resolver 的业务逻辑层B1 裁决P2 起 GraphQL
// 通过 IamClient 调下游 iam gRPCB2 裁决:首次实现即 gRPC
// P2: 仅 iam 数据P3+ 扩展 core-edu / content / data-ana / msg / ai
import { Injectable, Inject } from "@nestjs/common";
import { IAM_CLIENT } from "../clients/iam/iam-client.interface.js";
import type { IamClient } from "../clients/iam/iam-client.interface.js";
import type { CallContext } from "../clients/types.js";
import type {
UserInfo,
ViewportItem,
EffectivePermissions,
} from "../clients/iam/iam.types.js";
import { logger } from "../shared/observability/logger.js";
import { BadGatewayError } from "../shared/errors/application-error.js";
export interface ViewportItem {
key: string;
label: string;
route: string;
icon: string | null;
sortOrder: string;
requiredPermission: string | null;
/** GraphQL User 类型(聚合 UserInfo + EffectivePermissions */
export interface GraphQLUser {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
dataScope: string;
}
interface DownstreamEnvelope<T> {
success: boolean;
data?: T;
/** GraphQL DashboardData 类型 */
export interface DashboardData {
user: GraphQLUser | null;
classes: unknown[];
viewports: ViewportItem[];
stats: {
totalExams: number;
pendingGrading: number;
todayHomework: number;
} | null;
}
interface DashboardData {
user: unknown;
classes: unknown;
/** GraphQL Class 类型P2 mockP3+ core-edu 真实数据) */
export interface ClassInfo {
id: string;
name: string;
gradeId: string;
}
/** P2 mock 班级数据president §3.5P2 班级列表来自 iam 数据P3+ core-edu */
const MOCK_CLASSES: ClassInfo[] = [
{ id: "class-001", name: "三年级1班", gradeId: "grade-3" },
{ id: "class-002", name: "三年级2班", gradeId: "grade-3" },
{ id: "class-003", name: "三年级3班", gradeId: "grade-3" },
];
@Injectable()
export class TeacherService {
// 聚合 IAM + classes 服务的数据
async getDashboard(userId: string): Promise<DashboardData> {
const [iamRes, classesRes] = await Promise.allSettled([
fetch(`${env.IamServiceUrl}/iam/me`, {
headers: { "x-user-id": userId },
}),
fetch(`${env.ClassesServiceUrl}/classes`, {
headers: { "x-user-id": userId },
}),
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {}
/** 获取当前用户信息(聚合 iam.GetUserInfo + GetEffectivePermissions */
async getCurrentUser(ctx: CallContext): Promise<GraphQLUser> {
const [userInfo, perms] = await Promise.all([
this.iam.getUserInfo(ctx),
this.iam.getEffectivePermissions(ctx),
]);
return {
id: userInfo.id,
email: userInfo.email,
name: userInfo.name,
roles: userInfo.roles,
permissions: perms.permissions,
dataScope: perms.dataScope,
};
}
/** 获取视口配置iam.GetViewports */
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
return this.iam.getViewports(ctx);
}
/**
* 获取 Dashboard 聚合数据president §2.8P2 仅调 iam gRPC
* P2: user + viewports 有数据classes 返回 mockstats 返回 null + warning
* P3+: classes 来自 core-edustats 来自 data-ana.GetTeacherDashboard
*/
async getDashboard(ctx: CallContext): Promise<DashboardData> {
const [user, viewports] = await Promise.all([
this.getCurrentUser(ctx),
this.iam.getViewports(ctx),
]);
let user: unknown = null;
let classes: unknown = null;
if (iamRes.status === "fulfilled") {
if (iamRes.value.ok) {
user = await iamRes.value.json();
} else {
logger.warn(
{ status: iamRes.value.status, url: iamRes.value.url },
"Downstream IAM service call failed",
);
}
} else {
logger.warn(
{ err: iamRes.reason, service: "iam" },
"Downstream IAM service call rejected",
);
}
if (classesRes.status === "fulfilled") {
if (classesRes.value.ok) {
classes = await classesRes.value.json();
} else {
logger.warn(
{ status: classesRes.value.status, url: classesRes.value.url },
"Downstream classes service call failed",
);
}
} else {
logger.warn(
{ err: classesRes.reason, service: "classes" },
"Downstream classes service call rejected",
);
}
return { user, classes };
}
// 聚合 IAM 视口配置L1 导航)
async getViewports(userId: string): Promise<ViewportItem[]> {
const res = await fetch(`${env.IamServiceUrl}/iam/viewports`, {
headers: { "x-user-id": userId },
});
if (!res.ok) {
logger.warn(
{ status: res.status, url: res.url },
"Downstream IAM service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "iam",
endpoint: "viewports",
status: res.status,
});
}
const json = (await res.json()) as DownstreamEnvelope<ViewportItem[]>;
return json.data ?? [];
}
// 聚合班级下的考试列表core-edu
async listExamsByClass(userId: string, classId: string): Promise<unknown> {
const res = await fetch(
`${env.CoreEduServiceUrl}/exams/class/${encodeURIComponent(classId)}`,
{ headers: { "x-user-id": userId } },
// P2: classes 返回 mock 数据P3+ 替换为 core-edu.GetClassesByTeacher
// P2: stats 返回 nullP4+ 替换为 data-ana.GetTeacherDashboard
logger.warn(
{ userId: ctx.userId, phase: "P2" },
"Dashboard P2: classes using mock, stats unavailable (field_unavailable_in_p2)",
);
if (!res.ok) {
logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "exams-by-class",
status: res.status,
});
}
const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? [];
return {
user,
viewports,
classes: MOCK_CLASSES,
stats: null,
};
}
// 聚合班级下的作业列表core-edu
async listHomeworkByClass(userId: string, classId: string): Promise<unknown> {
const res = await fetch(
`${env.CoreEduServiceUrl}/homework/class/${encodeURIComponent(classId)}`,
{ headers: { "x-user-id": userId } },
/** 获取教师班级列表P2: mockP3+: core-edu.GetClassesByTeacher */
async getClasses(ctx: CallContext): Promise<ClassInfo[]> {
logger.warn(
{ userId: ctx.userId, phase: "P2" },
"Classes P2: using mock data (core-edu not ready, field_unavailable_in_p2)",
);
if (!res.ok) {
logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "homework-by-class",
status: res.status,
});
}
const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? [];
return MOCK_CLASSES;
}
// 聚合考试下的成绩列表(core-edu
async listGradesByExam(userId: string, examId: string): Promise<unknown> {
const res = await fetch(
`${env.CoreEduServiceUrl}/grades/exam/${encodeURIComponent(examId)}`,
{ headers: { "x-user-id": userId } },
/** 获取单个班级详情P2: mockP3+: core-edu */
async getClass(ctx: CallContext, classId: string): Promise<ClassInfo | null> {
logger.warn(
{ userId: ctx.userId, classId, phase: "P2" },
"Class P2: using mock data (core-edu not ready, field_unavailable_in_p2)",
);
if (!res.ok) {
logger.warn(
{ status: res.status, url: res.url },
"Downstream core-edu service call failed",
);
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
service: "core-edu",
endpoint: "grades-by-exam",
status: res.status,
});
}
const json = (await res.json()) as DownstreamEnvelope<unknown>;
return json.data ?? [];
return MOCK_CLASSES.find((c) => c.id === classId) ?? null;
}
}
/** 导出 EffectivePermissions 类型供外部使用 */
export type { EffectivePermissions, UserInfo };