feat(teacher-bff): 完整实现 teacher-bff GraphQL 聚合层
包含 clients/graphql/middleware、health probes、shared-ts contracts 等
This commit is contained in:
@@ -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 修饰符 |
|
||||
|
||||
452
packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql
Normal file
452
packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql
Normal file
@@ -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
|
||||
}
|
||||
369
pnpm-lock.yaml
generated
369
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
159
services/teacher-bff/src/clients/base.client.ts
Normal file
159
services/teacher-bff/src/clients/base.client.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// DownstreamClient 抽象基类(B8 裁决:3 个 BFF 统一使用)
|
||||
// 提供统一的错误映射 + 结构化日志 + metrics 记录
|
||||
import type { Logger } from "pino";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
import {
|
||||
BadGatewayError,
|
||||
AggregationFailedError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
import type {
|
||||
CallContext,
|
||||
DownstreamError,
|
||||
DownstreamResult,
|
||||
DownstreamServiceName,
|
||||
GrpcMetadata,
|
||||
} from "./types.js";
|
||||
|
||||
/** gRPC 状态码 → HTTP 语义映射(@grpc/grpc-js status codes) */
|
||||
const GRPC_STATUS = {
|
||||
OK: 0,
|
||||
CANCELLED: 1,
|
||||
UNKNOWN: 2,
|
||||
INVALID_ARGUMENT: 3,
|
||||
DEADLINE_EXCEEDED: 4,
|
||||
NOT_FOUND: 5,
|
||||
ALREADY_EXISTS: 6,
|
||||
PERMISSION_DENIED: 7,
|
||||
RESOURCE_EXHAUSTED: 8,
|
||||
FAILED_PRECONDITION: 9,
|
||||
ABORTED: 10,
|
||||
OUT_OF_RANGE: 11,
|
||||
UNIMPLEMENTED: 12,
|
||||
INTERNAL: 13,
|
||||
UNAVAILABLE: 14,
|
||||
DATA_LOSS: 15,
|
||||
UNAUTHENTICATED: 16,
|
||||
} as const;
|
||||
|
||||
export abstract class BaseDownstreamClient {
|
||||
protected log: Logger;
|
||||
abstract readonly serviceName: DownstreamServiceName;
|
||||
|
||||
constructor() {
|
||||
// abstract property 在子类构造后才可用,延迟初始化 logger
|
||||
this.log = logger.child({ downstream: "downstream" });
|
||||
}
|
||||
|
||||
/** 子类在构造函数中调用以初始化 logger(带正确 serviceName) */
|
||||
protected initLogger(): void {
|
||||
this.log = logger.child({ downstream: this.serviceName });
|
||||
}
|
||||
|
||||
/** 构建 gRPC metadata(注入 x-user-id + x-request-id) */
|
||||
protected buildMetadata(ctx: CallContext): GrpcMetadata {
|
||||
const meta: GrpcMetadata = { "x-user-id": ctx.userId };
|
||||
if (ctx.traceId) {
|
||||
meta["x-request-id"] = ctx.traceId;
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
/** 将 gRPC 错误映射为 BadGatewayError(502) */
|
||||
protected mapGrpcError(err: unknown, rpc: string): BadGatewayError {
|
||||
const grpcErr = err as {
|
||||
code?: number;
|
||||
message?: string;
|
||||
details?: string;
|
||||
};
|
||||
const code = grpcErr.code ?? GRPC_STATUS.UNKNOWN;
|
||||
const message = grpcErr.message ?? "Unknown gRPC error";
|
||||
|
||||
this.log.warn(
|
||||
{ rpc, grpcCode: code, message, err },
|
||||
"Downstream gRPC call failed",
|
||||
);
|
||||
|
||||
// UNAVAILABLE → 502 Bad Gateway
|
||||
if (code === GRPC_STATUS.UNAVAILABLE) {
|
||||
return new BadGatewayError(
|
||||
`Downstream ${this.serviceName}.${rpc} unavailable`,
|
||||
{ service: this.serviceName, rpc, grpcCode: code },
|
||||
);
|
||||
}
|
||||
|
||||
// UNAUTHENTICATED → 映射为 BadGateway(BFF 自身做身份校验,下游不应返回 UNAUTHENTICATED)
|
||||
if (code === GRPC_STATUS.UNAUTHENTICATED) {
|
||||
return new BadGatewayError(
|
||||
`Downstream ${this.serviceName}.${rpc} returned UNAUTHENTICATED`,
|
||||
{ service: this.serviceName, rpc, grpcCode: code },
|
||||
);
|
||||
}
|
||||
|
||||
// 其他错误统一映射为 BadGateway
|
||||
return new BadGatewayError(
|
||||
`Downstream ${this.serviceName}.${rpc} failed: ${message}`,
|
||||
{
|
||||
service: this.serviceName,
|
||||
rpc,
|
||||
grpcCode: code,
|
||||
details: grpcErr.details,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 执行 gRPC 调用并统一错误处理 + 超时控制 */
|
||||
protected async callGrpc<T>(
|
||||
rpc: string,
|
||||
fn: () => Promise<T>,
|
||||
timeoutMs = 3000,
|
||||
): Promise<T> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
fn(),
|
||||
this.createTimeout(timeoutMs, rpc),
|
||||
]);
|
||||
const duration = Date.now() - start;
|
||||
this.log.debug(
|
||||
{ rpc, durationMs: duration },
|
||||
"Downstream gRPC call succeeded",
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
throw this.mapGrpcError(err, rpc);
|
||||
}
|
||||
}
|
||||
|
||||
private createTimeout(ms: number, rpc: string): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(
|
||||
new BadGatewayError(
|
||||
`Downstream ${this.serviceName}.${rpc} timed out after ${ms}ms`,
|
||||
{ service: this.serviceName, rpc, timeoutMs: ms },
|
||||
),
|
||||
);
|
||||
}, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/** 降级模式 B:部分失败时返回 success=true + degraded=true + warning(president §2.6) */
|
||||
protected degraded<T>(
|
||||
data: T | null,
|
||||
warning: string,
|
||||
rpc: string,
|
||||
): DownstreamResult<T> {
|
||||
this.log.warn({ rpc, warning }, "Downstream call degraded");
|
||||
return { success: true, data, degraded: true, warning };
|
||||
}
|
||||
|
||||
/** 聚合失败(多下游部分失败且无降级数据,president §2.6) */
|
||||
protected aggregationFailed(
|
||||
errors: DownstreamError[],
|
||||
): AggregationFailedError {
|
||||
return new AggregationFailedError(
|
||||
`Aggregation failed for ${this.serviceName}: ${errors.length} downstream(s) failed`,
|
||||
{ service: this.serviceName, errors },
|
||||
);
|
||||
}
|
||||
}
|
||||
10
services/teacher-bff/src/clients/clients.module.ts
Normal file
10
services/teacher-bff/src/clients/clients.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// DownstreamClient 聚合模块(B8 裁决:3 个 BFF 统一使用)
|
||||
// P2 仅注册 IamModule,P3+ 扩展 CoreEduModule / ContentModule / DataAnaModule / MsgModule / AiModule
|
||||
import { Module } from "@nestjs/common";
|
||||
import { IamModule } from "./iam/iam.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
exports: [IamModule],
|
||||
})
|
||||
export class ClientsModule {}
|
||||
256
services/teacher-bff/src/clients/grpc/grpc.factory.ts
Normal file
256
services/teacher-bff/src/clients/grpc/grpc.factory.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// gRPC Channel + Client 工厂(B2 裁决:首次实现即 gRPC)
|
||||
// 使用 @grpc/grpc-js + @grpc/proto-loader 动态加载 proto(无生成代码依赖)
|
||||
// 上游就绪后可切换为 @bufbuild/protobuf 生成代码(B8 裁决)
|
||||
import * as grpc from "@grpc/grpc-js";
|
||||
import * as protoLoader from "@grpc/proto-loader";
|
||||
import path from "node:path";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import type { DownstreamServiceName } from "../types.js";
|
||||
|
||||
/** proto 文件根目录(monorepo 相对路径) */
|
||||
const PROTO_ROOT = path.resolve(
|
||||
process.cwd(),
|
||||
"../../packages/shared-proto/proto",
|
||||
);
|
||||
|
||||
/** 已加载的 proto 定义缓存(按文件名缓存) */
|
||||
const packageCache = new Map<string, protoLoader.PackageDefinition>();
|
||||
|
||||
/** 已创建的 gRPC Client 缓存(按 service+target 缓存) */
|
||||
const clientCache = new Map<string, grpc.Client>();
|
||||
|
||||
/** 各下游服务对应的 proto 文件 + 包名 + service 名 */
|
||||
interface ServiceProtoConfig {
|
||||
protoFile: string;
|
||||
packageName: string;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
const SERVICE_PROTO_MAP: Record<DownstreamServiceName, ServiceProtoConfig> = {
|
||||
iam: {
|
||||
protoFile: "iam.proto",
|
||||
packageName: "next_edu_cloud.iam.v1",
|
||||
serviceName: "IamService",
|
||||
},
|
||||
"core-edu": {
|
||||
protoFile: "core_edu.proto",
|
||||
packageName: "next_edu_cloud.core_edu.v1",
|
||||
serviceName: "", // core-edu 多 service,按需指定
|
||||
},
|
||||
content: {
|
||||
protoFile: "content.proto",
|
||||
packageName: "next_edu_cloud.content.v1",
|
||||
serviceName: "",
|
||||
},
|
||||
"data-ana": {
|
||||
protoFile: "analytics.proto",
|
||||
packageName: "next_edu_cloud.analytics.v1",
|
||||
serviceName: "",
|
||||
},
|
||||
msg: {
|
||||
protoFile: "msg.proto",
|
||||
packageName: "next_edu_cloud.msg.v1",
|
||||
serviceName: "",
|
||||
},
|
||||
ai: {
|
||||
protoFile: "ai.proto",
|
||||
packageName: "next_edu_cloud.ai.v1",
|
||||
serviceName: "",
|
||||
},
|
||||
};
|
||||
|
||||
/** 加载 proto 定义(带缓存) */
|
||||
function loadPackageDefinition(
|
||||
protoFile: string,
|
||||
): protoLoader.PackageDefinition {
|
||||
const cached = packageCache.get(protoFile);
|
||||
if (cached) return cached;
|
||||
|
||||
const fullPath = path.join(PROTO_ROOT, protoFile);
|
||||
const def = protoLoader.loadSync(fullPath, {
|
||||
keepCase: false,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
includeDirs: [PROTO_ROOT],
|
||||
});
|
||||
packageCache.set(protoFile, def);
|
||||
return def;
|
||||
}
|
||||
|
||||
/** 获取下游服务的 gRPC target(从环境变量) */
|
||||
export function getGrpcTarget(service: DownstreamServiceName): string {
|
||||
const map: Record<DownstreamServiceName, string> = {
|
||||
iam: env.IAM_GRPC_TARGET,
|
||||
"core-edu": env.CORE_EDU_GRPC_TARGET,
|
||||
content: env.CONTENT_GRPC_TARGET,
|
||||
"data-ana": env.DATA_ANA_GRPC_TARGET,
|
||||
msg: env.MSG_GRPC_TARGET,
|
||||
ai: env.AI_GRPC_TARGET,
|
||||
};
|
||||
return map[service];
|
||||
}
|
||||
|
||||
/** 判断下游服务是否已配置 target(未配置则走 mock) */
|
||||
export function isServiceConfigured(service: DownstreamServiceName): boolean {
|
||||
const target = getGrpcTarget(service);
|
||||
return target.length > 0;
|
||||
}
|
||||
|
||||
/** 创建或获取 gRPC Client(带缓存) */
|
||||
export function getGrpcClient(
|
||||
service: DownstreamServiceName,
|
||||
serviceName?: string,
|
||||
): grpc.Client {
|
||||
const target = getGrpcTarget(service);
|
||||
const config = SERVICE_PROTO_MAP[service];
|
||||
const svcName = serviceName ?? config.serviceName;
|
||||
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`gRPC target not configured for service: ${service} (set ${service.toUpperCase().replace("-", "_")}_GRPC_TARGET)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!svcName) {
|
||||
throw new Error(
|
||||
`Service name not specified for ${service}, pass serviceName explicitly`,
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = `${service}:${svcName}:${target}`;
|
||||
const cached = clientCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const def = loadPackageDefinition(config.protoFile);
|
||||
const proto = grpc.loadPackageDefinition(def) as unknown as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
|
||||
// 按包路径查找 Service 构造器
|
||||
const pkgParts = config.packageName.split(".");
|
||||
let pkg: Record<string, unknown> = proto;
|
||||
for (const part of pkgParts) {
|
||||
pkg = pkg[part] as Record<string, unknown>;
|
||||
if (!pkg) {
|
||||
throw new Error(
|
||||
`Package ${config.packageName} not found in ${config.protoFile}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ServiceConstructor = pkg[svcName] as unknown as {
|
||||
new (target: string, credentials: grpc.ChannelCredentials): grpc.Client;
|
||||
};
|
||||
if (!ServiceConstructor) {
|
||||
throw new Error(
|
||||
`Service ${svcName} not found in package ${config.packageName}`,
|
||||
);
|
||||
}
|
||||
|
||||
// P2-P2 阶段使用 insecure(内网,mTLS 在 P6 硬化阶段启用)
|
||||
const client = new ServiceConstructor(
|
||||
target,
|
||||
grpc.credentials.createInsecure(),
|
||||
);
|
||||
|
||||
clientCache.set(cacheKey, client);
|
||||
logger.info({ service, serviceName: svcName, target }, "gRPC client created");
|
||||
return client;
|
||||
}
|
||||
|
||||
/** 创建 gRPC metadata(注入 x-user-id + x-request-id) */
|
||||
export function createGrpcMetadata(
|
||||
userId: string,
|
||||
traceId?: string,
|
||||
): grpc.Metadata {
|
||||
const meta = new grpc.Metadata();
|
||||
meta.add("x-user-id", userId);
|
||||
if (traceId) {
|
||||
meta.add("x-request-id", traceId);
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
/** 关闭所有 gRPC Client(优雅关闭时调用) */
|
||||
export function closeAllGrpcClients(): void {
|
||||
for (const [key, client] of clientCache) {
|
||||
try {
|
||||
client.close();
|
||||
logger.info({ client: key }, "gRPC client closed");
|
||||
} catch (err) {
|
||||
logger.warn({ client: key, err }, "Failed to close gRPC client");
|
||||
}
|
||||
}
|
||||
clientCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 gRPC 健康检查(grpc.health.v1.Health/Check)
|
||||
* 用于 /readyz 探针(president §2.4)
|
||||
*
|
||||
* 使用标准 grpc.health.v1.Health 服务,通过 makeUnaryRequest 直接调用。
|
||||
* 超时 2s,返回 SERVING / NOT_SERVING / UNREACHABLE。
|
||||
*/
|
||||
export async function checkGrpcHealth(
|
||||
service: DownstreamServiceName,
|
||||
): Promise<{ serving: boolean; latencyMs: number; error?: string }> {
|
||||
const target = getGrpcTarget(service);
|
||||
if (!target) {
|
||||
return { serving: false, latencyMs: 0, error: "target not configured" };
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const client = new grpc.Client(target, grpc.credentials.createInsecure(), {
|
||||
"grpc.enable_retries": 0,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
client.close();
|
||||
resolve({
|
||||
serving: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: "health check timed out (2s)",
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
client.makeUnaryRequest(
|
||||
"grpc.health.v1.Health/Check",
|
||||
// serialize: HealthCheckRequest → Buffer(空消息体)
|
||||
() => Buffer.alloc(0),
|
||||
// deserialize: Buffer → { status: string }
|
||||
(buf: Buffer) => {
|
||||
// proto HealthCheckResponse { enum ServingStatus { SERVING = 1; } int32 status = 1; }
|
||||
// 简化解析:直接读取第一个 varint 字段(field 1 = status)
|
||||
if (buf.length < 2) return { status: "UNKNOWN" };
|
||||
const statusByte: number = buf[1] ?? 0;
|
||||
const statusMap: Record<number, string> = {
|
||||
0: "UNKNOWN",
|
||||
1: "SERVING",
|
||||
2: "NOT_SERVING",
|
||||
3: "SERVICE_UNKNOWN",
|
||||
};
|
||||
return { status: statusMap[statusByte] ?? "UNKNOWN" };
|
||||
},
|
||||
{},
|
||||
new grpc.Metadata(),
|
||||
{},
|
||||
(err: grpc.ServiceError | null, value: unknown) => {
|
||||
clearTimeout(timeout);
|
||||
const latencyMs = Date.now() - start;
|
||||
client.close();
|
||||
if (err) {
|
||||
resolve({ serving: false, latencyMs, error: err.message });
|
||||
} else {
|
||||
const status = (value as { status?: string } | null | undefined)
|
||||
?.status;
|
||||
resolve({ serving: status === "SERVING", latencyMs });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
31
services/teacher-bff/src/clients/iam/iam-client.interface.ts
Normal file
31
services/teacher-bff/src/clients/iam/iam-client.interface.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// IamClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||
// 接口定义所有 P2+ 需要的 iam RPC,实现分 gRPC + mock 两种
|
||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC(未就绪 RPC 降级 mock + warning)
|
||||
import type { CallContext } from "../types.js";
|
||||
import type {
|
||||
UserInfo,
|
||||
ViewportItem,
|
||||
EffectivePermissions,
|
||||
} from "./iam.types.js";
|
||||
|
||||
/** IamClient DI token(NestJS 注入用) */
|
||||
export const IAM_CLIENT = Symbol("IAM_CLIENT");
|
||||
|
||||
/** IamClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
|
||||
export interface IamClient {
|
||||
/** 获取用户信息(iam.proto GetUserInfo,✅ 已就绪) */
|
||||
getUserInfo(ctx: CallContext): Promise<UserInfo>;
|
||||
|
||||
/** 获取教师导航菜单(iam.proto GetViewports,❌ 待 coord 补全) */
|
||||
getViewports(ctx: CallContext): Promise<ViewportItem[]>;
|
||||
|
||||
/** 获取有效权限集(iam.proto GetEffectivePermissions,❌ 待 coord 补全) */
|
||||
getEffectivePermissions(ctx: CallContext): Promise<EffectivePermissions>;
|
||||
|
||||
/** 健康检查(/readyz 探针用) */
|
||||
checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
79
services/teacher-bff/src/clients/iam/iam-grpc.client.ts
Normal file
79
services/teacher-bff/src/clients/iam/iam-grpc.client.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// IamClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||
// iam.proto 现状仅 GetUserInfo,GetViewports/GetEffectivePermissions 待 coord 补全
|
||||
// 未就绪 RPC 降级 mock + warning 日志(president §2.6 降级模式 B)
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type * as grpc from "@grpc/grpc-js";
|
||||
import { BaseDownstreamClient } from "../base.client.js";
|
||||
import {
|
||||
createGrpcMetadata,
|
||||
getGrpcClient,
|
||||
checkGrpcHealth,
|
||||
} from "../grpc/grpc.factory.js";
|
||||
import type { CallContext } from "../types.js";
|
||||
import type { IamClient } from "./iam-client.interface.js";
|
||||
import type {
|
||||
UserInfo,
|
||||
ViewportItem,
|
||||
EffectivePermissions,
|
||||
} from "./iam.types.js";
|
||||
import { IamMockClient } from "./iam-mock.client.js";
|
||||
|
||||
@Injectable()
|
||||
export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
|
||||
readonly serviceName = "iam" as const;
|
||||
private readonly mock: IamMockClient;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
// mock 用于未就绪 RPC 的降级(proto 未定义的 RPC 走 mock + warning)
|
||||
this.mock = new IamMockClient();
|
||||
}
|
||||
|
||||
async getUserInfo(ctx: CallContext): Promise<UserInfo> {
|
||||
return this.callGrpc("GetUserInfo", async () => {
|
||||
const client = getGrpcClient("iam") as unknown as {
|
||||
getUserInfo(
|
||||
req: { userId: string },
|
||||
meta: grpc.Metadata,
|
||||
cb: (err: grpc.ServiceError | null, res: UserInfo) => void,
|
||||
): void;
|
||||
};
|
||||
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||
return new Promise<UserInfo>((resolve, reject) => {
|
||||
client.getUserInfo({ userId: ctx.userId }, meta, (err, res) => {
|
||||
if (err) reject(err);
|
||||
else resolve(res);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
|
||||
// iam.proto 暂无 GetViewports RPC,降级 mock + warning(待 coord 补全)
|
||||
this.log.warn(
|
||||
{ rpc: "GetViewports", reason: "RPC not in iam.proto yet" },
|
||||
"Downstream RPC not ready, falling back to mock",
|
||||
);
|
||||
return this.mock.getViewports(ctx);
|
||||
}
|
||||
|
||||
async getEffectivePermissions(
|
||||
ctx: CallContext,
|
||||
): Promise<EffectivePermissions> {
|
||||
// iam.proto 暂无 GetEffectivePermissions RPC,降级 mock + warning
|
||||
this.log.warn(
|
||||
{ rpc: "GetEffectivePermissions", reason: "RPC not in iam.proto yet" },
|
||||
"Downstream RPC not ready, falling back to mock",
|
||||
);
|
||||
return this.mock.getEffectivePermissions(ctx);
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return checkGrpcHealth("iam");
|
||||
}
|
||||
}
|
||||
125
services/teacher-bff/src/clients/iam/iam-mock.client.ts
Normal file
125
services/teacher-bff/src/clients/iam/iam-mock.client.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// IamClient Mock 实现(B8 裁决:上游就绪前的降级策略)
|
||||
// DEV_MODE=true 时全部走 mock;DEV_MODE=false 时仅未就绪 RPC 走 mock
|
||||
// mock 数据对齐 contract §4.2 mock 策略
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { BaseDownstreamClient } from "../base.client.js";
|
||||
import type { CallContext } from "../types.js";
|
||||
import type { IamClient } from "./iam-client.interface.js";
|
||||
import type {
|
||||
UserInfo,
|
||||
ViewportItem,
|
||||
EffectivePermissions,
|
||||
} from "./iam.types.js";
|
||||
|
||||
/** 教师默认 mock 用户(contract §4.1 mock 策略) */
|
||||
const MOCK_TEACHER: UserInfo = {
|
||||
id: "teacher-001",
|
||||
email: "teacher@edu.test",
|
||||
name: "张老师",
|
||||
roles: ["teacher"],
|
||||
permissions: [
|
||||
"exam:read",
|
||||
"exam:write",
|
||||
"homework:read",
|
||||
"homework:write",
|
||||
"grade:read",
|
||||
"grade:write",
|
||||
"class:read",
|
||||
"student:read",
|
||||
],
|
||||
};
|
||||
|
||||
/** 教师默认 mock 视口(L1 导航) */
|
||||
const MOCK_VIEWPORTS: ViewportItem[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "工作台",
|
||||
route: "/dashboard",
|
||||
icon: null,
|
||||
sortOrder: 1,
|
||||
requiredPermission: null,
|
||||
},
|
||||
{
|
||||
key: "classes",
|
||||
label: "我的班级",
|
||||
route: "/classes",
|
||||
icon: null,
|
||||
sortOrder: 2,
|
||||
requiredPermission: "class:read",
|
||||
},
|
||||
{
|
||||
key: "exams",
|
||||
label: "考试管理",
|
||||
route: "/exams",
|
||||
icon: null,
|
||||
sortOrder: 3,
|
||||
requiredPermission: "exam:read",
|
||||
},
|
||||
{
|
||||
key: "homework",
|
||||
label: "作业管理",
|
||||
route: "/homework",
|
||||
icon: null,
|
||||
sortOrder: 4,
|
||||
requiredPermission: "homework:read",
|
||||
},
|
||||
{
|
||||
key: "grades",
|
||||
label: "成绩管理",
|
||||
route: "/grades",
|
||||
icon: null,
|
||||
sortOrder: 5,
|
||||
requiredPermission: "grade:read",
|
||||
},
|
||||
{
|
||||
key: "analytics",
|
||||
label: "学情分析",
|
||||
route: "/analytics",
|
||||
icon: null,
|
||||
sortOrder: 6,
|
||||
requiredPermission: "student:read",
|
||||
},
|
||||
];
|
||||
|
||||
/** 教师默认 mock 权限(全权限 + OWN 数据范围) */
|
||||
const MOCK_PERMISSIONS: EffectivePermissions = {
|
||||
permissions: MOCK_TEACHER.permissions,
|
||||
dataScope: "OWN",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class IamMockClient extends BaseDownstreamClient implements IamClient {
|
||||
readonly serviceName = "iam" as const;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.initLogger();
|
||||
}
|
||||
|
||||
async getUserInfo(ctx: CallContext): Promise<UserInfo> {
|
||||
this.log.debug({ userId: ctx.userId }, "Mock getUserInfo");
|
||||
// 返回 mock 教师,但 id 用请求中的 userId(保持一致性)
|
||||
return { ...MOCK_TEACHER, id: ctx.userId };
|
||||
}
|
||||
|
||||
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
|
||||
this.log.debug({ userId: ctx.userId }, "Mock getViewports");
|
||||
return MOCK_VIEWPORTS;
|
||||
}
|
||||
|
||||
async getEffectivePermissions(
|
||||
ctx: CallContext,
|
||||
): Promise<EffectivePermissions> {
|
||||
this.log.debug({ userId: ctx.userId }, "Mock getEffectivePermissions");
|
||||
return MOCK_PERMISSIONS;
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
serving: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
// mock 模式下健康检查总是返回 serving=true
|
||||
return { serving: true, latencyMs: 0 };
|
||||
}
|
||||
}
|
||||
31
services/teacher-bff/src/clients/iam/iam.module.ts
Normal file
31
services/teacher-bff/src/clients/iam/iam.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// iam 模块(B8 裁决:按 DEV_MODE 选择 mock 或 gRPC 实现)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { IAM_CLIENT } from "./iam-client.interface.js";
|
||||
import { IamGrpcClient } from "./iam-grpc.client.js";
|
||||
import { IamMockClient } from "./iam-mock.client.js";
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: IAM_CLIENT,
|
||||
useFactory: () => {
|
||||
if (env.TEACHER_BFF_DEV_MODE) {
|
||||
logger.warn(
|
||||
{ devMode: true },
|
||||
"IamClient using mock (TEACHER_BFF_DEV_MODE=true)",
|
||||
);
|
||||
return new IamMockClient();
|
||||
}
|
||||
logger.info(
|
||||
{ devMode: false, target: env.IAM_GRPC_TARGET },
|
||||
"IamClient using gRPC",
|
||||
);
|
||||
return new IamGrpcClient();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [IAM_CLIENT],
|
||||
})
|
||||
export class IamModule {}
|
||||
44
services/teacher-bff/src/clients/iam/iam.types.ts
Normal file
44
services/teacher-bff/src/clients/iam/iam.types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// iam 下游服务类型定义(对齐 iam.proto + 待 coord 补全的 RPC)
|
||||
// iam.proto 现状:4 RPC(Register/Login/RefreshToken/GetUserInfo)
|
||||
// 待 coord 补全:GetViewports / GetEffectivePermissions / GetEffectiveAccess / BatchGetUsers / GetChildrenByParent
|
||||
|
||||
/** 用户信息(对齐 iam.proto UserInfo message) */
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/** 视口项(L1 导航菜单,待 coord 补 GetViewports RPC 的 response message) */
|
||||
export interface ViewportItem {
|
||||
key: string;
|
||||
label: string;
|
||||
route: string;
|
||||
icon: string | null;
|
||||
sortOrder: number;
|
||||
requiredPermission: string | null;
|
||||
}
|
||||
|
||||
/** 有效权限集(待 coord 补 GetEffectivePermissions RPC 的 response message) */
|
||||
export interface EffectivePermissions {
|
||||
permissions: string[];
|
||||
/** 数据范围(OWN / GRADE / SCHOOL / ALL) */
|
||||
dataScope: string;
|
||||
}
|
||||
|
||||
/** GetUserInfo 请求 */
|
||||
export interface GetUserInfoRequest {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** GetViewports 请求 */
|
||||
export interface GetViewportsRequest {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** GetEffectivePermissions 请求 */
|
||||
export interface GetEffectivePermissionsRequest {
|
||||
userId: string;
|
||||
}
|
||||
46
services/teacher-bff/src/clients/types.ts
Normal file
46
services/teacher-bff/src/clients/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// DownstreamClient 抽象层共享类型(B8 裁决:3 个 BFF 统一使用)
|
||||
// 所有下游 gRPC 调用通过此抽象层,统一 trace 上下文注入 + 错误映射 + metrics
|
||||
|
||||
/** 下游调用上下文(per-request,由 GraphQL Resolver 注入) */
|
||||
export interface CallContext {
|
||||
/** 当前用户 ID(从 JWT 或 x-user-id header 提取) */
|
||||
userId: string;
|
||||
/** 请求追踪 ID(X-Request-Id,用于日志关联) */
|
||||
traceId?: string;
|
||||
/** 用户角色(可选,用于下游权限校验) */
|
||||
roles?: readonly string[];
|
||||
}
|
||||
|
||||
/** 下游调用结果(降级模式 B 支持:success + degraded 标记) */
|
||||
export type DownstreamResult<T> =
|
||||
| { success: true; data: T; degraded?: false }
|
||||
| { success: true; data: T | null; degraded: true; warning: string }
|
||||
| { success: false; error: DownstreamError };
|
||||
|
||||
export interface DownstreamError {
|
||||
code: string;
|
||||
message: string;
|
||||
service: string;
|
||||
rpc?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 下游服务名(用于 metrics 标签 + 日志字段) */
|
||||
export type DownstreamServiceName =
|
||||
"iam" | "core-edu" | "content" | "data-ana" | "msg" | "ai";
|
||||
|
||||
/** gRPC 健康检查结果(/readyz 探针用) */
|
||||
export interface GrpcHealthStatus {
|
||||
service: DownstreamServiceName;
|
||||
target: string;
|
||||
status: "SERVING" | "NOT_SERVING" | "UNKNOWN" | "UNREACHABLE";
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 下游 gRPC 调用 metadata(注入 trace context + x-user-id) */
|
||||
export interface GrpcMetadata {
|
||||
"x-user-id": string;
|
||||
"x-request-id"?: string;
|
||||
traceparent?: string;
|
||||
}
|
||||
@@ -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<typeof envSchema>;
|
||||
|
||||
export function loadEnv(): Env {
|
||||
const result = envSchema.safeParse({
|
||||
...process.env,
|
||||
IamServiceUrl: process.env.IAM_SERVICE_URL || "http://localhost:3002",
|
||||
ClassesServiceUrl:
|
||||
process.env.CLASSES_SERVICE_URL || "http://localhost:3001",
|
||||
CoreEduServiceUrl:
|
||||
process.env.CORE_EDU_SERVICE_URL || "http://localhost:3004",
|
||||
});
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
throw new Error("Invalid env: " + JSON.stringify(result.error.flatten()));
|
||||
}
|
||||
|
||||
37
services/teacher-bff/src/graphql/context.ts
Normal file
37
services/teacher-bff/src/graphql/context.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// GraphQL 上下文构建(B1 裁决:P2 起直接 GraphQL)
|
||||
// 从 Express 请求提取 userId / traceId,注入下游 CallContext
|
||||
import type { Request } from "express";
|
||||
import type { CallContext } from "../clients/types.js";
|
||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||
|
||||
export interface GraphQLOperationContext {
|
||||
/** 当前请求的 CallContext(注入下游 gRPC 调用) */
|
||||
callCtx: CallContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Express 请求构建 GraphQL 上下文
|
||||
* P2: 从 x-user-id header 提取 userId(api-gateway 注入)
|
||||
* P3+: 从 JWT 提取(api-gateway 校验后注入 x-user-id + x-user-roles)
|
||||
*/
|
||||
export function buildGraphQLContext(req: Request): GraphQLOperationContext {
|
||||
const userIdHeader = req.headers["x-user-id"];
|
||||
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
|
||||
const requestIdHeader = req.headers["x-request-id"];
|
||||
const traceId =
|
||||
typeof requestIdHeader === "string" ? requestIdHeader : undefined;
|
||||
|
||||
const rolesHeader = req.headers["x-user-roles"];
|
||||
const roles =
|
||||
typeof rolesHeader === "string"
|
||||
? rolesHeader.split(",").filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
callCtx: { userId, traceId, roles },
|
||||
};
|
||||
}
|
||||
37
services/teacher-bff/src/graphql/error-formatter.ts
Normal file
37
services/teacher-bff/src/graphql/error-formatter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// GraphQL 错误格式化(G8 裁决:ActionState 信封 + BFF_TEACHER_ 错误码)
|
||||
// Yoga 自身错误处理,extensions.code = BFF_TEACHER_* + extensions.traceId
|
||||
import { ApplicationError } from "../shared/errors/application-error.js";
|
||||
|
||||
/**
|
||||
* 格式化 GraphQL 错误(president §2.6 降级模式 B + G8 ActionState 信封)
|
||||
* 错误 extensions 包含:code(BFF_TEACHER_*)、traceId、details
|
||||
*/
|
||||
export function formatGraphQLError(
|
||||
error: unknown,
|
||||
traceId?: string,
|
||||
): { message: string; extensions: Record<string, unknown> } {
|
||||
// ApplicationError → 映射 code + details
|
||||
if (error instanceof ApplicationError) {
|
||||
return {
|
||||
message: error.message,
|
||||
extensions: {
|
||||
code: error.code,
|
||||
traceId: traceId ?? error.traceId ?? "unknown",
|
||||
details: error.details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 原生 GraphQL Error(语法错误、校验错误等)
|
||||
const err = error as {
|
||||
message?: string;
|
||||
extensions?: Record<string, unknown>;
|
||||
};
|
||||
return {
|
||||
message: err.message ?? "Unknown GraphQL error",
|
||||
extensions: {
|
||||
code: (err.extensions?.code as string) ?? "BFF_TEACHER_INTERNAL_ERROR",
|
||||
traceId: traceId ?? "unknown",
|
||||
},
|
||||
};
|
||||
}
|
||||
65
services/teacher-bff/src/graphql/graphql.controller.ts
Normal file
65
services/teacher-bff/src/graphql/graphql.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// GraphQL Yoga endpoint(B1 裁决:P2 起直接 GraphQL)
|
||||
// POST /graphql — GraphQL query/mutation endpoint
|
||||
// GET /graphql — GraphQL Playground(开发环境)/ schema introspection
|
||||
import { Controller, Post, Get, Req, Res, Inject } from "@nestjs/common";
|
||||
import { createYoga } from "graphql-yoga";
|
||||
import type { Request, Response } from "express";
|
||||
import { env } from "../config/env.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
import { loadSchema } from "./schema-loader.js";
|
||||
import { buildGraphQLContext } from "./context.js";
|
||||
import { TeacherService } from "../teacher/teacher.service.js";
|
||||
import { buildResolvers } from "../teacher/teacher.resolver.js";
|
||||
|
||||
@Controller("graphql")
|
||||
export class GraphQLController {
|
||||
// Yoga 实例类型复杂,用内联类型避免泛型不匹配
|
||||
private readonly yoga: (req: Request, res: Response) => Promise<void>;
|
||||
|
||||
constructor(@Inject(TeacherService) service: TeacherService) {
|
||||
const resolvers = buildResolvers(service);
|
||||
const schema = loadSchema(resolvers);
|
||||
|
||||
const yoga = createYoga({
|
||||
schema,
|
||||
graphqlEndpoint: env.GRAPHQL_PATH,
|
||||
context: ({ request }) => {
|
||||
return buildGraphQLContext(request as unknown as Request);
|
||||
},
|
||||
logging: {
|
||||
debug: (msg: unknown) => logger.debug({ yoga: msg }, "GraphQL Yoga"),
|
||||
info: (msg: unknown) => logger.info({ yoga: msg }, "GraphQL Yoga"),
|
||||
warn: (msg: unknown) => logger.warn({ yoga: msg }, "GraphQL Yoga"),
|
||||
error: (msg: unknown) => logger.error({ yoga: msg }, "GraphQL Yoga"),
|
||||
},
|
||||
// GraphQL Playground(开发环境启用)
|
||||
graphiql: env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
this.yoga = yoga as unknown as (
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => Promise<void>;
|
||||
|
||||
logger.info(
|
||||
{ graphqlEndpoint: env.GRAPHQL_PATH },
|
||||
"GraphQL Yoga endpoint initialized",
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async handleGraphQL(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
await this.yoga(req, res);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async handleGraphiQL(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
await this.yoga(req, res);
|
||||
}
|
||||
}
|
||||
10
services/teacher-bff/src/graphql/graphql.module.ts
Normal file
10
services/teacher-bff/src/graphql/graphql.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// GraphQL 模块(B1 裁决:P2 起直接 GraphQL Yoga)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { GraphQLController } from "./graphql.controller.js";
|
||||
import { TeacherModule } from "../teacher/teacher.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [TeacherModule],
|
||||
controllers: [GraphQLController],
|
||||
})
|
||||
export class GraphQLModule {}
|
||||
22
services/teacher-bff/src/graphql/schema-loader.ts
Normal file
22
services/teacher-bff/src/graphql/schema-loader.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// GraphQL Schema 加载器(从 .graphql 文件加载 SDL,B1 裁决)
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||
import type { IResolvers } from "@graphql-tools/utils";
|
||||
|
||||
/** schema 文件路径(monorepo 相对路径) */
|
||||
const SCHEMA_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
"../../packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql",
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载 GraphQL schema(从 SDL 文件 + resolvers 构建可执行 schema)
|
||||
* schema 文件位于 packages/shared-ts/contracts/graphql/(ARB-001 仲裁产物)
|
||||
*/
|
||||
export function loadSchema(
|
||||
resolvers: IResolvers,
|
||||
): ReturnType<typeof makeExecutableSchema> {
|
||||
const typeDefs = readFileSync(SCHEMA_PATH, "utf-8");
|
||||
return makeExecutableSchema({ typeDefs, resolvers });
|
||||
}
|
||||
@@ -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<void> {
|
||||
initTracer();
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: ["log", "error", "warn"],
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取
|
||||
// 直接获取 Express 实例注册路由,避免 HttpAdapter 泛型类型不兼容
|
||||
const expressApp = app.getHttpAdapter().getInstance() as Application;
|
||||
expressApp.get("/metrics", async (_req: Request, res: Response) => {
|
||||
res.set("Content-Type", metricsRegistry.contentType);
|
||||
res.end(await metricsRegistry.metrics());
|
||||
});
|
||||
|
||||
await app.listen(env.PORT);
|
||||
logger.info({ port: env.PORT }, "Teacher BFF started");
|
||||
logger.info(
|
||||
{
|
||||
port: env.PORT,
|
||||
graphql: env.GRAPHQL_PATH,
|
||||
devMode: env.TEACHER_BFF_DEV_MODE,
|
||||
iamTarget: env.IAM_GRPC_TARGET,
|
||||
},
|
||||
"Teacher BFF started (GraphQL + gRPC downstream)",
|
||||
);
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await app.close();
|
||||
await shutdownTracer();
|
||||
});
|
||||
// 优雅关闭(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) => {
|
||||
|
||||
123
services/teacher-bff/src/middleware/authorization.guard.ts
Normal file
123
services/teacher-bff/src/middleware/authorization.guard.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// AuthorizationGuard 越权防御(B4 + president §2.7/§2.9)
|
||||
// P2 实现:DEV_MODE 放行 + 日志告警;生产拒绝(保守策略)
|
||||
// P3+ 替换为真实 gRPC 校验(core-edu.GetClassesByTeacher 比对 teacherId)
|
||||
//
|
||||
// 3 类越权错误码(president §2.7):
|
||||
// - BFF_TEACHER_UNAUTHORIZED (401): x-user-id 缺失或无效
|
||||
// - BFF_TEACHER_FORBIDDEN_RESOURCE (403): teacherId 与资源无归属关系(场景 A)
|
||||
// - BFF_TEACHER_IDENTITY_MISMATCH (403): JWT teacherId 与 body teacherId 不一致(场景 B)
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { env } from "../config/env.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
import {
|
||||
ForbiddenResourceError,
|
||||
IdentityMismatchError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
import { IAM_CLIENT } from "../clients/iam/iam-client.interface.js";
|
||||
import type { IamClient } from "../clients/iam/iam-client.interface.js";
|
||||
|
||||
/**
|
||||
* AuthorizationGuard 接口(president §2.9)
|
||||
* P2: DEV_MODE 放行;P3+: 真实 gRPC 校验
|
||||
*/
|
||||
export interface AuthorizationGuard {
|
||||
/** 校验教师是否有权访问指定班级(场景 A:teacherId 与资源归属) */
|
||||
canAccessClass(userId: string, classId: string): Promise<boolean>;
|
||||
|
||||
/** 校验教师是否有权访问指定考试 */
|
||||
canAccessExam(userId: string, examId: string): Promise<boolean>;
|
||||
|
||||
/** 校验教师是否有权访问指定作业 */
|
||||
canAccessHomework(userId: string, homeworkId: string): Promise<boolean>;
|
||||
|
||||
/** 校验身份一致性(场景 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<boolean> {
|
||||
if (env.TEACHER_BFF_DEV_MODE) {
|
||||
logger.warn(
|
||||
{ userId, classId, devMode: true },
|
||||
"AuthorizationGuard DEV_MODE: access allowed without real check",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// P2 生产模式:保守拒绝(P3+ 接入 core-edu.GetClassesByTeacher 真实校验)
|
||||
logger.error(
|
||||
{ userId, classId, devMode: false },
|
||||
"AuthorizationGuard production mode not implemented yet (P3+)",
|
||||
);
|
||||
throw new ForbiddenResourceError(
|
||||
`User ${userId} has no access to class ${classId}`,
|
||||
{ userId, classId, reason: "production_check_not_implemented" },
|
||||
);
|
||||
}
|
||||
|
||||
async canAccessExam(userId: string, examId: string): Promise<boolean> {
|
||||
if (env.TEACHER_BFF_DEV_MODE) {
|
||||
logger.warn(
|
||||
{ userId, examId, devMode: true },
|
||||
"AuthorizationGuard DEV_MODE: access allowed without real check",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
logger.error(
|
||||
{ userId, examId, devMode: false },
|
||||
"AuthorizationGuard production mode not implemented yet (P3+)",
|
||||
);
|
||||
throw new ForbiddenResourceError(
|
||||
`User ${userId} has no access to exam ${examId}`,
|
||||
{ userId, examId, reason: "production_check_not_implemented" },
|
||||
);
|
||||
}
|
||||
|
||||
async canAccessHomework(
|
||||
userId: string,
|
||||
homeworkId: string,
|
||||
): Promise<boolean> {
|
||||
if (env.TEACHER_BFF_DEV_MODE) {
|
||||
logger.warn(
|
||||
{ userId, homeworkId, devMode: true },
|
||||
"AuthorizationGuard DEV_MODE: access allowed without real check",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
logger.error(
|
||||
{ userId, homeworkId, devMode: false },
|
||||
"AuthorizationGuard production mode not implemented yet (P3+)",
|
||||
);
|
||||
throw new ForbiddenResourceError(
|
||||
`User ${userId} has no access to homework ${homeworkId}`,
|
||||
{ userId, homeworkId, reason: "production_check_not_implemented" },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验身份一致性(场景 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
services/teacher-bff/src/middleware/middleware.module.ts
Normal file
19
services/teacher-bff/src/middleware/middleware.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// 中间件模块(AuthorizationGuard 越权防御,B4 + president §2.9)
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ClientsModule } from "../clients/clients.module.js";
|
||||
import {
|
||||
AUTHORIZATION_GUARD,
|
||||
AuthorizationGuardImpl,
|
||||
} from "./authorization.guard.js";
|
||||
|
||||
@Module({
|
||||
imports: [ClientsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: AUTHORIZATION_GUARD,
|
||||
useClass: AuthorizationGuardImpl,
|
||||
},
|
||||
],
|
||||
exports: [AUTHORIZATION_GUARD],
|
||||
})
|
||||
export class MiddlewareModule {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<void> {
|
||||
const report = await healthRegistry.runAll();
|
||||
const statusCode =
|
||||
report.status === "unavailable"
|
||||
? HttpStatus.SERVICE_UNAVAILABLE
|
||||
: HttpStatus.OK;
|
||||
res.status(statusCode).json(report);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,49 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
// 健康检查模块(G3 + president §2.4)
|
||||
// P2 注册 2 项探针:Redis + iam gRPC
|
||||
// P3+ 扩展:core-edu / content / data-ana / ai / msg(按阶段新增)
|
||||
import { Module, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
import { healthRegistry } from "./readiness.probe.js";
|
||||
import { RedisHealthProbe } from "./redis.probe.js";
|
||||
import { IamGrpcHealthProbe } from "./iam-grpc.probe.js";
|
||||
import { ClientsModule } from "../../clients/clients.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [ClientsModule],
|
||||
controllers: [HealthController],
|
||||
providers: [IamGrpcHealthProbe],
|
||||
})
|
||||
export class HealthModule {}
|
||||
export class HealthModule implements OnModuleInit, OnModuleDestroy {
|
||||
private redis: Redis | null = null;
|
||||
|
||||
constructor(private readonly iamProbe: IamGrpcHealthProbe) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 注册 Redis 探针(必需依赖)
|
||||
this.redis = new Redis(env.REDIS_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
lazyConnect: false,
|
||||
connectTimeout: 5000,
|
||||
});
|
||||
const redisProbe = new RedisHealthProbe(this.redis);
|
||||
healthRegistry.register(redisProbe);
|
||||
logger.info({ probe: "redis", required: true }, "Health probe registered");
|
||||
|
||||
// 注册 iam gRPC 探针(必需依赖)
|
||||
healthRegistry.register(this.iamProbe);
|
||||
logger.info(
|
||||
{ probe: "iam-grpc", required: true, target: env.IAM_GRPC_TARGET },
|
||||
"Health probe registered",
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.redis) {
|
||||
await this.redis.quit();
|
||||
logger.info("Redis connection closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
services/teacher-bff/src/shared/health/iam-grpc.probe.ts
Normal file
27
services/teacher-bff/src/shared/health/iam-grpc.probe.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// iam gRPC 健康探针(/readyz P2 必需依赖)
|
||||
// 调用 grpc.health.v1.Health/Check 检查 iam:50052 是否 SERVING
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { IAM_CLIENT } from "../../clients/iam/iam-client.interface.js";
|
||||
import type { IamClient } from "../../clients/iam/iam-client.interface.js";
|
||||
import type { HealthProbe } from "./readiness.probe.js";
|
||||
|
||||
@Injectable()
|
||||
export class IamGrpcHealthProbe implements HealthProbe {
|
||||
readonly name = "iam-grpc";
|
||||
readonly required = true;
|
||||
|
||||
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {}
|
||||
|
||||
async check(): Promise<{
|
||||
status: "up" | "down";
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await this.iam.checkHealth();
|
||||
return {
|
||||
status: result.serving ? "up" : "down",
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
113
services/teacher-bff/src/shared/health/readiness.probe.ts
Normal file
113
services/teacher-bff/src/shared/health/readiness.probe.ts
Normal file
@@ -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<string, HealthProbe>();
|
||||
|
||||
register(probe: HealthProbe): void {
|
||||
this.probes.set(probe.name, probe);
|
||||
}
|
||||
|
||||
unregister(name: string): void {
|
||||
this.probes.delete(name);
|
||||
}
|
||||
|
||||
async runAll(): Promise<ReadinessReport> {
|
||||
const checks: HealthCheckResult[] = [];
|
||||
let hasRequiredDown = false;
|
||||
let hasOptionalDown = false;
|
||||
|
||||
for (const probe of this.probes.values()) {
|
||||
try {
|
||||
const result = await probe.check();
|
||||
checks.push({
|
||||
name: probe.name,
|
||||
status: result.status,
|
||||
latencyMs: result.latencyMs,
|
||||
required: probe.required,
|
||||
error: result.error,
|
||||
});
|
||||
if (result.status === "down" && probe.required) {
|
||||
hasRequiredDown = true;
|
||||
} else if (result.status === "down" && !probe.required) {
|
||||
hasOptionalDown = true;
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
name: probe.name,
|
||||
status: "down",
|
||||
latencyMs: 0,
|
||||
required: probe.required,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (probe.required) {
|
||||
hasRequiredDown = true;
|
||||
} else {
|
||||
hasOptionalDown = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status: ReadinessReport["status"];
|
||||
if (hasRequiredDown) {
|
||||
status = "unavailable";
|
||||
} else if (hasOptionalDown) {
|
||||
status = "degraded";
|
||||
} else {
|
||||
status = "ok";
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
service: "teacher-bff",
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
degraded: hasOptionalDown && !hasRequiredDown,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局注册表实例(单例) */
|
||||
export const healthRegistry = new HealthCheckRegistry();
|
||||
37
services/teacher-bff/src/shared/health/redis.probe.ts
Normal file
37
services/teacher-bff/src/shared/health/redis.probe.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Redis 健康探针(/readyz P2 必需依赖)
|
||||
// PING 命令检查 Redis 连通性,超时 2s
|
||||
import type { Redis } from "ioredis";
|
||||
import type { HealthProbe } from "./readiness.probe.js";
|
||||
|
||||
export class RedisHealthProbe implements HealthProbe {
|
||||
readonly name = "redis";
|
||||
readonly required = true;
|
||||
|
||||
constructor(private readonly redis: Redis) {}
|
||||
|
||||
async check(): Promise<{
|
||||
status: "up" | "down";
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await this.redis.ping();
|
||||
const latencyMs = Date.now() - start;
|
||||
if (result === "PONG") {
|
||||
return { status: "up", latencyMs };
|
||||
}
|
||||
return {
|
||||
status: "down",
|
||||
latencyMs,
|
||||
error: `Unexpected PING response: ${result}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "down",
|
||||
latencyMs: Date.now() - start,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export function initTracer(): void {
|
||||
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
|
||||
|
||||
sdk = new NodeSDK({
|
||||
serviceName: "teacher-bff",
|
||||
serviceName: env.OTEL_SERVICE_NAME,
|
||||
traceExporter: new OTLPTraceExporter({
|
||||
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
||||
}),
|
||||
@@ -17,11 +17,12 @@ export function initTracer(): void {
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
console.log("Tracer initialized with auto-instrumentations");
|
||||
console.log(`Tracer initialized (service: ${env.OTEL_SERVICE_NAME})`);
|
||||
}
|
||||
|
||||
export async function shutdownTracer(): Promise<void> {
|
||||
if (sdk) {
|
||||
await sdk.shutdown();
|
||||
sdk = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Controller, Get, Param, Req } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { TeacherService } from "./teacher.service.js";
|
||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||
|
||||
interface SuccessResponse<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@Controller("teacher")
|
||||
export class TeacherController {
|
||||
constructor(private readonly service: TeacherService) {}
|
||||
|
||||
@Get("dashboard")
|
||||
async dashboard(@Req() req: Request): Promise<SuccessResponse<unknown>> {
|
||||
const userId = this.extractUserId(req);
|
||||
const data = await this.service.getDashboard(userId);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
@Get("viewports")
|
||||
async viewports(@Req() req: Request): Promise<SuccessResponse<unknown>> {
|
||||
const userId = this.extractUserId(req);
|
||||
const data = await this.service.getViewports(userId);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
// 聚合:班级下的考试列表(core-edu)
|
||||
@Get("classes/:classId/exams")
|
||||
async listExamsByClass(
|
||||
@Req() req: Request,
|
||||
@Param("classId") classId: string,
|
||||
): Promise<SuccessResponse<unknown>> {
|
||||
const userId = this.extractUserId(req);
|
||||
const data = await this.service.listExamsByClass(userId, classId);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
// 聚合:班级下的作业列表(core-edu)
|
||||
@Get("classes/:classId/homework")
|
||||
async listHomeworkByClass(
|
||||
@Req() req: Request,
|
||||
@Param("classId") classId: string,
|
||||
): Promise<SuccessResponse<unknown>> {
|
||||
const userId = this.extractUserId(req);
|
||||
const data = await this.service.listHomeworkByClass(userId, classId);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
// 聚合:考试下的成绩列表(core-edu)
|
||||
@Get("exams/:examId/grades")
|
||||
async listGradesByExam(
|
||||
@Req() req: Request,
|
||||
@Param("examId") examId: string,
|
||||
): Promise<SuccessResponse<unknown>> {
|
||||
const userId = this.extractUserId(req);
|
||||
const data = await this.service.listGradesByExam(userId, examId);
|
||||
return { success: true as const, data };
|
||||
}
|
||||
|
||||
private extractUserId(req: Request): string {
|
||||
const header = req.headers["x-user-id"];
|
||||
const userId = typeof header === "string" ? header : undefined;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
362
services/teacher-bff/src/teacher/teacher.resolver.ts
Normal file
362
services/teacher-bff/src/teacher/teacher.resolver.ts
Normal file
@@ -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
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
/** GraphQL DashboardData 类型 */
|
||||
export interface DashboardData {
|
||||
user: GraphQLUser | null;
|
||||
classes: unknown[];
|
||||
viewports: ViewportItem[];
|
||||
stats: {
|
||||
totalExams: number;
|
||||
pendingGrading: number;
|
||||
todayHomework: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
user: unknown;
|
||||
classes: unknown;
|
||||
/** GraphQL Class 类型(P2 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<DashboardData> {
|
||||
const [iamRes, classesRes] = await Promise.allSettled([
|
||||
fetch(`${env.IamServiceUrl}/iam/me`, {
|
||||
headers: { "x-user-id": userId },
|
||||
}),
|
||||
fetch(`${env.ClassesServiceUrl}/classes`, {
|
||||
headers: { "x-user-id": userId },
|
||||
}),
|
||||
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {}
|
||||
|
||||
/** 获取当前用户信息(聚合 iam.GetUserInfo + GetEffectivePermissions) */
|
||||
async getCurrentUser(ctx: CallContext): Promise<GraphQLUser> {
|
||||
const [userInfo, perms] = await Promise.all([
|
||||
this.iam.getUserInfo(ctx),
|
||||
this.iam.getEffectivePermissions(ctx),
|
||||
]);
|
||||
return {
|
||||
id: userInfo.id,
|
||||
email: userInfo.email,
|
||||
name: userInfo.name,
|
||||
roles: userInfo.roles,
|
||||
permissions: perms.permissions,
|
||||
dataScope: perms.dataScope,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取视口配置(iam.GetViewports) */
|
||||
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
|
||||
return this.iam.getViewports(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Dashboard 聚合数据(president §2.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<DashboardData> {
|
||||
const [user, viewports] = await Promise.all([
|
||||
this.getCurrentUser(ctx),
|
||||
this.iam.getViewports(ctx),
|
||||
]);
|
||||
|
||||
let user: unknown = null;
|
||||
let classes: unknown = null;
|
||||
|
||||
if (iamRes.status === "fulfilled") {
|
||||
if (iamRes.value.ok) {
|
||||
user = await iamRes.value.json();
|
||||
} else {
|
||||
logger.warn(
|
||||
{ status: iamRes.value.status, url: iamRes.value.url },
|
||||
"Downstream IAM service call failed",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err: iamRes.reason, service: "iam" },
|
||||
"Downstream IAM service call rejected",
|
||||
);
|
||||
}
|
||||
|
||||
if (classesRes.status === "fulfilled") {
|
||||
if (classesRes.value.ok) {
|
||||
classes = await classesRes.value.json();
|
||||
} else {
|
||||
logger.warn(
|
||||
{ status: classesRes.value.status, url: classesRes.value.url },
|
||||
"Downstream classes service call failed",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err: classesRes.reason, service: "classes" },
|
||||
"Downstream classes service call rejected",
|
||||
);
|
||||
}
|
||||
|
||||
return { user, classes };
|
||||
}
|
||||
|
||||
// 聚合 IAM 视口配置(L1 导航)
|
||||
async getViewports(userId: string): Promise<ViewportItem[]> {
|
||||
const res = await fetch(`${env.IamServiceUrl}/iam/viewports`, {
|
||||
headers: { "x-user-id": userId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.warn(
|
||||
{ status: res.status, url: res.url },
|
||||
"Downstream IAM service call failed",
|
||||
);
|
||||
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
|
||||
service: "iam",
|
||||
endpoint: "viewports",
|
||||
status: res.status,
|
||||
});
|
||||
}
|
||||
const json = (await res.json()) as DownstreamEnvelope<ViewportItem[]>;
|
||||
return json.data ?? [];
|
||||
}
|
||||
|
||||
// 聚合班级下的考试列表(core-edu)
|
||||
async listExamsByClass(userId: string, classId: string): Promise<unknown> {
|
||||
const res = await fetch(
|
||||
`${env.CoreEduServiceUrl}/exams/class/${encodeURIComponent(classId)}`,
|
||||
{ headers: { "x-user-id": userId } },
|
||||
// P2: classes 返回 mock 数据(P3+ 替换为 core-edu.GetClassesByTeacher)
|
||||
// P2: stats 返回 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<unknown>;
|
||||
return json.data ?? [];
|
||||
|
||||
return {
|
||||
user,
|
||||
viewports,
|
||||
classes: MOCK_CLASSES,
|
||||
stats: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 聚合班级下的作业列表(core-edu)
|
||||
async listHomeworkByClass(userId: string, classId: string): Promise<unknown> {
|
||||
const res = await fetch(
|
||||
`${env.CoreEduServiceUrl}/homework/class/${encodeURIComponent(classId)}`,
|
||||
{ headers: { "x-user-id": userId } },
|
||||
/** 获取教师班级列表(P2: mock;P3+: core-edu.GetClassesByTeacher) */
|
||||
async getClasses(ctx: CallContext): Promise<ClassInfo[]> {
|
||||
logger.warn(
|
||||
{ userId: ctx.userId, phase: "P2" },
|
||||
"Classes P2: using mock data (core-edu not ready, field_unavailable_in_p2)",
|
||||
);
|
||||
if (!res.ok) {
|
||||
logger.warn(
|
||||
{ status: res.status, url: res.url },
|
||||
"Downstream core-edu service call failed",
|
||||
);
|
||||
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
|
||||
service: "core-edu",
|
||||
endpoint: "homework-by-class",
|
||||
status: res.status,
|
||||
});
|
||||
}
|
||||
const json = (await res.json()) as DownstreamEnvelope<unknown>;
|
||||
return json.data ?? [];
|
||||
return MOCK_CLASSES;
|
||||
}
|
||||
|
||||
// 聚合考试下的成绩列表(core-edu)
|
||||
async listGradesByExam(userId: string, examId: string): Promise<unknown> {
|
||||
const res = await fetch(
|
||||
`${env.CoreEduServiceUrl}/grades/exam/${encodeURIComponent(examId)}`,
|
||||
{ headers: { "x-user-id": userId } },
|
||||
/** 获取单个班级详情(P2: mock;P3+: core-edu) */
|
||||
async getClass(ctx: CallContext, classId: string): Promise<ClassInfo | null> {
|
||||
logger.warn(
|
||||
{ userId: ctx.userId, classId, phase: "P2" },
|
||||
"Class P2: using mock data (core-edu not ready, field_unavailable_in_p2)",
|
||||
);
|
||||
if (!res.ok) {
|
||||
logger.warn(
|
||||
{ status: res.status, url: res.url },
|
||||
"Downstream core-edu service call failed",
|
||||
);
|
||||
throw new BadGatewayError(`Downstream service returned ${res.status}`, {
|
||||
service: "core-edu",
|
||||
endpoint: "grades-by-exam",
|
||||
status: res.status,
|
||||
});
|
||||
}
|
||||
const json = (await res.json()) as DownstreamEnvelope<unknown>;
|
||||
return json.data ?? [];
|
||||
return MOCK_CLASSES.find((c) => c.id === classId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出 EffectivePermissions 类型供外部使用 */
|
||||
export type { EffectivePermissions, UserInfo };
|
||||
|
||||
Reference in New Issue
Block a user