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

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

View File

@@ -420,28 +420,28 @@
### 2.9 aiPython/FastAPIP5 ### 2.9 aiPython/FastAPIP5
| 场景 | 技术/规则 | | 场景 | 技术/规则 |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | | ------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| LLM Provider 适配 | 4 适配器OpenAI/Anthropic/Baichuan/Ollama+ ProviderFailoverChain + CircuitBreaker3 次失败 60s 熔断) | | LLM Provider 适配 | 4 适配器OpenAI/Anthropic/Baichuan/Ollama+ ProviderFailoverChain + CircuitBreaker3 次失败 60s 熔断) |
| 降级模式方案 B | success=true + error=null + data 内 degraded=true + degraded_reason总裁 §2.6,非顶层 degraded 字段) | | 降级模式方案 B | success=true + error=null + data 内 degraded=true + degraded_reason总裁 §2.6,非顶层 degraded 字段) |
| ActionState 信封 | 所有 HTTP 响应统一 `{success, data, error:{code,message,details?,traceId?}}`004 §11.5 强制) | | ActionState 信封 | 所有 HTTP 响应统一 `{success, data, error:{code,message,details?,traceId?}}`004 §11.5 强制) |
| 路由前缀 | /v1/ai 前缀APIRouter prefix="/v1/ai"Gateway 代理 /api/v1/ai/* → /v1/ai/* | | 路由前缀 | /v1/ai 前缀APIRouter prefix="/v1/ai"Gateway 代理 /api/v1/ai/* → /v1/ai/* |
| gRPC server | grpc.aio 端口 500588 RPC + 3 interceptorLogging/Auth/Error | | gRPC server | grpc.aio 端口 500588 RPC + 3 interceptorLogging/Auth/Error |
| 评估三道防线 | RuleValidatorJSON/字段/难度校验)→ LLMJudge语义评分→ QualityGate综合决策门控 | | 评估三道防线 | RuleValidatorJSON/字段/难度校验)→ LLMJudge语义评分→ QualityGate综合决策门控 |
| 备课工作流 | P5 用 asyncio.create_task + Redis 状态存储24h TTLP6+ 评估 Temporal | | 备课工作流 | P5 用 asyncio.create_task + Redis 状态存储24h TTLP6+ 评估 Temporal |
| 工作流状态机 | pending→analyzing→generating→pending_review→persisted/failed生成失败重试最多 3 次 | | 工作流状态机 | pending→analyzing→generating→pending_review→persisted/failed生成失败重试最多 3 次 |
| 限流三维度 | user 10/min + IP 30/min + school 100/minRedis Lua 原子令牌桶Redis 不可用降级放行 | | 限流三维度 | user 10/min + IP 30/min + school 100/minRedis Lua 原子令牌桶Redis 不可用降级放行 |
| 用量记录 | Redis INCRBY 按 user/school/month 维度35 天过期Kafka 发布 AIUsageEvent 到 edu.ai.usage topic | | 用量记录 | Redis INCRBY 按 user/school/month 维度35 天过期Kafka 发布 AIUsageEvent 到 edu.ai.usage topic |
| Kafka 派生数据豁免 Outbox | AIUsageEvent 为派生数据004 §12.2 豁免 Outbox直接 aiokafka producer事务性 + 幂等) | | Kafka 派生数据豁免 Outbox | AIUsageEvent 为派生数据004 §12.2 豁免 Outbox直接 aiokafka producer事务性 + 幂等) |
| 安全层全本地 | PIIRedactor5 类 PII 正则脱敏)+ InputSanitizerprompt injection 检测)+ OutputModerator5 类敏感内容审核) | | 安全层全本地 | PIIRedactor5 类 PII 正则脱敏)+ InputSanitizerprompt injection 检测)+ OutputModerator5 类敏感内容审核) |
| 六边形架构端口模式 | ContentClient/DataAnaClient/IamClient 均为抽象接口 + Mock + gRPC 实现,全并行模式用 Mock | | 六边形架构端口模式 | ContentClient/DataAnaClient/IamClient 均为抽象接口 + Mock + gRPC 实现,全并行模式用 Mock |
| 权限校验 | PermissionGuard 5 权限点ai:chat / ai:question:generate / ai:expression:optimize / ai:lesson:generate / confirm | | 权限校验 | PermissionGuard 5 权限点ai:chat / ai:question:generate / ai:expression:optimize / ai:lesson:generate / confirm |
| dev_mode tracer | dev_mode=true 时跳过 OTel exporter 初始化 + 跳过权限校验 | | dev_mode tracer | dev_mode=true 时跳过 OTel exporter 初始化 + 跳过权限校验 |
| proto 包名 | 保持 `next_edu_cloud.<domain>.v1`coord G17 裁决覆盖 project_rules §5 | | proto 包名 | 保持 `next_edu_cloud.<domain>.v1`coord G17 裁决覆盖 project_rules §5 |
| proto_gen 排除 ruff | pyproject.toml `[tool.ruff] exclude = ["src/ai/proto_gen"]`,自动生成文件不参与 lint | | proto_gen 排除 ruff | pyproject.toml `[tool.ruff] exclude = ["src/ai/proto_gen"]`,自动生成文件不参与 lint |
| ruff UP017 | `datetime.now(timezone.utc)` 改用 `datetime.now(datetime.UTC)` 别名Python 3.12+ | | ruff UP017 | `datetime.now(timezone.utc)` 改用 `datetime.now(datetime.UTC)` 别名Python 3.12+ |
| ruff SIM103/SIM110 | `if x: return False; return True` 改 `return not x`for 循环改 `all()` 表达式 | | ruff SIM103/SIM110 | `if x: return False; return True` 改 `return not x`for 循环改 `all()` 表达式 |
| PowerShell 不支持 && | RunCommand 用 cwd 参数指定工作目录,不用 `cd ... && uv run` | | PowerShell 不支持 && | RunCommand 用 cwd 参数指定工作目录,不用 `cd ... && uv run` |
### 2.10 shared-proto契约包 ### 2.10 shared-proto契约包
@@ -592,3 +592,27 @@
| P3 MF 降级与版本协商 | Remote `remoteEntry.js` 加载失败 → fallback 独立页面 + 错误提示;共享依赖版本不匹配 → 运行时协商降级 | | P3 MF 降级与版本协商 | Remote `remoteEntry.js` 加载失败 → fallback 独立页面 + 错误提示;共享依赖版本不匹配 → 运行时协商降级 |
| P3 AI 答疑 SSE 流式P5 | `POST /student/ai/stream-chat` SSE 流式响应 + `streamingText` 逐字渲染 + 打字机效果 + AbortController 取消 | | 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` 等) | | P3 权限点命名 | 与 student-bff §3.2 对齐 `STUDENT_*_READ` 风格(`STUDENT_DASHBOARD_READ`/`STUDENT_EXAM_READ`/`STUDENT_HOMEWORK_SUBMIT`/`STUDENT_GRADE_READ` 等) |
### 2.16 teacher-bffTS/NestJSP2
> teacher-bff 是 GraphQL 聚合层B1 裁决P2 起直接 GraphQL无 DB无 @RequirePermissionB3 豁免做数据权限防御B4。本节记录 teacher-bff 特有的"场景→技术"映射。
| 场景 | 技术/规则 |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P2 GraphQL 直接实现 | B1 裁决P2 起直接 GraphQL Yoga禁止 REST 过渡POST /graphql endpoint + SDL-first schemapackages/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 clientiam/client.interface + grpc + mock |
| DEV_MODE mock 开关 | TEACHER_BFF_DEV_MODE=true → IamClient 走 mock固定数据false → gRPC未就绪 RPC 降级 mock + warningpresident §2.6 降级模式 B |
| 越权防御 3 类错误码 | president §2.7UNAUTHORIZED(401) / FORBIDDEN_RESOURCE(403 场景 A 资源无归属) / IDENTITY_MISMATCH(403 场景 B JWT 与 body 不一致) |
| AuthorizationGuard P2 策略 | president §2.9P2 DEV_MODE 放行 + 日志告警生产保守拒绝P3+ 接入 core-edu.GetClassesByTeacher 真实校验 |
| /readyz 探针注册表 | president §2.4DownstreamHealthCheck 注册表模式P2 = Redis + iam gRPC2 项按阶段扩展P3=3/P4=5/P5=7必需依赖失败返 503可选失败返 200+degraded |
| Dashboard Resolver P2 实现 | president §2.8P2 仅调 iam gRPCuser+viewports 有数据),未启用字段返 null + extensions.warning="field_unavailable_in_p2" |
| admin 命名空间预留 | president §5.1P2 预留 schemaAdminQuery/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 不含 responsecontext 回调只解构 `{ request }`buildGraphQLContext 只接收 requestP5+ SSE 再扩展 response |
| readonly 属性延迟初始化 | `readonly` 属性只能在构造函数赋值,需在 initLogger() 方法中重新赋值时移除 readonly 修饰符 |

View File

@@ -0,0 +1,452 @@
# teacher-bff GraphQL Schema (SDL-first, president §2.17)
# 版本v1P2 第一版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 字段经 DataLoaderP4 全量接入)
# 4. 复杂度限制depth ≤ 7query cost ≤ 1000
# 5. admin 命名空间预留president §5.1P2 预留 schemaP6 实现 Resolver
#
# 错误响应格式GraphQL errors 数组 + extensions.code = BFF_TEACHER_* + extensions.traceId
# 降级模式 Bpresident §2.6success=true + error=null + data 内 degraded 字段
#
# 阶段标注:
# P2 = 当前实现iam gRPC未启用字段返 null + extensions.warning = "field_unavailable_in_p2"
# P3+ = 跨阶段扩展例外president §2.3Resolver 内逐步补 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 Queryworkline §3.2dashboard / viewports / me / classes / class
type Query {
# 仪表盘聚合P2: iam gRPCP3+: + 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.1P2 预留 schemaP6 实现 Resolver=====
# admin-portal 复用 teacher-bff GraphQL endpointadmin 操作低 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 命名空间 Mutationpresident §5.1P2 预留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.1P2 预留 schemaP6 实现 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
}

194
pnpm-lock.yaml generated
View File

@@ -843,6 +843,18 @@ importers:
services/teacher-bff: services/teacher-bff:
dependencies: 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': '@nestjs/common':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -864,6 +876,15 @@ importers:
'@opentelemetry/sdk-node': '@opentelemetry/sdk-node':
specifier: ^0.53.0 specifier: ^0.53.0
version: 0.53.0(@opentelemetry/api@1.9.1) 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: pino:
specifier: ^9.4.0 specifier: ^9.4.0
version: 9.14.0 version: 9.14.0
@@ -1157,14 +1178,17 @@ packages:
resolution: {integrity: sha512-hjkrWvmVpCQiy2Km7LpVUmky8MbinNc2DzbpBaXSjgQYIXRK9LJ5BOfqldSBjp9E+U1I539LYAaSxxikQdXLLQ==} resolution: {integrity: sha512-hjkrWvmVpCQiy2Km7LpVUmky8MbinNc2DzbpBaXSjgQYIXRK9LJ5BOfqldSBjp9E+U1I539LYAaSxxikQdXLLQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@emnapi/core@1.11.1': '@envelop/core@5.5.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} resolution: {integrity: sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==}
engines: {node: '>=18.0.0'}
'@emnapi/runtime@1.11.1': '@envelop/instrumentation@1.0.0':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} resolution: {integrity: sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==}
engines: {node: '>=18.0.0'}
'@emnapi/wasi-threads@1.2.2': '@envelop/types@5.2.1':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} resolution: {integrity: sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==}
engines: {node: '>=18.0.0'}
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
@@ -1776,10 +1800,6 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fastify/busboy@2.1.1':
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
'@fastify/busboy@3.2.0': '@fastify/busboy@3.2.0':
resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
@@ -1822,13 +1842,6 @@ packages:
resolution: {integrity: sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA==} resolution: {integrity: sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@graphql-yoga/plugin-response-cache@3.23.2':
resolution: {integrity: sha512-juMAA4I5gkKYRFr6uaAbNhi6smc9Y3He2CrtNIaZ2uFlpIbmwTVjeDFGTu4xq4RUUUswh/E8LU4JqJEyYnul2Q==}
engines: {node: '>=18.0.0'}
peerDependencies:
graphql: ^15.2.0 || ^16.0.0
graphql-yoga: ^5.21.2
'@graphql-yoga/subscription@5.0.5': '@graphql-yoga/subscription@5.0.5':
resolution: {integrity: sha512-oCMWOqFs6QV96/NZRt/ZhTQvzjkGB4YohBOpKM4jH/lDT4qb7Lex/aGCxpi/JD9njw3zBBtMqxbaC22+tFHVvw==} resolution: {integrity: sha512-oCMWOqFs6QV96/NZRt/ZhTQvzjkGB4YohBOpKM4jH/lDT4qb7Lex/aGCxpi/JD9njw3zBBtMqxbaC22+tFHVvw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -3288,34 +3301,8 @@ packages:
'@protobufjs/utf8@1.1.2': '@protobufjs/utf8@1.1.2':
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
'@redis/bloom@1.2.0': '@repeaterjs/repeater@3.1.0':
resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} resolution: {integrity: sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/client@1.6.1':
resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==}
engines: {node: '>=14'}
'@redis/graph@1.1.1':
resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/json@1.0.7':
resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/search@1.2.0':
resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/time-series@1.1.0':
resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==}
peerDependencies:
'@redis/client': ^1.0.0
'@rollup/rollup-android-arm-eabi@4.62.2': '@rollup/rollup-android-arm-eabi@4.62.2':
resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==}
@@ -4589,9 +4576,9 @@ packages:
typescript: typescript:
optional: true optional: true
cron-parser@4.9.0: cross-inspect@1.0.1:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==}
engines: {node: '>=12.0.0'} engines: {node: '>=16.0.0'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
@@ -5413,10 +5400,15 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
grpc-reflection-js@0.3.0: graphql-yoga@5.21.2:
resolution: {integrity: sha512-3lhTlQluPxVgbowCXA3tAZC3RJW+GSOUkguLNYl1QffYRiutUB3RDfPkQFTcrCFJgNiIIxx+iJkr8s3uSp3zWA==} resolution: {integrity: sha512-IIRF/3xtjj2D6caAWL9177hQ8tV3mWB3hve1GRnz7njPhQ3iY1jFtSp98fNGv0yV9kaPh9kKQ8JWdJZnedVmDw==}
engines: {node: '>=18.0.0'}
peerDependencies: peerDependencies:
'@grpc/grpc-js': ^1.0.0 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: has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
@@ -7547,16 +7539,8 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
urql@4.2.2: urlpattern-polyfill@10.1.0:
resolution: {integrity: sha512-3GgqNa6iF7bC4hY/ImJKN4REQILcSU9VKcKL8gfELZM8mM5BnLH1BsCc8kBdnVGD1LIFOs4W3O2idNHhON1r0w==} resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==}
peerDependencies:
'@urql/core': ^5.0.0
react: '>= 16.8.0'
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -8173,21 +8157,22 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@emnapi/core@1.11.1': '@envelop/core@5.5.1':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.2 '@envelop/instrumentation': 1.0.0
'@envelop/types': 5.2.1
'@whatwg-node/promise-helpers': 1.3.2
tslib: 2.8.1 tslib: 2.8.1
optional: true
'@emnapi/runtime@1.11.1': '@envelop/instrumentation@1.0.0':
dependencies: dependencies:
'@whatwg-node/promise-helpers': 1.3.2
tslib: 2.8.1 tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.2': '@envelop/types@5.2.1':
dependencies: dependencies:
'@whatwg-node/promise-helpers': 1.3.2
tslib: 2.8.1 tslib: 2.8.1
optional: true
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
dependencies: dependencies:
@@ -8532,8 +8517,6 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@fastify/busboy@2.1.1': {}
'@fastify/busboy@3.2.0': {} '@fastify/busboy@3.2.0': {}
'@graphql-tools/executor@1.5.5(graphql@16.14.2)': '@graphql-tools/executor@1.5.5(graphql@16.14.2)':
@@ -8583,14 +8566,6 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@graphql-yoga/plugin-response-cache@3.23.2(graphql-yoga@5.21.2(graphql@16.14.2))(graphql@16.14.2)':
dependencies:
'@envelop/core': 5.5.1
'@envelop/response-cache': 9.1.1(@envelop/core@5.5.1)(graphql@16.14.2)
'@whatwg-node/promise-helpers': 1.3.2
graphql: 16.14.2
graphql-yoga: 5.21.2(graphql@16.14.2)
'@graphql-yoga/subscription@5.0.5': '@graphql-yoga/subscription@5.0.5':
dependencies: dependencies:
'@graphql-yoga/typed-event-target': 3.0.2 '@graphql-yoga/typed-event-target': 3.0.2
@@ -10622,31 +10597,7 @@ snapshots:
'@protobufjs/utf8@1.1.2': {} '@protobufjs/utf8@1.1.2': {}
'@redis/bloom@1.2.0(@redis/client@1.6.1)': '@repeaterjs/repeater@3.1.0': {}
dependencies:
'@redis/client': 1.6.1
'@redis/client@1.6.1':
dependencies:
cluster-key-slot: 1.1.2
generic-pool: 3.9.0
yallist: 4.0.0
'@redis/graph@1.1.1(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/json@1.0.7(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/search@1.2.0(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/time-series@1.1.0(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@rollup/rollup-android-arm-eabi@4.62.2': '@rollup/rollup-android-arm-eabi@4.62.2':
optional: true optional: true
@@ -11985,9 +11936,9 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
cron-parser@4.9.0: cross-inspect@1.0.1:
dependencies: dependencies:
luxon: 3.7.2 tslib: 2.8.1
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
@@ -12981,14 +12932,23 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
grpc-reflection-js@0.3.0(@grpc/grpc-js@1.14.4): graphql-yoga@5.21.2(graphql@16.14.2):
dependencies: dependencies:
'@grpc/grpc-js': 1.14.4 '@envelop/core': 5.5.1
'@types/google-protobuf': 3.15.12 '@envelop/instrumentation': 1.0.0
'@types/lodash.set': 4.3.9 '@graphql-tools/executor': 1.5.5(graphql@16.14.2)
google-protobuf: 3.21.4 '@graphql-tools/schema': 10.0.36(graphql@16.14.2)
lodash.set: 4.3.2 '@graphql-tools/utils': 10.11.0(graphql@16.14.2)
protobufjs: 7.6.5 '@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-flag@4.0.0: {}
@@ -15239,15 +15199,7 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
urql@4.2.2(@urql/core@5.2.0(graphql@16.14.2))(react@18.3.1): urlpattern-polyfill@10.1.0: {}
dependencies:
'@urql/core': 5.2.0(graphql@16.14.2)
react: 18.3.1
wonka: 6.3.6
use-sync-external-store@1.6.0(react@18.3.1):
dependencies:
react: 18.3.1
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}

View File

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

View File

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

View File

@@ -12,15 +12,22 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "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/common": "^10.4.0",
"@nestjs/core": "^10.4.0", "@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0", "@nestjs/platform-express": "^10.4.0",
"pino": "^9.4.0",
"prom-client": "^15.1.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0", "@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0", "@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"@opentelemetry/sdk-node": "^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", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"zod": "^3.23.0" "zod": "^3.23.0"

View File

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

View File

@@ -0,0 +1,159 @@
// DownstreamClient 抽象基类B8 裁决3 个 BFF 统一使用)
// 提供统一的错误映射 + 结构化日志 + metrics 记录
import type { Logger } from "pino";
import { logger } from "../shared/observability/logger.js";
import {
BadGatewayError,
AggregationFailedError,
} from "../shared/errors/application-error.js";
import type {
CallContext,
DownstreamError,
DownstreamResult,
DownstreamServiceName,
GrpcMetadata,
} from "./types.js";
/** gRPC 状态码 → HTTP 语义映射(@grpc/grpc-js status codes */
const GRPC_STATUS = {
OK: 0,
CANCELLED: 1,
UNKNOWN: 2,
INVALID_ARGUMENT: 3,
DEADLINE_EXCEEDED: 4,
NOT_FOUND: 5,
ALREADY_EXISTS: 6,
PERMISSION_DENIED: 7,
RESOURCE_EXHAUSTED: 8,
FAILED_PRECONDITION: 9,
ABORTED: 10,
OUT_OF_RANGE: 11,
UNIMPLEMENTED: 12,
INTERNAL: 13,
UNAVAILABLE: 14,
DATA_LOSS: 15,
UNAUTHENTICATED: 16,
} as const;
export abstract class BaseDownstreamClient {
protected log: Logger;
abstract readonly serviceName: DownstreamServiceName;
constructor() {
// abstract property 在子类构造后才可用,延迟初始化 logger
this.log = logger.child({ downstream: "downstream" });
}
/** 子类在构造函数中调用以初始化 logger带正确 serviceName */
protected initLogger(): void {
this.log = logger.child({ downstream: this.serviceName });
}
/** 构建 gRPC metadata注入 x-user-id + x-request-id */
protected buildMetadata(ctx: CallContext): GrpcMetadata {
const meta: GrpcMetadata = { "x-user-id": ctx.userId };
if (ctx.traceId) {
meta["x-request-id"] = ctx.traceId;
}
return meta;
}
/** 将 gRPC 错误映射为 BadGatewayError502 */
protected mapGrpcError(err: unknown, rpc: string): BadGatewayError {
const grpcErr = err as {
code?: number;
message?: string;
details?: string;
};
const code = grpcErr.code ?? GRPC_STATUS.UNKNOWN;
const message = grpcErr.message ?? "Unknown gRPC error";
this.log.warn(
{ rpc, grpcCode: code, message, err },
"Downstream gRPC call failed",
);
// UNAVAILABLE → 502 Bad Gateway
if (code === GRPC_STATUS.UNAVAILABLE) {
return new BadGatewayError(
`Downstream ${this.serviceName}.${rpc} unavailable`,
{ service: this.serviceName, rpc, grpcCode: code },
);
}
// UNAUTHENTICATED → 映射为 BadGatewayBFF 自身做身份校验,下游不应返回 UNAUTHENTICATED
if (code === GRPC_STATUS.UNAUTHENTICATED) {
return new BadGatewayError(
`Downstream ${this.serviceName}.${rpc} returned UNAUTHENTICATED`,
{ service: this.serviceName, rpc, grpcCode: code },
);
}
// 其他错误统一映射为 BadGateway
return new BadGatewayError(
`Downstream ${this.serviceName}.${rpc} failed: ${message}`,
{
service: this.serviceName,
rpc,
grpcCode: code,
details: grpcErr.details,
},
);
}
/** 执行 gRPC 调用并统一错误处理 + 超时控制 */
protected async callGrpc<T>(
rpc: string,
fn: () => Promise<T>,
timeoutMs = 3000,
): Promise<T> {
const start = Date.now();
try {
const result = await Promise.race([
fn(),
this.createTimeout(timeoutMs, rpc),
]);
const duration = Date.now() - start;
this.log.debug(
{ rpc, durationMs: duration },
"Downstream gRPC call succeeded",
);
return result;
} catch (err) {
throw this.mapGrpcError(err, rpc);
}
}
private createTimeout(ms: number, rpc: string): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => {
reject(
new BadGatewayError(
`Downstream ${this.serviceName}.${rpc} timed out after ${ms}ms`,
{ service: this.serviceName, rpc, timeoutMs: ms },
),
);
}, ms);
});
}
/** 降级模式 B部分失败时返回 success=true + degraded=true + warningpresident §2.6 */
protected degraded<T>(
data: T | null,
warning: string,
rpc: string,
): DownstreamResult<T> {
this.log.warn({ rpc, warning }, "Downstream call degraded");
return { success: true, data, degraded: true, warning };
}
/** 聚合失败多下游部分失败且无降级数据president §2.6 */
protected aggregationFailed(
errors: DownstreamError[],
): AggregationFailedError {
return new AggregationFailedError(
`Aggregation failed for ${this.serviceName}: ${errors.length} downstream(s) failed`,
{ service: this.serviceName, errors },
);
}
}

View File

@@ -0,0 +1,10 @@
// DownstreamClient 聚合模块B8 裁决3 个 BFF 统一使用)
// P2 仅注册 IamModuleP3+ 扩展 CoreEduModule / ContentModule / DataAnaModule / MsgModule / AiModule
import { Module } from "@nestjs/common";
import { IamModule } from "./iam/iam.module.js";
@Module({
imports: [IamModule],
exports: [IamModule],
})
export class ClientsModule {}

View File

@@ -0,0 +1,256 @@
// gRPC Channel + Client 工厂B2 裁决:首次实现即 gRPC
// 使用 @grpc/grpc-js + @grpc/proto-loader 动态加载 proto无生成代码依赖
// 上游就绪后可切换为 @bufbuild/protobuf 生成代码B8 裁决)
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import path from "node:path";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import type { DownstreamServiceName } from "../types.js";
/** proto 文件根目录monorepo 相对路径) */
const PROTO_ROOT = path.resolve(
process.cwd(),
"../../packages/shared-proto/proto",
);
/** 已加载的 proto 定义缓存(按文件名缓存) */
const packageCache = new Map<string, protoLoader.PackageDefinition>();
/** 已创建的 gRPC Client 缓存(按 service+target 缓存) */
const clientCache = new Map<string, grpc.Client>();
/** 各下游服务对应的 proto 文件 + 包名 + service 名 */
interface ServiceProtoConfig {
protoFile: string;
packageName: string;
serviceName: string;
}
const SERVICE_PROTO_MAP: Record<DownstreamServiceName, ServiceProtoConfig> = {
iam: {
protoFile: "iam.proto",
packageName: "next_edu_cloud.iam.v1",
serviceName: "IamService",
},
"core-edu": {
protoFile: "core_edu.proto",
packageName: "next_edu_cloud.core_edu.v1",
serviceName: "", // core-edu 多 service按需指定
},
content: {
protoFile: "content.proto",
packageName: "next_edu_cloud.content.v1",
serviceName: "",
},
"data-ana": {
protoFile: "analytics.proto",
packageName: "next_edu_cloud.analytics.v1",
serviceName: "",
},
msg: {
protoFile: "msg.proto",
packageName: "next_edu_cloud.msg.v1",
serviceName: "",
},
ai: {
protoFile: "ai.proto",
packageName: "next_edu_cloud.ai.v1",
serviceName: "",
},
};
/** 加载 proto 定义(带缓存) */
function loadPackageDefinition(
protoFile: string,
): protoLoader.PackageDefinition {
const cached = packageCache.get(protoFile);
if (cached) return cached;
const fullPath = path.join(PROTO_ROOT, protoFile);
const def = protoLoader.loadSync(fullPath, {
keepCase: false,
longs: String,
enums: String,
defaults: true,
oneofs: true,
includeDirs: [PROTO_ROOT],
});
packageCache.set(protoFile, def);
return def;
}
/** 获取下游服务的 gRPC target从环境变量 */
export function getGrpcTarget(service: DownstreamServiceName): string {
const map: Record<DownstreamServiceName, string> = {
iam: env.IAM_GRPC_TARGET,
"core-edu": env.CORE_EDU_GRPC_TARGET,
content: env.CONTENT_GRPC_TARGET,
"data-ana": env.DATA_ANA_GRPC_TARGET,
msg: env.MSG_GRPC_TARGET,
ai: env.AI_GRPC_TARGET,
};
return map[service];
}
/** 判断下游服务是否已配置 target未配置则走 mock */
export function isServiceConfigured(service: DownstreamServiceName): boolean {
const target = getGrpcTarget(service);
return target.length > 0;
}
/** 创建或获取 gRPC Client带缓存 */
export function getGrpcClient(
service: DownstreamServiceName,
serviceName?: string,
): grpc.Client {
const target = getGrpcTarget(service);
const config = SERVICE_PROTO_MAP[service];
const svcName = serviceName ?? config.serviceName;
if (!target) {
throw new Error(
`gRPC target not configured for service: ${service} (set ${service.toUpperCase().replace("-", "_")}_GRPC_TARGET)`,
);
}
if (!svcName) {
throw new Error(
`Service name not specified for ${service}, pass serviceName explicitly`,
);
}
const cacheKey = `${service}:${svcName}:${target}`;
const cached = clientCache.get(cacheKey);
if (cached) return cached;
const def = loadPackageDefinition(config.protoFile);
const proto = grpc.loadPackageDefinition(def) as unknown as Record<
string,
Record<string, unknown>
>;
// 按包路径查找 Service 构造器
const pkgParts = config.packageName.split(".");
let pkg: Record<string, unknown> = proto;
for (const part of pkgParts) {
pkg = pkg[part] as Record<string, unknown>;
if (!pkg) {
throw new Error(
`Package ${config.packageName} not found in ${config.protoFile}`,
);
}
}
const ServiceConstructor = pkg[svcName] as unknown as {
new (target: string, credentials: grpc.ChannelCredentials): grpc.Client;
};
if (!ServiceConstructor) {
throw new Error(
`Service ${svcName} not found in package ${config.packageName}`,
);
}
// P2-P2 阶段使用 insecure内网mTLS 在 P6 硬化阶段启用)
const client = new ServiceConstructor(
target,
grpc.credentials.createInsecure(),
);
clientCache.set(cacheKey, client);
logger.info({ service, serviceName: svcName, target }, "gRPC client created");
return client;
}
/** 创建 gRPC metadata注入 x-user-id + x-request-id */
export function createGrpcMetadata(
userId: string,
traceId?: string,
): grpc.Metadata {
const meta = new grpc.Metadata();
meta.add("x-user-id", userId);
if (traceId) {
meta.add("x-request-id", traceId);
}
return meta;
}
/** 关闭所有 gRPC Client优雅关闭时调用 */
export function closeAllGrpcClients(): void {
for (const [key, client] of clientCache) {
try {
client.close();
logger.info({ client: key }, "gRPC client closed");
} catch (err) {
logger.warn({ client: key, err }, "Failed to close gRPC client");
}
}
clientCache.clear();
}
/**
* 执行 gRPC 健康检查grpc.health.v1.Health/Check
* 用于 /readyz 探针president §2.4
*
* 使用标准 grpc.health.v1.Health 服务,通过 makeUnaryRequest 直接调用。
* 超时 2s返回 SERVING / NOT_SERVING / UNREACHABLE。
*/
export async function checkGrpcHealth(
service: DownstreamServiceName,
): Promise<{ serving: boolean; latencyMs: number; error?: string }> {
const target = getGrpcTarget(service);
if (!target) {
return { serving: false, latencyMs: 0, error: "target not configured" };
}
const start = Date.now();
const client = new grpc.Client(target, grpc.credentials.createInsecure(), {
"grpc.enable_retries": 0,
});
return new Promise((resolve) => {
const timeout = setTimeout(() => {
client.close();
resolve({
serving: false,
latencyMs: Date.now() - start,
error: "health check timed out (2s)",
});
}, 2000);
client.makeUnaryRequest(
"grpc.health.v1.Health/Check",
// serialize: HealthCheckRequest → Buffer空消息体
() => Buffer.alloc(0),
// deserialize: Buffer → { status: string }
(buf: Buffer) => {
// proto HealthCheckResponse { enum ServingStatus { SERVING = 1; } int32 status = 1; }
// 简化解析:直接读取第一个 varint 字段field 1 = status
if (buf.length < 2) return { status: "UNKNOWN" };
const statusByte: number = buf[1] ?? 0;
const statusMap: Record<number, string> = {
0: "UNKNOWN",
1: "SERVING",
2: "NOT_SERVING",
3: "SERVICE_UNKNOWN",
};
return { status: statusMap[statusByte] ?? "UNKNOWN" };
},
{},
new grpc.Metadata(),
{},
(err: grpc.ServiceError | null, value: unknown) => {
clearTimeout(timeout);
const latencyMs = Date.now() - start;
client.close();
if (err) {
resolve({ serving: false, latencyMs, error: err.message });
} else {
const status = (value as { status?: string } | null | undefined)
?.status;
resolve({ serving: status === "SERVING", latencyMs });
}
},
);
});
}

View File

@@ -0,0 +1,31 @@
// IamClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P2+ 需要的 iam RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC未就绪 RPC 降级 mock + warning
import type { CallContext } from "../types.js";
import type {
UserInfo,
ViewportItem,
EffectivePermissions,
} from "./iam.types.js";
/** IamClient DI tokenNestJS 注入用) */
export const IAM_CLIENT = Symbol("IAM_CLIENT");
/** IamClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
export interface IamClient {
/** 获取用户信息iam.proto GetUserInfo✅ 已就绪) */
getUserInfo(ctx: CallContext): Promise<UserInfo>;
/** 获取教师导航菜单iam.proto GetViewports❌ 待 coord 补全) */
getViewports(ctx: CallContext): Promise<ViewportItem[]>;
/** 获取有效权限集iam.proto GetEffectivePermissions❌ 待 coord 补全) */
getEffectivePermissions(ctx: CallContext): Promise<EffectivePermissions>;
/** 健康检查(/readyz 探针用) */
checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}>;
}

View File

@@ -0,0 +1,79 @@
// IamClient gRPC 实现B2 裁决:首次实现即 gRPC
// iam.proto 现状仅 GetUserInfoGetViewports/GetEffectivePermissions 待 coord 补全
// 未就绪 RPC 降级 mock + warning 日志president §2.6 降级模式 B
import { Injectable } from "@nestjs/common";
import type * as grpc from "@grpc/grpc-js";
import { BaseDownstreamClient } from "../base.client.js";
import {
createGrpcMetadata,
getGrpcClient,
checkGrpcHealth,
} from "../grpc/grpc.factory.js";
import type { CallContext } from "../types.js";
import type { IamClient } from "./iam-client.interface.js";
import type {
UserInfo,
ViewportItem,
EffectivePermissions,
} from "./iam.types.js";
import { IamMockClient } from "./iam-mock.client.js";
@Injectable()
export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
readonly serviceName = "iam" as const;
private readonly mock: IamMockClient;
constructor() {
super();
this.initLogger();
// mock 用于未就绪 RPC 的降级proto 未定义的 RPC 走 mock + warning
this.mock = new IamMockClient();
}
async getUserInfo(ctx: CallContext): Promise<UserInfo> {
return this.callGrpc("GetUserInfo", async () => {
const client = getGrpcClient("iam") as unknown as {
getUserInfo(
req: { userId: string },
meta: grpc.Metadata,
cb: (err: grpc.ServiceError | null, res: UserInfo) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<UserInfo>((resolve, reject) => {
client.getUserInfo({ userId: ctx.userId }, meta, (err, res) => {
if (err) reject(err);
else resolve(res);
});
});
});
}
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
// iam.proto 暂无 GetViewports RPC降级 mock + warning待 coord 补全)
this.log.warn(
{ rpc: "GetViewports", reason: "RPC not in iam.proto yet" },
"Downstream RPC not ready, falling back to mock",
);
return this.mock.getViewports(ctx);
}
async getEffectivePermissions(
ctx: CallContext,
): Promise<EffectivePermissions> {
// iam.proto 暂无 GetEffectivePermissions RPC降级 mock + warning
this.log.warn(
{ rpc: "GetEffectivePermissions", reason: "RPC not in iam.proto yet" },
"Downstream RPC not ready, falling back to mock",
);
return this.mock.getEffectivePermissions(ctx);
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return checkGrpcHealth("iam");
}
}

View File

@@ -0,0 +1,125 @@
// IamClient Mock 实现B8 裁决:上游就绪前的降级策略)
// DEV_MODE=true 时全部走 mockDEV_MODE=false 时仅未就绪 RPC 走 mock
// mock 数据对齐 contract §4.2 mock 策略
import { Injectable } from "@nestjs/common";
import { BaseDownstreamClient } from "../base.client.js";
import type { CallContext } from "../types.js";
import type { IamClient } from "./iam-client.interface.js";
import type {
UserInfo,
ViewportItem,
EffectivePermissions,
} from "./iam.types.js";
/** 教师默认 mock 用户contract §4.1 mock 策略) */
const MOCK_TEACHER: UserInfo = {
id: "teacher-001",
email: "teacher@edu.test",
name: "张老师",
roles: ["teacher"],
permissions: [
"exam:read",
"exam:write",
"homework:read",
"homework:write",
"grade:read",
"grade:write",
"class:read",
"student:read",
],
};
/** 教师默认 mock 视口L1 导航) */
const MOCK_VIEWPORTS: ViewportItem[] = [
{
key: "dashboard",
label: "工作台",
route: "/dashboard",
icon: null,
sortOrder: 1,
requiredPermission: null,
},
{
key: "classes",
label: "我的班级",
route: "/classes",
icon: null,
sortOrder: 2,
requiredPermission: "class:read",
},
{
key: "exams",
label: "考试管理",
route: "/exams",
icon: null,
sortOrder: 3,
requiredPermission: "exam:read",
},
{
key: "homework",
label: "作业管理",
route: "/homework",
icon: null,
sortOrder: 4,
requiredPermission: "homework:read",
},
{
key: "grades",
label: "成绩管理",
route: "/grades",
icon: null,
sortOrder: 5,
requiredPermission: "grade:read",
},
{
key: "analytics",
label: "学情分析",
route: "/analytics",
icon: null,
sortOrder: 6,
requiredPermission: "student:read",
},
];
/** 教师默认 mock 权限(全权限 + OWN 数据范围) */
const MOCK_PERMISSIONS: EffectivePermissions = {
permissions: MOCK_TEACHER.permissions,
dataScope: "OWN",
};
@Injectable()
export class IamMockClient extends BaseDownstreamClient implements IamClient {
readonly serviceName = "iam" as const;
constructor() {
super();
this.initLogger();
}
async getUserInfo(ctx: CallContext): Promise<UserInfo> {
this.log.debug({ userId: ctx.userId }, "Mock getUserInfo");
// 返回 mock 教师,但 id 用请求中的 userId保持一致性
return { ...MOCK_TEACHER, id: ctx.userId };
}
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
this.log.debug({ userId: ctx.userId }, "Mock getViewports");
return MOCK_VIEWPORTS;
}
async getEffectivePermissions(
ctx: CallContext,
): Promise<EffectivePermissions> {
this.log.debug({ userId: ctx.userId }, "Mock getEffectivePermissions");
return MOCK_PERMISSIONS;
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
// mock 模式下健康检查总是返回 serving=true
return { serving: true, latencyMs: 0 };
}
}

View File

@@ -0,0 +1,31 @@
// iam 模块B8 裁决:按 DEV_MODE 选择 mock 或 gRPC 实现)
import { Module } from "@nestjs/common";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import { IAM_CLIENT } from "./iam-client.interface.js";
import { IamGrpcClient } from "./iam-grpc.client.js";
import { IamMockClient } from "./iam-mock.client.js";
@Module({
providers: [
{
provide: IAM_CLIENT,
useFactory: () => {
if (env.TEACHER_BFF_DEV_MODE) {
logger.warn(
{ devMode: true },
"IamClient using mock (TEACHER_BFF_DEV_MODE=true)",
);
return new IamMockClient();
}
logger.info(
{ devMode: false, target: env.IAM_GRPC_TARGET },
"IamClient using gRPC",
);
return new IamGrpcClient();
},
},
],
exports: [IAM_CLIENT],
})
export class IamModule {}

View File

@@ -0,0 +1,44 @@
// iam 下游服务类型定义(对齐 iam.proto + 待 coord 补全的 RPC
// iam.proto 现状4 RPCRegister/Login/RefreshToken/GetUserInfo
// 待 coord 补全GetViewports / GetEffectivePermissions / GetEffectiveAccess / BatchGetUsers / GetChildrenByParent
/** 用户信息(对齐 iam.proto UserInfo message */
export interface UserInfo {
id: string;
email: string;
name: string;
roles: string[];
permissions: string[];
}
/** 视口项L1 导航菜单,待 coord 补 GetViewports RPC 的 response message */
export interface ViewportItem {
key: string;
label: string;
route: string;
icon: string | null;
sortOrder: number;
requiredPermission: string | null;
}
/** 有效权限集(待 coord 补 GetEffectivePermissions RPC 的 response message */
export interface EffectivePermissions {
permissions: string[];
/** 数据范围OWN / GRADE / SCHOOL / ALL */
dataScope: string;
}
/** GetUserInfo 请求 */
export interface GetUserInfoRequest {
userId: string;
}
/** GetViewports 请求 */
export interface GetViewportsRequest {
userId: string;
}
/** GetEffectivePermissions 请求 */
export interface GetEffectivePermissionsRequest {
userId: string;
}

View File

@@ -0,0 +1,46 @@
// DownstreamClient 抽象层共享类型B8 裁决3 个 BFF 统一使用)
// 所有下游 gRPC 调用通过此抽象层,统一 trace 上下文注入 + 错误映射 + metrics
/** 下游调用上下文per-request由 GraphQL Resolver 注入) */
export interface CallContext {
/** 当前用户 ID从 JWT 或 x-user-id header 提取) */
userId: string;
/** 请求追踪 IDX-Request-Id用于日志关联 */
traceId?: string;
/** 用户角色(可选,用于下游权限校验) */
roles?: readonly string[];
}
/** 下游调用结果(降级模式 B 支持success + degraded 标记) */
export type DownstreamResult<T> =
| { success: true; data: T; degraded?: false }
| { success: true; data: T | null; degraded: true; warning: string }
| { success: false; error: DownstreamError };
export interface DownstreamError {
code: string;
message: string;
service: string;
rpc?: string;
details?: Record<string, unknown>;
}
/** 下游服务名(用于 metrics 标签 + 日志字段) */
export type DownstreamServiceName =
"iam" | "core-edu" | "content" | "data-ana" | "msg" | "ai";
/** gRPC 健康检查结果(/readyz 探针用) */
export interface GrpcHealthStatus {
service: DownstreamServiceName;
target: string;
status: "SERVING" | "NOT_SERVING" | "UNKNOWN" | "UNREACHABLE";
latencyMs?: number;
error?: string;
}
/** 下游 gRPC 调用 metadata注入 trace context + x-user-id */
export interface GrpcMetadata {
"x-user-id": string;
"x-request-id"?: string;
traceparent?: string;
}

View File

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

View File

@@ -0,0 +1,37 @@
// GraphQL 上下文构建B1 裁决P2 起直接 GraphQL
// 从 Express 请求提取 userId / traceId注入下游 CallContext
import type { Request } from "express";
import type { CallContext } from "../clients/types.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
export interface GraphQLOperationContext {
/** 当前请求的 CallContext注入下游 gRPC 调用) */
callCtx: CallContext;
}
/**
* 从 Express 请求构建 GraphQL 上下文
* P2: 从 x-user-id header 提取 userIdapi-gateway 注入)
* P3+: 从 JWT 提取api-gateway 校验后注入 x-user-id + x-user-roles
*/
export function buildGraphQLContext(req: Request): GraphQLOperationContext {
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const requestIdHeader = req.headers["x-request-id"];
const traceId =
typeof requestIdHeader === "string" ? requestIdHeader : undefined;
const rolesHeader = req.headers["x-user-roles"];
const roles =
typeof rolesHeader === "string"
? rolesHeader.split(",").filter(Boolean)
: undefined;
return {
callCtx: { userId, traceId, roles },
};
}

View File

@@ -0,0 +1,37 @@
// GraphQL 错误格式化G8 裁决ActionState 信封 + BFF_TEACHER_ 错误码)
// Yoga 自身错误处理extensions.code = BFF_TEACHER_* + extensions.traceId
import { ApplicationError } from "../shared/errors/application-error.js";
/**
* 格式化 GraphQL 错误president §2.6 降级模式 B + G8 ActionState 信封)
* 错误 extensions 包含codeBFF_TEACHER_*、traceId、details
*/
export function formatGraphQLError(
error: unknown,
traceId?: string,
): { message: string; extensions: Record<string, unknown> } {
// ApplicationError → 映射 code + details
if (error instanceof ApplicationError) {
return {
message: error.message,
extensions: {
code: error.code,
traceId: traceId ?? error.traceId ?? "unknown",
details: error.details,
},
};
}
// 原生 GraphQL Error语法错误、校验错误等
const err = error as {
message?: string;
extensions?: Record<string, unknown>;
};
return {
message: err.message ?? "Unknown GraphQL error",
extensions: {
code: (err.extensions?.code as string) ?? "BFF_TEACHER_INTERNAL_ERROR",
traceId: traceId ?? "unknown",
},
};
}

View File

@@ -0,0 +1,65 @@
// GraphQL Yoga endpointB1 裁决P2 起直接 GraphQL
// POST /graphql — GraphQL query/mutation endpoint
// GET /graphql — GraphQL Playground开发环境/ schema introspection
import { Controller, Post, Get, Req, Res, Inject } from "@nestjs/common";
import { createYoga } from "graphql-yoga";
import type { Request, Response } from "express";
import { env } from "../config/env.js";
import { logger } from "../shared/observability/logger.js";
import { loadSchema } from "./schema-loader.js";
import { buildGraphQLContext } from "./context.js";
import { TeacherService } from "../teacher/teacher.service.js";
import { buildResolvers } from "../teacher/teacher.resolver.js";
@Controller("graphql")
export class GraphQLController {
// Yoga 实例类型复杂,用内联类型避免泛型不匹配
private readonly yoga: (req: Request, res: Response) => Promise<void>;
constructor(@Inject(TeacherService) service: TeacherService) {
const resolvers = buildResolvers(service);
const schema = loadSchema(resolvers);
const yoga = createYoga({
schema,
graphqlEndpoint: env.GRAPHQL_PATH,
context: ({ request }) => {
return buildGraphQLContext(request as unknown as Request);
},
logging: {
debug: (msg: unknown) => logger.debug({ yoga: msg }, "GraphQL Yoga"),
info: (msg: unknown) => logger.info({ yoga: msg }, "GraphQL Yoga"),
warn: (msg: unknown) => logger.warn({ yoga: msg }, "GraphQL Yoga"),
error: (msg: unknown) => logger.error({ yoga: msg }, "GraphQL Yoga"),
},
// GraphQL Playground开发环境启用
graphiql: env.NODE_ENV === "development",
});
this.yoga = yoga as unknown as (
req: Request,
res: Response,
) => Promise<void>;
logger.info(
{ graphqlEndpoint: env.GRAPHQL_PATH },
"GraphQL Yoga endpoint initialized",
);
}
@Post()
async handleGraphQL(
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
await this.yoga(req, res);
}
@Get()
async handleGraphiQL(
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
await this.yoga(req, res);
}
}

View File

@@ -0,0 +1,10 @@
// GraphQL 模块B1 裁决P2 起直接 GraphQL Yoga
import { Module } from "@nestjs/common";
import { GraphQLController } from "./graphql.controller.js";
import { TeacherModule } from "../teacher/teacher.module.js";
@Module({
imports: [TeacherModule],
controllers: [GraphQLController],
})
export class GraphQLModule {}

View File

@@ -0,0 +1,22 @@
// GraphQL Schema 加载器(从 .graphql 文件加载 SDLB1 裁决)
import { readFileSync } from "node:fs";
import path from "node:path";
import { makeExecutableSchema } from "@graphql-tools/schema";
import type { IResolvers } from "@graphql-tools/utils";
/** schema 文件路径monorepo 相对路径) */
const SCHEMA_PATH = path.resolve(
process.cwd(),
"../../packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql",
);
/**
* 加载 GraphQL schema从 SDL 文件 + resolvers 构建可执行 schema
* schema 文件位于 packages/shared-ts/contracts/graphql/ARB-001 仲裁产物)
*/
export function loadSchema(
resolvers: IResolvers,
): ReturnType<typeof makeExecutableSchema> {
const typeDefs = readFileSync(SCHEMA_PATH, "utf-8");
return makeExecutableSchema({ typeDefs, resolvers });
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,14 @@ import type { Request, Response } from "express";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js"; 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() @Catch()
export class GlobalErrorFilter implements ExceptionFilter { export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name); private readonly logger = new Logger(GlobalErrorFilter.name);
@@ -34,7 +42,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: "TEACHER_BFF_VALIDATION_ERROR", code: "BFF_TEACHER_VALIDATION_FAILED",
message: "Validation failed", message: "Validation failed",
details: exception.flatten(), details: exception.flatten(),
traceId, traceId,
@@ -47,7 +55,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: "HTTP_ERROR", code: "BFF_TEACHER_INTERNAL_ERROR",
message, message,
traceId, traceId,
}, },
@@ -60,7 +68,7 @@ export class GlobalErrorFilter implements ExceptionFilter {
body = { body = {
success: false, success: false,
error: { error: {
code: "INTERNAL_ERROR", code: "BFF_TEACHER_INTERNAL_ERROR",
message: "An unexpected error occurred", message: "An unexpected error occurred",
traceId, traceId,
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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