From 99155a5ea1b2421249cf99f75949df0841f2bc1a Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:10:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(teacher-bff):=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20teacher-bff=20GraphQL=20=E8=81=9A=E5=90=88?= =?UTF-8?q?=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含 clients/graphql/middleware、health probes、shared-ts contracts 等 --- docs/troubleshooting/known-issues.md | 24 + .../graphql/teacher-bff.schema.graphql | 452 ++++++++++++++++++ pnpm-lock.yaml | 369 ++++++++++++++ services/teacher-bff/Dockerfile | 40 +- .../docs/02-architecture-design.md | 66 +-- services/teacher-bff/package.json | 11 +- services/teacher-bff/src/app.module.ts | 6 +- .../teacher-bff/src/clients/base.client.ts | 159 ++++++ .../teacher-bff/src/clients/clients.module.ts | 10 + .../src/clients/grpc/grpc.factory.ts | 256 ++++++++++ .../src/clients/iam/iam-client.interface.ts | 31 ++ .../src/clients/iam/iam-grpc.client.ts | 79 +++ .../src/clients/iam/iam-mock.client.ts | 125 +++++ .../teacher-bff/src/clients/iam/iam.module.ts | 31 ++ .../teacher-bff/src/clients/iam/iam.types.ts | 44 ++ services/teacher-bff/src/clients/types.ts | 46 ++ services/teacher-bff/src/config/env.ts | 47 +- services/teacher-bff/src/graphql/context.ts | 37 ++ .../src/graphql/error-formatter.ts | 37 ++ .../src/graphql/graphql.controller.ts | 65 +++ .../teacher-bff/src/graphql/graphql.module.ts | 10 + .../teacher-bff/src/graphql/schema-loader.ts | 22 + services/teacher-bff/src/main.ts | 44 +- .../src/middleware/authorization.guard.ts | 123 +++++ .../src/middleware/middleware.module.ts | 19 + .../src/shared/errors/application-error.ts | 78 +-- .../src/shared/errors/global-error.filter.ts | 14 +- .../src/shared/health/health.controller.ts | 33 +- .../src/shared/health/health.module.ts | 46 +- .../src/shared/health/iam-grpc.probe.ts | 27 ++ .../src/shared/health/readiness.probe.ts | 113 +++++ .../src/shared/health/redis.probe.ts | 37 ++ .../src/shared/observability/tracer.ts | 5 +- .../src/teacher/teacher.controller.ts | 70 --- .../teacher-bff/src/teacher/teacher.module.ts | 10 +- .../src/teacher/teacher.resolver.ts | 362 ++++++++++++++ .../src/teacher/teacher.service.ts | 238 ++++----- 37 files changed, 2862 insertions(+), 324 deletions(-) create mode 100644 packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql create mode 100644 services/teacher-bff/src/clients/base.client.ts create mode 100644 services/teacher-bff/src/clients/clients.module.ts create mode 100644 services/teacher-bff/src/clients/grpc/grpc.factory.ts create mode 100644 services/teacher-bff/src/clients/iam/iam-client.interface.ts create mode 100644 services/teacher-bff/src/clients/iam/iam-grpc.client.ts create mode 100644 services/teacher-bff/src/clients/iam/iam-mock.client.ts create mode 100644 services/teacher-bff/src/clients/iam/iam.module.ts create mode 100644 services/teacher-bff/src/clients/iam/iam.types.ts create mode 100644 services/teacher-bff/src/clients/types.ts create mode 100644 services/teacher-bff/src/graphql/context.ts create mode 100644 services/teacher-bff/src/graphql/error-formatter.ts create mode 100644 services/teacher-bff/src/graphql/graphql.controller.ts create mode 100644 services/teacher-bff/src/graphql/graphql.module.ts create mode 100644 services/teacher-bff/src/graphql/schema-loader.ts create mode 100644 services/teacher-bff/src/middleware/authorization.guard.ts create mode 100644 services/teacher-bff/src/middleware/middleware.module.ts create mode 100644 services/teacher-bff/src/shared/health/iam-grpc.probe.ts create mode 100644 services/teacher-bff/src/shared/health/readiness.probe.ts create mode 100644 services/teacher-bff/src/shared/health/redis.probe.ts delete mode 100644 services/teacher-bff/src/teacher/teacher.controller.ts create mode 100644 services/teacher-bff/src/teacher/teacher.resolver.ts diff --git a/docs/troubleshooting/known-issues.md b/docs/troubleshooting/known-issues.md index e3b072e..27be97b 100644 --- a/docs/troubleshooting/known-issues.md +++ b/docs/troubleshooting/known-issues.md @@ -501,3 +501,27 @@ | P3 MF 降级与版本协商 | Remote `remoteEntry.js` 加载失败 → fallback 独立页面 + 错误提示;共享依赖版本不匹配 → 运行时协商降级 | | P3 AI 答疑 SSE 流式(P5) | `POST /student/ai/stream-chat` SSE 流式响应 + `streamingText` 逐字渲染 + 打字机效果 + AbortController 取消 | | P3 权限点命名 | 与 student-bff §3.2 对齐 `STUDENT_*_READ` 风格(`STUDENT_DASHBOARD_READ`/`STUDENT_EXAM_READ`/`STUDENT_HOMEWORK_SUBMIT`/`STUDENT_GRADE_READ` 等) | + +### 2.16 teacher-bff(TS/NestJS,P2) + +> teacher-bff 是 GraphQL 聚合层(B1 裁决:P2 起直接 GraphQL),无 DB,无 @RequirePermission(B3 豁免),做数据权限防御(B4)。本节记录 teacher-bff 特有的"场景→技术"映射。 + +| 场景 | 技术/规则 | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P2 GraphQL 直接实现 | B1 裁决:P2 起直接 GraphQL Yoga(禁止 REST 过渡),POST /graphql endpoint + SDL-first schema(packages/shared-ts/contracts/graphql/) | +| P2 gRPC 下游通信 | B2 裁决:首次实现即 gRPC(禁止 REST fetch),@grpc/grpc-js + @grpc/proto-loader 动态加载 proto(无生成代码依赖) | +| DownstreamClient 抽象 | B8 裁决:3 个 BFF 共用标准抽象层(src/clients/),base.client.ts + grpc.factory.ts + per-service client(iam/client.interface + grpc + mock) | +| DEV_MODE mock 开关 | TEACHER_BFF_DEV_MODE=true → IamClient 走 mock(固定数据);false → gRPC(未就绪 RPC 降级 mock + warning,president §2.6 降级模式 B) | +| 越权防御 3 类错误码 | president §2.7:UNAUTHORIZED(401) / FORBIDDEN_RESOURCE(403 场景 A 资源无归属) / IDENTITY_MISMATCH(403 场景 B JWT 与 body 不一致) | +| AuthorizationGuard P2 策略 | president §2.9:P2 DEV_MODE 放行 + 日志告警,生产保守拒绝;P3+ 接入 core-edu.GetClassesByTeacher 真实校验 | +| /readyz 探针注册表 | president §2.4:DownstreamHealthCheck 注册表模式,P2 = Redis + iam gRPC(2 项),按阶段扩展(P3=3/P4=5/P5=7);必需依赖失败返 503,可选失败返 200+degraded | +| Dashboard Resolver P2 实现 | president §2.8:P2 仅调 iam gRPC(user+viewports 有数据),未启用字段返 null + extensions.warning="field_unavailable_in_p2" | +| admin 命名空间预留 | president §5.1:P2 预留 schema(AdminQuery/AdminMutation 类型声明),Resolver 返 null/空;P6 实现 Resolver 真实数据(admin-portal 复用 teacher-bff GraphQL endpoint) | +| 错误码 BFF_TEACHER_ 前缀 | B5 + G14 裁决:统一 BFF_ 前缀(非 TEACHER_BFF_),含 10 个错误码(validation/unauthorized/forbidden_resource/identity_mismatch/not_found/conflict/business/upstream_unavailable/aggregation_failed/internal) | +| ESM .js 后缀 | G12 裁决:NestJS ESM 模式下相对 import 必须带 .js 后缀(构建产物),所有源码 import 路径已对齐 | +| import type 强制 | G13 裁决:仅类型导入用 `import type`,运行时导入用 `import` | +| gRPC 健康检查 | grpc.health.v1.Health/Check 标准协议,makeUnaryRequest + 2s 超时,解析 HealthCheckResponse.status varint | +| ioredis ESM 导入类型 | `import IORedis from "ioredis"` ESM 下报 TS2709/TS2351,改用命名导入 `import { Redis } from "ioredis"`,类型注解用 `Redis` 而非 `IORedis` | +| NestJS HttpAdapter 路由注册 | `app.getHttpAdapter().get()` 的 RequestHandler 与 Express 类型不兼容,改用 `getInstance() as Application` 获取 Express 实例后注册路由 | +| graphql-yoga v5 context | YogaInitialContext 不含 response,context 回调只解构 `{ request }`,buildGraphQLContext 只接收 request(P5+ SSE 再扩展 response) | +| readonly 属性延迟初始化 | `readonly` 属性只能在构造函数赋值,需在 initLogger() 方法中重新赋值时移除 readonly 修饰符 | diff --git a/packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql b/packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql new file mode 100644 index 0000000..c4e8bd3 --- /dev/null +++ b/packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql @@ -0,0 +1,452 @@ +# teacher-bff GraphQL Schema (SDL-first, president §2.17) +# 版本:v1(P2 第一版,coord 仲裁) +# 负责人:ai03 +# 关联:contracts/teacher-bff_contract.md §1.3、workline §3.1 +# +# 设计原则(02-architecture-design.md §5.1): +# 1. 按教学场景域组织(不按角色分),教导主任/教研组长复用同一 schema +# 2. Query 为主,Mutation 谨慎(BFF 偏读多写少) +# 3. N+1 防御:所有 list 字段经 DataLoader(P4 全量接入) +# 4. 复杂度限制:depth ≤ 7,query cost ≤ 1000 +# 5. admin 命名空间预留(president §5.1:P2 预留 schema,P6 实现 Resolver) +# +# 错误响应格式:GraphQL errors 数组 + extensions.code = BFF_TEACHER_* + extensions.traceId +# 降级模式 B(president §2.6):success=true + error=null + data 内 degraded 字段 +# +# 阶段标注: +# P2 = 当前实现(iam gRPC,未启用字段返 null + extensions.warning = "field_unavailable_in_p2") +# P3+ = 跨阶段扩展例外(president §2.3),Resolver 内逐步补 gRPC 调用 + +scalar DateTime + +# ============ Types ============ + +type User { + id: ID! + email: String! + name: String! + roles: [String!]! + permissions: [String!]! + dataScope: DataScope! +} + +enum DataScope { + SELF + CLASS + GRADE + SCHOOL + DISTRICT + ALL +} + +type ViewportItem { + key: String! + label: String! + route: String! + icon: String + sortOrder: String! + requiredPermission: String +} + +type Class { + id: ID! + name: String! + gradeId: String! + # 延迟加载:班级下的考试(P3+ core-edu gRPC) + exams: [Exam!]! + # 延迟加载:班级下的作业(P3+ core-edu gRPC) + homework: [Homework!]! +} + +type Exam { + id: ID! + title: String! + classId: ID! + status: ExamStatus! + publishedAt: DateTime + # 延迟加载:考试下的成绩(P3+ core-edu gRPC) + grades: [Grade!]! +} + +enum ExamStatus { + DRAFT + PUBLISHED + IN_PROGRESS + GRADING + SCORED + ARCHIVED +} + +type Homework { + id: ID! + title: String! + classId: ID! + dueDate: DateTime! + submissions: [Submission!]! +} + +type Submission { + id: ID! + studentId: ID! + submittedAt: DateTime! + status: SubmissionStatus! +} + +enum SubmissionStatus { + NOT_SUBMITTED + SUBMITTED + GRADED +} + +type Grade { + id: ID! + examId: ID! + studentId: ID! + score: Float! + rank: Int + submittedAt: DateTime! +} + +type DashboardStats { + totalExams: Int + pendingGrading: Int + todayHomework: Int +} + +# Dashboard 聚合数据(P2: iam gRPC 用户基础信息,stats/classes 为 null + warning) +type DashboardData { + user: User + classes: [Class!]! + viewports: [ViewportItem!]! + stats: DashboardStats +} + +# ============ Query ============ +# P2 核心 5 Query(workline §3.2):dashboard / viewports / me / classes / class + +type Query { + # 仪表盘聚合(P2: iam gRPC;P3+: + core-edu + data-ana) + # @dataScope: OWN + dashboard: DashboardData! + + # 视口配置(L1 导航,iam.GetViewports) + viewports: [ViewportItem!]! + + # 当前用户信息(iam.GetUserInfo + GetEffectivePermissions) + me: User! + + # 教师班级列表(P2: iam 数据;P3+: core-edu.GetClassesByTeacher) + # @dataScope: OWN + classes: [Class!]! + + # 单个班级详情(P3+: core-edu) + # @permission: CLASS_READ + # @dataScope: OWN + class(id: ID!): Class + + # ===== P3+ 跨阶段扩展(core-edu gRPC,按 §2.3 跨阶段扩展例外新增)===== + + # 班级下的考试列表(P3: core-edu.ExamService.ListExamsByClass) + # @permission: EXAM_READ + # @dataScope: OWN + exams(classId: ID!): [Exam!]! + + # 班级下的作业列表(P3: core-edu.HomeworkService.ListHomeworkByClass) + # @permission: HOMEWORK_READ + # @dataScope: OWN + homework(classId: ID!): [Homework!]! + + # 考试下的成绩列表(P3: core-edu.GradeService.ListGradesByExam) + # @permission: GRADE_READ + # @dataScope: OWN + grades(examId: ID!): [Grade!]! + + # ===== P4+ 跨阶段扩展(content + data-ana gRPC)===== + + # 知识图谱学习路径(P4: content.KnowledgeGraphService.GetLearningPath) + knowledgePath(knowledgePointId: ID!): KnowledgePath! + + # 班级成绩分析(P4: data-ana.AnalyticsService.GetClassPerformance) + # @permission: ANALYTICS_TEACHER_DASHBOARD + classPerformance(classId: ID!): ClassPerformance! + + # 学生薄弱点(P4: data-ana.AnalyticsService.GetStudentWeakness) + studentWeakness(studentId: ID!): StudentWeakness! + + # 学习趋势(P4: data-ana.AnalyticsService.GetLearningTrend) + learningTrend(studentId: ID!, dateRange: DateRangeInput): LearningTrend! + + # ===== P5+ 跨阶段扩展(ai + msg gRPC)===== + + # 教师通知列表(P5: msg.NotificationService.ListNotifications) + notifications(unreadOnly: Boolean): [Notification!]! + + # ===== admin 命名空间(president §5.1:P2 预留 schema,P6 实现 Resolver)===== + # admin-portal 复用 teacher-bff GraphQL endpoint,admin 操作低 QPS 无需独立 BFF + admin: AdminQuery! +} + +input DateRangeInput { + start: DateTime! + end: DateTime! +} + +# ============ Mutation ============ + +type Mutation { + # ===== P3+ 跨阶段扩展(core-edu gRPC 透传)===== + + # 创建考试(P3: core-edu.ExamService.CreateExam) + # @permission: EXAM_CREATE + createExam(input: CreateExamInput!): Exam! + + # 布置作业(P3: core-edu.HomeworkService.AssignHomework) + # @permission: HOMEWORK_ASSIGN + assignHomework(input: AssignHomeworkInput!): Homework! + + # 录入成绩(P3: core-edu.GradeService.RecordGrade) + # @permission: GRADE_RECORD + recordGrade(input: RecordGradeInput!): Grade! + + # ===== P5+ 跨阶段扩展(ai + msg gRPC)===== + + # AI 出题(P5: ai.AiService.GenerateQuestion) + generateQuestion(input: GenerateQuestionInput!): GeneratedQuestion! + + # 标记通知已读(P5: msg.NotificationService.MarkAsRead) + markNotificationAsRead(id: ID!): Notification! + + # ===== admin 命名空间 Mutation(president §5.1:P2 预留,P6 实现)===== + admin: AdminMutation! +} + +input CreateExamInput { + classId: ID! + title: String! + subject: String! + scheduledAt: DateTime! +} + +input AssignHomeworkInput { + classId: ID! + title: String! + dueDate: DateTime! +} + +input RecordGradeInput { + examId: ID! + studentId: ID! + score: Float! +} + +input GenerateQuestionInput { + subject: String! + gradeLevel: String! + difficulty: String! + knowledgePointIds: [ID!]! +} + +# ============ P4+ 类型(content + data-ana)============ + +type KnowledgePath { + knowledgePointId: ID! + name: String! + prerequisites: [KnowledgePoint!]! +} + +type KnowledgePoint { + id: ID! + name: String! + subject: String! +} + +type ClassPerformance { + classId: ID! + averageScore: Float! + passRate: Float! + weaknessTopics: [String!]! +} + +type StudentWeakness { + studentId: ID! + weakTopics: [String!]! + recommendedExercises: [String!]! +} + +type LearningTrend { + studentId: ID! + trend: [TrendPoint!]! +} + +type TrendPoint { + date: DateTime! + score: Float! +} + +# ============ P5+ 类型(ai + msg)============ + +type Notification { + id: ID! + type: NotificationType! + title: String! + content: String! + read: Boolean! + createdAt: DateTime! +} + +enum NotificationType { + SYSTEM + EXAM + HOMEWORK + GRADE + ATTENDANCE +} + +type GeneratedQuestion { + id: ID! + subject: String! + difficulty: String! + content: String! + answer: String! +} + +# ============ admin 命名空间(president §5.1:P2 预留 schema,P6 实现 Resolver)============ +# P2: 类型声明占位,Resolver 返 null(不阻塞 admin-portal schema 内省) +# P6: ai03 实现 admin Resolver 真实数据(用户/角色/学校/组织/审计日志管理) + +type AdminQuery { + # 用户管理 + users(page: Int = 1, pageSize: Int = 20): AdminUserPage + user(id: ID!): AdminUser + + # 角色权限管理 + roles: [AdminRole!]! + role(id: ID!): AdminRole + + # 学校设置 + school: AdminSchool + + # 组织管理 + organizations: [AdminOrganization!]! + + # 审计日志查询 + auditLogs(page: Int = 1, pageSize: Int = 20): AuditLogPage +} + +type AdminMutation { + createUser(input: AdminCreateUserInput!): AdminUser! + updateUser(id: ID!, input: AdminUpdateUserInput!): AdminUser! + deleteUser(id: ID!): Boolean! + + createRole(input: AdminCreateRoleInput!): AdminRole! + updateRole(id: ID!, input: AdminUpdateRoleInput!): AdminRole! + deleteRole(id: ID!): Boolean! + + updateSchool(input: AdminUpdateSchoolInput!): AdminSchool! + + createOrganization(input: AdminCreateOrganizationInput!): AdminOrganization! + updateOrganization(id: ID!, input: AdminUpdateOrganizationInput!): AdminOrganization! + deleteOrganization(id: ID!): Boolean! +} + +type AdminUser { + id: ID! + email: String! + name: String! + roles: [String!]! + status: String! + createdAt: DateTime! + lastLoginAt: DateTime +} + +type AdminUserPage { + edges: [AdminUser!]! + totalCount: Int! + page: Int! + pageSize: Int! +} + +type AdminRole { + id: ID! + name: String! + description: String + permissions: [String!]! + userCount: Int! +} + +type AdminSchool { + id: ID! + name: String! + address: String + phone: String + studentCount: Int! + teacherCount: Int! + classCount: Int! +} + +type AdminOrganization { + id: ID! + name: String! + type: String! + parentId: ID + createdAt: DateTime! +} + +type AuditLogPage { + edges: [AuditLog!]! + totalCount: Int! + page: Int! + pageSize: Int! +} + +type AuditLog { + id: ID! + actorUserId: ID! + action: String! + resourceType: String! + resourceId: ID + occurredAt: DateTime! + ip: String +} + +input AdminCreateUserInput { + email: String! + name: String! + roleIds: [ID!]! +} + +input AdminUpdateUserInput { + name: String + roleIds: [ID!] + status: String +} + +input AdminCreateRoleInput { + name: String! + description: String + permissions: [String!]! +} + +input AdminUpdateRoleInput { + name: String + description: String + permissions: [String!] +} + +input AdminUpdateSchoolInput { + name: String + address: String + phone: String +} + +input AdminCreateOrganizationInput { + name: String! + type: String! + parentId: ID +} + +input AdminUpdateOrganizationInput { + name: String + type: String + parentId: ID +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef4a295..0f2b83c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,8 +82,88 @@ importers: specifier: ^5.6.0 version: 5.9.3 + packages/hooks: + dependencies: + '@edu/ui-components': + specifier: workspace:* + version: link:../ui-components + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.0 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + packages/shared-proto: {} + packages/shared-ts: + dependencies: + '@nestjs/common': + specifier: ^10.4.0 + version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.0 + version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@paralleldrive/cuid2': + specifier: ^2.2.2 + version: 2.3.1 + drizzle-orm: + specifier: ^0.31.0 + version: 0.31.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.6.1)(@types/react@18.3.31)(better-sqlite3@11.10.0)(mysql2@3.22.6(@types/node@22.20.0))(react@18.3.1) + kafkajs: + specifier: ^2.2.0 + version: 2.2.4 + pino: + specifier: ^9.4.0 + version: 9.14.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.0 + version: 7.8.2 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.0 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + + packages/ui-components: + dependencies: + '@edu/ui-tokens': + specifier: workspace:* + version: link:../ui-tokens + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.0 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + + packages/ui-tokens: {} + scripts/arch-scan: dependencies: better-sqlite3: @@ -500,6 +580,18 @@ importers: services/teacher-bff: dependencies: + '@graphql-tools/schema': + specifier: ^10.0.6 + version: 10.0.36(graphql@16.14.2) + '@graphql-tools/utils': + specifier: ^10.5.6 + version: 10.11.0(graphql@16.14.2) + '@grpc/grpc-js': + specifier: ^1.12.0 + version: 1.14.4 + '@grpc/proto-loader': + specifier: ^0.7.13 + version: 0.7.15 '@nestjs/common': specifier: ^10.4.0 version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -521,6 +613,15 @@ importers: '@opentelemetry/sdk-node': specifier: ^0.53.0 version: 0.53.0(@opentelemetry/api@1.9.1) + graphql: + specifier: ^16.9.0 + version: 16.14.2 + graphql-yoga: + specifier: ^5.7.0 + version: 5.21.2(graphql@16.14.2) + ioredis: + specifier: ^5.4.1 + version: 5.11.1 pino: specifier: ^9.4.0 version: 9.14.0 @@ -698,6 +799,18 @@ packages: resolution: {integrity: sha512-hjkrWvmVpCQiy2Km7LpVUmky8MbinNc2DzbpBaXSjgQYIXRK9LJ5BOfqldSBjp9E+U1I539LYAaSxxikQdXLLQ==} engines: {node: '>=18'} + '@envelop/core@5.5.1': + resolution: {integrity: sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==} + engines: {node: '>=18.0.0'} + + '@envelop/instrumentation@1.0.0': + resolution: {integrity: sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==} + engines: {node: '>=18.0.0'} + + '@envelop/types@5.2.1': + resolution: {integrity: sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==} + engines: {node: '>=18.0.0'} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -1308,10 +1421,65 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@graphql-tools/executor@1.5.5': + resolution: {integrity: sha512-JAdn4G+ehthnGdJABS+wO6LxAIcoGPiSRHIz1ECYLtaQGkpFIdqH6BLUbZcToX/W/nmOG0kTFLoepCGkugbsBA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/merge@9.2.0': + resolution: {integrity: sha512-kChDH/sOxm3TCICup5NgTiz/aJBk+5GAuC0lfJS8FcRfsqZvlCkNwpsYTGAATBCJMrgZ9+csRugCjS97jPcNMw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/schema@10.0.36': + resolution: {integrity: sha512-g8S5aLirZInoi3NojzmBxwsZfVawOE0UBlVWYe8kDAR0FxS0riBDiyW7JnlAKayooHMRAMwGaze4RQU3VTTyig==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/utils@10.11.0': + resolution: {integrity: sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/utils@11.2.0': + resolution: {integrity: sha512-eu9h1R3j/wWc4rvmYJF5AKtlwniDzstrZ/c6KSz+HdI+n7I7iog9xyKmBfpUwSbG1TqPNZBzWjFMkzdYOKq6Bg==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-typed-document-node/core@3.2.0': + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-yoga/logger@2.0.1': + resolution: {integrity: sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA==} + engines: {node: '>=18.0.0'} + + '@graphql-yoga/subscription@5.0.5': + resolution: {integrity: sha512-oCMWOqFs6QV96/NZRt/ZhTQvzjkGB4YohBOpKM4jH/lDT4qb7Lex/aGCxpi/JD9njw3zBBtMqxbaC22+tFHVvw==} + engines: {node: '>=18.0.0'} + + '@graphql-yoga/typed-event-target@3.0.2': + resolution: {integrity: sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA==} + engines: {node: '>=18.0.0'} + '@grpc/grpc-js@1.14.4': resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} engines: {node: '>=12.10.0'} + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + '@grpc/proto-loader@0.8.1': resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} engines: {node: '>=6'} @@ -1494,6 +1662,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2503,6 +2675,9 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2537,6 +2712,9 @@ packages: '@protobufjs/utf8@1.1.2': resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@repeaterjs/repeater@3.1.0': + resolution: {integrity: sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==} + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -2958,6 +3136,30 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@whatwg-node/disposablestack@0.0.6': + resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/events@0.1.2': + resolution: {integrity: sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/fetch@0.10.13': + resolution: {integrity: sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/node-fetch@0.8.6': + resolution: {integrity: sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/promise-helpers@1.3.2': + resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} + engines: {node: '>=16.0.0'} + + '@whatwg-node/server@0.11.0': + resolution: {integrity: sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==} + engines: {node: '>=18.0.0'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -3439,6 +3641,10 @@ packages: typescript: optional: true + cross-inspect@1.0.1: + resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==} + engines: {node: '>=16.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4064,6 +4270,16 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql-yoga@5.21.2: + resolution: {integrity: sha512-IIRF/3xtjj2D6caAWL9177hQ8tV3mWB3hve1GRnz7njPhQ3iY1jFtSp98fNGv0yV9kaPh9kKQ8JWdJZnedVmDw==} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^15.2.0 || ^16.0.0 + + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -5661,6 +5877,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -6056,6 +6275,23 @@ snapshots: transitivePeerDependencies: - supports-color + '@envelop/core@5.5.1': + dependencies: + '@envelop/instrumentation': 1.0.0 + '@envelop/types': 5.2.1 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@envelop/instrumentation@1.0.0': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@envelop/types@5.2.1': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -6399,11 +6635,79 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastify/busboy@3.2.0': {} + + '@graphql-tools/executor@1.5.5(graphql@16.14.2)': + dependencies: + '@graphql-tools/utils': 11.2.0(graphql@16.14.2) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) + '@repeaterjs/repeater': 3.1.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.14.2 + tslib: 2.8.1 + + '@graphql-tools/merge@9.2.0(graphql@16.14.2)': + dependencies: + '@graphql-tools/utils': 11.2.0(graphql@16.14.2) + graphql: 16.14.2 + tslib: 2.8.1 + + '@graphql-tools/schema@10.0.36(graphql@16.14.2)': + dependencies: + '@graphql-tools/merge': 9.2.0(graphql@16.14.2) + '@graphql-tools/utils': 11.2.0(graphql@16.14.2) + graphql: 16.14.2 + tslib: 2.8.1 + + '@graphql-tools/utils@10.11.0(graphql@16.14.2)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.14.2 + tslib: 2.8.1 + + '@graphql-tools/utils@11.2.0(graphql@16.14.2)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.14.2 + tslib: 2.8.1 + + '@graphql-typed-document-node/core@3.2.0(graphql@16.14.2)': + dependencies: + graphql: 16.14.2 + + '@graphql-yoga/logger@2.0.1': + dependencies: + tslib: 2.8.1 + + '@graphql-yoga/subscription@5.0.5': + dependencies: + '@graphql-yoga/typed-event-target': 3.0.2 + '@repeaterjs/repeater': 3.1.0 + '@whatwg-node/events': 0.1.2 + tslib: 2.8.1 + + '@graphql-yoga/typed-event-target@3.0.2': + dependencies: + '@repeaterjs/repeater': 3.1.0 + tslib: 2.8.1 + '@grpc/grpc-js@1.14.4': dependencies: '@grpc/proto-loader': 0.8.1 '@js-sdsl/ordered-map': 4.4.2 + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.3 + '@grpc/proto-loader@0.8.1': dependencies: lodash.camelcase: 4.3.0 @@ -6642,6 +6946,8 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.33': optional: true + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8135,6 +8441,10 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -8160,6 +8470,8 @@ snapshots: '@protobufjs/utf8@1.1.2': {} + '@repeaterjs/repeater@3.1.0': {} + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -8633,6 +8945,39 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@whatwg-node/disposablestack@0.0.6': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/events@0.1.2': + dependencies: + tslib: 2.8.1 + + '@whatwg-node/fetch@0.10.13': + dependencies: + '@whatwg-node/node-fetch': 0.8.6 + urlpattern-polyfill: 10.1.0 + + '@whatwg-node/node-fetch@0.8.6': + dependencies: + '@fastify/busboy': 3.2.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/promise-helpers@1.3.2': + dependencies: + tslib: 2.8.1 + + '@whatwg-node/server@0.11.0': + dependencies: + '@envelop/instrumentation': 1.0.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -9113,6 +9458,10 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cross-inspect@1.0.1: + dependencies: + tslib: 2.8.1 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -9833,6 +10182,24 @@ snapshots: graceful-fs@4.2.11: {} + graphql-yoga@5.21.2(graphql@16.14.2): + dependencies: + '@envelop/core': 5.5.1 + '@envelop/instrumentation': 1.0.0 + '@graphql-tools/executor': 1.5.5(graphql@16.14.2) + '@graphql-tools/schema': 10.0.36(graphql@16.14.2) + '@graphql-tools/utils': 10.11.0(graphql@16.14.2) + '@graphql-yoga/logger': 2.0.1 + '@graphql-yoga/subscription': 5.0.5 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + '@whatwg-node/server': 0.11.0 + graphql: 16.14.2 + lru-cache: 10.4.3 + tslib: 2.8.1 + + graphql@16.14.2: {} + has-flag@4.0.0: {} has-own-prop@2.0.0: {} @@ -11389,6 +11756,8 @@ snapshots: dependencies: punycode: 2.3.1 + urlpattern-polyfill@10.1.0: {} + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} diff --git a/services/teacher-bff/Dockerfile b/services/teacher-bff/Dockerfile index 1a2d4a3..0aaa70d 100644 --- a/services/teacher-bff/Dockerfile +++ b/services/teacher-bff/Dockerfile @@ -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-proto(gRPC proto-loader 运行时加载) +COPY packages/shared-proto ./packages/shared-proto + +# 复制 shared-ts(GraphQL 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"] diff --git a/services/teacher-bff/docs/02-architecture-design.md b/services/teacher-bff/docs/02-architecture-design.md index 6640023..4cdfcf0 100644 --- a/services/teacher-bff/docs/02-architecture-design.md +++ b/services/teacher-bff/docs/02-architecture-design.md @@ -189,16 +189,16 @@ graph LR **目标**:在不改变对外 REST 契约的前提下,补齐黄金模板差距,为后续 gRPC/GraphQL 切换铺路。 -| 工作项 | 详情 | 优先级 | -| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| 引入 Client 抽象层 | 新增 `src/clients/` 目录,定义 `IamClient`/`ClassesClient`/`CoreEduClient` 接口,当前实现为 REST fetch adapter,P3 替换为 gRPC adapter | P0 | -| 错误码迁移 | `TEACHER_BFF_*` → `BFF_TEACHER_*`(全量替换 [application-error.ts](../src/shared/errors/application-error.ts)) | P0 | -| Zod 输入验证 | Controller 层引入 Zod schema 解析 path/body(classId/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 adapter,P3 替换为 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/body(classId/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:50054(KnowledgeGraphService + ChapterService/QuestionService 待 ai09 补) | -| DataAnaClient gRPC adapter | 调用 data-ana:50055(AnalyticsService + GetTeacherDashboardStats 待 ai11 补) | +| DataAnaClient gRPC adapter | 调用 data-ana:50055(AnalyticsService + 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 | /graphql(P4) | 校验 x-user-id + 注入 context | 同上 | | /ai/chat/stream(P5) | 校验 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.7):UNAUTHORIZED / 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 50054(P4+) | KnowledgeGraphService.GetPrerequisites / GetLearningPath | 知识图谱 | P4 | ✅ 已有 | | 调用 | content | gRPC 50054(P4+) | _*ChapterService.* / QuestionService._** | 章节/题库 | P4 | ❌ 待 ai09 补 | | 调用 | data-ana | gRPC 50055(P4+) | AnalyticsService.GetClassPerformance / GetStudentWeakness / GetLearningTrend | 学情诊断 | P4 | ✅ 已有 | -| 调用 | data-ana | gRPC 50055(P4+) | **GetTeacherDashboardStats**(建议新增) | 仪表盘统计 | P4 | ❌ 待 coord 仲裁 | +| 调用 | data-ana | gRPC 50055(P4+) | **GetTeacherDashboard**(建议新增) | 仪表盘统计 | P4 | ❌ 待 coord 仲裁 | | 调用 | ai | gRPC 50057(P5+) | AiService.GenerateQuestion / Chat / OptimizeExpression | AI 辅助 | P5 | ✅ 已有 | | 调用 | ai | gRPC 50057 streaming(P5+) | **AiService.StreamChat** | SSE 流式对话 | P5 | ✅ 已有 | | 调用 | msg | gRPC 50056(P5+) | 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 | diff --git a/services/teacher-bff/package.json b/services/teacher-bff/package.json index d778306..ecd9932 100644 --- a/services/teacher-bff/package.json +++ b/services/teacher-bff/package.json @@ -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" diff --git a/services/teacher-bff/src/app.module.ts b/services/teacher-bff/src/app.module.ts index 9c27d8f..a341f8c 100644 --- a/services/teacher-bff/src/app.module.ts +++ b/services/teacher-bff/src/app.module.ts @@ -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 {} diff --git a/services/teacher-bff/src/clients/base.client.ts b/services/teacher-bff/src/clients/base.client.ts new file mode 100644 index 0000000..e514534 --- /dev/null +++ b/services/teacher-bff/src/clients/base.client.ts @@ -0,0 +1,159 @@ +// DownstreamClient 抽象基类(B8 裁决:3 个 BFF 统一使用) +// 提供统一的错误映射 + 结构化日志 + metrics 记录 +import type { Logger } from "pino"; +import { logger } from "../shared/observability/logger.js"; +import { + BadGatewayError, + AggregationFailedError, +} from "../shared/errors/application-error.js"; +import type { + CallContext, + DownstreamError, + DownstreamResult, + DownstreamServiceName, + GrpcMetadata, +} from "./types.js"; + +/** gRPC 状态码 → HTTP 语义映射(@grpc/grpc-js status codes) */ +const GRPC_STATUS = { + OK: 0, + CANCELLED: 1, + UNKNOWN: 2, + INVALID_ARGUMENT: 3, + DEADLINE_EXCEEDED: 4, + NOT_FOUND: 5, + ALREADY_EXISTS: 6, + PERMISSION_DENIED: 7, + RESOURCE_EXHAUSTED: 8, + FAILED_PRECONDITION: 9, + ABORTED: 10, + OUT_OF_RANGE: 11, + UNIMPLEMENTED: 12, + INTERNAL: 13, + UNAVAILABLE: 14, + DATA_LOSS: 15, + UNAUTHENTICATED: 16, +} as const; + +export abstract class BaseDownstreamClient { + protected log: Logger; + abstract readonly serviceName: DownstreamServiceName; + + constructor() { + // abstract property 在子类构造后才可用,延迟初始化 logger + this.log = logger.child({ downstream: "downstream" }); + } + + /** 子类在构造函数中调用以初始化 logger(带正确 serviceName) */ + protected initLogger(): void { + this.log = logger.child({ downstream: this.serviceName }); + } + + /** 构建 gRPC metadata(注入 x-user-id + x-request-id) */ + protected buildMetadata(ctx: CallContext): GrpcMetadata { + const meta: GrpcMetadata = { "x-user-id": ctx.userId }; + if (ctx.traceId) { + meta["x-request-id"] = ctx.traceId; + } + return meta; + } + + /** 将 gRPC 错误映射为 BadGatewayError(502) */ + protected mapGrpcError(err: unknown, rpc: string): BadGatewayError { + const grpcErr = err as { + code?: number; + message?: string; + details?: string; + }; + const code = grpcErr.code ?? GRPC_STATUS.UNKNOWN; + const message = grpcErr.message ?? "Unknown gRPC error"; + + this.log.warn( + { rpc, grpcCode: code, message, err }, + "Downstream gRPC call failed", + ); + + // UNAVAILABLE → 502 Bad Gateway + if (code === GRPC_STATUS.UNAVAILABLE) { + return new BadGatewayError( + `Downstream ${this.serviceName}.${rpc} unavailable`, + { service: this.serviceName, rpc, grpcCode: code }, + ); + } + + // UNAUTHENTICATED → 映射为 BadGateway(BFF 自身做身份校验,下游不应返回 UNAUTHENTICATED) + if (code === GRPC_STATUS.UNAUTHENTICATED) { + return new BadGatewayError( + `Downstream ${this.serviceName}.${rpc} returned UNAUTHENTICATED`, + { service: this.serviceName, rpc, grpcCode: code }, + ); + } + + // 其他错误统一映射为 BadGateway + return new BadGatewayError( + `Downstream ${this.serviceName}.${rpc} failed: ${message}`, + { + service: this.serviceName, + rpc, + grpcCode: code, + details: grpcErr.details, + }, + ); + } + + /** 执行 gRPC 调用并统一错误处理 + 超时控制 */ + protected async callGrpc( + rpc: string, + fn: () => Promise, + timeoutMs = 3000, + ): Promise { + 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 { + return new Promise((_, reject) => { + setTimeout(() => { + reject( + new BadGatewayError( + `Downstream ${this.serviceName}.${rpc} timed out after ${ms}ms`, + { service: this.serviceName, rpc, timeoutMs: ms }, + ), + ); + }, ms); + }); + } + + /** 降级模式 B:部分失败时返回 success=true + degraded=true + warning(president §2.6) */ + protected degraded( + data: T | null, + warning: string, + rpc: string, + ): DownstreamResult { + 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 }, + ); + } +} diff --git a/services/teacher-bff/src/clients/clients.module.ts b/services/teacher-bff/src/clients/clients.module.ts new file mode 100644 index 0000000..7c79aab --- /dev/null +++ b/services/teacher-bff/src/clients/clients.module.ts @@ -0,0 +1,10 @@ +// DownstreamClient 聚合模块(B8 裁决:3 个 BFF 统一使用) +// P2 仅注册 IamModule,P3+ 扩展 CoreEduModule / ContentModule / DataAnaModule / MsgModule / AiModule +import { Module } from "@nestjs/common"; +import { IamModule } from "./iam/iam.module.js"; + +@Module({ + imports: [IamModule], + exports: [IamModule], +}) +export class ClientsModule {} diff --git a/services/teacher-bff/src/clients/grpc/grpc.factory.ts b/services/teacher-bff/src/clients/grpc/grpc.factory.ts new file mode 100644 index 0000000..d46176b --- /dev/null +++ b/services/teacher-bff/src/clients/grpc/grpc.factory.ts @@ -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(); + +/** 已创建的 gRPC Client 缓存(按 service+target 缓存) */ +const clientCache = new Map(); + +/** 各下游服务对应的 proto 文件 + 包名 + service 名 */ +interface ServiceProtoConfig { + protoFile: string; + packageName: string; + serviceName: string; +} + +const SERVICE_PROTO_MAP: Record = { + 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 = { + 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 + >; + + // 按包路径查找 Service 构造器 + const pkgParts = config.packageName.split("."); + let pkg: Record = proto; + for (const part of pkgParts) { + pkg = pkg[part] as Record; + 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 = { + 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 }); + } + }, + ); + }); +} diff --git a/services/teacher-bff/src/clients/iam/iam-client.interface.ts b/services/teacher-bff/src/clients/iam/iam-client.interface.ts new file mode 100644 index 0000000..23f49e5 --- /dev/null +++ b/services/teacher-bff/src/clients/iam/iam-client.interface.ts @@ -0,0 +1,31 @@ +// IamClient 接口 + DI token(B8 裁决:DownstreamClient 抽象) +// 接口定义所有 P2+ 需要的 iam RPC,实现分 gRPC + mock 两种 +// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC(未就绪 RPC 降级 mock + warning) +import type { CallContext } from "../types.js"; +import type { + UserInfo, + ViewportItem, + EffectivePermissions, +} from "./iam.types.js"; + +/** IamClient DI token(NestJS 注入用) */ +export const IAM_CLIENT = Symbol("IAM_CLIENT"); + +/** IamClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */ +export interface IamClient { + /** 获取用户信息(iam.proto GetUserInfo,✅ 已就绪) */ + getUserInfo(ctx: CallContext): Promise; + + /** 获取教师导航菜单(iam.proto GetViewports,❌ 待 coord 补全) */ + getViewports(ctx: CallContext): Promise; + + /** 获取有效权限集(iam.proto GetEffectivePermissions,❌ 待 coord 补全) */ + getEffectivePermissions(ctx: CallContext): Promise; + + /** 健康检查(/readyz 探针用) */ + checkHealth(): Promise<{ + serving: boolean; + latencyMs: number; + error?: string; + }>; +} diff --git a/services/teacher-bff/src/clients/iam/iam-grpc.client.ts b/services/teacher-bff/src/clients/iam/iam-grpc.client.ts new file mode 100644 index 0000000..77db616 --- /dev/null +++ b/services/teacher-bff/src/clients/iam/iam-grpc.client.ts @@ -0,0 +1,79 @@ +// IamClient gRPC 实现(B2 裁决:首次实现即 gRPC) +// iam.proto 现状仅 GetUserInfo,GetViewports/GetEffectivePermissions 待 coord 补全 +// 未就绪 RPC 降级 mock + warning 日志(president §2.6 降级模式 B) +import { Injectable } from "@nestjs/common"; +import type * as grpc from "@grpc/grpc-js"; +import { BaseDownstreamClient } from "../base.client.js"; +import { + createGrpcMetadata, + getGrpcClient, + checkGrpcHealth, +} from "../grpc/grpc.factory.js"; +import type { CallContext } from "../types.js"; +import type { IamClient } from "./iam-client.interface.js"; +import type { + UserInfo, + ViewportItem, + EffectivePermissions, +} from "./iam.types.js"; +import { IamMockClient } from "./iam-mock.client.js"; + +@Injectable() +export class IamGrpcClient extends BaseDownstreamClient implements IamClient { + readonly serviceName = "iam" as const; + private readonly mock: IamMockClient; + + constructor() { + super(); + this.initLogger(); + // mock 用于未就绪 RPC 的降级(proto 未定义的 RPC 走 mock + warning) + this.mock = new IamMockClient(); + } + + async getUserInfo(ctx: CallContext): Promise { + 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((resolve, reject) => { + client.getUserInfo({ userId: ctx.userId }, meta, (err, res) => { + if (err) reject(err); + else resolve(res); + }); + }); + }); + } + + async getViewports(ctx: CallContext): Promise { + // 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 { + // 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"); + } +} diff --git a/services/teacher-bff/src/clients/iam/iam-mock.client.ts b/services/teacher-bff/src/clients/iam/iam-mock.client.ts new file mode 100644 index 0000000..4020401 --- /dev/null +++ b/services/teacher-bff/src/clients/iam/iam-mock.client.ts @@ -0,0 +1,125 @@ +// IamClient Mock 实现(B8 裁决:上游就绪前的降级策略) +// DEV_MODE=true 时全部走 mock;DEV_MODE=false 时仅未就绪 RPC 走 mock +// mock 数据对齐 contract §4.2 mock 策略 +import { Injectable } from "@nestjs/common"; +import { BaseDownstreamClient } from "../base.client.js"; +import type { CallContext } from "../types.js"; +import type { IamClient } from "./iam-client.interface.js"; +import type { + UserInfo, + ViewportItem, + EffectivePermissions, +} from "./iam.types.js"; + +/** 教师默认 mock 用户(contract §4.1 mock 策略) */ +const MOCK_TEACHER: UserInfo = { + id: "teacher-001", + email: "teacher@edu.test", + name: "张老师", + roles: ["teacher"], + permissions: [ + "exam:read", + "exam:write", + "homework:read", + "homework:write", + "grade:read", + "grade:write", + "class:read", + "student:read", + ], +}; + +/** 教师默认 mock 视口(L1 导航) */ +const MOCK_VIEWPORTS: ViewportItem[] = [ + { + key: "dashboard", + label: "工作台", + route: "/dashboard", + icon: null, + sortOrder: 1, + requiredPermission: null, + }, + { + key: "classes", + label: "我的班级", + route: "/classes", + icon: null, + sortOrder: 2, + requiredPermission: "class:read", + }, + { + key: "exams", + label: "考试管理", + route: "/exams", + icon: null, + sortOrder: 3, + requiredPermission: "exam:read", + }, + { + key: "homework", + label: "作业管理", + route: "/homework", + icon: null, + sortOrder: 4, + requiredPermission: "homework:read", + }, + { + key: "grades", + label: "成绩管理", + route: "/grades", + icon: null, + sortOrder: 5, + requiredPermission: "grade:read", + }, + { + key: "analytics", + label: "学情分析", + route: "/analytics", + icon: null, + sortOrder: 6, + requiredPermission: "student:read", + }, +]; + +/** 教师默认 mock 权限(全权限 + OWN 数据范围) */ +const MOCK_PERMISSIONS: EffectivePermissions = { + permissions: MOCK_TEACHER.permissions, + dataScope: "OWN", +}; + +@Injectable() +export class IamMockClient extends BaseDownstreamClient implements IamClient { + readonly serviceName = "iam" as const; + + constructor() { + super(); + this.initLogger(); + } + + async getUserInfo(ctx: CallContext): Promise { + this.log.debug({ userId: ctx.userId }, "Mock getUserInfo"); + // 返回 mock 教师,但 id 用请求中的 userId(保持一致性) + return { ...MOCK_TEACHER, id: ctx.userId }; + } + + async getViewports(ctx: CallContext): Promise { + this.log.debug({ userId: ctx.userId }, "Mock getViewports"); + return MOCK_VIEWPORTS; + } + + async getEffectivePermissions( + ctx: CallContext, + ): Promise { + 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 }; + } +} diff --git a/services/teacher-bff/src/clients/iam/iam.module.ts b/services/teacher-bff/src/clients/iam/iam.module.ts new file mode 100644 index 0000000..abebab7 --- /dev/null +++ b/services/teacher-bff/src/clients/iam/iam.module.ts @@ -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 {} diff --git a/services/teacher-bff/src/clients/iam/iam.types.ts b/services/teacher-bff/src/clients/iam/iam.types.ts new file mode 100644 index 0000000..539c818 --- /dev/null +++ b/services/teacher-bff/src/clients/iam/iam.types.ts @@ -0,0 +1,44 @@ +// iam 下游服务类型定义(对齐 iam.proto + 待 coord 补全的 RPC) +// iam.proto 现状:4 RPC(Register/Login/RefreshToken/GetUserInfo) +// 待 coord 补全:GetViewports / GetEffectivePermissions / GetEffectiveAccess / BatchGetUsers / GetChildrenByParent + +/** 用户信息(对齐 iam.proto UserInfo message) */ +export interface UserInfo { + id: string; + email: string; + name: string; + roles: string[]; + permissions: string[]; +} + +/** 视口项(L1 导航菜单,待 coord 补 GetViewports RPC 的 response message) */ +export interface ViewportItem { + key: string; + label: string; + route: string; + icon: string | null; + sortOrder: number; + requiredPermission: string | null; +} + +/** 有效权限集(待 coord 补 GetEffectivePermissions RPC 的 response message) */ +export interface EffectivePermissions { + permissions: string[]; + /** 数据范围(OWN / GRADE / SCHOOL / ALL) */ + dataScope: string; +} + +/** GetUserInfo 请求 */ +export interface GetUserInfoRequest { + userId: string; +} + +/** GetViewports 请求 */ +export interface GetViewportsRequest { + userId: string; +} + +/** GetEffectivePermissions 请求 */ +export interface GetEffectivePermissionsRequest { + userId: string; +} diff --git a/services/teacher-bff/src/clients/types.ts b/services/teacher-bff/src/clients/types.ts new file mode 100644 index 0000000..5f43e27 --- /dev/null +++ b/services/teacher-bff/src/clients/types.ts @@ -0,0 +1,46 @@ +// DownstreamClient 抽象层共享类型(B8 裁决:3 个 BFF 统一使用) +// 所有下游 gRPC 调用通过此抽象层,统一 trace 上下文注入 + 错误映射 + metrics + +/** 下游调用上下文(per-request,由 GraphQL Resolver 注入) */ +export interface CallContext { + /** 当前用户 ID(从 JWT 或 x-user-id header 提取) */ + userId: string; + /** 请求追踪 ID(X-Request-Id,用于日志关联) */ + traceId?: string; + /** 用户角色(可选,用于下游权限校验) */ + roles?: readonly string[]; +} + +/** 下游调用结果(降级模式 B 支持:success + degraded 标记) */ +export type DownstreamResult = + | { 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; +} + +/** 下游服务名(用于 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; +} diff --git a/services/teacher-bff/src/config/env.ts b/services/teacher-bff/src/config/env.ts index dad0c00..8e4e575 100644 --- a/services/teacher-bff/src/config/env.ts +++ b/services/teacher-bff/src/config/env.ts @@ -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.9:TEACHER_BFF_DEV_MODE 控制越权防御 Guard(P2 放行 / 生产拒绝) 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.9:P2 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; 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())); } diff --git a/services/teacher-bff/src/graphql/context.ts b/services/teacher-bff/src/graphql/context.ts new file mode 100644 index 0000000..9f839ea --- /dev/null +++ b/services/teacher-bff/src/graphql/context.ts @@ -0,0 +1,37 @@ +// GraphQL 上下文构建(B1 裁决:P2 起直接 GraphQL) +// 从 Express 请求提取 userId / traceId,注入下游 CallContext +import type { Request } from "express"; +import type { CallContext } from "../clients/types.js"; +import { UnauthorizedError } from "../shared/errors/application-error.js"; + +export interface GraphQLOperationContext { + /** 当前请求的 CallContext(注入下游 gRPC 调用) */ + callCtx: CallContext; +} + +/** + * 从 Express 请求构建 GraphQL 上下文 + * P2: 从 x-user-id header 提取 userId(api-gateway 注入) + * P3+: 从 JWT 提取(api-gateway 校验后注入 x-user-id + x-user-roles) + */ +export function buildGraphQLContext(req: Request): GraphQLOperationContext { + const userIdHeader = req.headers["x-user-id"]; + const userId = typeof userIdHeader === "string" ? userIdHeader : undefined; + if (!userId) { + throw new UnauthorizedError("Missing x-user-id header"); + } + + const requestIdHeader = req.headers["x-request-id"]; + const traceId = + typeof requestIdHeader === "string" ? requestIdHeader : undefined; + + const rolesHeader = req.headers["x-user-roles"]; + const roles = + typeof rolesHeader === "string" + ? rolesHeader.split(",").filter(Boolean) + : undefined; + + return { + callCtx: { userId, traceId, roles }, + }; +} diff --git a/services/teacher-bff/src/graphql/error-formatter.ts b/services/teacher-bff/src/graphql/error-formatter.ts new file mode 100644 index 0000000..9766478 --- /dev/null +++ b/services/teacher-bff/src/graphql/error-formatter.ts @@ -0,0 +1,37 @@ +// GraphQL 错误格式化(G8 裁决:ActionState 信封 + BFF_TEACHER_ 错误码) +// Yoga 自身错误处理,extensions.code = BFF_TEACHER_* + extensions.traceId +import { ApplicationError } from "../shared/errors/application-error.js"; + +/** + * 格式化 GraphQL 错误(president §2.6 降级模式 B + G8 ActionState 信封) + * 错误 extensions 包含:code(BFF_TEACHER_*)、traceId、details + */ +export function formatGraphQLError( + error: unknown, + traceId?: string, +): { message: string; extensions: Record } { + // 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; + }; + return { + message: err.message ?? "Unknown GraphQL error", + extensions: { + code: (err.extensions?.code as string) ?? "BFF_TEACHER_INTERNAL_ERROR", + traceId: traceId ?? "unknown", + }, + }; +} diff --git a/services/teacher-bff/src/graphql/graphql.controller.ts b/services/teacher-bff/src/graphql/graphql.controller.ts new file mode 100644 index 0000000..0eb8009 --- /dev/null +++ b/services/teacher-bff/src/graphql/graphql.controller.ts @@ -0,0 +1,65 @@ +// GraphQL Yoga endpoint(B1 裁决:P2 起直接 GraphQL) +// POST /graphql — GraphQL query/mutation endpoint +// GET /graphql — GraphQL Playground(开发环境)/ schema introspection +import { Controller, Post, Get, Req, Res, Inject } from "@nestjs/common"; +import { createYoga } from "graphql-yoga"; +import type { Request, Response } from "express"; +import { env } from "../config/env.js"; +import { logger } from "../shared/observability/logger.js"; +import { loadSchema } from "./schema-loader.js"; +import { buildGraphQLContext } from "./context.js"; +import { TeacherService } from "../teacher/teacher.service.js"; +import { buildResolvers } from "../teacher/teacher.resolver.js"; + +@Controller("graphql") +export class GraphQLController { + // Yoga 实例类型复杂,用内联类型避免泛型不匹配 + private readonly yoga: (req: Request, res: Response) => Promise; + + 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; + + logger.info( + { graphqlEndpoint: env.GRAPHQL_PATH }, + "GraphQL Yoga endpoint initialized", + ); + } + + @Post() + async handleGraphQL( + @Req() req: Request, + @Res() res: Response, + ): Promise { + await this.yoga(req, res); + } + + @Get() + async handleGraphiQL( + @Req() req: Request, + @Res() res: Response, + ): Promise { + await this.yoga(req, res); + } +} diff --git a/services/teacher-bff/src/graphql/graphql.module.ts b/services/teacher-bff/src/graphql/graphql.module.ts new file mode 100644 index 0000000..a9a7b00 --- /dev/null +++ b/services/teacher-bff/src/graphql/graphql.module.ts @@ -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 {} diff --git a/services/teacher-bff/src/graphql/schema-loader.ts b/services/teacher-bff/src/graphql/schema-loader.ts new file mode 100644 index 0000000..83b58c0 --- /dev/null +++ b/services/teacher-bff/src/graphql/schema-loader.ts @@ -0,0 +1,22 @@ +// GraphQL Schema 加载器(从 .graphql 文件加载 SDL,B1 裁决) +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { makeExecutableSchema } from "@graphql-tools/schema"; +import type { IResolvers } from "@graphql-tools/utils"; + +/** schema 文件路径(monorepo 相对路径) */ +const SCHEMA_PATH = path.resolve( + process.cwd(), + "../../packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql", +); + +/** + * 加载 GraphQL schema(从 SDL 文件 + resolvers 构建可执行 schema) + * schema 文件位于 packages/shared-ts/contracts/graphql/(ARB-001 仲裁产物) + */ +export function loadSchema( + resolvers: IResolvers, +): ReturnType { + const typeDefs = readFileSync(SCHEMA_PATH, "utf-8"); + return makeExecutableSchema({ typeDefs, resolvers }); +} diff --git a/services/teacher-bff/src/main.ts b/services/teacher-bff/src/main.ts index 008e3b2..03e3a76 100644 --- a/services/teacher-bff/src/main.ts +++ b/services/teacher-bff/src/main.ts @@ -1,36 +1,62 @@ +// teacher-bff 入口(B1 裁决:P2 起 GraphQL Yoga) +// 优雅关闭顺序(G9):HTTP → 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 { initTracer(); - const app = await NestFactory.create(AppModule, { + const app = await NestFactory.create(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(); - }); + // 优雅关闭(G9:HTTP → Redis → gRPC → Tracer) + const shutdown = async (signal: string) => { + logger.info({ signal }, "Shutting down Teacher BFF"); + try { + await app.close(); // 触发 OnModuleDestroy(Redis 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) => { diff --git a/services/teacher-bff/src/middleware/authorization.guard.ts b/services/teacher-bff/src/middleware/authorization.guard.ts new file mode 100644 index 0000000..207316b --- /dev/null +++ b/services/teacher-bff/src/middleware/authorization.guard.ts @@ -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 { + /** 校验教师是否有权访问指定班级(场景 A:teacherId 与资源归属) */ + canAccessClass(userId: string, classId: string): Promise; + + /** 校验教师是否有权访问指定考试 */ + canAccessExam(userId: string, examId: string): Promise; + + /** 校验教师是否有权访问指定作业 */ + canAccessHomework(userId: string, homeworkId: string): Promise; + + /** 校验身份一致性(场景 B:JWT 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 { + 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 { + 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 { + 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" }, + ); + } + + /** + * 校验身份一致性(场景 B,president §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 }, + ); + } + } +} diff --git a/services/teacher-bff/src/middleware/middleware.module.ts b/services/teacher-bff/src/middleware/middleware.module.ts new file mode 100644 index 0000000..d1694f3 --- /dev/null +++ b/services/teacher-bff/src/middleware/middleware.module.ts @@ -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 {} diff --git a/services/teacher-bff/src/shared/errors/application-error.ts b/services/teacher-bff/src/shared/errors/application-error.ts index fd323ea..47ec30b 100644 --- a/services/teacher-bff/src/shared/errors/application-error.ts +++ b/services/teacher-bff/src/shared/errors/application-error.ts @@ -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 缺失或无效(401,president §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 场景 A,president §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 场景 B,president §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); } } diff --git a/services/teacher-bff/src/shared/errors/global-error.filter.ts b/services/teacher-bff/src/shared/errors/global-error.filter.ts index f3622c3..188d71b 100644 --- a/services/teacher-bff/src/shared/errors/global-error.filter.ts +++ b/services/teacher-bff/src/shared/errors/global-error.filter.ts @@ -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, }, diff --git a/services/teacher-bff/src/shared/health/health.controller.ts b/services/teacher-bff/src/shared/health/health.controller.ts index eb02995..44e048b 100644 --- a/services/teacher-bff/src/shared/health/health.controller.ts +++ b/services/teacher-bff/src/shared/health/health.controller.ts @@ -1,15 +1,17 @@ -import { Controller, Get } from "@nestjs/common"; +// 健康检查端点(G3 + president §2.4) +// - GET /healthz:liveness,仅返回进程存活(不检查下游) +// - GET /readyz:readiness,DownstreamHealthCheck 注册表(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 /healthz:liveness,仅返回进程存活。 - * - GET /readyz:readiness,BFF 不直接访问 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 { + const report = await healthRegistry.runAll(); + const statusCode = + report.status === "unavailable" + ? HttpStatus.SERVICE_UNAVAILABLE + : HttpStatus.OK; + res.status(statusCode).json(report); } } diff --git a/services/teacher-bff/src/shared/health/health.module.ts b/services/teacher-bff/src/shared/health/health.module.ts index 8c202b9..6924063 100644 --- a/services/teacher-bff/src/shared/health/health.module.ts +++ b/services/teacher-bff/src/shared/health/health.module.ts @@ -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 { + // 注册 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 { + if (this.redis) { + await this.redis.quit(); + logger.info("Redis connection closed"); + } + } +} diff --git a/services/teacher-bff/src/shared/health/iam-grpc.probe.ts b/services/teacher-bff/src/shared/health/iam-grpc.probe.ts new file mode 100644 index 0000000..6dc976a --- /dev/null +++ b/services/teacher-bff/src/shared/health/iam-grpc.probe.ts @@ -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, + }; + } +} diff --git a/services/teacher-bff/src/shared/health/readiness.probe.ts b/services/teacher-bff/src/shared/health/readiness.probe.ts new file mode 100644 index 0000000..4baf59f --- /dev/null +++ b/services/teacher-bff/src/shared/health/readiness.probe.ts @@ -0,0 +1,113 @@ +// /readyz 探针注册表(president §2.4 + G2) +// DownstreamHealthCheck 注册表模式:每个下游注册独立探针,按阶段启用 +// P2: Redis PING + iam gRPC 50052(2 项) +// 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(); + + register(probe: HealthProbe): void { + this.probes.set(probe.name, probe); + } + + unregister(name: string): void { + this.probes.delete(name); + } + + async runAll(): Promise { + 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(); diff --git a/services/teacher-bff/src/shared/health/redis.probe.ts b/services/teacher-bff/src/shared/health/redis.probe.ts new file mode 100644 index 0000000..2762a75 --- /dev/null +++ b/services/teacher-bff/src/shared/health/redis.probe.ts @@ -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), + }; + } + } +} diff --git a/services/teacher-bff/src/shared/observability/tracer.ts b/services/teacher-bff/src/shared/observability/tracer.ts index 6222b66..dcbdce2 100644 --- a/services/teacher-bff/src/shared/observability/tracer.ts +++ b/services/teacher-bff/src/shared/observability/tracer.ts @@ -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 { if (sdk) { await sdk.shutdown(); + sdk = null; } } diff --git a/services/teacher-bff/src/teacher/teacher.controller.ts b/services/teacher-bff/src/teacher/teacher.controller.ts deleted file mode 100644 index 1bcd379..0000000 --- a/services/teacher-bff/src/teacher/teacher.controller.ts +++ /dev/null @@ -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 { - success: true; - data: T; -} - -@Controller("teacher") -export class TeacherController { - constructor(private readonly service: TeacherService) {} - - @Get("dashboard") - async dashboard(@Req() req: Request): Promise> { - 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> { - 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> { - 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> { - 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> { - 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; - } -} diff --git a/services/teacher-bff/src/teacher/teacher.module.ts b/services/teacher-bff/src/teacher/teacher.module.ts index 91864c1..0c96f96 100644 --- a/services/teacher-bff/src/teacher/teacher.module.ts +++ b/services/teacher-bff/src/teacher/teacher.module.ts @@ -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 {} diff --git a/services/teacher-bff/src/teacher/teacher.resolver.ts b/services/teacher-bff/src/teacher/teacher.resolver.ts new file mode 100644 index 0000000..80fd098 --- /dev/null +++ b/services/teacher-bff/src/teacher/teacher.resolver.ts @@ -0,0 +1,362 @@ +// GraphQL Resolvers(B1 裁决:P2 起 GraphQL) +// P2 核心 5 Query:dashboard / 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.1:P2 预留,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 命名空间 Mutation(P2 预留,P6 实现)===== + + admin: () => { + return {}; + }, + }, + + // ===== admin 命名空间子字段 Resolver(P2 全部返回 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" }, + ); + }, + }, + + // ===== 类型字段 Resolver(Class.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 + }, + }; +} diff --git a/services/teacher-bff/src/teacher/teacher.service.ts b/services/teacher-bff/src/teacher/teacher.service.ts index a043143..83016d1 100644 --- a/services/teacher-bff/src/teacher/teacher.service.ts +++ b/services/teacher-bff/src/teacher/teacher.service.ts @@ -1,158 +1,122 @@ -import { Injectable } from "@nestjs/common"; -import { env } from "../config/env.js"; +// TeacherService — GraphQL Resolver 的业务逻辑层(B1 裁决:P2 起 GraphQL) +// 通过 IamClient 调下游 iam gRPC(B2 裁决:首次实现即 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 { - 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 mock,P3+ core-edu 真实数据) */ +export interface ClassInfo { + id: string; + name: string; + gradeId: string; } +/** P2 mock 班级数据(president §3.5:P2 班级列表来自 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 { - 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 { + 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 { + return this.iam.getViewports(ctx); + } + + /** + * 获取 Dashboard 聚合数据(president §2.8:P2 仅调 iam gRPC) + * P2: user + viewports 有数据,classes 返回 mock,stats 返回 null + warning + * P3+: classes 来自 core-edu,stats 来自 data-ana.GetTeacherDashboard + */ + async getDashboard(ctx: CallContext): Promise { + 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 { - 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; - return json.data ?? []; - } - - // 聚合班级下的考试列表(core-edu) - async listExamsByClass(userId: string, classId: string): Promise { - const res = await fetch( - `${env.CoreEduServiceUrl}/exams/class/${encodeURIComponent(classId)}`, - { headers: { "x-user-id": userId } }, + // P2: classes 返回 mock 数据(P3+ 替换为 core-edu.GetClassesByTeacher) + // P2: stats 返回 null(P4+ 替换为 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; - return json.data ?? []; + + return { + user, + viewports, + classes: MOCK_CLASSES, + stats: null, + }; } - // 聚合班级下的作业列表(core-edu) - async listHomeworkByClass(userId: string, classId: string): Promise { - const res = await fetch( - `${env.CoreEduServiceUrl}/homework/class/${encodeURIComponent(classId)}`, - { headers: { "x-user-id": userId } }, + /** 获取教师班级列表(P2: mock;P3+: core-edu.GetClassesByTeacher) */ + async getClasses(ctx: CallContext): Promise { + 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; - return json.data ?? []; + return MOCK_CLASSES; } - // 聚合考试下的成绩列表(core-edu) - async listGradesByExam(userId: string, examId: string): Promise { - const res = await fetch( - `${env.CoreEduServiceUrl}/grades/exam/${encodeURIComponent(examId)}`, - { headers: { "x-user-id": userId } }, + /** 获取单个班级详情(P2: mock;P3+: core-edu) */ + async getClass(ctx: CallContext, classId: string): Promise { + 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; - return json.data ?? []; + return MOCK_CLASSES.find((c) => c.id === classId) ?? null; } } + +/** 导出 EffectivePermissions 类型供外部使用 */ +export type { EffectivePermissions, UserInfo };