chore(teacher-bff): merge teacher-bff full implementation into main
This commit is contained in:
@@ -420,28 +420,28 @@
|
|||||||
|
|
||||||
### 2.9 ai(Python/FastAPI,P5)
|
### 2.9 ai(Python/FastAPI,P5)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
| ------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
| LLM Provider 适配 | 4 适配器(OpenAI/Anthropic/Baichuan/Ollama)+ ProviderFailoverChain + CircuitBreaker(3 次失败 60s 熔断) |
|
| LLM Provider 适配 | 4 适配器(OpenAI/Anthropic/Baichuan/Ollama)+ ProviderFailoverChain + CircuitBreaker(3 次失败 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 端口 50058,8 RPC + 3 interceptor(Logging/Auth/Error) |
|
| gRPC server | grpc.aio 端口 50058,8 RPC + 3 interceptor(Logging/Auth/Error) |
|
||||||
| 评估三道防线 | RuleValidator(JSON/字段/难度校验)→ LLMJudge(语义评分)→ QualityGate(综合决策门控) |
|
| 评估三道防线 | RuleValidator(JSON/字段/难度校验)→ LLMJudge(语义评分)→ QualityGate(综合决策门控) |
|
||||||
| 备课工作流 | P5 用 asyncio.create_task + Redis 状态存储(24h TTL),P6+ 评估 Temporal |
|
| 备课工作流 | P5 用 asyncio.create_task + Redis 状态存储(24h TTL),P6+ 评估 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/min,Redis Lua 原子令牌桶,Redis 不可用降级放行 |
|
| 限流三维度 | user 10/min + IP 30/min + school 100/min,Redis 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(事务性 + 幂等) |
|
||||||
| 安全层全本地 | PIIRedactor(5 类 PII 正则脱敏)+ InputSanitizer(prompt injection 检测)+ OutputModerator(5 类敏感内容审核) |
|
| 安全层全本地 | PIIRedactor(5 类 PII 正则脱敏)+ InputSanitizer(prompt injection 检测)+ OutputModerator(5 类敏感内容审核) |
|
||||||
| 六边形架构端口模式 | 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-bff(TS/NestJS,P2)
|
||||||
|
|
||||||
|
> teacher-bff 是 GraphQL 聚合层(B1 裁决:P2 起直接 GraphQL),无 DB,无 @RequirePermission(B3 豁免),做数据权限防御(B4)。本节记录 teacher-bff 特有的"场景→技术"映射。
|
||||||
|
|
||||||
|
| 场景 | 技术/规则 |
|
||||||
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P2 GraphQL 直接实现 | B1 裁决:P2 起直接 GraphQL Yoga(禁止 REST 过渡),POST /graphql endpoint + SDL-first schema(packages/shared-ts/contracts/graphql/) |
|
||||||
|
| P2 gRPC 下游通信 | B2 裁决:首次实现即 gRPC(禁止 REST fetch),@grpc/grpc-js + @grpc/proto-loader 动态加载 proto(无生成代码依赖) |
|
||||||
|
| DownstreamClient 抽象 | B8 裁决:3 个 BFF 共用标准抽象层(src/clients/),base.client.ts + grpc.factory.ts + per-service client(iam/client.interface + grpc + mock) |
|
||||||
|
| DEV_MODE mock 开关 | TEACHER_BFF_DEV_MODE=true → IamClient 走 mock(固定数据);false → gRPC(未就绪 RPC 降级 mock + warning,president §2.6 降级模式 B) |
|
||||||
|
| 越权防御 3 类错误码 | president §2.7:UNAUTHORIZED(401) / FORBIDDEN_RESOURCE(403 场景 A 资源无归属) / IDENTITY_MISMATCH(403 场景 B JWT 与 body 不一致) |
|
||||||
|
| AuthorizationGuard P2 策略 | president §2.9:P2 DEV_MODE 放行 + 日志告警,生产保守拒绝;P3+ 接入 core-edu.GetClassesByTeacher 真实校验 |
|
||||||
|
| /readyz 探针注册表 | president §2.4:DownstreamHealthCheck 注册表模式,P2 = Redis + iam gRPC(2 项),按阶段扩展(P3=3/P4=5/P5=7);必需依赖失败返 503,可选失败返 200+degraded |
|
||||||
|
| Dashboard Resolver P2 实现 | president §2.8:P2 仅调 iam gRPC(user+viewports 有数据),未启用字段返 null + extensions.warning="field_unavailable_in_p2" |
|
||||||
|
| admin 命名空间预留 | president §5.1:P2 预留 schema(AdminQuery/AdminMutation 类型声明),Resolver 返 null/空;P6 实现 Resolver 真实数据(admin-portal 复用 teacher-bff GraphQL endpoint) |
|
||||||
|
| 错误码 BFF_TEACHER_ 前缀 | B5 + G14 裁决:统一 BFF_ 前缀(非 TEACHER_BFF_),含 10 个错误码(validation/unauthorized/forbidden_resource/identity_mismatch/not_found/conflict/business/upstream_unavailable/aggregation_failed/internal) |
|
||||||
|
| ESM .js 后缀 | G12 裁决:NestJS ESM 模式下相对 import 必须带 .js 后缀(构建产物),所有源码 import 路径已对齐 |
|
||||||
|
| import type 强制 | G13 裁决:仅类型导入用 `import type`,运行时导入用 `import` |
|
||||||
|
| gRPC 健康检查 | grpc.health.v1.Health/Check 标准协议,makeUnaryRequest + 2s 超时,解析 HealthCheckResponse.status varint |
|
||||||
|
| ioredis ESM 导入类型 | `import IORedis from "ioredis"` ESM 下报 TS2709/TS2351,改用命名导入 `import { Redis } from "ioredis"`,类型注解用 `Redis` 而非 `IORedis` |
|
||||||
|
| NestJS HttpAdapter 路由注册 | `app.getHttpAdapter().get()` 的 RequestHandler 与 Express 类型不兼容,改用 `getInstance() as Application` 获取 Express 实例后注册路由 |
|
||||||
|
| graphql-yoga v5 context | YogaInitialContext 不含 response,context 回调只解构 `{ request }`,buildGraphQLContext 只接收 request(P5+ SSE 再扩展 response) |
|
||||||
|
| readonly 属性延迟初始化 | `readonly` 属性只能在构造函数赋值,需在 initLogger() 方法中重新赋值时移除 readonly 修饰符 |
|
||||||
|
|||||||
452
packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql
Normal file
452
packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
# teacher-bff GraphQL Schema (SDL-first, president §2.17)
|
||||||
|
# 版本:v1(P2 第一版,coord 仲裁)
|
||||||
|
# 负责人:ai03
|
||||||
|
# 关联:contracts/teacher-bff_contract.md §1.3、workline §3.1
|
||||||
|
#
|
||||||
|
# 设计原则(02-architecture-design.md §5.1):
|
||||||
|
# 1. 按教学场景域组织(不按角色分),教导主任/教研组长复用同一 schema
|
||||||
|
# 2. Query 为主,Mutation 谨慎(BFF 偏读多写少)
|
||||||
|
# 3. N+1 防御:所有 list 字段经 DataLoader(P4 全量接入)
|
||||||
|
# 4. 复杂度限制:depth ≤ 7,query cost ≤ 1000
|
||||||
|
# 5. admin 命名空间预留(president §5.1:P2 预留 schema,P6 实现 Resolver)
|
||||||
|
#
|
||||||
|
# 错误响应格式:GraphQL errors 数组 + extensions.code = BFF_TEACHER_* + extensions.traceId
|
||||||
|
# 降级模式 B(president §2.6):success=true + error=null + data 内 degraded 字段
|
||||||
|
#
|
||||||
|
# 阶段标注:
|
||||||
|
# P2 = 当前实现(iam gRPC,未启用字段返 null + extensions.warning = "field_unavailable_in_p2")
|
||||||
|
# P3+ = 跨阶段扩展例外(president §2.3),Resolver 内逐步补 gRPC 调用
|
||||||
|
|
||||||
|
scalar DateTime
|
||||||
|
|
||||||
|
# ============ Types ============
|
||||||
|
|
||||||
|
type User {
|
||||||
|
id: ID!
|
||||||
|
email: String!
|
||||||
|
name: String!
|
||||||
|
roles: [String!]!
|
||||||
|
permissions: [String!]!
|
||||||
|
dataScope: DataScope!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DataScope {
|
||||||
|
SELF
|
||||||
|
CLASS
|
||||||
|
GRADE
|
||||||
|
SCHOOL
|
||||||
|
DISTRICT
|
||||||
|
ALL
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewportItem {
|
||||||
|
key: String!
|
||||||
|
label: String!
|
||||||
|
route: String!
|
||||||
|
icon: String
|
||||||
|
sortOrder: String!
|
||||||
|
requiredPermission: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type Class {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
gradeId: String!
|
||||||
|
# 延迟加载:班级下的考试(P3+ core-edu gRPC)
|
||||||
|
exams: [Exam!]!
|
||||||
|
# 延迟加载:班级下的作业(P3+ core-edu gRPC)
|
||||||
|
homework: [Homework!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Exam {
|
||||||
|
id: ID!
|
||||||
|
title: String!
|
||||||
|
classId: ID!
|
||||||
|
status: ExamStatus!
|
||||||
|
publishedAt: DateTime
|
||||||
|
# 延迟加载:考试下的成绩(P3+ core-edu gRPC)
|
||||||
|
grades: [Grade!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExamStatus {
|
||||||
|
DRAFT
|
||||||
|
PUBLISHED
|
||||||
|
IN_PROGRESS
|
||||||
|
GRADING
|
||||||
|
SCORED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
type Homework {
|
||||||
|
id: ID!
|
||||||
|
title: String!
|
||||||
|
classId: ID!
|
||||||
|
dueDate: DateTime!
|
||||||
|
submissions: [Submission!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Submission {
|
||||||
|
id: ID!
|
||||||
|
studentId: ID!
|
||||||
|
submittedAt: DateTime!
|
||||||
|
status: SubmissionStatus!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubmissionStatus {
|
||||||
|
NOT_SUBMITTED
|
||||||
|
SUBMITTED
|
||||||
|
GRADED
|
||||||
|
}
|
||||||
|
|
||||||
|
type Grade {
|
||||||
|
id: ID!
|
||||||
|
examId: ID!
|
||||||
|
studentId: ID!
|
||||||
|
score: Float!
|
||||||
|
rank: Int
|
||||||
|
submittedAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardStats {
|
||||||
|
totalExams: Int
|
||||||
|
pendingGrading: Int
|
||||||
|
todayHomework: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dashboard 聚合数据(P2: iam gRPC 用户基础信息,stats/classes 为 null + warning)
|
||||||
|
type DashboardData {
|
||||||
|
user: User
|
||||||
|
classes: [Class!]!
|
||||||
|
viewports: [ViewportItem!]!
|
||||||
|
stats: DashboardStats
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============ Query ============
|
||||||
|
# P2 核心 5 Query(workline §3.2):dashboard / viewports / me / classes / class
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
# 仪表盘聚合(P2: iam gRPC;P3+: + core-edu + data-ana)
|
||||||
|
# @dataScope: OWN
|
||||||
|
dashboard: DashboardData!
|
||||||
|
|
||||||
|
# 视口配置(L1 导航,iam.GetViewports)
|
||||||
|
viewports: [ViewportItem!]!
|
||||||
|
|
||||||
|
# 当前用户信息(iam.GetUserInfo + GetEffectivePermissions)
|
||||||
|
me: User!
|
||||||
|
|
||||||
|
# 教师班级列表(P2: iam 数据;P3+: core-edu.GetClassesByTeacher)
|
||||||
|
# @dataScope: OWN
|
||||||
|
classes: [Class!]!
|
||||||
|
|
||||||
|
# 单个班级详情(P3+: core-edu)
|
||||||
|
# @permission: CLASS_READ
|
||||||
|
# @dataScope: OWN
|
||||||
|
class(id: ID!): Class
|
||||||
|
|
||||||
|
# ===== P3+ 跨阶段扩展(core-edu gRPC,按 §2.3 跨阶段扩展例外新增)=====
|
||||||
|
|
||||||
|
# 班级下的考试列表(P3: core-edu.ExamService.ListExamsByClass)
|
||||||
|
# @permission: EXAM_READ
|
||||||
|
# @dataScope: OWN
|
||||||
|
exams(classId: ID!): [Exam!]!
|
||||||
|
|
||||||
|
# 班级下的作业列表(P3: core-edu.HomeworkService.ListHomeworkByClass)
|
||||||
|
# @permission: HOMEWORK_READ
|
||||||
|
# @dataScope: OWN
|
||||||
|
homework(classId: ID!): [Homework!]!
|
||||||
|
|
||||||
|
# 考试下的成绩列表(P3: core-edu.GradeService.ListGradesByExam)
|
||||||
|
# @permission: GRADE_READ
|
||||||
|
# @dataScope: OWN
|
||||||
|
grades(examId: ID!): [Grade!]!
|
||||||
|
|
||||||
|
# ===== P4+ 跨阶段扩展(content + data-ana gRPC)=====
|
||||||
|
|
||||||
|
# 知识图谱学习路径(P4: content.KnowledgeGraphService.GetLearningPath)
|
||||||
|
knowledgePath(knowledgePointId: ID!): KnowledgePath!
|
||||||
|
|
||||||
|
# 班级成绩分析(P4: data-ana.AnalyticsService.GetClassPerformance)
|
||||||
|
# @permission: ANALYTICS_TEACHER_DASHBOARD
|
||||||
|
classPerformance(classId: ID!): ClassPerformance!
|
||||||
|
|
||||||
|
# 学生薄弱点(P4: data-ana.AnalyticsService.GetStudentWeakness)
|
||||||
|
studentWeakness(studentId: ID!): StudentWeakness!
|
||||||
|
|
||||||
|
# 学习趋势(P4: data-ana.AnalyticsService.GetLearningTrend)
|
||||||
|
learningTrend(studentId: ID!, dateRange: DateRangeInput): LearningTrend!
|
||||||
|
|
||||||
|
# ===== P5+ 跨阶段扩展(ai + msg gRPC)=====
|
||||||
|
|
||||||
|
# 教师通知列表(P5: msg.NotificationService.ListNotifications)
|
||||||
|
notifications(unreadOnly: Boolean): [Notification!]!
|
||||||
|
|
||||||
|
# ===== admin 命名空间(president §5.1:P2 预留 schema,P6 实现 Resolver)=====
|
||||||
|
# admin-portal 复用 teacher-bff GraphQL endpoint,admin 操作低 QPS 无需独立 BFF
|
||||||
|
admin: AdminQuery!
|
||||||
|
}
|
||||||
|
|
||||||
|
input DateRangeInput {
|
||||||
|
start: DateTime!
|
||||||
|
end: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============ Mutation ============
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
# ===== P3+ 跨阶段扩展(core-edu gRPC 透传)=====
|
||||||
|
|
||||||
|
# 创建考试(P3: core-edu.ExamService.CreateExam)
|
||||||
|
# @permission: EXAM_CREATE
|
||||||
|
createExam(input: CreateExamInput!): Exam!
|
||||||
|
|
||||||
|
# 布置作业(P3: core-edu.HomeworkService.AssignHomework)
|
||||||
|
# @permission: HOMEWORK_ASSIGN
|
||||||
|
assignHomework(input: AssignHomeworkInput!): Homework!
|
||||||
|
|
||||||
|
# 录入成绩(P3: core-edu.GradeService.RecordGrade)
|
||||||
|
# @permission: GRADE_RECORD
|
||||||
|
recordGrade(input: RecordGradeInput!): Grade!
|
||||||
|
|
||||||
|
# ===== P5+ 跨阶段扩展(ai + msg gRPC)=====
|
||||||
|
|
||||||
|
# AI 出题(P5: ai.AiService.GenerateQuestion)
|
||||||
|
generateQuestion(input: GenerateQuestionInput!): GeneratedQuestion!
|
||||||
|
|
||||||
|
# 标记通知已读(P5: msg.NotificationService.MarkAsRead)
|
||||||
|
markNotificationAsRead(id: ID!): Notification!
|
||||||
|
|
||||||
|
# ===== admin 命名空间 Mutation(president §5.1:P2 预留,P6 实现)=====
|
||||||
|
admin: AdminMutation!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateExamInput {
|
||||||
|
classId: ID!
|
||||||
|
title: String!
|
||||||
|
subject: String!
|
||||||
|
scheduledAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
input AssignHomeworkInput {
|
||||||
|
classId: ID!
|
||||||
|
title: String!
|
||||||
|
dueDate: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
input RecordGradeInput {
|
||||||
|
examId: ID!
|
||||||
|
studentId: ID!
|
||||||
|
score: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
input GenerateQuestionInput {
|
||||||
|
subject: String!
|
||||||
|
gradeLevel: String!
|
||||||
|
difficulty: String!
|
||||||
|
knowledgePointIds: [ID!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============ P4+ 类型(content + data-ana)============
|
||||||
|
|
||||||
|
type KnowledgePath {
|
||||||
|
knowledgePointId: ID!
|
||||||
|
name: String!
|
||||||
|
prerequisites: [KnowledgePoint!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgePoint {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
subject: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClassPerformance {
|
||||||
|
classId: ID!
|
||||||
|
averageScore: Float!
|
||||||
|
passRate: Float!
|
||||||
|
weaknessTopics: [String!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type StudentWeakness {
|
||||||
|
studentId: ID!
|
||||||
|
weakTopics: [String!]!
|
||||||
|
recommendedExercises: [String!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type LearningTrend {
|
||||||
|
studentId: ID!
|
||||||
|
trend: [TrendPoint!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrendPoint {
|
||||||
|
date: DateTime!
|
||||||
|
score: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============ P5+ 类型(ai + msg)============
|
||||||
|
|
||||||
|
type Notification {
|
||||||
|
id: ID!
|
||||||
|
type: NotificationType!
|
||||||
|
title: String!
|
||||||
|
content: String!
|
||||||
|
read: Boolean!
|
||||||
|
createdAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationType {
|
||||||
|
SYSTEM
|
||||||
|
EXAM
|
||||||
|
HOMEWORK
|
||||||
|
GRADE
|
||||||
|
ATTENDANCE
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeneratedQuestion {
|
||||||
|
id: ID!
|
||||||
|
subject: String!
|
||||||
|
difficulty: String!
|
||||||
|
content: String!
|
||||||
|
answer: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============ admin 命名空间(president §5.1:P2 预留 schema,P6 实现 Resolver)============
|
||||||
|
# P2: 类型声明占位,Resolver 返 null(不阻塞 admin-portal schema 内省)
|
||||||
|
# P6: ai03 实现 admin Resolver 真实数据(用户/角色/学校/组织/审计日志管理)
|
||||||
|
|
||||||
|
type AdminQuery {
|
||||||
|
# 用户管理
|
||||||
|
users(page: Int = 1, pageSize: Int = 20): AdminUserPage
|
||||||
|
user(id: ID!): AdminUser
|
||||||
|
|
||||||
|
# 角色权限管理
|
||||||
|
roles: [AdminRole!]!
|
||||||
|
role(id: ID!): AdminRole
|
||||||
|
|
||||||
|
# 学校设置
|
||||||
|
school: AdminSchool
|
||||||
|
|
||||||
|
# 组织管理
|
||||||
|
organizations: [AdminOrganization!]!
|
||||||
|
|
||||||
|
# 审计日志查询
|
||||||
|
auditLogs(page: Int = 1, pageSize: Int = 20): AuditLogPage
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminMutation {
|
||||||
|
createUser(input: AdminCreateUserInput!): AdminUser!
|
||||||
|
updateUser(id: ID!, input: AdminUpdateUserInput!): AdminUser!
|
||||||
|
deleteUser(id: ID!): Boolean!
|
||||||
|
|
||||||
|
createRole(input: AdminCreateRoleInput!): AdminRole!
|
||||||
|
updateRole(id: ID!, input: AdminUpdateRoleInput!): AdminRole!
|
||||||
|
deleteRole(id: ID!): Boolean!
|
||||||
|
|
||||||
|
updateSchool(input: AdminUpdateSchoolInput!): AdminSchool!
|
||||||
|
|
||||||
|
createOrganization(input: AdminCreateOrganizationInput!): AdminOrganization!
|
||||||
|
updateOrganization(id: ID!, input: AdminUpdateOrganizationInput!): AdminOrganization!
|
||||||
|
deleteOrganization(id: ID!): Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUser {
|
||||||
|
id: ID!
|
||||||
|
email: String!
|
||||||
|
name: String!
|
||||||
|
roles: [String!]!
|
||||||
|
status: String!
|
||||||
|
createdAt: DateTime!
|
||||||
|
lastLoginAt: DateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUserPage {
|
||||||
|
edges: [AdminUser!]!
|
||||||
|
totalCount: Int!
|
||||||
|
page: Int!
|
||||||
|
pageSize: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminRole {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
description: String
|
||||||
|
permissions: [String!]!
|
||||||
|
userCount: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminSchool {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
address: String
|
||||||
|
phone: String
|
||||||
|
studentCount: Int!
|
||||||
|
teacherCount: Int!
|
||||||
|
classCount: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminOrganization {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
type: String!
|
||||||
|
parentId: ID
|
||||||
|
createdAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLogPage {
|
||||||
|
edges: [AuditLog!]!
|
||||||
|
totalCount: Int!
|
||||||
|
page: Int!
|
||||||
|
pageSize: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLog {
|
||||||
|
id: ID!
|
||||||
|
actorUserId: ID!
|
||||||
|
action: String!
|
||||||
|
resourceType: String!
|
||||||
|
resourceId: ID
|
||||||
|
occurredAt: DateTime!
|
||||||
|
ip: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input AdminCreateUserInput {
|
||||||
|
email: String!
|
||||||
|
name: String!
|
||||||
|
roleIds: [ID!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
input AdminUpdateUserInput {
|
||||||
|
name: String
|
||||||
|
roleIds: [ID!]
|
||||||
|
status: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input AdminCreateRoleInput {
|
||||||
|
name: String!
|
||||||
|
description: String
|
||||||
|
permissions: [String!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
input AdminUpdateRoleInput {
|
||||||
|
name: String
|
||||||
|
description: String
|
||||||
|
permissions: [String!]
|
||||||
|
}
|
||||||
|
|
||||||
|
input AdminUpdateSchoolInput {
|
||||||
|
name: String
|
||||||
|
address: String
|
||||||
|
phone: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input AdminCreateOrganizationInput {
|
||||||
|
name: String!
|
||||||
|
type: String!
|
||||||
|
parentId: ID
|
||||||
|
}
|
||||||
|
|
||||||
|
input AdminUpdateOrganizationInput {
|
||||||
|
name: String
|
||||||
|
type: String
|
||||||
|
parentId: ID
|
||||||
|
}
|
||||||
194
pnpm-lock.yaml
generated
194
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|
||||||
|
|||||||
@@ -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-proto(gRPC proto-loader 运行时加载)
|
||||||
|
COPY packages/shared-proto ./packages/shared-proto
|
||||||
|
|
||||||
|
# 复制 shared-ts(GraphQL schema 文件)
|
||||||
|
COPY packages/shared-ts ./packages/shared-ts
|
||||||
|
|
||||||
|
# 复制 teacher-bff 源码
|
||||||
|
COPY services/teacher-bff ./services/teacher-bff
|
||||||
|
|
||||||
|
# 安装依赖(仅 teacher-bff + shared-ts)
|
||||||
|
RUN cd services/teacher-bff && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
WORKDIR /app/services/teacher-bff
|
||||||
RUN pnpm build
|
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"]
|
||||||
|
|||||||
@@ -189,16 +189,16 @@ graph LR
|
|||||||
|
|
||||||
**目标**:在不改变对外 REST 契约的前提下,补齐黄金模板差距,为后续 gRPC/GraphQL 切换铺路。
|
**目标**:在不改变对外 REST 契约的前提下,补齐黄金模板差距,为后续 gRPC/GraphQL 切换铺路。
|
||||||
|
|
||||||
| 工作项 | 详情 | 优先级 |
|
| 工作项 | 详情 | 优先级 |
|
||||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------ |
|
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
|
||||||
| 引入 Client 抽象层 | 新增 `src/clients/` 目录,定义 `IamClient`/`ClassesClient`/`CoreEduClient` 接口,当前实现为 REST fetch adapter,P3 替换为 gRPC adapter | P0 |
|
| 引入 Client 抽象层 | 新增 `src/clients/` 目录,定义 `IamClient`/`ClassesClient`/`CoreEduClient` 接口,当前实现为 REST fetch adapter,P3 替换为 gRPC adapter | P0 |
|
||||||
| 错误码迁移 | `TEACHER_BFF_*` → `BFF_TEACHER_*`(全量替换 [application-error.ts](../src/shared/errors/application-error.ts)) | P0 |
|
| 错误码迁移 | `TEACHER_BFF_*` → `BFF_TEACHER_*`(已完成,见 [application-error.ts](../src/shared/errors/application-error.ts))+ 3 类越权错误码(president §2.7) | P0 |
|
||||||
| Zod 输入验证 | Controller 层引入 Zod schema 解析 path/body(classId/examId 等) | P0 |
|
| Zod 输入验证 | Controller 层引入 Zod schema 解析 path/body(classId/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:50054(KnowledgeGraphService + ChapterService/QuestionService 待 ai09 补) |
|
| ContentClient gRPC adapter | 调用 content:50054(KnowledgeGraphService + ChapterService/QuestionService 待 ai09 补) |
|
||||||
| DataAnaClient gRPC adapter | 调用 data-ana:50055(AnalyticsService + GetTeacherDashboardStats 待 ai11 补) |
|
| DataAnaClient gRPC adapter | 调用 data-ana:50055(AnalyticsService + GetTeacherDashboard 待 ai11 补) |
|
||||||
| 双轨读支持 | data-ana 学情查询支持实时查主库 + 聚合查 ClickHouse 宽表 |
|
| 双轨读支持 | 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
|
|||||||
| /graphql(P4) | 校验 x-user-id + 注入 context | 同上 |
|
| /graphql(P4) | 校验 x-user-id + 注入 context | 同上 |
|
||||||
| /ai/chat/stream(P5) | 校验 x-user-id | 同上 |
|
| /ai/chat/stream(P5) | 校验 x-user-id | 同上 |
|
||||||
|
|
||||||
### 15.2 错误码清单(迁移为 `BFF_TEACHER_*`)
|
### 15.2 错误码清单(`BFF_TEACHER_*` 前缀,B5 + G14 裁决)
|
||||||
|
|
||||||
| 错误码 | HTTP | 触发条件 | 当前状态 |
|
> 越权防御 3 类错误码(president §2.7):UNAUTHORIZED / 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 50054(P4+) | KnowledgeGraphService.GetPrerequisites / GetLearningPath | 知识图谱 | P4 | ✅ 已有 |
|
| 调用 | content | gRPC 50054(P4+) | KnowledgeGraphService.GetPrerequisites / GetLearningPath | 知识图谱 | P4 | ✅ 已有 |
|
||||||
| 调用 | content | gRPC 50054(P4+) | _*ChapterService.* / QuestionService._** | 章节/题库 | P4 | ❌ 待 ai09 补 |
|
| 调用 | content | gRPC 50054(P4+) | _*ChapterService.* / QuestionService._** | 章节/题库 | P4 | ❌ 待 ai09 补 |
|
||||||
| 调用 | data-ana | gRPC 50055(P4+) | AnalyticsService.GetClassPerformance / GetStudentWeakness / GetLearningTrend | 学情诊断 | P4 | ✅ 已有 |
|
| 调用 | data-ana | gRPC 50055(P4+) | AnalyticsService.GetClassPerformance / GetStudentWeakness / GetLearningTrend | 学情诊断 | P4 | ✅ 已有 |
|
||||||
| 调用 | data-ana | gRPC 50055(P4+) | **GetTeacherDashboardStats**(建议新增) | 仪表盘统计 | P4 | ❌ 待 coord 仲裁 |
|
| 调用 | data-ana | gRPC 50055(P4+) | **GetTeacherDashboard**(建议新增) | 仪表盘统计 | P4 | ❌ 待 coord 仲裁 |
|
||||||
| 调用 | ai | gRPC 50057(P5+) | AiService.GenerateQuestion / Chat / OptimizeExpression | AI 辅助 | P5 | ✅ 已有 |
|
| 调用 | ai | gRPC 50057(P5+) | AiService.GenerateQuestion / Chat / OptimizeExpression | AI 辅助 | P5 | ✅ 已有 |
|
||||||
| 调用 | ai | gRPC 50057 streaming(P5+) | **AiService.StreamChat** | SSE 流式对话 | P5 | ✅ 已有 |
|
| 调用 | ai | gRPC 50057 streaming(P5+) | **AiService.StreamChat** | SSE 流式对话 | P5 | ✅ 已有 |
|
||||||
| 调用 | msg | gRPC 50056(P5+) | NotificationService.ListNotifications / SearchNotifications / MarkAsRead | 通知聚合 | P5 | ✅ 已有 |
|
| 调用 | msg | gRPC 50056(P5+) | NotificationService.ListNotifications / SearchNotifications / MarkAsRead | 通知聚合 | P5 | ✅ 已有 |
|
||||||
@@ -1463,7 +1467,7 @@ Redis 单实例 256MB 足够,无需集群。
|
|||||||
| 3 | **iam 补 4 个 RPC** | GetViewports / GetEffectivePermissions / Logout / GetPublicKey | 强烈建议 P2 补全(teacher-bff 直接依赖) | P2 |
|
| 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 |
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
159
services/teacher-bff/src/clients/base.client.ts
Normal file
159
services/teacher-bff/src/clients/base.client.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// DownstreamClient 抽象基类(B8 裁决:3 个 BFF 统一使用)
|
||||||
|
// 提供统一的错误映射 + 结构化日志 + metrics 记录
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
import { logger } from "../shared/observability/logger.js";
|
||||||
|
import {
|
||||||
|
BadGatewayError,
|
||||||
|
AggregationFailedError,
|
||||||
|
} from "../shared/errors/application-error.js";
|
||||||
|
import type {
|
||||||
|
CallContext,
|
||||||
|
DownstreamError,
|
||||||
|
DownstreamResult,
|
||||||
|
DownstreamServiceName,
|
||||||
|
GrpcMetadata,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
/** gRPC 状态码 → HTTP 语义映射(@grpc/grpc-js status codes) */
|
||||||
|
const GRPC_STATUS = {
|
||||||
|
OK: 0,
|
||||||
|
CANCELLED: 1,
|
||||||
|
UNKNOWN: 2,
|
||||||
|
INVALID_ARGUMENT: 3,
|
||||||
|
DEADLINE_EXCEEDED: 4,
|
||||||
|
NOT_FOUND: 5,
|
||||||
|
ALREADY_EXISTS: 6,
|
||||||
|
PERMISSION_DENIED: 7,
|
||||||
|
RESOURCE_EXHAUSTED: 8,
|
||||||
|
FAILED_PRECONDITION: 9,
|
||||||
|
ABORTED: 10,
|
||||||
|
OUT_OF_RANGE: 11,
|
||||||
|
UNIMPLEMENTED: 12,
|
||||||
|
INTERNAL: 13,
|
||||||
|
UNAVAILABLE: 14,
|
||||||
|
DATA_LOSS: 15,
|
||||||
|
UNAUTHENTICATED: 16,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export abstract class BaseDownstreamClient {
|
||||||
|
protected log: Logger;
|
||||||
|
abstract readonly serviceName: DownstreamServiceName;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// abstract property 在子类构造后才可用,延迟初始化 logger
|
||||||
|
this.log = logger.child({ downstream: "downstream" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子类在构造函数中调用以初始化 logger(带正确 serviceName) */
|
||||||
|
protected initLogger(): void {
|
||||||
|
this.log = logger.child({ downstream: this.serviceName });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建 gRPC metadata(注入 x-user-id + x-request-id) */
|
||||||
|
protected buildMetadata(ctx: CallContext): GrpcMetadata {
|
||||||
|
const meta: GrpcMetadata = { "x-user-id": ctx.userId };
|
||||||
|
if (ctx.traceId) {
|
||||||
|
meta["x-request-id"] = ctx.traceId;
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 gRPC 错误映射为 BadGatewayError(502) */
|
||||||
|
protected mapGrpcError(err: unknown, rpc: string): BadGatewayError {
|
||||||
|
const grpcErr = err as {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
const code = grpcErr.code ?? GRPC_STATUS.UNKNOWN;
|
||||||
|
const message = grpcErr.message ?? "Unknown gRPC error";
|
||||||
|
|
||||||
|
this.log.warn(
|
||||||
|
{ rpc, grpcCode: code, message, err },
|
||||||
|
"Downstream gRPC call failed",
|
||||||
|
);
|
||||||
|
|
||||||
|
// UNAVAILABLE → 502 Bad Gateway
|
||||||
|
if (code === GRPC_STATUS.UNAVAILABLE) {
|
||||||
|
return new BadGatewayError(
|
||||||
|
`Downstream ${this.serviceName}.${rpc} unavailable`,
|
||||||
|
{ service: this.serviceName, rpc, grpcCode: code },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNAUTHENTICATED → 映射为 BadGateway(BFF 自身做身份校验,下游不应返回 UNAUTHENTICATED)
|
||||||
|
if (code === GRPC_STATUS.UNAUTHENTICATED) {
|
||||||
|
return new BadGatewayError(
|
||||||
|
`Downstream ${this.serviceName}.${rpc} returned UNAUTHENTICATED`,
|
||||||
|
{ service: this.serviceName, rpc, grpcCode: code },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他错误统一映射为 BadGateway
|
||||||
|
return new BadGatewayError(
|
||||||
|
`Downstream ${this.serviceName}.${rpc} failed: ${message}`,
|
||||||
|
{
|
||||||
|
service: this.serviceName,
|
||||||
|
rpc,
|
||||||
|
grpcCode: code,
|
||||||
|
details: grpcErr.details,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行 gRPC 调用并统一错误处理 + 超时控制 */
|
||||||
|
protected async callGrpc<T>(
|
||||||
|
rpc: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
timeoutMs = 3000,
|
||||||
|
): Promise<T> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([
|
||||||
|
fn(),
|
||||||
|
this.createTimeout(timeoutMs, rpc),
|
||||||
|
]);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
this.log.debug(
|
||||||
|
{ rpc, durationMs: duration },
|
||||||
|
"Downstream gRPC call succeeded",
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
throw this.mapGrpcError(err, rpc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createTimeout(ms: number, rpc: string): Promise<never> {
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(
|
||||||
|
new BadGatewayError(
|
||||||
|
`Downstream ${this.serviceName}.${rpc} timed out after ${ms}ms`,
|
||||||
|
{ service: this.serviceName, rpc, timeoutMs: ms },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 降级模式 B:部分失败时返回 success=true + degraded=true + warning(president §2.6) */
|
||||||
|
protected degraded<T>(
|
||||||
|
data: T | null,
|
||||||
|
warning: string,
|
||||||
|
rpc: string,
|
||||||
|
): DownstreamResult<T> {
|
||||||
|
this.log.warn({ rpc, warning }, "Downstream call degraded");
|
||||||
|
return { success: true, data, degraded: true, warning };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聚合失败(多下游部分失败且无降级数据,president §2.6) */
|
||||||
|
protected aggregationFailed(
|
||||||
|
errors: DownstreamError[],
|
||||||
|
): AggregationFailedError {
|
||||||
|
return new AggregationFailedError(
|
||||||
|
`Aggregation failed for ${this.serviceName}: ${errors.length} downstream(s) failed`,
|
||||||
|
{ service: this.serviceName, errors },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
services/teacher-bff/src/clients/clients.module.ts
Normal file
10
services/teacher-bff/src/clients/clients.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// DownstreamClient 聚合模块(B8 裁决:3 个 BFF 统一使用)
|
||||||
|
// P2 仅注册 IamModule,P3+ 扩展 CoreEduModule / ContentModule / DataAnaModule / MsgModule / AiModule
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { IamModule } from "./iam/iam.module.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [IamModule],
|
||||||
|
exports: [IamModule],
|
||||||
|
})
|
||||||
|
export class ClientsModule {}
|
||||||
256
services/teacher-bff/src/clients/grpc/grpc.factory.ts
Normal file
256
services/teacher-bff/src/clients/grpc/grpc.factory.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
// gRPC Channel + Client 工厂(B2 裁决:首次实现即 gRPC)
|
||||||
|
// 使用 @grpc/grpc-js + @grpc/proto-loader 动态加载 proto(无生成代码依赖)
|
||||||
|
// 上游就绪后可切换为 @bufbuild/protobuf 生成代码(B8 裁决)
|
||||||
|
import * as grpc from "@grpc/grpc-js";
|
||||||
|
import * as protoLoader from "@grpc/proto-loader";
|
||||||
|
import path from "node:path";
|
||||||
|
import { env } from "../../config/env.js";
|
||||||
|
import { logger } from "../../shared/observability/logger.js";
|
||||||
|
import type { DownstreamServiceName } from "../types.js";
|
||||||
|
|
||||||
|
/** proto 文件根目录(monorepo 相对路径) */
|
||||||
|
const PROTO_ROOT = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"../../packages/shared-proto/proto",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 已加载的 proto 定义缓存(按文件名缓存) */
|
||||||
|
const packageCache = new Map<string, protoLoader.PackageDefinition>();
|
||||||
|
|
||||||
|
/** 已创建的 gRPC Client 缓存(按 service+target 缓存) */
|
||||||
|
const clientCache = new Map<string, grpc.Client>();
|
||||||
|
|
||||||
|
/** 各下游服务对应的 proto 文件 + 包名 + service 名 */
|
||||||
|
interface ServiceProtoConfig {
|
||||||
|
protoFile: string;
|
||||||
|
packageName: string;
|
||||||
|
serviceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE_PROTO_MAP: Record<DownstreamServiceName, ServiceProtoConfig> = {
|
||||||
|
iam: {
|
||||||
|
protoFile: "iam.proto",
|
||||||
|
packageName: "next_edu_cloud.iam.v1",
|
||||||
|
serviceName: "IamService",
|
||||||
|
},
|
||||||
|
"core-edu": {
|
||||||
|
protoFile: "core_edu.proto",
|
||||||
|
packageName: "next_edu_cloud.core_edu.v1",
|
||||||
|
serviceName: "", // core-edu 多 service,按需指定
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
protoFile: "content.proto",
|
||||||
|
packageName: "next_edu_cloud.content.v1",
|
||||||
|
serviceName: "",
|
||||||
|
},
|
||||||
|
"data-ana": {
|
||||||
|
protoFile: "analytics.proto",
|
||||||
|
packageName: "next_edu_cloud.analytics.v1",
|
||||||
|
serviceName: "",
|
||||||
|
},
|
||||||
|
msg: {
|
||||||
|
protoFile: "msg.proto",
|
||||||
|
packageName: "next_edu_cloud.msg.v1",
|
||||||
|
serviceName: "",
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
protoFile: "ai.proto",
|
||||||
|
packageName: "next_edu_cloud.ai.v1",
|
||||||
|
serviceName: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 加载 proto 定义(带缓存) */
|
||||||
|
function loadPackageDefinition(
|
||||||
|
protoFile: string,
|
||||||
|
): protoLoader.PackageDefinition {
|
||||||
|
const cached = packageCache.get(protoFile);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const fullPath = path.join(PROTO_ROOT, protoFile);
|
||||||
|
const def = protoLoader.loadSync(fullPath, {
|
||||||
|
keepCase: false,
|
||||||
|
longs: String,
|
||||||
|
enums: String,
|
||||||
|
defaults: true,
|
||||||
|
oneofs: true,
|
||||||
|
includeDirs: [PROTO_ROOT],
|
||||||
|
});
|
||||||
|
packageCache.set(protoFile, def);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取下游服务的 gRPC target(从环境变量) */
|
||||||
|
export function getGrpcTarget(service: DownstreamServiceName): string {
|
||||||
|
const map: Record<DownstreamServiceName, string> = {
|
||||||
|
iam: env.IAM_GRPC_TARGET,
|
||||||
|
"core-edu": env.CORE_EDU_GRPC_TARGET,
|
||||||
|
content: env.CONTENT_GRPC_TARGET,
|
||||||
|
"data-ana": env.DATA_ANA_GRPC_TARGET,
|
||||||
|
msg: env.MSG_GRPC_TARGET,
|
||||||
|
ai: env.AI_GRPC_TARGET,
|
||||||
|
};
|
||||||
|
return map[service];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断下游服务是否已配置 target(未配置则走 mock) */
|
||||||
|
export function isServiceConfigured(service: DownstreamServiceName): boolean {
|
||||||
|
const target = getGrpcTarget(service);
|
||||||
|
return target.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建或获取 gRPC Client(带缓存) */
|
||||||
|
export function getGrpcClient(
|
||||||
|
service: DownstreamServiceName,
|
||||||
|
serviceName?: string,
|
||||||
|
): grpc.Client {
|
||||||
|
const target = getGrpcTarget(service);
|
||||||
|
const config = SERVICE_PROTO_MAP[service];
|
||||||
|
const svcName = serviceName ?? config.serviceName;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(
|
||||||
|
`gRPC target not configured for service: ${service} (set ${service.toUpperCase().replace("-", "_")}_GRPC_TARGET)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!svcName) {
|
||||||
|
throw new Error(
|
||||||
|
`Service name not specified for ${service}, pass serviceName explicitly`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${service}:${svcName}:${target}`;
|
||||||
|
const cached = clientCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const def = loadPackageDefinition(config.protoFile);
|
||||||
|
const proto = grpc.loadPackageDefinition(def) as unknown as Record<
|
||||||
|
string,
|
||||||
|
Record<string, unknown>
|
||||||
|
>;
|
||||||
|
|
||||||
|
// 按包路径查找 Service 构造器
|
||||||
|
const pkgParts = config.packageName.split(".");
|
||||||
|
let pkg: Record<string, unknown> = proto;
|
||||||
|
for (const part of pkgParts) {
|
||||||
|
pkg = pkg[part] as Record<string, unknown>;
|
||||||
|
if (!pkg) {
|
||||||
|
throw new Error(
|
||||||
|
`Package ${config.packageName} not found in ${config.protoFile}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServiceConstructor = pkg[svcName] as unknown as {
|
||||||
|
new (target: string, credentials: grpc.ChannelCredentials): grpc.Client;
|
||||||
|
};
|
||||||
|
if (!ServiceConstructor) {
|
||||||
|
throw new Error(
|
||||||
|
`Service ${svcName} not found in package ${config.packageName}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2-P2 阶段使用 insecure(内网,mTLS 在 P6 硬化阶段启用)
|
||||||
|
const client = new ServiceConstructor(
|
||||||
|
target,
|
||||||
|
grpc.credentials.createInsecure(),
|
||||||
|
);
|
||||||
|
|
||||||
|
clientCache.set(cacheKey, client);
|
||||||
|
logger.info({ service, serviceName: svcName, target }, "gRPC client created");
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 gRPC metadata(注入 x-user-id + x-request-id) */
|
||||||
|
export function createGrpcMetadata(
|
||||||
|
userId: string,
|
||||||
|
traceId?: string,
|
||||||
|
): grpc.Metadata {
|
||||||
|
const meta = new grpc.Metadata();
|
||||||
|
meta.add("x-user-id", userId);
|
||||||
|
if (traceId) {
|
||||||
|
meta.add("x-request-id", traceId);
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭所有 gRPC Client(优雅关闭时调用) */
|
||||||
|
export function closeAllGrpcClients(): void {
|
||||||
|
for (const [key, client] of clientCache) {
|
||||||
|
try {
|
||||||
|
client.close();
|
||||||
|
logger.info({ client: key }, "gRPC client closed");
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ client: key, err }, "Failed to close gRPC client");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clientCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 gRPC 健康检查(grpc.health.v1.Health/Check)
|
||||||
|
* 用于 /readyz 探针(president §2.4)
|
||||||
|
*
|
||||||
|
* 使用标准 grpc.health.v1.Health 服务,通过 makeUnaryRequest 直接调用。
|
||||||
|
* 超时 2s,返回 SERVING / NOT_SERVING / UNREACHABLE。
|
||||||
|
*/
|
||||||
|
export async function checkGrpcHealth(
|
||||||
|
service: DownstreamServiceName,
|
||||||
|
): Promise<{ serving: boolean; latencyMs: number; error?: string }> {
|
||||||
|
const target = getGrpcTarget(service);
|
||||||
|
if (!target) {
|
||||||
|
return { serving: false, latencyMs: 0, error: "target not configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const client = new grpc.Client(target, grpc.credentials.createInsecure(), {
|
||||||
|
"grpc.enable_retries": 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
client.close();
|
||||||
|
resolve({
|
||||||
|
serving: false,
|
||||||
|
latencyMs: Date.now() - start,
|
||||||
|
error: "health check timed out (2s)",
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
client.makeUnaryRequest(
|
||||||
|
"grpc.health.v1.Health/Check",
|
||||||
|
// serialize: HealthCheckRequest → Buffer(空消息体)
|
||||||
|
() => Buffer.alloc(0),
|
||||||
|
// deserialize: Buffer → { status: string }
|
||||||
|
(buf: Buffer) => {
|
||||||
|
// proto HealthCheckResponse { enum ServingStatus { SERVING = 1; } int32 status = 1; }
|
||||||
|
// 简化解析:直接读取第一个 varint 字段(field 1 = status)
|
||||||
|
if (buf.length < 2) return { status: "UNKNOWN" };
|
||||||
|
const statusByte: number = buf[1] ?? 0;
|
||||||
|
const statusMap: Record<number, string> = {
|
||||||
|
0: "UNKNOWN",
|
||||||
|
1: "SERVING",
|
||||||
|
2: "NOT_SERVING",
|
||||||
|
3: "SERVICE_UNKNOWN",
|
||||||
|
};
|
||||||
|
return { status: statusMap[statusByte] ?? "UNKNOWN" };
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
new grpc.Metadata(),
|
||||||
|
{},
|
||||||
|
(err: grpc.ServiceError | null, value: unknown) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const latencyMs = Date.now() - start;
|
||||||
|
client.close();
|
||||||
|
if (err) {
|
||||||
|
resolve({ serving: false, latencyMs, error: err.message });
|
||||||
|
} else {
|
||||||
|
const status = (value as { status?: string } | null | undefined)
|
||||||
|
?.status;
|
||||||
|
resolve({ serving: status === "SERVING", latencyMs });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
31
services/teacher-bff/src/clients/iam/iam-client.interface.ts
Normal file
31
services/teacher-bff/src/clients/iam/iam-client.interface.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// IamClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||||
|
// 接口定义所有 P2+ 需要的 iam RPC,实现分 gRPC + mock 两种
|
||||||
|
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC(未就绪 RPC 降级 mock + warning)
|
||||||
|
import type { CallContext } from "../types.js";
|
||||||
|
import type {
|
||||||
|
UserInfo,
|
||||||
|
ViewportItem,
|
||||||
|
EffectivePermissions,
|
||||||
|
} from "./iam.types.js";
|
||||||
|
|
||||||
|
/** IamClient DI token(NestJS 注入用) */
|
||||||
|
export const IAM_CLIENT = Symbol("IAM_CLIENT");
|
||||||
|
|
||||||
|
/** IamClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
|
||||||
|
export interface IamClient {
|
||||||
|
/** 获取用户信息(iam.proto GetUserInfo,✅ 已就绪) */
|
||||||
|
getUserInfo(ctx: CallContext): Promise<UserInfo>;
|
||||||
|
|
||||||
|
/** 获取教师导航菜单(iam.proto GetViewports,❌ 待 coord 补全) */
|
||||||
|
getViewports(ctx: CallContext): Promise<ViewportItem[]>;
|
||||||
|
|
||||||
|
/** 获取有效权限集(iam.proto GetEffectivePermissions,❌ 待 coord 补全) */
|
||||||
|
getEffectivePermissions(ctx: CallContext): Promise<EffectivePermissions>;
|
||||||
|
|
||||||
|
/** 健康检查(/readyz 探针用) */
|
||||||
|
checkHealth(): Promise<{
|
||||||
|
serving: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
79
services/teacher-bff/src/clients/iam/iam-grpc.client.ts
Normal file
79
services/teacher-bff/src/clients/iam/iam-grpc.client.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// IamClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||||
|
// iam.proto 现状仅 GetUserInfo,GetViewports/GetEffectivePermissions 待 coord 补全
|
||||||
|
// 未就绪 RPC 降级 mock + warning 日志(president §2.6 降级模式 B)
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type * as grpc from "@grpc/grpc-js";
|
||||||
|
import { BaseDownstreamClient } from "../base.client.js";
|
||||||
|
import {
|
||||||
|
createGrpcMetadata,
|
||||||
|
getGrpcClient,
|
||||||
|
checkGrpcHealth,
|
||||||
|
} from "../grpc/grpc.factory.js";
|
||||||
|
import type { CallContext } from "../types.js";
|
||||||
|
import type { IamClient } from "./iam-client.interface.js";
|
||||||
|
import type {
|
||||||
|
UserInfo,
|
||||||
|
ViewportItem,
|
||||||
|
EffectivePermissions,
|
||||||
|
} from "./iam.types.js";
|
||||||
|
import { IamMockClient } from "./iam-mock.client.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
|
||||||
|
readonly serviceName = "iam" as const;
|
||||||
|
private readonly mock: IamMockClient;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.initLogger();
|
||||||
|
// mock 用于未就绪 RPC 的降级(proto 未定义的 RPC 走 mock + warning)
|
||||||
|
this.mock = new IamMockClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserInfo(ctx: CallContext): Promise<UserInfo> {
|
||||||
|
return this.callGrpc("GetUserInfo", async () => {
|
||||||
|
const client = getGrpcClient("iam") as unknown as {
|
||||||
|
getUserInfo(
|
||||||
|
req: { userId: string },
|
||||||
|
meta: grpc.Metadata,
|
||||||
|
cb: (err: grpc.ServiceError | null, res: UserInfo) => void,
|
||||||
|
): void;
|
||||||
|
};
|
||||||
|
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
|
||||||
|
return new Promise<UserInfo>((resolve, reject) => {
|
||||||
|
client.getUserInfo({ userId: ctx.userId }, meta, (err, res) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
|
||||||
|
// iam.proto 暂无 GetViewports RPC,降级 mock + warning(待 coord 补全)
|
||||||
|
this.log.warn(
|
||||||
|
{ rpc: "GetViewports", reason: "RPC not in iam.proto yet" },
|
||||||
|
"Downstream RPC not ready, falling back to mock",
|
||||||
|
);
|
||||||
|
return this.mock.getViewports(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEffectivePermissions(
|
||||||
|
ctx: CallContext,
|
||||||
|
): Promise<EffectivePermissions> {
|
||||||
|
// iam.proto 暂无 GetEffectivePermissions RPC,降级 mock + warning
|
||||||
|
this.log.warn(
|
||||||
|
{ rpc: "GetEffectivePermissions", reason: "RPC not in iam.proto yet" },
|
||||||
|
"Downstream RPC not ready, falling back to mock",
|
||||||
|
);
|
||||||
|
return this.mock.getEffectivePermissions(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkHealth(): Promise<{
|
||||||
|
serving: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return checkGrpcHealth("iam");
|
||||||
|
}
|
||||||
|
}
|
||||||
125
services/teacher-bff/src/clients/iam/iam-mock.client.ts
Normal file
125
services/teacher-bff/src/clients/iam/iam-mock.client.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// IamClient Mock 实现(B8 裁决:上游就绪前的降级策略)
|
||||||
|
// DEV_MODE=true 时全部走 mock;DEV_MODE=false 时仅未就绪 RPC 走 mock
|
||||||
|
// mock 数据对齐 contract §4.2 mock 策略
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { BaseDownstreamClient } from "../base.client.js";
|
||||||
|
import type { CallContext } from "../types.js";
|
||||||
|
import type { IamClient } from "./iam-client.interface.js";
|
||||||
|
import type {
|
||||||
|
UserInfo,
|
||||||
|
ViewportItem,
|
||||||
|
EffectivePermissions,
|
||||||
|
} from "./iam.types.js";
|
||||||
|
|
||||||
|
/** 教师默认 mock 用户(contract §4.1 mock 策略) */
|
||||||
|
const MOCK_TEACHER: UserInfo = {
|
||||||
|
id: "teacher-001",
|
||||||
|
email: "teacher@edu.test",
|
||||||
|
name: "张老师",
|
||||||
|
roles: ["teacher"],
|
||||||
|
permissions: [
|
||||||
|
"exam:read",
|
||||||
|
"exam:write",
|
||||||
|
"homework:read",
|
||||||
|
"homework:write",
|
||||||
|
"grade:read",
|
||||||
|
"grade:write",
|
||||||
|
"class:read",
|
||||||
|
"student:read",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 教师默认 mock 视口(L1 导航) */
|
||||||
|
const MOCK_VIEWPORTS: ViewportItem[] = [
|
||||||
|
{
|
||||||
|
key: "dashboard",
|
||||||
|
label: "工作台",
|
||||||
|
route: "/dashboard",
|
||||||
|
icon: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
requiredPermission: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "classes",
|
||||||
|
label: "我的班级",
|
||||||
|
route: "/classes",
|
||||||
|
icon: null,
|
||||||
|
sortOrder: 2,
|
||||||
|
requiredPermission: "class:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "exams",
|
||||||
|
label: "考试管理",
|
||||||
|
route: "/exams",
|
||||||
|
icon: null,
|
||||||
|
sortOrder: 3,
|
||||||
|
requiredPermission: "exam:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "homework",
|
||||||
|
label: "作业管理",
|
||||||
|
route: "/homework",
|
||||||
|
icon: null,
|
||||||
|
sortOrder: 4,
|
||||||
|
requiredPermission: "homework:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "grades",
|
||||||
|
label: "成绩管理",
|
||||||
|
route: "/grades",
|
||||||
|
icon: null,
|
||||||
|
sortOrder: 5,
|
||||||
|
requiredPermission: "grade:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "analytics",
|
||||||
|
label: "学情分析",
|
||||||
|
route: "/analytics",
|
||||||
|
icon: null,
|
||||||
|
sortOrder: 6,
|
||||||
|
requiredPermission: "student:read",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 教师默认 mock 权限(全权限 + OWN 数据范围) */
|
||||||
|
const MOCK_PERMISSIONS: EffectivePermissions = {
|
||||||
|
permissions: MOCK_TEACHER.permissions,
|
||||||
|
dataScope: "OWN",
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IamMockClient extends BaseDownstreamClient implements IamClient {
|
||||||
|
readonly serviceName = "iam" as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.initLogger();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserInfo(ctx: CallContext): Promise<UserInfo> {
|
||||||
|
this.log.debug({ userId: ctx.userId }, "Mock getUserInfo");
|
||||||
|
// 返回 mock 教师,但 id 用请求中的 userId(保持一致性)
|
||||||
|
return { ...MOCK_TEACHER, id: ctx.userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
|
||||||
|
this.log.debug({ userId: ctx.userId }, "Mock getViewports");
|
||||||
|
return MOCK_VIEWPORTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEffectivePermissions(
|
||||||
|
ctx: CallContext,
|
||||||
|
): Promise<EffectivePermissions> {
|
||||||
|
this.log.debug({ userId: ctx.userId }, "Mock getEffectivePermissions");
|
||||||
|
return MOCK_PERMISSIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkHealth(): Promise<{
|
||||||
|
serving: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
// mock 模式下健康检查总是返回 serving=true
|
||||||
|
return { serving: true, latencyMs: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
31
services/teacher-bff/src/clients/iam/iam.module.ts
Normal file
31
services/teacher-bff/src/clients/iam/iam.module.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// iam 模块(B8 裁决:按 DEV_MODE 选择 mock 或 gRPC 实现)
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { env } from "../../config/env.js";
|
||||||
|
import { logger } from "../../shared/observability/logger.js";
|
||||||
|
import { IAM_CLIENT } from "./iam-client.interface.js";
|
||||||
|
import { IamGrpcClient } from "./iam-grpc.client.js";
|
||||||
|
import { IamMockClient } from "./iam-mock.client.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: IAM_CLIENT,
|
||||||
|
useFactory: () => {
|
||||||
|
if (env.TEACHER_BFF_DEV_MODE) {
|
||||||
|
logger.warn(
|
||||||
|
{ devMode: true },
|
||||||
|
"IamClient using mock (TEACHER_BFF_DEV_MODE=true)",
|
||||||
|
);
|
||||||
|
return new IamMockClient();
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
{ devMode: false, target: env.IAM_GRPC_TARGET },
|
||||||
|
"IamClient using gRPC",
|
||||||
|
);
|
||||||
|
return new IamGrpcClient();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [IAM_CLIENT],
|
||||||
|
})
|
||||||
|
export class IamModule {}
|
||||||
44
services/teacher-bff/src/clients/iam/iam.types.ts
Normal file
44
services/teacher-bff/src/clients/iam/iam.types.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// iam 下游服务类型定义(对齐 iam.proto + 待 coord 补全的 RPC)
|
||||||
|
// iam.proto 现状:4 RPC(Register/Login/RefreshToken/GetUserInfo)
|
||||||
|
// 待 coord 补全:GetViewports / GetEffectivePermissions / GetEffectiveAccess / BatchGetUsers / GetChildrenByParent
|
||||||
|
|
||||||
|
/** 用户信息(对齐 iam.proto UserInfo message) */
|
||||||
|
export interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
roles: string[];
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视口项(L1 导航菜单,待 coord 补 GetViewports RPC 的 response message) */
|
||||||
|
export interface ViewportItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
route: string;
|
||||||
|
icon: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
requiredPermission: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 有效权限集(待 coord 补 GetEffectivePermissions RPC 的 response message) */
|
||||||
|
export interface EffectivePermissions {
|
||||||
|
permissions: string[];
|
||||||
|
/** 数据范围(OWN / GRADE / SCHOOL / ALL) */
|
||||||
|
dataScope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GetUserInfo 请求 */
|
||||||
|
export interface GetUserInfoRequest {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GetViewports 请求 */
|
||||||
|
export interface GetViewportsRequest {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GetEffectivePermissions 请求 */
|
||||||
|
export interface GetEffectivePermissionsRequest {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
46
services/teacher-bff/src/clients/types.ts
Normal file
46
services/teacher-bff/src/clients/types.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// DownstreamClient 抽象层共享类型(B8 裁决:3 个 BFF 统一使用)
|
||||||
|
// 所有下游 gRPC 调用通过此抽象层,统一 trace 上下文注入 + 错误映射 + metrics
|
||||||
|
|
||||||
|
/** 下游调用上下文(per-request,由 GraphQL Resolver 注入) */
|
||||||
|
export interface CallContext {
|
||||||
|
/** 当前用户 ID(从 JWT 或 x-user-id header 提取) */
|
||||||
|
userId: string;
|
||||||
|
/** 请求追踪 ID(X-Request-Id,用于日志关联) */
|
||||||
|
traceId?: string;
|
||||||
|
/** 用户角色(可选,用于下游权限校验) */
|
||||||
|
roles?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下游调用结果(降级模式 B 支持:success + degraded 标记) */
|
||||||
|
export type DownstreamResult<T> =
|
||||||
|
| { success: true; data: T; degraded?: false }
|
||||||
|
| { success: true; data: T | null; degraded: true; warning: string }
|
||||||
|
| { success: false; error: DownstreamError };
|
||||||
|
|
||||||
|
export interface DownstreamError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
service: string;
|
||||||
|
rpc?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下游服务名(用于 metrics 标签 + 日志字段) */
|
||||||
|
export type DownstreamServiceName =
|
||||||
|
"iam" | "core-edu" | "content" | "data-ana" | "msg" | "ai";
|
||||||
|
|
||||||
|
/** gRPC 健康检查结果(/readyz 探针用) */
|
||||||
|
export interface GrpcHealthStatus {
|
||||||
|
service: DownstreamServiceName;
|
||||||
|
target: string;
|
||||||
|
status: "SERVING" | "NOT_SERVING" | "UNKNOWN" | "UNREACHABLE";
|
||||||
|
latencyMs?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下游 gRPC 调用 metadata(注入 trace context + x-user-id) */
|
||||||
|
export interface GrpcMetadata {
|
||||||
|
"x-user-id": string;
|
||||||
|
"x-request-id"?: string;
|
||||||
|
traceparent?: string;
|
||||||
|
}
|
||||||
@@ -1,26 +1,45 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// 环境变量定义(B2 裁决:gRPC 下游通信;B8 裁决:DownstreamClient 抽象)
|
||||||
|
// 端口分配见 docs/architecture/port-allocation.md §5:
|
||||||
|
// iam 50052 / core-edu 50053 / content 50054 / data-ana 50055 / msg 50056 / ai 50058
|
||||||
|
// president §2.9:TEACHER_BFF_DEV_MODE 控制越权防御 Guard(P2 放行 / 生产拒绝)
|
||||||
const envSchema = z.object({
|
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.9:P2 DEV_MODE 放行 + 日志告警,生产拒绝)
|
||||||
|
// 生产环境必须显式设为 false
|
||||||
|
TEACHER_BFF_DEV_MODE: z
|
||||||
|
.string()
|
||||||
|
.default("true")
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.pipe(z.boolean()),
|
||||||
|
|
||||||
|
// Redis(/readyz 探针 + 后续 DataLoader 缓存)
|
||||||
|
REDIS_URL: z.string().default("redis://localhost:6379"),
|
||||||
|
|
||||||
|
// 下游 gRPC targets(按阶段启用,未启用者可留空字符串)
|
||||||
|
IAM_GRPC_TARGET: z.string().default("localhost:50052"),
|
||||||
|
CORE_EDU_GRPC_TARGET: z.string().default(""),
|
||||||
|
CONTENT_GRPC_TARGET: z.string().default(""),
|
||||||
|
DATA_ANA_GRPC_TARGET: z.string().default(""),
|
||||||
|
MSG_GRPC_TARGET: z.string().default(""),
|
||||||
|
AI_GRPC_TARGET: z.string().default(""),
|
||||||
|
|
||||||
|
// 可观测性(OTEL_EXPORTER_OTLP_ENDPOINT 缺省则不启用 tracer)
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||||
|
OTEL_SERVICE_NAME: z.string().default("teacher-bff"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
export 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()));
|
||||||
}
|
}
|
||||||
|
|||||||
37
services/teacher-bff/src/graphql/context.ts
Normal file
37
services/teacher-bff/src/graphql/context.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// GraphQL 上下文构建(B1 裁决:P2 起直接 GraphQL)
|
||||||
|
// 从 Express 请求提取 userId / traceId,注入下游 CallContext
|
||||||
|
import type { Request } from "express";
|
||||||
|
import type { CallContext } from "../clients/types.js";
|
||||||
|
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||||
|
|
||||||
|
export interface GraphQLOperationContext {
|
||||||
|
/** 当前请求的 CallContext(注入下游 gRPC 调用) */
|
||||||
|
callCtx: CallContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Express 请求构建 GraphQL 上下文
|
||||||
|
* P2: 从 x-user-id header 提取 userId(api-gateway 注入)
|
||||||
|
* P3+: 从 JWT 提取(api-gateway 校验后注入 x-user-id + x-user-roles)
|
||||||
|
*/
|
||||||
|
export function buildGraphQLContext(req: Request): GraphQLOperationContext {
|
||||||
|
const userIdHeader = req.headers["x-user-id"];
|
||||||
|
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnauthorizedError("Missing x-user-id header");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestIdHeader = req.headers["x-request-id"];
|
||||||
|
const traceId =
|
||||||
|
typeof requestIdHeader === "string" ? requestIdHeader : undefined;
|
||||||
|
|
||||||
|
const rolesHeader = req.headers["x-user-roles"];
|
||||||
|
const roles =
|
||||||
|
typeof rolesHeader === "string"
|
||||||
|
? rolesHeader.split(",").filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
callCtx: { userId, traceId, roles },
|
||||||
|
};
|
||||||
|
}
|
||||||
37
services/teacher-bff/src/graphql/error-formatter.ts
Normal file
37
services/teacher-bff/src/graphql/error-formatter.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// GraphQL 错误格式化(G8 裁决:ActionState 信封 + BFF_TEACHER_ 错误码)
|
||||||
|
// Yoga 自身错误处理,extensions.code = BFF_TEACHER_* + extensions.traceId
|
||||||
|
import { ApplicationError } from "../shared/errors/application-error.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化 GraphQL 错误(president §2.6 降级模式 B + G8 ActionState 信封)
|
||||||
|
* 错误 extensions 包含:code(BFF_TEACHER_*)、traceId、details
|
||||||
|
*/
|
||||||
|
export function formatGraphQLError(
|
||||||
|
error: unknown,
|
||||||
|
traceId?: string,
|
||||||
|
): { message: string; extensions: Record<string, unknown> } {
|
||||||
|
// ApplicationError → 映射 code + details
|
||||||
|
if (error instanceof ApplicationError) {
|
||||||
|
return {
|
||||||
|
message: error.message,
|
||||||
|
extensions: {
|
||||||
|
code: error.code,
|
||||||
|
traceId: traceId ?? error.traceId ?? "unknown",
|
||||||
|
details: error.details,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原生 GraphQL Error(语法错误、校验错误等)
|
||||||
|
const err = error as {
|
||||||
|
message?: string;
|
||||||
|
extensions?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
message: err.message ?? "Unknown GraphQL error",
|
||||||
|
extensions: {
|
||||||
|
code: (err.extensions?.code as string) ?? "BFF_TEACHER_INTERNAL_ERROR",
|
||||||
|
traceId: traceId ?? "unknown",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
65
services/teacher-bff/src/graphql/graphql.controller.ts
Normal file
65
services/teacher-bff/src/graphql/graphql.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// GraphQL Yoga endpoint(B1 裁决:P2 起直接 GraphQL)
|
||||||
|
// POST /graphql — GraphQL query/mutation endpoint
|
||||||
|
// GET /graphql — GraphQL Playground(开发环境)/ schema introspection
|
||||||
|
import { Controller, Post, Get, Req, Res, Inject } from "@nestjs/common";
|
||||||
|
import { createYoga } from "graphql-yoga";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
import { logger } from "../shared/observability/logger.js";
|
||||||
|
import { loadSchema } from "./schema-loader.js";
|
||||||
|
import { buildGraphQLContext } from "./context.js";
|
||||||
|
import { TeacherService } from "../teacher/teacher.service.js";
|
||||||
|
import { buildResolvers } from "../teacher/teacher.resolver.js";
|
||||||
|
|
||||||
|
@Controller("graphql")
|
||||||
|
export class GraphQLController {
|
||||||
|
// Yoga 实例类型复杂,用内联类型避免泛型不匹配
|
||||||
|
private readonly yoga: (req: Request, res: Response) => Promise<void>;
|
||||||
|
|
||||||
|
constructor(@Inject(TeacherService) service: TeacherService) {
|
||||||
|
const resolvers = buildResolvers(service);
|
||||||
|
const schema = loadSchema(resolvers);
|
||||||
|
|
||||||
|
const yoga = createYoga({
|
||||||
|
schema,
|
||||||
|
graphqlEndpoint: env.GRAPHQL_PATH,
|
||||||
|
context: ({ request }) => {
|
||||||
|
return buildGraphQLContext(request as unknown as Request);
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
debug: (msg: unknown) => logger.debug({ yoga: msg }, "GraphQL Yoga"),
|
||||||
|
info: (msg: unknown) => logger.info({ yoga: msg }, "GraphQL Yoga"),
|
||||||
|
warn: (msg: unknown) => logger.warn({ yoga: msg }, "GraphQL Yoga"),
|
||||||
|
error: (msg: unknown) => logger.error({ yoga: msg }, "GraphQL Yoga"),
|
||||||
|
},
|
||||||
|
// GraphQL Playground(开发环境启用)
|
||||||
|
graphiql: env.NODE_ENV === "development",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.yoga = yoga as unknown as (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ graphqlEndpoint: env.GRAPHQL_PATH },
|
||||||
|
"GraphQL Yoga endpoint initialized",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async handleGraphQL(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.yoga(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async handleGraphiQL(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.yoga(req, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
services/teacher-bff/src/graphql/graphql.module.ts
Normal file
10
services/teacher-bff/src/graphql/graphql.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// GraphQL 模块(B1 裁决:P2 起直接 GraphQL Yoga)
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { GraphQLController } from "./graphql.controller.js";
|
||||||
|
import { TeacherModule } from "../teacher/teacher.module.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TeacherModule],
|
||||||
|
controllers: [GraphQLController],
|
||||||
|
})
|
||||||
|
export class GraphQLModule {}
|
||||||
22
services/teacher-bff/src/graphql/schema-loader.ts
Normal file
22
services/teacher-bff/src/graphql/schema-loader.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// GraphQL Schema 加载器(从 .graphql 文件加载 SDL,B1 裁决)
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||||
|
import type { IResolvers } from "@graphql-tools/utils";
|
||||||
|
|
||||||
|
/** schema 文件路径(monorepo 相对路径) */
|
||||||
|
const SCHEMA_PATH = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"../../packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载 GraphQL schema(从 SDL 文件 + resolvers 构建可执行 schema)
|
||||||
|
* schema 文件位于 packages/shared-ts/contracts/graphql/(ARB-001 仲裁产物)
|
||||||
|
*/
|
||||||
|
export function loadSchema(
|
||||||
|
resolvers: IResolvers,
|
||||||
|
): ReturnType<typeof makeExecutableSchema> {
|
||||||
|
const typeDefs = readFileSync(SCHEMA_PATH, "utf-8");
|
||||||
|
return makeExecutableSchema({ typeDefs, resolvers });
|
||||||
|
}
|
||||||
@@ -1,36 +1,62 @@
|
|||||||
|
// teacher-bff 入口(B1 裁决:P2 起 GraphQL Yoga)
|
||||||
|
// 优雅关闭顺序(G9):HTTP → Redis → gRPC → Tracer
|
||||||
import "reflect-metadata";
|
import "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 () => {
|
// 优雅关闭(G9:HTTP → Redis → gRPC → Tracer)
|
||||||
await app.close();
|
const shutdown = async (signal: string) => {
|
||||||
await shutdownTracer();
|
logger.info({ signal }, "Shutting down Teacher BFF");
|
||||||
});
|
try {
|
||||||
|
await app.close(); // 触发 OnModuleDestroy(Redis quit)
|
||||||
|
closeAllGrpcClients(); // 关闭所有 gRPC channel
|
||||||
|
await shutdownTracer();
|
||||||
|
logger.info("Teacher BFF shutdown complete");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Error during shutdown");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||||
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap().catch((err: unknown) => {
|
bootstrap().catch((err: unknown) => {
|
||||||
|
|||||||
123
services/teacher-bff/src/middleware/authorization.guard.ts
Normal file
123
services/teacher-bff/src/middleware/authorization.guard.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// AuthorizationGuard 越权防御(B4 + president §2.7/§2.9)
|
||||||
|
// P2 实现:DEV_MODE 放行 + 日志告警;生产拒绝(保守策略)
|
||||||
|
// P3+ 替换为真实 gRPC 校验(core-edu.GetClassesByTeacher 比对 teacherId)
|
||||||
|
//
|
||||||
|
// 3 类越权错误码(president §2.7):
|
||||||
|
// - BFF_TEACHER_UNAUTHORIZED (401): x-user-id 缺失或无效
|
||||||
|
// - BFF_TEACHER_FORBIDDEN_RESOURCE (403): teacherId 与资源无归属关系(场景 A)
|
||||||
|
// - BFF_TEACHER_IDENTITY_MISMATCH (403): JWT teacherId 与 body teacherId 不一致(场景 B)
|
||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
import { logger } from "../shared/observability/logger.js";
|
||||||
|
import {
|
||||||
|
ForbiddenResourceError,
|
||||||
|
IdentityMismatchError,
|
||||||
|
} from "../shared/errors/application-error.js";
|
||||||
|
import { IAM_CLIENT } from "../clients/iam/iam-client.interface.js";
|
||||||
|
import type { IamClient } from "../clients/iam/iam-client.interface.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuthorizationGuard 接口(president §2.9)
|
||||||
|
* P2: DEV_MODE 放行;P3+: 真实 gRPC 校验
|
||||||
|
*/
|
||||||
|
export interface AuthorizationGuard {
|
||||||
|
/** 校验教师是否有权访问指定班级(场景 A:teacherId 与资源归属) */
|
||||||
|
canAccessClass(userId: string, classId: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/** 校验教师是否有权访问指定考试 */
|
||||||
|
canAccessExam(userId: string, examId: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/** 校验教师是否有权访问指定作业 */
|
||||||
|
canAccessHomework(userId: string, homeworkId: string): Promise<boolean>;
|
||||||
|
|
||||||
|
/** 校验身份一致性(场景 B:JWT teacherId 与 body teacherId) */
|
||||||
|
assertIdentityMatch(jwtTeacherId: string, bodyTeacherId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTHORIZATION_GUARD = Symbol("AUTHORIZATION_GUARD");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P2 实现:DEV_MODE 放行 + 日志告警
|
||||||
|
* 生产环境(DEV_MODE=false)对所有资源校验返回 false(保守拒绝)
|
||||||
|
* P3+ 替换为 AuthorizationGuardGrpcImpl(接入 core-edu gRPC 真实校验)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AuthorizationGuardImpl implements AuthorizationGuard {
|
||||||
|
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {}
|
||||||
|
|
||||||
|
async canAccessClass(userId: string, classId: string): Promise<boolean> {
|
||||||
|
if (env.TEACHER_BFF_DEV_MODE) {
|
||||||
|
logger.warn(
|
||||||
|
{ userId, classId, devMode: true },
|
||||||
|
"AuthorizationGuard DEV_MODE: access allowed without real check",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// P2 生产模式:保守拒绝(P3+ 接入 core-edu.GetClassesByTeacher 真实校验)
|
||||||
|
logger.error(
|
||||||
|
{ userId, classId, devMode: false },
|
||||||
|
"AuthorizationGuard production mode not implemented yet (P3+)",
|
||||||
|
);
|
||||||
|
throw new ForbiddenResourceError(
|
||||||
|
`User ${userId} has no access to class ${classId}`,
|
||||||
|
{ userId, classId, reason: "production_check_not_implemented" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async canAccessExam(userId: string, examId: string): Promise<boolean> {
|
||||||
|
if (env.TEACHER_BFF_DEV_MODE) {
|
||||||
|
logger.warn(
|
||||||
|
{ userId, examId, devMode: true },
|
||||||
|
"AuthorizationGuard DEV_MODE: access allowed without real check",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
logger.error(
|
||||||
|
{ userId, examId, devMode: false },
|
||||||
|
"AuthorizationGuard production mode not implemented yet (P3+)",
|
||||||
|
);
|
||||||
|
throw new ForbiddenResourceError(
|
||||||
|
`User ${userId} has no access to exam ${examId}`,
|
||||||
|
{ userId, examId, reason: "production_check_not_implemented" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async canAccessHomework(
|
||||||
|
userId: string,
|
||||||
|
homeworkId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (env.TEACHER_BFF_DEV_MODE) {
|
||||||
|
logger.warn(
|
||||||
|
{ userId, homeworkId, devMode: true },
|
||||||
|
"AuthorizationGuard DEV_MODE: access allowed without real check",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
logger.error(
|
||||||
|
{ userId, homeworkId, devMode: false },
|
||||||
|
"AuthorizationGuard production mode not implemented yet (P3+)",
|
||||||
|
);
|
||||||
|
throw new ForbiddenResourceError(
|
||||||
|
`User ${userId} has no access to homework ${homeworkId}`,
|
||||||
|
{ userId, homeworkId, reason: "production_check_not_implemented" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验身份一致性(场景 B,president §2.7)
|
||||||
|
* JWT 中的 teacherId 必须与请求 body 中的 teacherId 一致
|
||||||
|
* 此校验在所有模式(DEV_MODE / 生产)下都强制执行
|
||||||
|
*/
|
||||||
|
assertIdentityMatch(jwtTeacherId: string, bodyTeacherId: string): void {
|
||||||
|
if (jwtTeacherId !== bodyTeacherId) {
|
||||||
|
logger.error(
|
||||||
|
{ jwtTeacherId, bodyTeacherId },
|
||||||
|
"Identity mismatch: JWT teacherId != body teacherId",
|
||||||
|
);
|
||||||
|
throw new IdentityMismatchError(
|
||||||
|
`JWT teacherId (${jwtTeacherId}) does not match body teacherId (${bodyTeacherId})`,
|
||||||
|
{ jwtTeacherId, bodyTeacherId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
services/teacher-bff/src/middleware/middleware.module.ts
Normal file
19
services/teacher-bff/src/middleware/middleware.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// 中间件模块(AuthorizationGuard 越权防御,B4 + president §2.9)
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ClientsModule } from "../clients/clients.module.js";
|
||||||
|
import {
|
||||||
|
AUTHORIZATION_GUARD,
|
||||||
|
AuthorizationGuardImpl,
|
||||||
|
} from "./authorization.guard.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ClientsModule],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AUTHORIZATION_GUARD,
|
||||||
|
useClass: AuthorizationGuardImpl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [AUTHORIZATION_GUARD],
|
||||||
|
})
|
||||||
|
export class MiddlewareModule {}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
|
// BFF_TEACHER_ 错误码(B5 + G14 裁决,统一 BFF_ 前缀)
|
||||||
|
// 越权防御 3 类错误码(president §2.7 裁决):
|
||||||
|
// - BFF_TEACHER_UNAUTHORIZED (401): x-user-id 缺失或无效
|
||||||
|
// - BFF_TEACHER_FORBIDDEN_RESOURCE (403): teacherId 与资源无归属关系(场景 A)
|
||||||
|
// - BFF_TEACHER_IDENTITY_MISMATCH (403): JWT teacherId 与 body teacherId 不一致(场景 B)
|
||||||
|
// 其他聚合层错误码(contract §1.5)
|
||||||
|
|
||||||
export type ErrorType =
|
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 缺失或无效(401,president §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 场景 A,president §2.7) */
|
||||||
|
export class ForbiddenResourceError extends ApplicationError {
|
||||||
|
readonly type = "forbidden_resource" as const;
|
||||||
|
readonly statusCode = 403;
|
||||||
|
constructor(message: string, details?: ErrorDetails) {
|
||||||
|
super(message, "BFF_TEACHER_FORBIDDEN_RESOURCE", details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JWT teacherId 与请求 body teacherId 不一致(403 场景 B,president §2.7) */
|
||||||
|
export class IdentityMismatchError extends ApplicationError {
|
||||||
|
readonly type = "identity_mismatch" as const;
|
||||||
|
readonly statusCode = 403;
|
||||||
|
constructor(message: string, details?: ErrorDetails) {
|
||||||
|
super(message, "BFF_TEACHER_IDENTITY_MISMATCH", details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 并发冲突(409) */
|
||||||
export class ConflictError extends ApplicationError {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Controller, Get } from "@nestjs/common";
|
// 健康检查端点(G3 + president §2.4)
|
||||||
|
// - GET /healthz:liveness,仅返回进程存活(不检查下游)
|
||||||
|
// - GET /readyz:readiness,DownstreamHealthCheck 注册表(P2: Redis + iam gRPC)
|
||||||
|
//
|
||||||
|
// 软失败规则(contract §3.3):
|
||||||
|
// - status=ok → 200
|
||||||
|
// - status=degraded → 200(可选依赖失败,告警但不阻断)
|
||||||
|
// - status=unavailable → 503(必需依赖失败)
|
||||||
|
import { Controller, Get, Res, HttpStatus } from "@nestjs/common";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import { healthRegistry } from "./readiness.probe.js";
|
||||||
|
|
||||||
const SERVICE_NAME = "teacher-bff";
|
const SERVICE_NAME = "teacher-bff";
|
||||||
|
|
||||||
/**
|
|
||||||
* 健康检查端点。
|
|
||||||
*
|
|
||||||
* - GET /healthz:liveness,仅返回进程存活。
|
|
||||||
* - GET /readyz:readiness,BFF 不直接访问 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
27
services/teacher-bff/src/shared/health/iam-grpc.probe.ts
Normal file
27
services/teacher-bff/src/shared/health/iam-grpc.probe.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// iam gRPC 健康探针(/readyz P2 必需依赖)
|
||||||
|
// 调用 grpc.health.v1.Health/Check 检查 iam:50052 是否 SERVING
|
||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { IAM_CLIENT } from "../../clients/iam/iam-client.interface.js";
|
||||||
|
import type { IamClient } from "../../clients/iam/iam-client.interface.js";
|
||||||
|
import type { HealthProbe } from "./readiness.probe.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IamGrpcHealthProbe implements HealthProbe {
|
||||||
|
readonly name = "iam-grpc";
|
||||||
|
readonly required = true;
|
||||||
|
|
||||||
|
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {}
|
||||||
|
|
||||||
|
async check(): Promise<{
|
||||||
|
status: "up" | "down";
|
||||||
|
latencyMs: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const result = await this.iam.checkHealth();
|
||||||
|
return {
|
||||||
|
status: result.serving ? "up" : "down",
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
113
services/teacher-bff/src/shared/health/readiness.probe.ts
Normal file
113
services/teacher-bff/src/shared/health/readiness.probe.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// /readyz 探针注册表(president §2.4 + G2)
|
||||||
|
// DownstreamHealthCheck 注册表模式:每个下游注册独立探针,按阶段启用
|
||||||
|
// P2: Redis PING + iam gRPC 50052(2 项)
|
||||||
|
// P3+: + core-edu / content / data-ana / ai / msg(按阶段扩展)
|
||||||
|
//
|
||||||
|
// 软失败规则(contract §3.3):
|
||||||
|
// - 必需依赖(Redis / 已启用 gRPC 下游)失败返 503
|
||||||
|
// - 可选依赖(未启用 gRPC 下游)失败仅告警返 200 + degraded: true
|
||||||
|
|
||||||
|
/** 单个健康检查结果 */
|
||||||
|
export interface HealthCheckResult {
|
||||||
|
name: string;
|
||||||
|
status: "up" | "down" | "degraded";
|
||||||
|
latencyMs: number;
|
||||||
|
required: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /readyz 聚合结果 */
|
||||||
|
export interface ReadinessReport {
|
||||||
|
status: "ok" | "degraded" | "unavailable";
|
||||||
|
service: string;
|
||||||
|
timestamp: string;
|
||||||
|
checks: HealthCheckResult[];
|
||||||
|
degraded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 健康检查探针接口(每个下游注册一个) */
|
||||||
|
export interface HealthProbe {
|
||||||
|
/** 探针名称(如 "redis" / "iam-grpc") */
|
||||||
|
readonly name: string;
|
||||||
|
/** 是否为必需依赖(必需失败返 503,可选失败返 200 + degraded) */
|
||||||
|
readonly required: boolean;
|
||||||
|
/** 执行健康检查 */
|
||||||
|
check(): Promise<{
|
||||||
|
status: "up" | "down";
|
||||||
|
latencyMs: number;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康检查注册表(DownstreamHealthCheck 模式)
|
||||||
|
* 按阶段注册探针,/readyz 调用时遍历所有已注册探针
|
||||||
|
*/
|
||||||
|
export class HealthCheckRegistry {
|
||||||
|
private readonly probes = new Map<string, HealthProbe>();
|
||||||
|
|
||||||
|
register(probe: HealthProbe): void {
|
||||||
|
this.probes.set(probe.name, probe);
|
||||||
|
}
|
||||||
|
|
||||||
|
unregister(name: string): void {
|
||||||
|
this.probes.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runAll(): Promise<ReadinessReport> {
|
||||||
|
const checks: HealthCheckResult[] = [];
|
||||||
|
let hasRequiredDown = false;
|
||||||
|
let hasOptionalDown = false;
|
||||||
|
|
||||||
|
for (const probe of this.probes.values()) {
|
||||||
|
try {
|
||||||
|
const result = await probe.check();
|
||||||
|
checks.push({
|
||||||
|
name: probe.name,
|
||||||
|
status: result.status,
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
required: probe.required,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
if (result.status === "down" && probe.required) {
|
||||||
|
hasRequiredDown = true;
|
||||||
|
} else if (result.status === "down" && !probe.required) {
|
||||||
|
hasOptionalDown = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
name: probe.name,
|
||||||
|
status: "down",
|
||||||
|
latencyMs: 0,
|
||||||
|
required: probe.required,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
if (probe.required) {
|
||||||
|
hasRequiredDown = true;
|
||||||
|
} else {
|
||||||
|
hasOptionalDown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: ReadinessReport["status"];
|
||||||
|
if (hasRequiredDown) {
|
||||||
|
status = "unavailable";
|
||||||
|
} else if (hasOptionalDown) {
|
||||||
|
status = "degraded";
|
||||||
|
} else {
|
||||||
|
status = "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
service: "teacher-bff",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks,
|
||||||
|
degraded: hasOptionalDown && !hasRequiredDown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局注册表实例(单例) */
|
||||||
|
export const healthRegistry = new HealthCheckRegistry();
|
||||||
37
services/teacher-bff/src/shared/health/redis.probe.ts
Normal file
37
services/teacher-bff/src/shared/health/redis.probe.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Redis 健康探针(/readyz P2 必需依赖)
|
||||||
|
// PING 命令检查 Redis 连通性,超时 2s
|
||||||
|
import type { Redis } from "ioredis";
|
||||||
|
import type { HealthProbe } from "./readiness.probe.js";
|
||||||
|
|
||||||
|
export class RedisHealthProbe implements HealthProbe {
|
||||||
|
readonly name = "redis";
|
||||||
|
readonly required = true;
|
||||||
|
|
||||||
|
constructor(private readonly redis: Redis) {}
|
||||||
|
|
||||||
|
async check(): Promise<{
|
||||||
|
status: "up" | "down";
|
||||||
|
latencyMs: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const result = await this.redis.ping();
|
||||||
|
const latencyMs = Date.now() - start;
|
||||||
|
if (result === "PONG") {
|
||||||
|
return { status: "up", latencyMs };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "down",
|
||||||
|
latencyMs,
|
||||||
|
error: `Unexpected PING response: ${result}`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: "down",
|
||||||
|
latencyMs: Date.now() - start,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export function initTracer(): void {
|
|||||||
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { Controller, Get, Param, Req } from "@nestjs/common";
|
|
||||||
import type { Request } from "express";
|
|
||||||
import { TeacherService } from "./teacher.service.js";
|
|
||||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
|
||||||
|
|
||||||
interface SuccessResponse<T> {
|
|
||||||
success: true;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller("teacher")
|
|
||||||
export class TeacherController {
|
|
||||||
constructor(private readonly service: TeacherService) {}
|
|
||||||
|
|
||||||
@Get("dashboard")
|
|
||||||
async dashboard(@Req() req: Request): Promise<SuccessResponse<unknown>> {
|
|
||||||
const userId = this.extractUserId(req);
|
|
||||||
const data = await this.service.getDashboard(userId);
|
|
||||||
return { success: true as const, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("viewports")
|
|
||||||
async viewports(@Req() req: Request): Promise<SuccessResponse<unknown>> {
|
|
||||||
const userId = this.extractUserId(req);
|
|
||||||
const data = await this.service.getViewports(userId);
|
|
||||||
return { success: true as const, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 聚合:班级下的考试列表(core-edu)
|
|
||||||
@Get("classes/:classId/exams")
|
|
||||||
async listExamsByClass(
|
|
||||||
@Req() req: Request,
|
|
||||||
@Param("classId") classId: string,
|
|
||||||
): Promise<SuccessResponse<unknown>> {
|
|
||||||
const userId = this.extractUserId(req);
|
|
||||||
const data = await this.service.listExamsByClass(userId, classId);
|
|
||||||
return { success: true as const, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 聚合:班级下的作业列表(core-edu)
|
|
||||||
@Get("classes/:classId/homework")
|
|
||||||
async listHomeworkByClass(
|
|
||||||
@Req() req: Request,
|
|
||||||
@Param("classId") classId: string,
|
|
||||||
): Promise<SuccessResponse<unknown>> {
|
|
||||||
const userId = this.extractUserId(req);
|
|
||||||
const data = await this.service.listHomeworkByClass(userId, classId);
|
|
||||||
return { success: true as const, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 聚合:考试下的成绩列表(core-edu)
|
|
||||||
@Get("exams/:examId/grades")
|
|
||||||
async listGradesByExam(
|
|
||||||
@Req() req: Request,
|
|
||||||
@Param("examId") examId: string,
|
|
||||||
): Promise<SuccessResponse<unknown>> {
|
|
||||||
const userId = this.extractUserId(req);
|
|
||||||
const data = await this.service.listGradesByExam(userId, examId);
|
|
||||||
return { success: true as const, data };
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractUserId(req: Request): string {
|
|
||||||
const header = req.headers["x-user-id"];
|
|
||||||
const userId = typeof header === "string" ? header : undefined;
|
|
||||||
if (!userId) {
|
|
||||||
throw new UnauthorizedError("Missing x-user-id header");
|
|
||||||
}
|
|
||||||
return userId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
// 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 {}
|
||||||
|
|||||||
362
services/teacher-bff/src/teacher/teacher.resolver.ts
Normal file
362
services/teacher-bff/src/teacher/teacher.resolver.ts
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
// GraphQL Resolvers(B1 裁决:P2 起 GraphQL)
|
||||||
|
// P2 核心 5 Query:dashboard / viewports / me / classes / class
|
||||||
|
// P3+ Query/Mutation:占位返回 null/空 + extensions.warning
|
||||||
|
// admin 命名空间:占位返回 null/空(P6 实现)
|
||||||
|
import type { IResolvers } from "@graphql-tools/utils";
|
||||||
|
import { TeacherService } from "./teacher.service.js";
|
||||||
|
import type { GraphQLOperationContext } from "../graphql/context.js";
|
||||||
|
import { BusinessError } from "../shared/errors/application-error.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 GraphQL Resolvers
|
||||||
|
* 依赖 TeacherService(通过闭包注入,避免 NestJS DI 与 Yoga 的集成复杂度)
|
||||||
|
*/
|
||||||
|
export function buildResolvers(service: TeacherService): IResolvers {
|
||||||
|
return {
|
||||||
|
Query: {
|
||||||
|
// ===== P2 核心 5 Query =====
|
||||||
|
|
||||||
|
dashboard: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: unknown,
|
||||||
|
ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
return service.getDashboard(ctx.callCtx);
|
||||||
|
},
|
||||||
|
|
||||||
|
viewports: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: unknown,
|
||||||
|
ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
return service.getViewports(ctx.callCtx);
|
||||||
|
},
|
||||||
|
|
||||||
|
me: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: unknown,
|
||||||
|
ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
return service.getCurrentUser(ctx.callCtx);
|
||||||
|
},
|
||||||
|
|
||||||
|
classes: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: unknown,
|
||||||
|
ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
return service.getClasses(ctx.callCtx);
|
||||||
|
},
|
||||||
|
|
||||||
|
class: async (
|
||||||
|
_parent: unknown,
|
||||||
|
args: { id: string },
|
||||||
|
ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
return service.getClass(ctx.callCtx, args.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== P3+ Query 占位(返回空 + warning,跨阶段扩展例外)=====
|
||||||
|
|
||||||
|
exams: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: { classId: string },
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"exams query not available in P2 (core-edu gRPC not ready, P3+)",
|
||||||
|
{ phase: "P2", field: "exams", warning: "field_unavailable_in_p2" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
homework: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: { classId: string },
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"homework query not available in P2 (core-edu gRPC not ready, P3+)",
|
||||||
|
{
|
||||||
|
phase: "P2",
|
||||||
|
field: "homework",
|
||||||
|
warning: "field_unavailable_in_p2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
grades: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: { examId: string },
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"grades query not available in P2 (core-edu gRPC not ready, P3+)",
|
||||||
|
{ phase: "P2", field: "grades", warning: "field_unavailable_in_p2" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== P4+ Query 占位 =====
|
||||||
|
|
||||||
|
knowledgePath: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: { knowledgePointId: string },
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"knowledgePath query not available in P2 (content gRPC not ready, P4+)",
|
||||||
|
{
|
||||||
|
phase: "P2",
|
||||||
|
field: "knowledgePath",
|
||||||
|
warning: "field_unavailable_in_p2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
classPerformance: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: { classId: string },
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"classPerformance query not available in P2 (data-ana gRPC not ready, P4+)",
|
||||||
|
{
|
||||||
|
phase: "P2",
|
||||||
|
field: "classPerformance",
|
||||||
|
warning: "field_unavailable_in_p2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
studentWeakness: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: { studentId: string },
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"studentWeakness query not available in P2 (data-ana gRPC not ready, P4+)",
|
||||||
|
{
|
||||||
|
phase: "P2",
|
||||||
|
field: "studentWeakness",
|
||||||
|
warning: "field_unavailable_in_p2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
learningTrend: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: { studentId: string; dateRange?: unknown },
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"learningTrend query not available in P2 (data-ana gRPC not ready, P4+)",
|
||||||
|
{
|
||||||
|
phase: "P2",
|
||||||
|
field: "learningTrend",
|
||||||
|
warning: "field_unavailable_in_p2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== P5+ Query 占位 =====
|
||||||
|
|
||||||
|
notifications: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: { unreadOnly?: boolean },
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"notifications query not available in P2 (msg gRPC not ready, P5+)",
|
||||||
|
{
|
||||||
|
phase: "P2",
|
||||||
|
field: "notifications",
|
||||||
|
warning: "field_unavailable_in_p2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== admin 命名空间(president §5.1:P2 预留,P6 实现)=====
|
||||||
|
|
||||||
|
admin: () => {
|
||||||
|
// 返回空对象,admin 子字段 resolver 返回 null/空
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Mutation: {
|
||||||
|
// ===== P3+ Mutation 占位 =====
|
||||||
|
|
||||||
|
createExam: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: unknown,
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"createExam mutation not available in P2 (core-edu gRPC not ready, P3+)",
|
||||||
|
{
|
||||||
|
phase: "P2",
|
||||||
|
field: "createExam",
|
||||||
|
warning: "field_unavailable_in_p2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
assignHomework: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: unknown,
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"assignHomework mutation not available in P2 (core-edu gRPC not ready, P3+)",
|
||||||
|
{
|
||||||
|
phase: "P2",
|
||||||
|
field: "assignHomework",
|
||||||
|
warning: "field_unavailable_in_p2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
recordGrade: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: unknown,
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"recordGrade mutation not available in P2 (core-edu gRPC not ready, P3+)",
|
||||||
|
{
|
||||||
|
phase: "P2",
|
||||||
|
field: "recordGrade",
|
||||||
|
warning: "field_unavailable_in_p2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== P5+ Mutation 占位 =====
|
||||||
|
|
||||||
|
generateQuestion: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: unknown,
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"generateQuestion mutation not available in P2 (ai gRPC not ready, P5+)",
|
||||||
|
{
|
||||||
|
phase: "P2",
|
||||||
|
field: "generateQuestion",
|
||||||
|
warning: "field_unavailable_in_p2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
markNotificationAsRead: async (
|
||||||
|
_parent: unknown,
|
||||||
|
_args: { id: string },
|
||||||
|
_ctx: GraphQLOperationContext,
|
||||||
|
) => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"markNotificationAsRead mutation not available in P2 (msg gRPC not ready, P5+)",
|
||||||
|
{
|
||||||
|
phase: "P2",
|
||||||
|
field: "markNotificationAsRead",
|
||||||
|
warning: "field_unavailable_in_p2",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== admin 命名空间 Mutation(P2 预留,P6 实现)=====
|
||||||
|
|
||||||
|
admin: () => {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== admin 命名空间子字段 Resolver(P2 全部返回 null/空,P6 实现)=====
|
||||||
|
|
||||||
|
AdminQuery: {
|
||||||
|
users: () => null,
|
||||||
|
user: () => null,
|
||||||
|
roles: () => [],
|
||||||
|
role: () => null,
|
||||||
|
school: () => null,
|
||||||
|
organizations: () => [],
|
||||||
|
auditLogs: () => null,
|
||||||
|
},
|
||||||
|
|
||||||
|
AdminMutation: {
|
||||||
|
createUser: () => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"admin.createUser not available in P2 (P6 implementation)",
|
||||||
|
{ phase: "P2", field: "admin.createUser" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateUser: () => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"admin.updateUser not available in P2 (P6 implementation)",
|
||||||
|
{ phase: "P2", field: "admin.updateUser" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
deleteUser: () => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"admin.deleteUser not available in P2 (P6 implementation)",
|
||||||
|
{ phase: "P2", field: "admin.deleteUser" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createRole: () => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"admin.createRole not available in P2 (P6 implementation)",
|
||||||
|
{ phase: "P2", field: "admin.createRole" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateRole: () => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"admin.updateRole not available in P2 (P6 implementation)",
|
||||||
|
{ phase: "P2", field: "admin.updateRole" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
deleteRole: () => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"admin.deleteRole not available in P2 (P6 implementation)",
|
||||||
|
{ phase: "P2", field: "admin.deleteRole" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateSchool: () => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"admin.updateSchool not available in P2 (P6 implementation)",
|
||||||
|
{ phase: "P2", field: "admin.updateSchool" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createOrganization: () => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"admin.createOrganization not available in P2 (P6 implementation)",
|
||||||
|
{ phase: "P2", field: "admin.createOrganization" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateOrganization: () => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"admin.updateOrganization not available in P2 (P6 implementation)",
|
||||||
|
{ phase: "P2", field: "admin.updateOrganization" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
deleteOrganization: () => {
|
||||||
|
throw new BusinessError(
|
||||||
|
"admin.deleteOrganization not available in P2 (P6 implementation)",
|
||||||
|
{ phase: "P2", field: "admin.deleteOrganization" },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 类型字段 Resolver(Class.exams / Class.homework / Exam.grades 延迟加载)=====
|
||||||
|
|
||||||
|
Class: {
|
||||||
|
exams: () => [], // P3+ core-edu gRPC
|
||||||
|
homework: () => [], // P3+ core-edu gRPC
|
||||||
|
},
|
||||||
|
|
||||||
|
Exam: {
|
||||||
|
grades: () => [], // P3+ core-edu gRPC
|
||||||
|
},
|
||||||
|
|
||||||
|
Homework: {
|
||||||
|
submissions: () => [], // P3+ core-edu gRPC
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,158 +1,122 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
// TeacherService — GraphQL Resolver 的业务逻辑层(B1 裁决:P2 起 GraphQL)
|
||||||
import { env } from "../config/env.js";
|
// 通过 IamClient 调下游 iam gRPC(B2 裁决:首次实现即 gRPC)
|
||||||
|
// P2: 仅 iam 数据;P3+ 扩展 core-edu / content / data-ana / msg / ai
|
||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { IAM_CLIENT } from "../clients/iam/iam-client.interface.js";
|
||||||
|
import type { IamClient } from "../clients/iam/iam-client.interface.js";
|
||||||
|
import type { CallContext } from "../clients/types.js";
|
||||||
|
import type {
|
||||||
|
UserInfo,
|
||||||
|
ViewportItem,
|
||||||
|
EffectivePermissions,
|
||||||
|
} from "../clients/iam/iam.types.js";
|
||||||
import { logger } from "../shared/observability/logger.js";
|
import { 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 mock,P3+ core-edu 真实数据) */
|
||||||
user: unknown;
|
export interface ClassInfo {
|
||||||
classes: unknown;
|
id: string;
|
||||||
|
name: string;
|
||||||
|
gradeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** P2 mock 班级数据(president §3.5:P2 班级列表来自 iam 数据,P3+ core-edu) */
|
||||||
|
const MOCK_CLASSES: ClassInfo[] = [
|
||||||
|
{ id: "class-001", name: "三年级1班", gradeId: "grade-3" },
|
||||||
|
{ id: "class-002", name: "三年级2班", gradeId: "grade-3" },
|
||||||
|
{ id: "class-003", name: "三年级3班", gradeId: "grade-3" },
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@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.8:P2 仅调 iam gRPC)
|
||||||
|
* P2: user + viewports 有数据,classes 返回 mock,stats 返回 null + warning
|
||||||
|
* P3+: classes 来自 core-edu,stats 来自 data-ana.GetTeacherDashboard
|
||||||
|
*/
|
||||||
|
async getDashboard(ctx: CallContext): Promise<DashboardData> {
|
||||||
|
const [user, viewports] = await Promise.all([
|
||||||
|
this.getCurrentUser(ctx),
|
||||||
|
this.iam.getViewports(ctx),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let user: unknown = null;
|
// P2: classes 返回 mock 数据(P3+ 替换为 core-edu.GetClassesByTeacher)
|
||||||
let classes: unknown = null;
|
// P2: stats 返回 null(P4+ 替换为 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: mock;P3+: 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: mock;P3+: 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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user