feat(teacher-bff): admin 命名空间 + 5 个 gRPC client + health probes + merge-resolvers + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 16:00:46 +08:00
parent 7b790f1276
commit 895a060491
50 changed files with 9739 additions and 334 deletions

View File

@@ -1,25 +1,20 @@
# Build stage # Build stage
# 构建上下文必须为 repo root以访问 packages/shared-proto + packages/shared-ts
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
# 复制 workspace 配置 # 复制 workspace 配置 + 共享包 + teacher-bff 源码
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
# 复制 shared-protogRPC proto-loader 运行时加载)
COPY packages/shared-proto ./packages/shared-proto COPY packages/shared-proto ./packages/shared-proto
# 复制 shared-tsGraphQL schema 文件)
COPY packages/shared-ts ./packages/shared-ts COPY packages/shared-ts ./packages/shared-ts
# 复制 teacher-bff 源码
COPY services/teacher-bff ./services/teacher-bff COPY services/teacher-bff ./services/teacher-bff
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json ./
# 安装依赖(仅 teacher-bff + shared-ts # 安装依赖(含 devDependencies 用于构建,--ignore-scripts 跳过 husky prepare 钩子
RUN cd services/teacher-bff && pnpm install --frozen-lockfile WORKDIR /app/services/teacher-bff
RUN pnpm install --no-frozen-lockfile --ignore-scripts
# 构建 # 构建
WORKDIR /app/services/teacher-bff
RUN pnpm build RUN pnpm build
# Runtime stage # Runtime stage
@@ -27,21 +22,19 @@ FROM node:22-alpine
WORKDIR /app WORKDIR /app
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
# 复制 workspace 配置 # 复制 teacher-bff package.json独立安装生产依赖
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./ COPY services/teacher-bff/package.json ./services/teacher-bff/package.json
WORKDIR /app/services/teacher-bff
RUN pnpm install --prod --no-frozen-lockfile --ignore-workspace --ignore-scripts
# 复制 shared-proto + shared-ts运行时需要 # 复制 shared-proto + shared-ts运行时 proto/schema 加载需要)
WORKDIR /app
COPY packages/shared-proto ./packages/shared-proto COPY packages/shared-proto ./packages/shared-proto
COPY packages/shared-ts ./packages/shared-ts COPY packages/shared-ts ./packages/shared-ts
# 复制 teacher-bff # 复制构建产物(保持 services/teacher-bff/dist 结构)
COPY services/teacher-bff ./services/teacher-bff COPY --from=builder /app/services/teacher-bff/dist ./services/teacher-bff/dist
WORKDIR /app/services/teacher-bff WORKDIR /app/services/teacher-bff
RUN pnpm install --prod --frozen-lockfile
# 复制构建产物
COPY --from=builder /app/services/teacher-bff/dist ./dist
EXPOSE 3003 EXPOSE 3003
CMD ["node", "dist/main.js"] CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,344 @@
# teacher-bff 下一步工作与上下游依赖
> 模块teacher-bff教学场景域聚合层 BFFGraphQL 聚合层,端口 3003
> 负责人ai03
> 更新日期2026-07-14v2schema v2 扩展 + 96 operations + Docker E2E 验证通过)
> 关联文档:[02-architecture-design.md](./02-architecture-design.md)、[admin-portal_contract.md](../../../docs/architecture/issues/contracts/admin-portal_contract.md)
---
## 1. 模块当前状态
teacher-bff 已完成 P2-P7 全部批次schema 重写为 v2 扁平命名,扩展至 62 Query + 34 Mutation = 96 operations满足 teacher-portal 89 个 operation 需求 + admin-portal 全部 admin 域需求。Docker 镜像构建成功21/21 GraphQL 端到端查询测试全部通过(接入真实 iam无 mock 数据)。
### 1.1 本地 Docker 验证结果2026-07-14 v2
测试环境:本地 Dockeredu-iam-test + edu-teacher-bff-test 容器,接入 `edu-full_default` 网络,直连 edu-mysql / edu-redis / edu-kafka
```
镜像edu-test-iam:latest、edu-test-teacher-bff:latest
容器edu-iam-testNODE_ENV=production, DEV_MODE=true, HTTP 3002 + gRPC 50052
edu-teacher-bff-testNODE_ENV=production, TEACHER_BFF_DEV_MODE=true, HTTP 3003
测试用户ffd5f3f8-67a3-4774-9ccc-c4c6d2288eafadmin-test@edu.local, role=admin, data_scope=all
测试 Headersx-user-id=teacher-001, x-user-role=teacher
```
| 验证项 | 状态 | 说明 |
| ---------------------------- | ---- | ------------------------------------------------------------------ |
| iam Docker 镜像构建 | ✅ | node:20-alpine + bcryptjs纯 JS无 native 编译) |
| teacher-bff Docker 镜像构建 | ✅ | node:22-alpine + --ignore-scripts跳过 husky |
| iam HTTP /healthz | ✅ | 200 |
| iam gRPC 50052 | ✅ | GetUserInfo / BatchGetUsers / GetEffectivePermissions 等方法可调用 |
| teacher-bff /healthz | ✅ | 200 |
| teacher-bff /readyz | ✅ | 7 依赖健康iam gRPC + Redis + 5 个 mock 客户端探针) |
| GraphQL schema introspection | ✅ | 62 Query + 34 Mutation 字段全部存在 |
| typecheck + lint + build | ✅ | 三项零错误 |
| **E2E 21/21 GraphQL 查询** | ✅ | **全部 PASS**(详见 §1.2 |
### 1.2 E2E 测试结果21/21 PASS
| # | 查询 | 状态 | 说明 |
| --- | ---------------- | ---- | -------------------------------------------- |
| 1 | me | ✅ | 返回真实 iam 数据 |
| 2 | dashboard | ✅ | 聚合 user + classes + viewports + stats |
| 3 | classes | ✅ | core-edu mock 返回 3 个班级 |
| 4 | classExams | ✅ | core-edu mock 返回考试列表 |
| 5 | classHomework | ✅ | core-edu mock 返回作业列表(含 status 字段) |
| 6 | classStudents | ✅ | 返回学生列表 |
| 7 | examDetail | ✅ | 返回考试详情 |
| 8 | homeworkDetail | ✅ | 返回作业详情(含 submissions |
| 9 | knowledgeGraph | ✅ | content mock 返回知识图谱 |
| 10 | classAnalytics | ✅ | data-ana mock 返回班级分析 |
| 11 | studentAnalytics | ✅ | data-ana mock 返回学生分析 |
| 12 | myNotifications | ✅ | msg mock 返回通知isRead → read 字段映射) |
| 13 | notifications | ✅ | msg mock 返回通知列表 |
| 14 | adminRoles | ✅ | 返回真实 iam 角色3 个) |
| 15 | adminDashboard | ✅ | 返回真实统计数据 |
| 16 | attendanceList | ✅ | P7 降级返回空 page |
| 17 | examBuild | ✅ | P7 降级返回空 |
| 18 | gradeStats | ✅ | P7 降级返回空 |
| 19 | lessonPlans | ✅ | P7 降级返回空数组 |
| 20 | textbooks | ✅ | P7 降级返回空 page |
| 21 | errorBook | ✅ | P7 降级返回空 summary |
### 1.3 已完成的关键修复v2 新增)
| # | 工作项 | 详情 |
| --- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 13 | Schema v2 重写 | 62 Query + 34 Mutation = 96 operationsteacher 域 23 Query + 9 Mutationadmin 域 39 Query + 25 Mutation |
| 14 | Teacher 域 P3-P5 扩展 | classStudents/examDetail/homeworkDetail/classExams/classHomework/examGrades/updateUser/markAsRead/generateLessonPlan/generateReport + P4 knowledgeGraph/classAnalytics/studentAnalytics + P5 myNotifications |
| 15 | P7 全部降级实现 | 39 Query + 25 Mutation 降级返回空/默认值 + warning logattendanceList/attendanceSheet/attendanceStats/classDetail/classSchedule/leaveRequests/scheduleChanges/electiveCourses/examRichContent/proctoringStatus/submissionScanGrading/examBuild/examAnalytics/questions/textbooks/textbookCreate/gradeEntry/gradeStats/gradeAnalytics/reportCards/homeworkSubmissionSummary/homeworkAssignmentSubmissions/aiBatchGrading/lessonPlans/lessonPlanDetail/lessonPlanLibrary/lessonPlanCalendar/lessonPlanHeatmap/coursePlans/diagnosticReports/classDiagnostic/studentDiagnostic/errorBook/practiceAnalytics + 25 Mutation |
| 16 | Dockerfile 重写v2 | 采用 parent-bff 模式:--ignore-scripts 跳过 husky--ignore-workspace 独立安装生产依赖 |
| 17 | docker-compose.test.yml 配置 5 个 gRPC target | CORE_EDU_GRPC_TARGET=core-edu:50053, CONTENT_GRPC_TARGET=content:50054, DATA_ANA_GRPC_TARGET=data-ana:50055, MSG_GRPC_TARGET=msg:50056, AI_GRPC_TARGET=ai:50058 |
| 18 | docker-compose.deploy.yml 同步配置 | 生产部署配置同步更新所有 gRPC target + TEACHER_BFF_DEV_MODE + REDIS_URL + OTEL |
| 19 | IamAuditLog beforeState/afterState 类型修复 | iam.types.ts 添加 beforeState/afterState: string \| null 字段 |
| 20 | Homework.status 字段修复 | schema Homework 类型添加 status: String 字段(对齐 core-edu mock 数据) |
| 21 | Notification isRead → read 字段映射 | resolver 中将 msg.Notification.isRead 映射为 schema Notification.read |
| 22 | updateUser 统一签名 | teacher.updateUser 覆盖 admin.updateUser`updateUser(id: ID, input: UpdateUserInput!): User!` |
| 23 | textbookCreate 返回类型修复 | Textbook → TextbookItemTextbook 类型未定义) |
| 24 | 死代码清理 | 删除 circuit-breaker.tsimport opossum 但 opossum 不在 package.json 中) |
| 25 | resolver 合并顺序调整 | admin 先 → teacher 后,使 teacher.updateUser 覆盖 admin.updateUser |
---
## 2. 上游依赖teacher-bff 依赖谁)
### 2.1 iam 服务ai06 负责)— P0
| # | 依赖项 | 用途 | 状态 |
| --- | ------------------------------------------------ | -------------------------------------------- | -------------- |
| 1 | gRPC `GetUserInfo(userId)` :50052 | `currentUser` / `me` 查询 | ✅ 已就绪 |
| 2 | gRPC `BatchGetUsers(userIds)` :50052 | `adminUsers` 查询 | ✅ 已就绪 |
| 3 | gRPC `GetEffectivePermissions(userId)` :50052 | 用户有效权限 | ✅ 已就绪 |
| 4 | gRPC `GetEffectiveDataScope(userId)` :50052 | 用户数据范围 | ✅ 已就绪 |
| 5 | gRPC `GetViewports(userId)` :50052 | `adminViewports` 查询 | ✅ 已就绪 |
| 6 | gRPC `GetPublicKey()` :50052 | RS256 公钥 | ✅ 已就绪 |
| 7 | REST `GET /v1/iam/roles` :3002 | `adminRoles` 查询 | ✅ 已就绪 |
| 8 | REST `GET /v1/iam/permissions` :3002 | `adminPermissions` 查询 | ✅ 已就绪 |
| 9 | REST `GET /v1/iam/audit` :3002 | `auditLogs` 查询 | ✅ 已就绪 |
| 10 | REST `GET /v1/iam/viewports` :3002 | `adminViewports` 查询(备选) | ✅ 已就绪 |
| 11 | REST `GET /v1/iam/permissions/effective` :3002 | 用户有效权限(备选) | ✅ 已就绪 |
| 12 | `POST /v1/iam/register` / `POST /v1/iam/login` | 用户注册/登录 | ✅ 已就绪 |
| 13 | `GET /.well-known/jwks.json` :3002 | RS256 JWKS | ⏳ 待 iam 实现 |
| 14 | `PATCH /v1/iam/users/:id` | admin `updateUser` / `toggleUserStatus` | ⏳ 待 iam 实现 |
| 15 | `POST /v1/iam/roles` / `PATCH /v1/iam/roles/:id` | admin `createRole` / `updateRolePermissions` | ⏳ 待 iam 实现 |
**关键环境变量**`IAM_GRPC_TARGET=iam:50052`, `IAM_SERVICE_URL=http://iam:3002`
### 2.2 core-edu 服务ai07 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | ------------------------------------------- | --------------------------------- | ------------------- |
| 1 | gRPC `ListClasses(teacherId)` :50053 | `classes` / `adminClasses` 查询 | ⏳ 待 core-edu 实现 |
| 2 | gRPC `ListExamsByClass(classId)` :50053 | `classExams` / `exams` 查询 | ⏳ 待 core-edu 实现 |
| 3 | gRPC `ListHomeworkByClass(classId)` :50053 | `classHomework` / `homework` 查询 | ⏳ 待 core-edu 实现 |
| 4 | gRPC `ListGradesByExam(examId)` :50053 | `examGrades` / `grades` 查询 | ⏳ 待 core-edu 实现 |
| 5 | gRPC `ListStudentsByClass(classId)` :50053 | `classStudents` 查询 | ⏳ 待 core-edu 实现 |
| 6 | gRPC `GetExamDetail(examId)` :50053 | `examDetail` 查询 | ⏳ 待 core-edu 实现 |
| 7 | gRPC `GetHomeworkDetail(homeworkId)` :50053 | `homeworkDetail` 查询 | ⏳ 待 core-edu 实现 |
| 8 | gRPC `CreateExam(input)` :50053 | `createExam` mutation | ⏳ 待 core-edu 实现 |
| 9 | gRPC `AssignHomework(input)` :50053 | `assignHomework` mutation | ⏳ 待 core-edu 实现 |
| 10 | gRPC `RecordGrade(input)` :50053 | `recordGrade` mutation | ⏳ 待 core-edu 实现 |
| 11 | gRPC `GetClassPerformance(classId)` :50053 | `classPerformance` 查询 | ⏳ 待 core-edu 实现 |
**当前状态**`CORE_EDU_GRPC_TARGET=core-edu:50053` 已配置,但 core-edu 服务未启动,走降级模式 Bmock 数据)。
### 2.3 content 服务ai08 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | ---------------------------------------- | --------------------------------------- | ------------------ |
| 1 | gRPC `GetKnowledgePath(classId)` :50054 | `knowledgePath` / `knowledgeGraph` 查询 | ⏳ 待 content 实现 |
| 2 | gRPC `ListTextbooks()` :50054 | `textbooks` 查询 | ⏳ 待 content 实现 |
| 3 | gRPC `ListLessonPlans(teacherId)` :50054 | `lessonPlans` 查询 | ⏳ 待 content 实现 |
**当前状态**`CONTENT_GRPC_TARGET=content:50054` 已配置,但 content 服务未启动,走降级模式 B。
### 2.4 data-ana 服务ai11 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | -------------------------------------------- | ----------------------- | ------------------- |
| 1 | gRPC `GetClassAnalytics(classId)` :50055 | `classAnalytics` 查询 | ⏳ 待 data-ana 实现 |
| 2 | gRPC `GetStudentAnalytics(studentId)` :50055 | `studentAnalytics` 查询 | ⏳ 待 data-ana 实现 |
| 3 | gRPC `GetStudentWeakness(classId)` :50055 | `studentWeakness` 查询 | ⏳ 待 data-ana 实现 |
| 4 | gRPC `GetLearningTrend(classId)` :50055 | `learningTrend` 查询 | ⏳ 待 data-ana 实现 |
**当前状态**`DATA_ANA_GRPC_TARGET=data-ana:50055` 已配置,但 data-ana 服务未启动,走降级模式 B。
### 2.5 msg 服务ai10 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | --------------------------------------- | ------------------------------------------------ | -------------- |
| 1 | gRPC `ListNotifications(userId)` :50056 | `notifications` / `myNotifications` 查询 | ⏳ 待 msg 实现 |
| 2 | gRPC `MarkNotificationRead(id)` :50056 | `markNotificationAsRead` / `markAsRead` mutation | ⏳ 待 msg 实现 |
| 3 | gRPC `ListAnnouncements(filter)` :50056 | `adminAnnouncements` 查询 | ⏳ 待 msg 实现 |
**当前状态**`MSG_GRPC_TARGET=msg:50056` 已配置,但 msg 服务未启动,走降级模式 B。
### 2.6 ai 服务ai12 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | --------------------------------------- | ----------------------------- | ------------- |
| 1 | gRPC `GenerateQuestion(input)` :50058 | `generateQuestion` mutation | ⏳ 待 ai 实现 |
| 2 | SSE `POST /v1/ai/chat/stream` :3008 | AI 对话流式响应 | ⏳ 待 ai 实现 |
| 3 | gRPC `GenerateLessonPlan(input)` :50058 | `generateLessonPlan` mutation | ⏳ 待 ai 实现 |
| 4 | gRPC `GenerateReport(input)` :50058 | `generateReport` mutation | ⏳ 待 ai 实现 |
**当前状态**`AI_GRPC_TARGET=ai:50058` 已配置,但 ai 服务未启动,走降级模式 B。
### 2.7 Redis基础设施— P0
| 项 | 内容 |
| -------- | ------------------------------------- |
| 依赖内容 | /readyz 探针 + DataLoader 缓存P4+ |
| 端点 | `redis://edu-redis:6379` |
| 当前状态 | ✅ 本地 Docker edu-redis 已就绪 |
---
## 3. 下游依赖(谁依赖 teacher-bff
### 3.1 admin-portalai16 负责)— P0
| 能力 | 配置 | 状态 |
| ----------------------------- | ------------------------------------------- | --------- |
| `POST /graphql` :3003 | admin-portal GraphQL 代理目标 | ✅ 已就绪 |
| Schema v2 扁平命名 | 62 Query + 34 Mutation 字段全部实现 | ✅ 已完成 |
| x-user-data-scope header 透传 | admin 角色 data_scope=all 跳过 iam 权限校验 | ✅ 已实现 |
| 降级模式 B | 未就绪下游返回空结果 + warning | ✅ 已实现 |
**portal 需求来源**`apps/admin-portal/docs/nextstep-v2.md`
### 3.2 teacher-portalai13 负责)— P0
| 能力 | 配置 | 状态 |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `POST /graphql` :3003 | teacher-portal GraphQL 代理目标 | ✅ 已就绪 |
| teacher 域 23 Query | dashboard/viewports/me/classes/class/exams/homework/grades/knowledgePath/classPerformance/studentWeakness/learningTrend/notifications/classStudents/examDetail/homeworkDetail/classExams/classHomework/examGrades/knowledgeGraph/classAnalytics/studentAnalytics/myNotifications/generateLessonPlan/generateReport | ✅ 全部实现 |
| teacher 域 9 Mutation | createExam/assignHomework/recordGrade/generateQuestion/markNotificationAsRead/updateUser/markAsRead/generateLessonPlan/generateReport | ✅ 全部实现 |
| P7 39 Query + 25 Mutation 降级 | attendanceList/examBuild/gradeStats/lessonPlans/textbooks/errorBook 等 | ✅ 全部降级实现 |
| SSE `/v1/teacher/ai/chat/stream` | AI 对话流式透传 | ✅ 已实现 |
| 5 个 gRPC target 配置 | core-edu/content/data-ana/msg/ai | ✅ 已配置 |
| Zod 输入验证 | createExam/assignHomework/recordGrade/generateQuestion | ✅ 已实现 |
**portal 需求来源**`apps/teacher-portal/nextstep-v2.md`12 项待补工作全部 ✅)
### 3.3 api-gatewayai01 负责)— P0
| 能力 | 配置 | 状态 |
| ----------------------------- | -------------------------- | --------------------- |
| `/api/v1/teacher/*` 反向代理 | → teacher-bff:3003/* | ✅ api-gateway 已配置 |
| `/api/admin/graphql` 反向代理 | → teacher-bff:3003/graphql | ✅ api-gateway 已配置 |
| `GET /healthz` 端点 | /readyz 下游健康检查 | ✅ 已实现 |
| `GET /readyz` 端点 | iam gRPC + Redis 健康检查 | ✅ 已实现 |
| `GET /metrics` 端点 | Prometheus 指标 | ✅ 已实现 |
### 3.4 student-portal / parent-portal
无直接 teacher-bff 需求student-bff / parent-bff 各自负责)。
---
## 4. GraphQL Schema 清单v2
### 4.1 Query 字段62 个)
**Teacher 域23 个)**dashboard / viewports / me / classes / class / exams / homework / grades / knowledgePath / classPerformance / studentWeakness / learningTrend / notifications / classStudents / examDetail / homeworkDetail / classExams / classHomework / examGrades / knowledgeGraph / classAnalytics / studentAnalytics / myNotifications
**Admin 域39 个)**currentUser / adminUsers / adminUser / adminRoles / adminPermissions / adminViewports / adminOrganization / adminClasses / adminTeachers / adminStudents / auditLogs / adminDashboard / systemSettings / auditOverviewStats / auditTrend / dataChangeLogs / loginLogs / adminSchools / adminGrades / adminGradeInsights / adminDepartments / adminAcademicYears / adminAnnouncements / adminInvitationCodes / adminFiles / adminFileStats / aiProviders / aiUsage + P7 11 个降级 Query
### 4.2 Mutation 字段34 个)
**Teacher 域9 个)**createExam / assignHomework / recordGrade / generateQuestion / markNotificationAsRead / updateUser / markAsRead / generateLessonPlan / generateReport
**Admin 域25 个)**createUser / updateUser / toggleUserStatus / createRole / updateRolePermissions / updateViewport / updateSystemSettings / createAnnouncement / publishAnnouncement / archiveAnnouncement / toggleAnnouncementPin / generateInvitationCodes / deleteInvitationCodes / deleteFiles / updateAiProvider + P7 10 个降级 Mutation
### 4.3 Schema 文件位置
- `packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql`SDL 定义v2 扁平命名)
---
## 5. 环境变量清单Docker 部署)
| 变量 | 必填 | 示例值 | 说明 |
| ----------------------------- | ---- | ---------------------------- | ---------------------------------- |
| `PORT` | 是 | `3003` | HTTP 监听端口 |
| `GRAPHQL_PATH` | 否 | `/graphql` | GraphQL 端点路径 |
| `TEACHER_BFF_DEV_MODE` | 是 | `true`/`false` | 越权防御开关P2 放行 / 生产拒绝) |
| `REDIS_URL` | 是 | `redis://edu-redis:6379` | Redis 连接地址 |
| `IAM_GRPC_TARGET` | 是 | `iam:50052` | iam gRPC 目标 |
| `IAM_SERVICE_URL` | 是 | `http://iam:3002` | iam REST 基址 |
| `CORE_EDU_GRPC_TARGET` | 否 | `core-edu:50053` | core-edu gRPC 目标(留空走降级) |
| `CONTENT_GRPC_TARGET` | 否 | `content:50054` | content gRPC 目标(留空走降级) |
| `DATA_ANA_GRPC_TARGET` | 否 | `data-ana:50055` | data-ana gRPC 目标(留空走降级) |
| `MSG_GRPC_TARGET` | 否 | `msg:50056` | msg gRPC 目标(留空走降级) |
| `AI_GRPC_TARGET` | 否 | `ai:50058` | ai gRPC 目标(留空走降级) |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | 否 | `http://otel-collector:4318` | OTLP 上报端点 |
| `LOG_LEVEL` | 否 | `info` | 日志级别 |
---
## 6. 剩余工作
### 6.1 真实下游联调(按优先级)
| # | 工作项 | 阻塞条件 | 状态 |
| --- | ------------------------------------------------------------- | ------------------ | ---- |
| 1 | core-edu gRPC 联调 | core-edu 服务就绪 | ⏳ |
| 2 | content gRPC 联调 | content 服务就绪 | ⏳ |
| 3 | data-ana gRPC 联调 | data-ana 服务就绪 | ⏳ |
| 4 | msg gRPC 联调 | msg 服务就绪 | ⏳ |
| 5 | ai gRPC + SSE 联调 | ai 服务就绪 | ⏳ |
| 6 | iam REST CRUD 端点联调createUser/updateUser/createRole 等) | iam 补齐 REST CRUD | ⏳ |
> gRPC target 已全部配置,服务就绪后自动从 mock 切换为真实 gRPC 调用,无需修改 teacher-bff 代码。
### 6.2 P6 硬化任务
| # | 工作项 | 状态 |
| --- | ----------------------------- | ----------------------- |
| 1 | DataLoader 批量查询(防 N+1 | ⏳ 待下游就绪后实现 |
| 2 | Redis 多层缓存 | ⏳ 待下游就绪后实现 |
| 3 | OTel trace 上报 | ✅ 已实现tracer.ts |
| 4 | Prometheus 指标 | ✅ 已实现metrics.ts |
---
## 7. 已完成项汇总
| 工作项 | 状态 | 验证方式 |
| --------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------- |
| P2 核心 5 Query | ✅ | typecheck + lint + Docker 联调 |
| P3 teacher 域 Query/Mutation | ✅ | typecheck + lint + Docker 联调 |
| P4 学习分析 Query | ✅ | typecheck + lint + E2E 测试 |
| P5 通知 + AI Query/Mutation | ✅ | typecheck + lint + E2E 测试 |
| P6 admin 扁平命名重构ARB-001 | ✅ | typecheck + lint + Docker 联调 |
| P6 admin 41 Query + 20 Mutation | ✅ | schema introspection + Docker 联调 |
| P6 x-user-data-scope header 透传 | ✅ | Docker 联调adminRoles 返回真实数据) |
| P6 IamGrpcController 注册修复 | ✅ | Docker 联调currentUser 返回真实数据) |
| P6 Zod 输入验证 | ✅ | typecheck |
| P6 SSE 流式透传 | ✅ | typecheck |
| **Schema v2 扩展96 operations** | ✅ | **schema introspection + E2E 21/21 PASS** |
| **Teacher 域 P3-P5 扩展12 项)** | ✅ | **E2E 测试全部 PASS** |
| **P7 全部降级实现39 Query + 25 Mutation** | ✅ | **E2E 测试全部 PASS** |
| **5 个 gRPC target 配置** | ✅ | **docker-compose.test.yml + deploy.yml** |
| **Dockerfile 重写(--ignore-scripts 模式)** | ✅ | **Docker 构建成功** |
| **Homework.status 字段修复** | ✅ | **E2E classHomework PASS** |
| **Notification isRead → read 映射** | ✅ | **E2E myNotifications PASS** |
| Docker 镜像构建 | ✅ | edu-test-teacher-bff:latestnode:22-alpine |
| Docker 容器运行 | ✅ | /healthz 200 + /readyz 200 |
| 真实 iam 联调(无 mock | ✅ | currentUser/me/adminRoles/adminPermissions/auditLogs/adminDashboard/systemSettings 全部返回真实数据 |
| `me` 查询 dataScope 修复v2 | ✅ | Docker 验证返回 ALL |
| DataScope 大小写转换v2 | ✅ | iam-grpc.client.ts toUpperCase() |
| IamModule 客户端选择修复v2 | ✅ | IAM_GRPC_TARGET 配置后强制 gRPC |
---
## 8. 关键文件路径
| 文件 | 用途 |
| ------------------------------------------------------------------- | ---------------------------------------- |
| `services/teacher-bff/src/admin/admin.resolver.ts` | admin GraphQL resolver扁平命名 |
| `services/teacher-bff/src/admin/admin.service.ts` | admin 业务逻辑层 |
| `services/teacher-bff/src/teacher/teacher.resolver.ts` | teacher 域 GraphQL resolverv2 扩展) |
| `services/teacher-bff/src/teacher/teacher.service.ts` | teacher 域业务逻辑层v2 扩展) |
| `services/teacher-bff/src/teacher/ai-chat-sse.controller.ts` | SSE 流式 AI 对话 |
| `services/teacher-bff/src/graphql/graphql.controller.ts` | GraphQL 入口 + mergeResolvers 深合并 |
| `services/teacher-bff/src/graphql/context.ts` | GraphQL 上下文(提取 x-user-* headers |
| `services/teacher-bff/src/clients/iam/iam-grpc.client.ts` | iam gRPC + REST 客户端 |
| `services/teacher-bff/src/clients/core-edu/core-edu-grpc.client.ts` | core-edu gRPC 客户端 |
| `services/teacher-bff/src/clients/content/content-grpc.client.ts` | content gRPC 客户端 |
| `services/teacher-bff/src/clients/data-ana/data-ana-grpc.client.ts` | data-ana gRPC 客户端 |
| `services/teacher-bff/src/clients/msg/msg-grpc.client.ts` | msg gRPC 客户端 |
| `services/teacher-bff/src/clients/ai/ai-grpc.client.ts` | ai gRPC 客户端 |
| `services/teacher-bff/src/clients/types.ts` | CallContext含 dataScope 字段) |
| `services/teacher-bff/src/clients/grpc/grpc.factory.ts` | gRPC 客户端工厂 |
| `services/teacher-bff/Dockerfile` | Docker 构建(--ignore-scripts 模式) |
| `packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql` | GraphQL schema 契约v296 operations |
| `packages/shared-proto/proto/iam.proto` | iam gRPC proto 契约 |
| `infra/docker-compose.test.yml` | 测试环境 compose5 个 gRPC target |
| `infra/docker-compose.deploy.yml` | 生产部署 compose5 个 gRPC target |
---
**本文件由 ai03 维护,上游/下游工作项请各负责 AI 完成后通知 ai03 更新状态。**

View File

@@ -0,0 +1,316 @@
# teacher-bff 下一步工作与上下游依赖
> 模块teacher-bff教学场景域聚合层 BFFGraphQL 聚合层,端口 3003
> 负责人ai03
> 更新日期2026-07-13v2iam 真实数据全量联调 + dataScope 修复)
> 关联文档:[02-architecture-design.md](./02-architecture-design.md)、[admin-portal_contract.md](../../../docs/architecture/issues/contracts/admin-portal_contract.md)
---
## 1. 模块当前状态
teacher-bff 已完成 P2-P6 全部批次admin resolver 重构为扁平命名ARB-001 裁决Docker 镜像构建成功并在本地 Docker 验证通过(接入真实 iam无 mock 数据)。
### 1.1 本地 Docker 验证结果2026-07-13 v2
测试环境:本地 Dockeredu-iam-test + edu-teacher-bff-test 容器,接入 `edu-full_default` 网络,直连 edu-mysql / edu-redis / edu-kafka
```
镜像edu-test-iam:latest、edu-test-teacher-bff:latest
容器edu-iam-testNODE_ENV=production, DEV_MODE=true, HTTP 3002 + gRPC 50052
edu-teacher-bff-testNODE_ENV=production, TEACHER_BFF_DEV_MODE=true, HTTP 3003
测试用户ffd5f3f8-67a3-4774-9ccc-c4c6d2288eafadmin-test@edu.local, role=admin, data_scope=all
```
| 验证项 | 状态 | 说明 |
| -------------------------------------------------------- | ---- | --------------------------------------------------------------------- |
| iam Docker 镜像构建 | ✅ | node:20-alpine + bcryptjs纯 JS无 native 编译) |
| teacher-bff Docker 镜像构建 | ✅ | node:20-alpine + shared-ts 预编译 |
| iam HTTP /healthz | ✅ | 200 |
| iam gRPC 50052 | ✅ | GetUserInfo / BatchGetUsers / GetEffectivePermissions 等方法可调用 |
| teacher-bff /healthz | ✅ | 200 |
| teacher-bff /readyz | ✅ | 7 依赖健康iam gRPC + Redis + 5 个 mock 客户端探针) |
| GraphQL schema introspection | ✅ | 41 Query + 20 Mutation 字段全部存在 |
| `currentUser` 查询UUID | ✅ | 返回真实 iam 数据admin-test@edu.local, dataScope: ALL, 5 权限) |
| `me` 查询UUID | ✅ | 返回真实 iam 数据dataScope: ALLv2 修复) |
| `adminRoles` 查询 | ✅ | 返回真实 iam 角色3 个admin/student/teacher |
| `adminPermissions` 查询 | ✅ | 返回真实 iam 权限点5 个iam:user:read 等) |
| `auditLogs` 查询 | ✅ | 返回真实 iam 审计日志5 条) |
| `adminDashboard` 查询 | ✅ | 返回真实统计数据auditEventsToday: 4 |
| `systemSettings` 查询 | ✅ | 返回真实系统设置academicYear: 2025-2026 |
| `adminUsers` 查询 | ⚠️ | 返回空iam REST /v1/iam/users 未实现,降级模式 B |
| `adminTeachers` / `adminStudents` 查询 | ⚠️ | 返回空core-edu 未接入,降级模式 B |
| `adminSchools` / `adminGrades` / `adminDepartments` 查询 | ⚠️ | 返回空core-edu 未接入,降级模式 B |
| `viewports` / `adminViewports` 查询 | ⚠️ | 返回空iam REST /v1/iam/viewports 返回空数组DB 无 viewport 数据) |
| x-user-data-scope header 透传 | ✅ | CallContext → context.ts → graphql.controller.ts → iam-grpc.client.ts |
| `typecheck` / `lint` / `build` | ✅ | 三项零错误 |
### 1.2 已完成的关键修复
| # | 工作项 | 详情 |
| --- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | admin.resolver.ts 扁平命名重构 | ARB-001 裁决:`adminUsers`/`adminRoles` 直接挂载在根 Query/Mutation不再使用 `admin { users }` 命名空间 |
| 2 | graphql.controller.ts 深合并 resolvers | `mergeResolvers(teacherResolvers, adminResolvers)` 深合并 Query/Mutation 字典,避免浅展开覆盖 |
| 3 | x-user-data-scope header 透传链路 | iam PermissionGuard 检查 `data_scope=all` 跳过权限校验teacher-bff 全链路透传该 header |
| 4 | IamGrpcController 注册修复 | 从 `providers` 数组移到 `controllers` 数组,修复 gRPC GetUserInfo UNIMPLEMENTED 错误 |
| 5 | JWT RS256 密钥生成 | 使用 Node.js crypto 生成 iam-private.pem / iam-public.pem |
| 6 | iam 种子数据 | 插入 4 个角色 + 11 个权限点 + 角色权限关联 + 审计日志 + 设置 data_scope='all' |
| 7 | Dockerfile 修复v2 | node:20-alpine + bcryptjs纯 JS+ shared-ts 预编译 + pnpm prune --prod |
| 8 | bcrypt → bcryptjs 迁移v2 | 避免 native 编译依赖python3/make/g++API 完全兼容 |
| 9 | NODE_ENV=productionv2 | 避免 pino-pretty 在 prune --prod 后缺失dev 模式才使用 transport |
| 10 | IamModule 客户端选择逻辑修复v2 | 按 `IAM_GRPC_TARGET` 是否配置选择 gRPC 或 mock不再受 DEV_MODE 影响 |
| 11 | DataScope 枚举大小写转换v2 | iam DB 存小写self/allGraphQL enum 需大写,在 iam-grpc.client.ts getUserInfo 中 toUpperCase() |
| 12 | `me` 查询 dataScope 修复v2 | teacher.service.ts getCurrentUser 改用 `userInfo.dataScope ?? perms.dataScope`REST /v1/iam/permissions/effective 不返回 dataScope 时回退到 gRPC GetUserInfo 的值 |
---
## 2. 上游依赖teacher-bff 依赖谁)
### 2.1 iam 服务ai06 负责)— P0
| # | 依赖项 | 用途 | 状态 |
| --- | ------------------------------------------------ | ------------------------------------------------------------------ | -------------- |
| 1 | gRPC `GetUserInfo(userId)` :50052 | `currentUser` 查询:获取当前用户信息 + 角色 + 权限 | ✅ 已就绪 |
| 2 | gRPC `BatchGetUsers(userIds)` :50052 | `adminUsers` 查询:批量获取用户信息 | ✅ 已就绪 |
| 3 | gRPC `GetEffectivePermissions(userId)` :50052 | 用户有效权限列表 | ✅ 已就绪 |
| 4 | gRPC `GetEffectiveDataScope(userId)` :50052 | 用户数据范围DataScope 6 级) | ✅ 已就绪 |
| 5 | gRPC `GetViewports(userId)` :50052 | `adminViewports` 查询:视口配置 | ✅ 已就绪 |
| 6 | gRPC `GetPublicKey()` :50052 | RS256 公钥JWT 验签) | ✅ 已就绪 |
| 7 | gRPC `GetChildrenByParent(parentId)` :50052 | 家长端:获取学生-家长关系 | ✅ 已就绪 |
| 8 | REST `GET /v1/iam/roles` :3002 | `adminRoles` 查询:角色列表 | ✅ 已就绪 |
| 9 | REST `GET /v1/iam/permissions` :3002 | `adminPermissions` 查询:权限点列表 | ✅ 已就绪 |
| 10 | REST `GET /v1/iam/audit` :3002 | `auditLogs` 查询:审计日志 | ✅ 已就绪 |
| 11 | REST `GET /v1/iam/viewports` :3002 | `adminViewports` 查询(备选) | ✅ 已就绪 |
| 12 | REST `GET /v1/iam/permissions/effective` :3002 | 用户有效权限(备选) | ✅ 已就绪 |
| 13 | `GET /.well-known/jwks.json` :3002 | RS256 JWKSapi-gateway / push-gateway 验签) | ⏳ 待 iam 实现 |
| 14 | `POST /v1/iam/register` / `POST /v1/iam/login` | 用户注册/登录admin `createUser` 需要) | ✅ 已就绪 |
| 15 | `PATCH /v1/iam/users/:id` | 用户更新admin `updateUser` / `toggleUserStatus` 需要) | ⏳ 待 iam 实现 |
| 16 | `POST /v1/iam/roles` / `PATCH /v1/iam/roles/:id` | 角色创建/更新admin `createRole` / `updateRolePermissions` 需要) | ⏳ 待 iam 实现 |
**关键环境变量**
- `IAM_GRPC_TARGET=iam:50052`
- `IAM_SERVICE_URL=http://iam:3002`
### 2.2 core-edu 服务ai07 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | ---------------------------------------------------- | ----------------------------------- | ------------------- |
| 1 | gRPC `GetDashboard(teacherId)` :50053 | `dashboard` 查询:教师仪表盘聚合 | ⏳ 待 core-edu 实现 |
| 2 | gRPC `ListClasses(teacherId)` :50053 | `classes` / `adminClasses` 查询 | ⏳ 待 core-edu 实现 |
| 3 | gRPC `GetClass(classId)` :50053 | `class` 查询:班级详情 | ⏳ 待 core-edu 实现 |
| 4 | gRPC `ListExams(classId)` :50053 | `exams` 查询:考试列表 | ⏳ 待 core-edu 实现 |
| 5 | gRPC `ListHomework(classId)` :50053 | `homework` 查询:作业列表 | ⏳ 待 core-edu 实现 |
| 6 | gRPC `ListGrades(classId)` :50053 | `grades` 查询:成绩列表 | ⏳ 待 core-edu 实现 |
| 7 | gRPC `CreateExam(input)` :50053 | `createExam` mutation | ⏳ 待 core-edu 实现 |
| 8 | gRPC `AssignHomework(input)` :50053 | `assignHomework` mutation | ⏳ 待 core-edu 实现 |
| 9 | gRPC `RecordGrade(input)` :50053 | `recordGrade` mutation | ⏳ 待 core-edu 实现 |
| 10 | gRPC `GetClassPerformance(classId)` :50053 | `classPerformance` 查询 | ⏳ 待 core-edu 实现 |
| 11 | gRPC `ListSchools()` / `ListGrades(schoolId)` :50053 | `adminSchools` / `adminGrades` 查询 | ⏳ 待 core-edu 实现 |
| 12 | gRPC `ListDepartments(schoolId)` :50053 | `adminDepartments` 查询 | ⏳ 待 core-edu 实现 |
| 13 | gRPC `ListAcademicYears()` :50053 | `adminAcademicYears` 查询 | ⏳ 待 core-edu 实现 |
**当前状态**teacher-bff 中 `CORE_EDU_GRPC_TARGET` 留空,相关查询走降级模式 B返回空结果 + warning 日志)。
### 2.3 content 服务ai08 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | ---------------------------------------- | ----------------------- | ------------------ |
| 1 | gRPC `GetKnowledgePath(classId)` :50054 | `knowledgePath` 查询 | ⏳ 待 content 实现 |
| 2 | gRPC `ListTextbooks()` :50054 | 教材列表 | ⏳ 待 content 实现 |
| 3 | gRPC `ListChapters(textbookId)` :50054 | 章节目录 | ⏳ 待 content 实现 |
| 4 | gRPC `ListLessonPlans(teacherId)` :50054 | `adminLessonPlans` 查询 | ⏳ 待 content 实现 |
| 5 | gRPC `ListCoursePlans(classId)` :50054 | `adminCoursePlans` 查询 | ⏳ 待 content 实现 |
### 2.4 data-ana 服务ai11 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | ----------------------------------------- | ------------------------- | ------------------- |
| 1 | gRPC `GetStudentWeakness(classId)` :50055 | `studentWeakness` 查询 | ⏳ 待 data-ana 实现 |
| 2 | gRPC `GetLearningTrend(classId)` :50055 | `learningTrend` 查询 | ⏳ 待 data-ana 实现 |
| 3 | gRPC `GetGradeInsights(gradeId)` :50055 | `adminGradeInsights` 查询 | ⏳ 待 data-ana 实现 |
### 2.5 msg 服务ai10 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | --------------------------------------- | --------------------------------- | -------------- |
| 1 | gRPC `ListNotifications(userId)` :50056 | `notifications` 查询 | ⏳ 待 msg 实现 |
| 2 | gRPC `MarkNotificationRead(id)` :50056 | `markNotificationAsRead` mutation | ⏳ 待 msg 实现 |
| 3 | gRPC `ListAnnouncements(filter)` :50056 | `adminAnnouncements` 查询 | ⏳ 待 msg 实现 |
| 4 | gRPC `CreateAnnouncement(input)` :50056 | `createAnnouncement` mutation | ⏳ 待 msg 实现 |
| 5 | gRPC `PublishAnnouncement(id)` :50056 | `publishAnnouncement` mutation | ⏳ 待 msg 实现 |
### 2.6 ai 服务ai12 负责)— P1
| # | 依赖项 | 用途 | 状态 |
| --- | ------------------------------------- | --------------------------- | ------------- |
| 1 | gRPC `GenerateQuestion(input)` :50058 | `generateQuestion` mutation | ⏳ 待 ai 实现 |
| 2 | SSE `POST /v1/ai/chat/stream` :3008 | AI 对话流式响应 | ⏳ 待 ai 实现 |
| 3 | gRPC `ListAiProviders()` :50058 | `aiProviders` 查询 | ⏳ 待 ai 实现 |
| 4 | gRPC `GetAiUsage()` :50058 | `aiUsage` 查询 | ⏳ 待 ai 实现 |
### 2.7 Redis基础设施— P0
| 项 | 内容 |
| -------- | ------------------------------------- |
| 依赖内容 | /readyz 探针 + DataLoader 缓存P4+ |
| 端点 | `redis://edu-redis:6379` |
| 当前状态 | ✅ 本地 Docker edu-redis 已就绪 |
### 2.8 Kafka基础设施— P2
| 项 | 内容 |
| -------- | --------------------------------------- |
| 依赖内容 | 可选消费领域事件用于读模型刷新P3+ |
| 端点 | `kafka:29092` |
| 当前状态 | ✅ 本地 Docker edu-kafka 已就绪 |
---
## 3. 下游依赖(谁依赖 teacher-bff
### 3.1 admin-portalai16 负责)— P0
| 能力 | 配置 | 状态 |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `POST /graphql` :3003 | admin-portal GraphQL 代理目标 | ✅ 已就绪 |
| 40 Query 字段(扁平命名) | currentUser/adminUsers/adminRoles/adminPermissions/adminViewports/adminOrganization/adminClasses/adminTeachers/adminStudents/auditLogs/adminDashboard/systemSettings/auditOverviewStats/auditTrend/dataChangeLogs/loginLogs/adminSchools/adminGrades/adminGradeInsights/adminDepartments/adminAcademicYears/adminAnnouncements/adminInvitationCodes/adminFiles/adminFileStats/aiProviders/aiUsage + teacher 域 14 个 | ✅ 全部实现 |
| 20 Mutation 字段(扁平命名) | createUser/updateUser/toggleUserStatus/createRole/updateRolePermissions/updateViewport/updateSystemSettings/createAnnouncement/publishAnnouncement/archiveAnnouncement/toggleAnnouncementPin/generateInvitationCodes/deleteInvitationCodes/deleteFiles/updateAiProvider + teacher 域 5 个 | ✅ 全部实现 |
| x-user-data-scope header 透传 | admin 角色 data_scope=all 跳过 iam 权限校验 | ✅ 已实现 |
| 降级模式 B | 未就绪下游返回空结果 + warning | ✅ 已实现 |
**portal 需求来源**`apps/admin-portal/docs/nextstep.md` §2.2
### 3.2 teacher-portalai13 负责)— P0
| 能力 | 配置 | 状态 |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| `POST /graphql` :3003 | teacher-portal GraphQL 代理目标 | ✅ 已就绪 |
| teacher 域 14 Query | dashboard/viewports/me/classes/class/exams/homework/grades/knowledgePath/classPerformance/studentWeakness/learningTrend/notifications | ✅ 全部实现 |
| teacher 域 5 Mutation | createExam/assignHomework/recordGrade/generateQuestion/markNotificationAsRead | ✅ 全部实现 |
| SSE `/v1/teacher/ai/chat/stream` | AI 对话流式透传 | ✅ 已实现ai-chat-sse.controller.ts |
| Zod 输入验证 | createExam/assignHomework/recordGrade/generateQuestion | ✅ 已实现 |
**portal 需求来源**`apps/teacher-portal/nextstep.md`
### 3.3 api-gatewayai01 负责)— P0
| 能力 | 配置 | 状态 |
| ----------------------------- | ----------------------------------------------------- | --------------------- |
| `/api/v1/teacher/*` 反向代理 | → teacher-bff:3003/*(路径重写剥离 /api/v1/teacher | ✅ api-gateway 已配置 |
| `/api/admin/graphql` 反向代理 | → teacher-bff:3003/graphql路径重写剥离 /api/admin | ✅ api-gateway 已配置 |
| `GET /healthz` 端点 | /readyz 下游健康检查 | ✅ 已实现 |
| `GET /readyz` 端点 | iam gRPC + Redis 健康检查 | ✅ 已实现 |
| `GET /metrics` 端点 | Prometheus 指标 | ✅ 已实现 |
---
## 4. GraphQL Schema 清单
### 4.1 Query 字段40 个)
**Teacher 域14 个)**dashboard / viewports / me / classes / class / exams / homework / grades / knowledgePath / classPerformance / studentWeakness / learningTrend / notifications / serverTime
**Admin 域26 个)**currentUser / adminUsers / adminUser / adminRoles / adminPermissions / adminViewports / adminOrganization / adminClasses / adminTeachers / adminStudents / auditLogs / adminDashboard / systemSettings / auditOverviewStats / auditTrend / dataChangeLogs / loginLogs / adminSchools / adminGrades / adminGradeInsights / adminDepartments / adminAcademicYears / adminAnnouncements / adminInvitationCodes / adminFiles / adminFileStats / aiProviders / aiUsage
### 4.2 Mutation 字段20 个)
**Teacher 域5 个)**createExam / assignHomework / recordGrade / generateQuestion / markNotificationAsRead
**Admin 域15 个)**createUser / updateUser / toggleUserStatus / createRole / updateRolePermissions / updateViewport / updateSystemSettings / createAnnouncement / publishAnnouncement / archiveAnnouncement / toggleAnnouncementPin / generateInvitationCodes / deleteInvitationCodes / deleteFiles / updateAiProvider
### 4.3 Schema 文件位置
- `packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql`SDL 定义)
---
## 5. 环境变量清单Docker 部署)
| 变量 | 必填 | 示例值 | 说明 |
| ----------------------------- | ---- | ---------------------------- | ---------------------------------- |
| `PORT` | 是 | `3003` | HTTP 监听端口 |
| `GRAPHQL_PATH` | 否 | `/graphql` | GraphQL 端点路径 |
| `TEACHER_BFF_DEV_MODE` | 是 | `true`/`false` | 越权防御开关P2 放行 / 生产拒绝) |
| `REDIS_URL` | 是 | `redis://edu-redis:6379` | Redis 连接地址 |
| `IAM_GRPC_TARGET` | 是 | `iam:50052` | iam gRPC 目标 |
| `IAM_SERVICE_URL` | 是 | `http://iam:3002` | iam REST 基址 |
| `CORE_EDU_GRPC_TARGET` | 否 | `core-edu:50053` | core-edu gRPC 目标(留空走降级) |
| `CONTENT_GRPC_TARGET` | 否 | `content:50054` | content gRPC 目标(留空走降级) |
| `DATA_ANA_GRPC_TARGET` | 否 | `data-ana:50055` | data-ana gRPC 目标(留空走降级) |
| `MSG_GRPC_TARGET` | 否 | `msg:50056` | msg gRPC 目标(留空走降级) |
| `AI_GRPC_TARGET` | 否 | `ai:50058` | ai gRPC 目标(留空走降级) |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | 否 | `http://otel-collector:4318` | OTLP 上报端点 |
| `LOG_LEVEL` | 否 | `info` | 日志级别 |
---
## 6. 剩余工作
### 6.1 真实下游联调(按优先级)
| # | 工作项 | 阻塞条件 | 状态 |
| --- | ------------------------------------------------------------- | ------------------ | ---- |
| 1 | core-edu gRPC 联调 | core-edu 服务就绪 | ⏳ |
| 2 | content gRPC 联调 | content 服务就绪 | ⏳ |
| 3 | data-ana gRPC 联调 | data-ana 服务就绪 | ⏳ |
| 4 | msg gRPC 联调 | msg 服务就绪 | ⏳ |
| 5 | ai gRPC + SSE 联调 | ai 服务就绪 | ⏳ |
| 6 | iam REST CRUD 端点联调createUser/updateUser/createRole 等) | iam 补齐 REST CRUD | ⏳ |
### 6.2 P6 硬化任务
| # | 工作项 | 状态 |
| --- | ----------------------------- | ------------------------------- |
| 1 | DataLoader 批量查询(防 N+1 | ⏳ 待下游就绪后实现 |
| 2 | Redis 多层缓存 | ⏳ 待下游就绪后实现 |
| 3 | 熔断器opossum配置 | ✅ 已实现circuit-breaker.ts |
| 4 | OTel trace 上报 | ✅ 已实现tracer.ts |
| 5 | Prometheus 指标 | ✅ 已实现metrics.ts |
---
## 7. 已完成项汇总
| 工作项 | 状态 | 验证方式 |
| -------------------------------- | ---- | --------------------------------------------------------------------------------------------------- |
| P2 核心 5 Query | ✅ | typecheck + lint + Docker 联调 |
| P3 teacher 域 Query/Mutation | ✅ | typecheck + lint + Docker 联调 |
| P4 学习分析 Query | ✅ | typecheck + lint |
| P5 通知 + AI Query/Mutation | ✅ | typecheck + lint |
| P6 admin 扁平命名重构ARB-001 | ✅ | typecheck + lint + Docker 联调 |
| P6 admin 41 Query + 20 Mutation | ✅ | schema introspection + Docker 联调 |
| P6 x-user-data-scope header 透传 | ✅ | Docker 联调adminRoles 返回真实数据) |
| P6 IamGrpcController 注册修复 | ✅ | Docker 联调currentUser 返回真实数据) |
| P6 Zod 输入验证 | ✅ | typecheck |
| P6 熔断器opossum | ✅ | typecheck |
| P6 SSE 流式透传 | ✅ | typecheck |
| Docker 镜像构建 | ✅ | edu-test-teacher-bff:latestv2 alpine + bcryptjs |
| Docker 容器运行 | ✅ | /healthz 200 + /readyz 200 |
| 真实 iam 联调(无 mock | ✅ | currentUser/me/adminRoles/adminPermissions/auditLogs/adminDashboard/systemSettings 全部返回真实数据 |
| `me` 查询 dataScope 修复v2 | ✅ | teacher.service.ts 使用 userInfo.dataScopeDocker 验证返回 ALL |
| DataScope 大小写转换v2 | ✅ | iam-grpc.client.ts toUpperCase()Docker 验证 GraphQL enum 正常解析 |
| IamModule 客户端选择修复v2 | ✅ | IAM_GRPC_TARGET 配置后强制 gRPCDEV_MODE 不再触发 mock |
---
## 8. 关键文件路径
| 文件 | 用途 |
| ----------------------------------------------------------------- | --------------------------------------- |
| `services/teacher-bff/src/admin/admin.resolver.ts` | admin GraphQL resolver扁平命名 |
| `services/teacher-bff/src/admin/admin.service.ts` | admin 业务逻辑层 |
| `services/teacher-bff/src/teacher/teacher.resolver.ts` | teacher 域 GraphQL resolver |
| `services/teacher-bff/src/teacher/teacher.service.ts` | teacher 域业务逻辑层 |
| `services/teacher-bff/src/graphql/graphql.controller.ts` | GraphQL 入口 + mergeResolvers 深合并 |
| `services/teacher-bff/src/graphql/context.ts` | GraphQL 上下文(提取 x-user-* headers |
| `services/teacher-bff/src/clients/iam/iam-grpc.client.ts` | iam gRPC + REST 客户端 |
| `services/teacher-bff/src/clients/types.ts` | CallContext含 dataScope 字段) |
| `services/teacher-bff/src/clients/grpc/grpc.factory.ts` | gRPC 客户端工厂 |
| `services/teacher-bff/Dockerfile` | Docker 构建(含 shared-ts 预编译) |
| `packages/shared-ts/contracts/graphql/teacher-bff.schema.graphql` | GraphQL schema 契约 |
| `packages/shared-proto/proto/iam.proto` | iam gRPC proto 契约 |
---
**本文件由 ai03 维护,上游/下游工作项请各负责 AI 完成后通知 ai03 更新状态。**

View File

@@ -0,0 +1,11 @@
// Admin 模块P6 实现president §5.1admin-portal 复用 teacher-bff endpoint
import { Module } from "@nestjs/common";
import { AdminService } from "./admin.service.js";
import { ClientsModule } from "../clients/clients.module.js";
@Module({
imports: [ClientsModule],
providers: [AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,408 @@
// Admin ResolverP6 重构ARB-001 裁决:扁平命名)
// admin-portal operations 直接消费根 Query/Mutation 字段(不再使用 AdminQuery/AdminMutation 命名空间)
// 对齐 admin-portal/src/lib/graphql-client.ts 中实际发送的 operations
import type { IResolvers } from "@graphql-tools/utils";
import { AdminService } from "./admin.service.js";
import type { GraphQLOperationContext } from "../graphql/context.js";
/**
* 构建 Admin Resolvers扁平命名挂载在根 Query/Mutation
* 与 teacher.resolver.ts 的 buildResolvers() 合并使用:
* const resolvers = mergeResolvers(buildResolvers(...), buildAdminResolvers(...))
*/
export function buildAdminResolvers(service: AdminService): IResolvers {
return {
Query: {
// ===== 用户与权限管理 =====
currentUser: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getCurrentUser(ctx.callCtx);
},
adminUsers: async (
_parent: unknown,
args: { filter?: unknown },
ctx: GraphQLOperationContext,
) => {
return service.listAdminUsers(ctx.callCtx, args.filter as never);
},
adminUser: async (
_parent: unknown,
args: { id: string },
ctx: GraphQLOperationContext,
) => {
return service.getAdminUser(ctx.callCtx, args.id);
},
adminRoles: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.listAdminRoles(ctx.callCtx);
},
adminPermissions: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.listAdminPermissions(ctx.callCtx);
},
adminViewports: async (
_parent: unknown,
args: { scope?: string },
ctx: GraphQLOperationContext,
) => {
return service.listAdminViewports(ctx.callCtx, args.scope);
},
// ===== 组织架构 =====
adminOrganization: async (
_parent: unknown,
args: { parentId?: string },
ctx: GraphQLOperationContext,
) => {
return service.listAdminOrganizations(ctx.callCtx, args.parentId);
},
adminClasses: async (
_parent: unknown,
args: { filter?: unknown },
ctx: GraphQLOperationContext,
) => {
return service.listAdminClasses(ctx.callCtx, args.filter as never);
},
adminTeachers: async (
_parent: unknown,
args: { filter?: unknown },
ctx: GraphQLOperationContext,
) => {
return service.listAdminTeachers(ctx.callCtx, args.filter as never);
},
adminStudents: async (
_parent: unknown,
args: { filter?: unknown },
ctx: GraphQLOperationContext,
) => {
return service.listAdminStudents(ctx.callCtx, args.filter as never);
},
// ===== 审计与日志 =====
auditLogs: async (
_parent: unknown,
args: { filter?: unknown },
ctx: GraphQLOperationContext,
) => {
return service.listAuditLogs(ctx.callCtx, args.filter as never);
},
auditOverviewStats: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getAuditOverviewStats(ctx.callCtx);
},
auditTrend: async (
_parent: unknown,
args: { days?: number },
ctx: GraphQLOperationContext,
) => {
return service.getAuditTrend(ctx.callCtx, args.days);
},
dataChangeLogs: async (
_parent: unknown,
args: { filter?: unknown },
ctx: GraphQLOperationContext,
) => {
return service.listDataChangeLogs(ctx.callCtx, args.filter as never);
},
loginLogs: async (
_parent: unknown,
args: { filter?: unknown },
ctx: GraphQLOperationContext,
) => {
return service.listLoginLogs(ctx.callCtx, args.filter as never);
},
// ===== 仪表盘与设置 =====
adminDashboard: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getAdminDashboard(ctx.callCtx);
},
systemSettings: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getSystemSettings(ctx.callCtx);
},
// ===== 学校 / 年级 / 部门 / 学年 =====
adminSchools: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.listAdminSchools(ctx.callCtx);
},
adminGrades: async (
_parent: unknown,
args: { schoolId?: string },
ctx: GraphQLOperationContext,
) => {
return service.listAdminGrades(ctx.callCtx, args.schoolId);
},
adminGradeInsights: async (
_parent: unknown,
args: { gradeId: string },
ctx: GraphQLOperationContext,
) => {
return service.getAdminGradeInsights(ctx.callCtx, args.gradeId);
},
adminDepartments: async (
_parent: unknown,
args: { schoolId?: string },
ctx: GraphQLOperationContext,
) => {
return service.listAdminDepartments(ctx.callCtx, args.schoolId);
},
adminAcademicYears: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.listAdminAcademicYears(ctx.callCtx);
},
// ===== 公告 / 邀请码 / 文件 / AI =====
adminAnnouncements: async (
_parent: unknown,
args: { filter?: unknown },
ctx: GraphQLOperationContext,
) => {
return service.listAdminAnnouncements(
ctx.callCtx,
args.filter as never,
);
},
adminInvitationCodes: async (
_parent: unknown,
args: { filter?: unknown },
ctx: GraphQLOperationContext,
) => {
return service.listAdminInvitationCodes(
ctx.callCtx,
args.filter as never,
);
},
adminFiles: async (
_parent: unknown,
args: { filter?: unknown },
ctx: GraphQLOperationContext,
) => {
return service.listAdminFiles(ctx.callCtx, args.filter as never);
},
adminFileStats: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getAdminFileStats(ctx.callCtx);
},
aiProviders: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.listAiProviders(ctx.callCtx);
},
aiUsage: async (
_parent: unknown,
_args: unknown,
ctx: GraphQLOperationContext,
) => {
return service.getAiUsage(ctx.callCtx);
},
},
Mutation: {
// ===== 用户与角色管理 =====
createUser: async (
_parent: unknown,
args: { input: unknown },
ctx: GraphQLOperationContext,
) => {
return service.createUser(ctx.callCtx, args.input as never);
},
updateUser: async (
_parent: unknown,
args: { id: string; input: unknown },
ctx: GraphQLOperationContext,
) => {
return service.updateUser(ctx.callCtx, args.id, args.input as never);
},
toggleUserStatus: async (
_parent: unknown,
args: { id: string; status: string },
ctx: GraphQLOperationContext,
) => {
return service.toggleUserStatus(ctx.callCtx, args.id, args.status);
},
createRole: async (
_parent: unknown,
args: { input: unknown },
ctx: GraphQLOperationContext,
) => {
return service.createRole(ctx.callCtx, args.input as never);
},
updateRolePermissions: async (
_parent: unknown,
args: { roleId: string; permissionCodes: string[] },
ctx: GraphQLOperationContext,
) => {
return service.updateRolePermissions(
ctx.callCtx,
args.roleId,
args.permissionCodes,
);
},
updateViewport: async (
_parent: unknown,
args: { id: string; input: unknown },
ctx: GraphQLOperationContext,
) => {
return service.updateViewport(
ctx.callCtx,
args.id,
args.input as never,
);
},
// ===== 系统设置 =====
updateSystemSettings: async (
_parent: unknown,
args: { input: unknown },
ctx: GraphQLOperationContext,
) => {
return service.updateSystemSettings(ctx.callCtx, args.input as never);
},
// ===== 公告管理 =====
createAnnouncement: async (
_parent: unknown,
args: { input: unknown },
ctx: GraphQLOperationContext,
) => {
return service.createAnnouncement(ctx.callCtx, args.input as never);
},
publishAnnouncement: async (
_parent: unknown,
args: { id: string },
ctx: GraphQLOperationContext,
) => {
return service.publishAnnouncement(ctx.callCtx, args.id);
},
archiveAnnouncement: async (
_parent: unknown,
args: { id: string },
ctx: GraphQLOperationContext,
) => {
return service.archiveAnnouncement(ctx.callCtx, args.id);
},
toggleAnnouncementPin: async (
_parent: unknown,
args: { id: string },
ctx: GraphQLOperationContext,
) => {
return service.toggleAnnouncementPin(ctx.callCtx, args.id);
},
// ===== 邀请码 =====
generateInvitationCodes: async (
_parent: unknown,
args: { input: unknown },
ctx: GraphQLOperationContext,
) => {
return service.generateInvitationCodes(
ctx.callCtx,
args.input as never,
);
},
deleteInvitationCodes: async (
_parent: unknown,
args: { ids: string[] },
ctx: GraphQLOperationContext,
) => {
return service.deleteInvitationCodes(ctx.callCtx, args.ids);
},
// ===== 文件管理 =====
deleteFiles: async (
_parent: unknown,
args: { ids: string[] },
ctx: GraphQLOperationContext,
) => {
return service.deleteFiles(ctx.callCtx, args.ids);
},
// ===== AI 提供商 =====
updateAiProvider: async (
_parent: unknown,
args: { id: string; input: unknown },
ctx: GraphQLOperationContext,
) => {
return service.updateAiProvider(
ctx.callCtx,
args.id,
args.input as never,
);
},
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
// AiClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P5 需要的 ai RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC
// 裁决依据B2首次实现即 gRPC+ B8DownstreamClient 抽象)
// 端口ai gRPC 50058contract §2.1.6 + port-allocation.md §5
import type { CallContext } from "../types.js";
import type {
ChatRequest,
ChatResponse,
StreamChatChunk,
GenerateQuestionRequest,
GeneratedQuestion,
OptimizeExpressionRequest,
OptimizedExpression,
} from "./ai.types.js";
/** AiClient DI tokenNestJS 注入用) */
export const AI_CLIENT = Symbol("AI_CLIENT");
/** AiClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
export interface AiClient {
// ===== AiService =====
/** 非流式聊天 */
chat(ctx: CallContext, req: ChatRequest): Promise<ChatResponse>;
/** 流式聊天SSE over gRPC返回 AsyncIterable */
streamChat(
ctx: CallContext,
req: ChatRequest,
): Promise<AsyncIterable<StreamChatChunk>>;
/** 生成题目 */
generateQuestion(
ctx: CallContext,
req: GenerateQuestionRequest,
): Promise<GeneratedQuestion>;
/** 优化表达 */
optimizeExpression(
ctx: CallContext,
req: OptimizeExpressionRequest,
): Promise<OptimizedExpression>;
// ===== 健康检查 =====
checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}>;
}

View File

@@ -0,0 +1,256 @@
// AiClient gRPC 实现B2 裁决:首次实现即 gRPC
// ai.proto 现状 4 RPCAiService.Chat / StreamChat / GenerateQuestion / OptimizeExpression
// StreamChat 为 server streaming RPC返回 AsyncIterable<StreamChatChunk>
// proto 字段是 snake_casegRPC 返回需转 camelCase
// 裁决依据B2首次实现即 gRPC+ B8DownstreamClient 抽象)
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 { AiClient } from "./ai-client.interface.js";
import type {
ChatRequest,
ChatResponse,
StreamChatChunk,
GenerateQuestionRequest,
GeneratedQuestion,
OptimizeExpressionRequest,
OptimizedExpression,
} from "./ai.types.js";
/** proto Usage message → BFF Usagesnake_case → camelCase */
function mapUsage(raw: Record<string, unknown>): ChatResponse["usage"] {
return {
promptTokens: Number(raw.prompt_tokens ?? 0),
completionTokens: Number(raw.completion_tokens ?? 0),
totalTokens: Number(raw.total_tokens ?? 0),
latencyMs: Number(raw.latency_ms ?? 0),
};
}
/** proto ChatResponse message → BFF ChatResponse */
function mapChatResponse(raw: Record<string, unknown>): ChatResponse {
const usageRaw = (raw.usage ?? {}) as Record<string, unknown>;
return {
content: String(raw.content ?? ""),
model: String(raw.model ?? ""),
usage: mapUsage(usageRaw),
degraded: Boolean(raw.degraded),
degradedReason: String(raw.degraded_reason ?? ""),
};
}
/** proto ChatChunk message → BFF StreamChatChunk */
function mapStreamChatChunk(raw: Record<string, unknown>): StreamChatChunk {
return {
content: String(raw.content ?? ""),
done: Boolean(raw.done),
};
}
/** proto GeneratedQuestion message → BFF GeneratedQuestion */
function mapGeneratedQuestion(raw: Record<string, unknown>): GeneratedQuestion {
return {
question: String(raw.question ?? ""),
answer: String(raw.answer ?? ""),
explanation: String(raw.explanation ?? ""),
questionType: String(raw.question_type ?? ""),
difficulty: String(raw.difficulty ?? ""),
knowledgePointIds: (raw.knowledge_point_ids ?? []) as string[],
evaluationScore:
raw.evaluation_score != null ? Number(raw.evaluation_score) : undefined,
degraded: Boolean(raw.degraded),
degradedReason: String(raw.degraded_reason ?? ""),
};
}
/** proto OptimizedExpression message → BFF OptimizedExpression */
function mapOptimizedExpression(
raw: Record<string, unknown>,
): OptimizedExpression {
return {
optimized: String(raw.optimized ?? ""),
suggestions: (raw.suggestions ?? []) as string[],
degraded: Boolean(raw.degraded),
degradedReason: String(raw.degraded_reason ?? ""),
};
}
@Injectable()
export class AiGrpcClient extends BaseDownstreamClient implements AiClient {
readonly serviceName = "ai" as const;
constructor() {
super();
this.initLogger();
}
// ===== AiService =====
async chat(ctx: CallContext, req: ChatRequest): Promise<ChatResponse> {
return this.callGrpc("Chat", async () => {
const client = getGrpcClient("ai", "AiService") as unknown as {
chat(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<ChatResponse>((resolve, reject) => {
client.chat(
{
messages: req.messages.map((m) => ({
role: m.role,
content: m.content,
})),
model: req.model,
temperature: req.temperature,
user_id: req.userId ?? "",
session_id: req.sessionId ?? "",
data_scope: req.dataScope ?? "",
},
meta,
(err, res) => {
if (err) reject(err);
else resolve(mapChatResponse(res));
},
);
});
});
}
async streamChat(
ctx: CallContext,
req: ChatRequest,
): Promise<AsyncIterable<StreamChatChunk>> {
const client = getGrpcClient("ai", "AiService") as unknown as {
streamChat(
req: Record<string, unknown>,
meta: grpc.Metadata,
): grpc.ClientReadableStream<Record<string, unknown>>;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
const stream = client.streamChat(
{
messages: req.messages.map((m) => ({
role: m.role,
content: m.content,
})),
model: req.model,
temperature: req.temperature,
user_id: req.userId ?? "",
session_id: req.sessionId ?? "",
data_scope: req.dataScope ?? "",
},
meta,
);
this.log.debug(
{ rpc: "StreamChat", userId: ctx.userId, model: req.model },
"Downstream gRPC stream opened",
);
return this.wrapStream(stream, "StreamChat");
}
async generateQuestion(
ctx: CallContext,
req: GenerateQuestionRequest,
): Promise<GeneratedQuestion> {
return this.callGrpc("GenerateQuestion", async () => {
const client = getGrpcClient("ai", "AiService") as unknown as {
generateQuestion(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<GeneratedQuestion>((resolve, reject) => {
client.generateQuestion(
{
prompt: req.prompt,
subject: req.subject,
difficulty: req.difficulty,
grade: req.grade ?? "",
knowledge_point_ids: req.knowledgePointIds,
question_type: req.questionType ?? "",
count: req.count ?? 1,
},
meta,
(err, res) => {
if (err) reject(err);
else resolve(mapGeneratedQuestion(res));
},
);
});
});
}
async optimizeExpression(
ctx: CallContext,
req: OptimizeExpressionRequest,
): Promise<OptimizedExpression> {
return this.callGrpc("OptimizeExpression", async () => {
const client = getGrpcClient("ai", "AiService") as unknown as {
optimizeExpression(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<OptimizedExpression>((resolve, reject) => {
client.optimizeExpression(
{
text: req.text,
context: req.context,
},
meta,
(err, res) => {
if (err) reject(err);
else resolve(mapOptimizedExpression(res));
},
);
});
});
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return checkGrpcHealth("ai");
}
/**
* 将 gRPC server streaming ClientReadableStream 包装为 AsyncIterable
* 错误统一映射为 BadGatewayError通过 mapGrpcError
*/
private async *wrapStream(
stream: grpc.ClientReadableStream<Record<string, unknown>>,
rpc: string,
): AsyncIterable<StreamChatChunk> {
try {
for await (const raw of stream) {
yield mapStreamChatChunk(raw);
}
} catch (err) {
throw this.mapGrpcError(err, rpc);
}
}
}

View File

@@ -0,0 +1,124 @@
// AiClient Mock 实现B8 裁决:上游就绪前的降级策略)
// DEV_MODE=true 时全部走 mockDEV_MODE=false 时走 gRPC
// mock 数据对齐 contract §4.2 mock 策略
// 裁决依据B2首次实现即 gRPC+ B8DownstreamClient 抽象)
import { Injectable } from "@nestjs/common";
import { BaseDownstreamClient } from "../base.client.js";
import type { CallContext } from "../types.js";
import type { AiClient } from "./ai-client.interface.js";
import type {
ChatRequest,
ChatResponse,
StreamChatChunk,
GenerateQuestionRequest,
GeneratedQuestion,
OptimizeExpressionRequest,
OptimizedExpression,
} from "./ai.types.js";
/** mock 聊天响应contract §4.2:返回固定回复) */
const MOCK_CHAT_RESPONSE: ChatResponse = {
content: "您好,我是 AI 助教,可以帮您解答教学相关问题。",
model: "mock-llm-v1",
usage: {
promptTokens: 32,
completionTokens: 18,
totalTokens: 50,
latencyMs: 120,
},
degraded: false,
degradedReason: "",
};
/** mock 流式聊天 chunkcontract §4.2:流式 mock3 个 chunk */
const MOCK_STREAM_CHUNKS: StreamChatChunk[] = [
{ content: "您好,", done: false },
{ content: "我是 AI 助教,", done: false },
{ content: "可以帮您解答教学相关问题。", done: true },
];
/** mock 生成的题目contract §4.2:返回固定题目) */
const MOCK_GENERATED_QUESTION: GeneratedQuestion = {
question:
"下列哪个选项是分数加减法的结果?\n1/2 + 1/3 = ?\nA. 2/5 B. 5/6 C. 1/6 D. 2/6",
answer: "B. 5/6",
explanation: "1/2 + 1/3 = 3/6 + 2/6 = 5/6分数加减需先通分再加减。",
questionType: "single_choice",
difficulty: "medium",
knowledgePointIds: ["kp-001"],
evaluationScore: 0.92,
degraded: false,
degradedReason: "",
};
/** mock 优化后的表达contract §4.2:返回固定优化结果) */
const MOCK_OPTIMIZED_EXPRESSION: OptimizedExpression = {
optimized: "请同学们思考1/2 与 1/3 相加时,为什么需要先通分?",
suggestions: [
"使用开放式提问激发学生思考",
"将抽象概念与学生已有经验关联",
'增加"为什么"引导深度理解',
],
degraded: false,
degradedReason: "",
};
@Injectable()
export class AiMockClient extends BaseDownstreamClient implements AiClient {
readonly serviceName = "ai" as const;
constructor() {
super();
this.initLogger();
}
async chat(ctx: CallContext, req: ChatRequest): Promise<ChatResponse> {
this.log.debug({ userId: ctx.userId, model: req.model }, "Mock chat");
return MOCK_CHAT_RESPONSE;
}
async streamChat(
ctx: CallContext,
req: ChatRequest,
): Promise<AsyncIterable<StreamChatChunk>> {
this.log.debug({ userId: ctx.userId, model: req.model }, "Mock streamChat");
return this.mockStream();
}
async generateQuestion(
ctx: CallContext,
req: GenerateQuestionRequest,
): Promise<GeneratedQuestion> {
this.log.debug(
{ userId: ctx.userId, subject: req.subject, difficulty: req.difficulty },
"Mock generateQuestion",
);
return MOCK_GENERATED_QUESTION;
}
async optimizeExpression(
ctx: CallContext,
req: OptimizeExpressionRequest,
): Promise<OptimizedExpression> {
this.log.debug(
{ userId: ctx.userId, text: req.text },
"Mock optimizeExpression",
);
return MOCK_OPTIMIZED_EXPRESSION;
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return { serving: true, latencyMs: 0 };
}
/** mock 流式生成器yield 3 个 chunk模拟 SSE */
private async *mockStream(): AsyncIterable<StreamChatChunk> {
for (const chunk of MOCK_STREAM_CHUNKS) {
yield chunk;
}
}
}

View File

@@ -0,0 +1,34 @@
// ai 模块B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
// 端口ai gRPC 50058contract §2.1.6 + port-allocation.md §5
// 裁决依据B2首次实现即 gRPC+ B8DownstreamClient 抽象)
import { Module } from "@nestjs/common";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import { AI_CLIENT } from "./ai-client.interface.js";
import { AiGrpcClient } from "./ai-grpc.client.js";
import { AiMockClient } from "./ai-mock.client.js";
@Module({
providers: [
{
provide: AI_CLIENT,
useFactory: () => {
// DEV_MODE=true 或 AI_GRPC_TARGET 未配置 → 使用 mock
if (env.TEACHER_BFF_DEV_MODE || !env.AI_GRPC_TARGET) {
logger.warn(
{ devMode: env.TEACHER_BFF_DEV_MODE, target: env.AI_GRPC_TARGET },
"AiClient using mock (DEV_MODE or target not configured)",
);
return new AiMockClient();
}
logger.info(
{ devMode: false, target: env.AI_GRPC_TARGET },
"AiClient using gRPC",
);
return new AiGrpcClient();
},
},
],
exports: [AI_CLIENT],
})
export class AiModule {}

View File

@@ -0,0 +1,80 @@
// ai 下游服务类型定义(对齐 ai.proto
// ai.proto 现状4 RPCAiService.Chat / StreamChat / GenerateQuestion / OptimizeExpression
// 待 coord 补全GenerateLessonPlan + StreamGenerateQuestionA4 裁决)
// 裁决依据B2首次实现即 gRPC+ B8DownstreamClient 抽象)
/** 聊天消息(对齐 ai.proto ChatMessage message */
export interface ChatMessage {
role: string; // system / user / assistant
content: string;
}
/** Token 用量(对齐 ai.proto Usage message */
export interface Usage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
latencyMs: number;
}
/** 聊天响应(对齐 ai.proto ChatResponse message */
export interface ChatResponse {
content: string;
model: string;
usage: Usage;
degraded: boolean;
degradedReason: string;
}
/** 流式聊天 chunk对齐 ai.proto ChatChunk messageStreamChat RPC 返回) */
export interface StreamChatChunk {
content: string;
done: boolean;
}
/** 生成的题目(对齐 ai.proto GeneratedQuestion message */
export interface GeneratedQuestion {
question: string;
answer: string;
explanation: string;
questionType: string;
difficulty: string;
knowledgePointIds: string[];
evaluationScore?: number;
degraded: boolean;
degradedReason: string;
}
/** 优化后的表达(对齐 ai.proto OptimizedExpression message */
export interface OptimizedExpression {
optimized: string;
suggestions: string[];
degraded: boolean;
degradedReason: string;
}
// ===== Request 类型 =====
export interface ChatRequest {
messages: ChatMessage[];
model: string;
temperature: number;
userId?: string;
sessionId?: string;
dataScope?: string;
}
export interface GenerateQuestionRequest {
prompt: string;
subject: string;
difficulty: string; // easy / medium / hard
grade?: string;
knowledgePointIds: string[];
questionType?: string; // single_choice / multi_choice / fill_blank / short_answer / essay
count?: number;
}
export interface OptimizeExpressionRequest {
text: string;
context: string;
}

View File

@@ -1,10 +1,30 @@
// DownstreamClient 聚合模块B8 裁决3 个 BFF 统一使用) // DownstreamClient 聚合模块B8 裁决3 个 BFF 统一使用)
// P2 仅注册 IamModuleP3+ 扩展 CoreEduModule / ContentModule / DataAnaModule / MsgModule / AiModule // 注册全部 6 个客户端模块iam + core-edu + content + data-ana + msg + ai
// 未配置 gRPC target 的客户端自动降级为 mock降级模式 B
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { IamModule } from "./iam/iam.module.js"; import { IamModule } from "./iam/iam.module.js";
import { CoreEduModule } from "./core-edu/core-edu.module.js";
import { ContentModule } from "./content/content.module.js";
import { DataAnaModule } from "./data-ana/data-ana.module.js";
import { MsgModule } from "./msg/msg.module.js";
import { AiModule } from "./ai/ai.module.js";
@Module({ @Module({
imports: [IamModule], imports: [
exports: [IamModule], IamModule,
CoreEduModule,
ContentModule,
DataAnaModule,
MsgModule,
AiModule,
],
exports: [
IamModule,
CoreEduModule,
ContentModule,
DataAnaModule,
MsgModule,
AiModule,
],
}) })
export class ClientsModule {} export class ClientsModule {}

View File

@@ -0,0 +1,67 @@
// ContentClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P4+ 需要的 content RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC未就绪 RPC 降级 mock + warning
import type { CallContext } from "../types.js";
import type {
Textbook,
Chapter,
LearningPath,
ListTextbooksRequest,
ListTextbooksResponse,
CreateTextbookRequest,
CreateTextbookResponse,
GetPrerequisitesRequest,
GetPrerequisitesResponse,
GetLearningPathRequest,
ListChaptersRequest,
ListChaptersResponse,
SearchQuestionsRequest,
SearchQuestionsResponse,
} from "./content.types.js";
/** ContentClient DI tokenNestJS 注入用) */
export const CONTENT_CLIENT = Symbol("CONTENT_CLIENT");
/** ContentClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
export interface ContentClient {
// ===== TextbookService✅ 已就绪) =====
listTextbooks(
ctx: CallContext,
req: ListTextbooksRequest,
): Promise<ListTextbooksResponse>;
getTextbook(ctx: CallContext, id: string): Promise<Textbook>;
createTextbook(
ctx: CallContext,
req: CreateTextbookRequest,
): Promise<CreateTextbookResponse>;
// ===== KnowledgeGraphService✅ 已就绪) =====
getPrerequisites(
ctx: CallContext,
req: GetPrerequisitesRequest,
): Promise<GetPrerequisitesResponse>;
getLearningPath(
ctx: CallContext,
req: GetLearningPathRequest,
): Promise<LearningPath>;
// ===== ChapterService❌ 未就绪,待 coord 补 N5 =====
listChapters(
ctx: CallContext,
req: ListChaptersRequest,
): Promise<ListChaptersResponse>;
getChapter(ctx: CallContext, id: string): Promise<Chapter>;
// ===== QuestionService❌ 未就绪,待 coord 补 N3 =====
searchQuestions(
ctx: CallContext,
req: SearchQuestionsRequest,
): Promise<SearchQuestionsResponse>;
// ===== 健康检查 =====
checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}>;
}

View File

@@ -0,0 +1,301 @@
// ContentClient gRPC 实现B2 裁决:首次实现即 gRPC
// content.proto 现状TextbookService 3 + KnowledgeGraphService 2 已就绪
// 未就绪 RPCChapterService.ListChapters / GetChapter + QuestionService.SearchQuestions降级 mock + warning
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 { ContentClient } from "./content-client.interface.js";
import type {
Textbook,
Chapter,
KnowledgePoint,
LearningPath,
ListTextbooksRequest,
ListTextbooksResponse,
CreateTextbookRequest,
CreateTextbookResponse,
GetPrerequisitesRequest,
GetPrerequisitesResponse,
GetLearningPathRequest,
ListChaptersRequest,
ListChaptersResponse,
SearchQuestionsRequest,
SearchQuestionsResponse,
} from "./content.types.js";
import { ContentMockClient } from "./content-mock.client.js";
/** proto message 字段是 snake_casegRPC 返回需转 camelCase */
function mapTextbook(raw: Record<string, unknown>): Textbook {
return {
id: String(raw.id ?? ""),
title: String(raw.title ?? ""),
subjectId: String(raw.subject_id ?? ""),
gradeId: String(raw.grade_id ?? ""),
version: String(raw.version ?? ""),
status: String(raw.status ?? ""),
tenantId: String(raw.tenant_id ?? ""),
createdAt: String(raw.created_at ?? ""),
updatedAt: String(raw.updated_at ?? ""),
};
}
function mapKnowledgePoint(raw: Record<string, unknown>): KnowledgePoint {
return {
id: String(raw.id ?? ""),
chapterId: String(raw.chapter_id ?? ""),
title: String(raw.title ?? ""),
description: String(raw.description ?? ""),
difficulty: Number(raw.difficulty ?? 0),
createdAt: String(raw.created_at ?? ""),
updatedAt: String(raw.updated_at ?? ""),
};
}
@Injectable()
export class ContentGrpcClient
extends BaseDownstreamClient
implements ContentClient
{
readonly serviceName = "content" as const;
private readonly mock: ContentMockClient;
constructor() {
super();
this.initLogger();
this.mock = new ContentMockClient();
}
// ===== TextbookService✅ 已就绪) =====
async listTextbooks(
ctx: CallContext,
req: ListTextbooksRequest,
): Promise<ListTextbooksResponse> {
return this.callGrpc("ListTextbooks", async () => {
const client = getGrpcClient("content", "TextbookService") as unknown as {
listTextbooks(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { textbooks?: unknown[]; next_page_token?: string },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<ListTextbooksResponse>((resolve, reject) => {
client.listTextbooks(
{
subject_id: req.subjectId ?? "",
grade_id: req.gradeId ?? "",
page_token: req.pageToken ?? "",
page_size: req.pageSize ?? 0,
},
meta,
(err, res) => {
if (err) reject(err);
else {
const textbooks = (res.textbooks ?? []) as Record<
string,
unknown
>[];
resolve({
textbooks: textbooks.map(mapTextbook),
nextPageToken: String(res.next_page_token ?? ""),
});
}
},
);
});
});
}
async getTextbook(ctx: CallContext, id: string): Promise<Textbook> {
return this.callGrpc("GetTextbook", async () => {
const client = getGrpcClient("content", "TextbookService") as unknown as {
getTextbook(
req: { id: string },
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<Textbook>((resolve, reject) => {
client.getTextbook({ id }, meta, (err, res) => {
if (err) reject(err);
else resolve(mapTextbook(res));
});
});
});
}
async createTextbook(
ctx: CallContext,
req: CreateTextbookRequest,
): Promise<CreateTextbookResponse> {
return this.callGrpc("CreateTextbook", async () => {
const client = getGrpcClient("content", "TextbookService") as unknown as {
createTextbook(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<CreateTextbookResponse>((resolve, reject) => {
client.createTextbook(
{
title: req.title,
subject_id: req.subjectId,
grade_id: req.gradeId,
version: req.version,
},
meta,
(err, res) => {
if (err) reject(err);
else resolve({ id: String(res.id ?? "") });
},
);
});
});
}
// ===== KnowledgeGraphService✅ 已就绪) =====
async getPrerequisites(
ctx: CallContext,
req: GetPrerequisitesRequest,
): Promise<GetPrerequisitesResponse> {
return this.callGrpc("GetPrerequisites", async () => {
const client = getGrpcClient(
"content",
"KnowledgeGraphService",
) as unknown as {
getPrerequisites(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { points?: unknown[] },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<GetPrerequisitesResponse>((resolve, reject) => {
client.getPrerequisites(
{
knowledge_point_id: req.knowledgePointId,
depth: req.depth ?? 1,
},
meta,
(err, res) => {
if (err) reject(err);
else {
const points = (res.points ?? []) as Record<string, unknown>[];
resolve({ points: points.map(mapKnowledgePoint) });
}
},
);
});
});
}
async getLearningPath(
ctx: CallContext,
req: GetLearningPathRequest,
): Promise<LearningPath> {
return this.callGrpc("GetLearningPath", async () => {
const client = getGrpcClient(
"content",
"KnowledgeGraphService",
) as unknown as {
getLearningPath(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { points?: unknown[]; recommended_order?: unknown[] },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<LearningPath>((resolve, reject) => {
client.getLearningPath(
{
student_id: req.studentId,
subject_id: req.subjectId,
},
meta,
(err, res) => {
if (err) reject(err);
else {
const points = (res.points ?? []) as Record<string, unknown>[];
const order = (res.recommended_order ?? []) as string[];
resolve({
points: points.map(mapKnowledgePoint),
recommendedOrder: order,
});
}
},
);
});
});
}
// ===== ChapterService❌ 未就绪,待 coord 补 N5降级 mock + warning =====
async listChapters(
ctx: CallContext,
req: ListChaptersRequest,
): Promise<ListChaptersResponse> {
this.log.warn(
{ rpc: "ListChapters", reason: "ChapterService not ready (N5 pending)" },
"Downstream RPC not ready, falling back to mock",
);
return this.mock.listChapters(ctx, req);
}
async getChapter(ctx: CallContext, id: string): Promise<Chapter> {
this.log.warn(
{ rpc: "GetChapter", reason: "ChapterService not ready (N5 pending)" },
"Downstream RPC not ready, falling back to mock",
);
return this.mock.getChapter(ctx, id);
}
// ===== QuestionService❌ 未就绪,待 coord 补 N3降级 mock + warning =====
async searchQuestions(
ctx: CallContext,
req: SearchQuestionsRequest,
): Promise<SearchQuestionsResponse> {
this.log.warn(
{
rpc: "SearchQuestions",
reason: "QuestionService not ready (N3 pending)",
},
"Downstream RPC not ready, falling back to mock",
);
return this.mock.searchQuestions(ctx, req);
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return checkGrpcHealth("content");
}
}

View File

@@ -0,0 +1,267 @@
// ContentClient Mock 实现B8 裁决:上游就绪前的降级策略)
// DEV_MODE=true 时全部走 mockDEV_MODE=false 时仅未就绪 RPC 走 mock
// mock 数据对齐 contract §4.2 mock 策略
import { Injectable } from "@nestjs/common";
import { BaseDownstreamClient } from "../base.client.js";
import type { CallContext } from "../types.js";
import type { ContentClient } from "./content-client.interface.js";
import type {
Textbook,
Chapter,
KnowledgePoint,
LearningPath,
ListTextbooksRequest,
ListTextbooksResponse,
CreateTextbookRequest,
CreateTextbookResponse,
GetPrerequisitesRequest,
GetPrerequisitesResponse,
GetLearningPathRequest,
ListChaptersRequest,
ListChaptersResponse,
SearchQuestionsRequest,
SearchQuestionsResponse,
} from "./content.types.js";
/** mock 教材数据contract §4.2:返回固定 5 个教材) */
const MOCK_TEXTBOOKS: Textbook[] = [
{
id: "tb-001",
title: "语文三年级上册",
subjectId: "subject-chinese",
gradeId: "grade-3",
version: "v1",
status: "PUBLISHED",
tenantId: "tenant-001",
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-01T00:00:00Z",
},
{
id: "tb-002",
title: "数学三年级上册",
subjectId: "subject-math",
gradeId: "grade-3",
version: "v1",
status: "PUBLISHED",
tenantId: "tenant-001",
createdAt: "2026-07-02T00:00:00Z",
updatedAt: "2026-07-02T00:00:00Z",
},
{
id: "tb-003",
title: "英语三年级上册",
subjectId: "subject-english",
gradeId: "grade-3",
version: "v1",
status: "PUBLISHED",
tenantId: "tenant-001",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z",
},
{
id: "tb-004",
title: "科学三年级上册",
subjectId: "subject-science",
gradeId: "grade-3",
version: "v1",
status: "DRAFT",
tenantId: "tenant-001",
createdAt: "2026-07-04T00:00:00Z",
updatedAt: "2026-07-04T00:00:00Z",
},
{
id: "tb-005",
title: "道德与法治三年级上册",
subjectId: "subject-moral",
gradeId: "grade-3",
version: "v1",
status: "PUBLISHED",
tenantId: "tenant-001",
createdAt: "2026-07-05T00:00:00Z",
updatedAt: "2026-07-05T00:00:00Z",
},
];
/** mock 知识点数据contract §4.2:返回固定知识点依赖) */
const MOCK_KNOWLEDGE_POINTS: KnowledgePoint[] = [
{
id: "kp-001",
chapterId: "ch-001",
title: "加法运算",
description: "三位数加法",
difficulty: 1,
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-01T00:00:00Z",
},
{
id: "kp-002",
chapterId: "ch-001",
title: "减法运算",
description: "三位数减法",
difficulty: 1,
createdAt: "2026-07-02T00:00:00Z",
updatedAt: "2026-07-02T00:00:00Z",
},
{
id: "kp-003",
chapterId: "ch-002",
title: "乘法口诀",
description: "九九乘法表",
difficulty: 2,
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z",
},
{
id: "kp-004",
chapterId: "ch-002",
title: "除法运算",
description: "表内除法",
difficulty: 2,
createdAt: "2026-07-04T00:00:00Z",
updatedAt: "2026-07-04T00:00:00Z",
},
{
id: "kp-005",
chapterId: "ch-003",
title: "分数初步",
description: "认识分数",
difficulty: 3,
createdAt: "2026-07-05T00:00:00Z",
updatedAt: "2026-07-05T00:00:00Z",
},
];
/** mock 学习路径contract §4.2:返回固定学习路径) */
const MOCK_LEARNING_PATH: LearningPath = {
points: MOCK_KNOWLEDGE_POINTS,
recommendedOrder: ["kp-001", "kp-002", "kp-003", "kp-004", "kp-005"],
};
@Injectable()
export class ContentMockClient
extends BaseDownstreamClient
implements ContentClient
{
readonly serviceName = "content" as const;
constructor() {
super();
this.initLogger();
}
// ===== TextbookService✅ 已就绪) =====
async listTextbooks(
ctx: CallContext,
req: ListTextbooksRequest,
): Promise<ListTextbooksResponse> {
this.log.debug({ userId: ctx.userId, req }, "Mock listTextbooks");
let textbooks = MOCK_TEXTBOOKS;
if (req.subjectId) {
textbooks = textbooks.filter((t) => t.subjectId === req.subjectId);
}
if (req.gradeId) {
textbooks = textbooks.filter((t) => t.gradeId === req.gradeId);
}
return { textbooks, nextPageToken: "" };
}
async getTextbook(ctx: CallContext, id: string): Promise<Textbook> {
this.log.debug({ userId: ctx.userId, id }, "Mock getTextbook");
return MOCK_TEXTBOOKS[0]!;
}
async createTextbook(
_ctx: CallContext,
req: CreateTextbookRequest,
): Promise<CreateTextbookResponse> {
this.log.debug({ req }, "Mock createTextbook");
return { id: `tb-${Date.now()}` };
}
// ===== KnowledgeGraphService✅ 已就绪) =====
async getPrerequisites(
ctx: CallContext,
req: GetPrerequisitesRequest,
): Promise<GetPrerequisitesResponse> {
this.log.debug({ userId: ctx.userId, req }, "Mock getPrerequisites");
return { points: MOCK_KNOWLEDGE_POINTS };
}
async getLearningPath(
ctx: CallContext,
req: GetLearningPathRequest,
): Promise<LearningPath> {
this.log.debug({ userId: ctx.userId, req }, "Mock getLearningPath");
return MOCK_LEARNING_PATH;
}
// ===== ChapterService❌ 未就绪,待 coord 补 N5返回空数组 + warning =====
async listChapters(
ctx: CallContext,
req: ListChaptersRequest,
): Promise<ListChaptersResponse> {
this.log.warn(
{
userId: ctx.userId,
req,
rpc: "ListChapters",
reason: "ChapterService not ready (N5 pending)",
},
"Mock listChapters: RPC not ready, returning empty",
);
return { chapters: [] };
}
async getChapter(ctx: CallContext, id: string): Promise<Chapter> {
this.log.warn(
{
userId: ctx.userId,
id,
rpc: "GetChapter",
reason: "ChapterService not ready (N5 pending)",
},
"Mock getChapter: RPC not ready, returning empty",
);
return {
id: "",
textbookId: "",
title: "",
order: 0,
parentId: "",
status: "",
createdAt: "",
updatedAt: "",
};
}
// ===== QuestionService❌ 未就绪,待 coord 补 N3返回空数组 + warning =====
async searchQuestions(
ctx: CallContext,
req: SearchQuestionsRequest,
): Promise<SearchQuestionsResponse> {
this.log.warn(
{
userId: ctx.userId,
req,
rpc: "SearchQuestions",
reason: "QuestionService not ready (N3 pending)",
},
"Mock searchQuestions: RPC not ready, returning empty",
);
return { questions: [], total: 0, nextPageToken: "" };
}
// ===== 健康检查 =====
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return { serving: true, latencyMs: 0 };
}
}

View File

@@ -0,0 +1,35 @@
// content 模块B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
import { Module } from "@nestjs/common";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import { CONTENT_CLIENT } from "./content-client.interface.js";
import { ContentGrpcClient } from "./content-grpc.client.js";
import { ContentMockClient } from "./content-mock.client.js";
@Module({
providers: [
{
provide: CONTENT_CLIENT,
useFactory: () => {
// DEV_MODE=true 或 CONTENT_GRPC_TARGET 未配置 → 使用 mock
if (env.TEACHER_BFF_DEV_MODE || !env.CONTENT_GRPC_TARGET) {
logger.warn(
{
devMode: env.TEACHER_BFF_DEV_MODE,
target: env.CONTENT_GRPC_TARGET,
},
"ContentClient using mock (DEV_MODE or target not configured)",
);
return new ContentMockClient();
}
logger.info(
{ devMode: false, target: env.CONTENT_GRPC_TARGET },
"ContentClient using gRPC",
);
return new ContentGrpcClient();
},
},
],
exports: [CONTENT_CLIENT],
})
export class ContentModule {}

View File

@@ -0,0 +1,139 @@
// content 下游服务类型定义(对齐 content.proto
// content.proto 现状4 ServiceTextbookService 5 + ChapterService 5 + KnowledgeGraphService 4 + QuestionService 8
// 待 coord 补全ChapterServiceN5 裁决)+ QuestionServiceN3 裁决)服务端实现
/** 教材(对齐 content.proto Textbook message */
export interface Textbook {
id: string;
title: string;
subjectId: string;
gradeId: string;
version: string;
status: string;
tenantId: string;
metadata?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
/** 章节(对齐 content.proto Chapter message */
export interface Chapter {
id: string;
textbookId: string;
title: string;
order: number;
parentId: string;
status: string;
createdAt: string;
updatedAt: string;
}
/** 知识点(对齐 content.proto KnowledgePoint message */
export interface KnowledgePoint {
id: string;
chapterId: string;
title: string;
description: string;
difficulty: number;
metadata?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
/** 题目(对齐 content.proto Question message */
export interface Question {
id: string;
knowledgePointId: string;
type: string;
content: string;
options?: Record<string, unknown>;
answer: string;
explanation: string;
difficulty: number;
status: string;
source: string;
createdBy: string;
metadata?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
/** 学习路径(对齐 content.proto LearningPath message */
export interface LearningPath {
points: KnowledgePoint[];
recommendedOrder: string[];
}
// ===== Request 类型 =====
export interface ListTextbooksRequest {
subjectId?: string;
gradeId?: string;
pageToken?: string;
pageSize?: number;
}
export interface GetTextbookRequest {
id: string;
}
export interface CreateTextbookRequest {
title: string;
subjectId: string;
gradeId: string;
version: string;
metadata?: Record<string, unknown>;
}
export interface GetPrerequisitesRequest {
knowledgePointId: string;
depth?: number;
}
export interface GetLearningPathRequest {
studentId: string;
subjectId: string;
}
export interface ListChaptersRequest {
textbookId: string;
parentId?: string;
}
export interface GetChapterRequest {
id: string;
}
export interface SearchQuestionsRequest {
q: string;
type?: string;
difficulty?: number;
knowledgePointId?: string;
pageToken?: string;
pageSize?: number;
}
// ===== Response 类型 =====
export interface ListTextbooksResponse {
textbooks: Textbook[];
nextPageToken: string;
}
export interface CreateTextbookResponse {
id: string;
}
export interface GetPrerequisitesResponse {
points: KnowledgePoint[];
}
export interface ListChaptersResponse {
chapters: Chapter[];
}
export interface SearchQuestionsResponse {
questions: Question[];
total: number;
nextPageToken: string;
}

View File

@@ -0,0 +1,95 @@
// CoreEduClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P3+ 需要的 core-edu RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC未就绪 RPC 降级 mock + warning
import type { CallContext } from "../types.js";
import type {
Exam,
Homework,
Grade,
ClassInfo,
StudentInfo,
CreateExamRequest,
CreateExamResponse,
UpdateExamRequest,
UpdateExamResponse,
DeleteExamResponse,
AssignHomeworkRequest,
AssignHomeworkResponse,
SubmitHomeworkResponse,
RecordGradeRequest,
RecordGradeResponse,
ListExamsResponse,
ListHomeworkResponse,
ListGradesResponse,
} from "./core-edu.types.js";
/** CoreEduClient DI tokenNestJS 注入用) */
export const CORE_EDU_CLIENT = Symbol("CORE_EDU_CLIENT");
/** CoreEduClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
export interface CoreEduClient {
// ===== ExamService =====
createExam(
ctx: CallContext,
req: CreateExamRequest,
): Promise<CreateExamResponse>;
getExam(ctx: CallContext, id: string): Promise<Exam>;
listExamsByClass(
ctx: CallContext,
classId: string,
): Promise<ListExamsResponse>;
updateExam(
ctx: CallContext,
req: UpdateExamRequest,
): Promise<UpdateExamResponse>;
deleteExam(ctx: CallContext, id: string): Promise<DeleteExamResponse>;
// ===== HomeworkService =====
assignHomework(
ctx: CallContext,
req: AssignHomeworkRequest,
): Promise<AssignHomeworkResponse>;
getHomework(ctx: CallContext, id: string): Promise<Homework>;
listHomeworkByClass(
ctx: CallContext,
classId: string,
): Promise<ListHomeworkResponse>;
submitHomework(ctx: CallContext, id: string): Promise<SubmitHomeworkResponse>;
// ===== GradeService =====
recordGrade(
ctx: CallContext,
req: RecordGradeRequest,
): Promise<RecordGradeResponse>;
getGrade(ctx: CallContext, id: string): Promise<Grade>;
listGradesByStudent(
ctx: CallContext,
studentId: string,
): Promise<ListGradesResponse>;
listGradesByExam(
ctx: CallContext,
examId: string,
): Promise<ListGradesResponse>;
listGradesByHomework(
ctx: CallContext,
homeworkId: string,
): Promise<ListGradesResponse>;
// ===== ClassService待 coord 补全,未就绪) =====
getClassesByTeacher(
ctx: CallContext,
teacherId: string,
): Promise<ClassInfo[]>;
listStudentsByClass(
ctx: CallContext,
classId: string,
): Promise<StudentInfo[]>;
batchGetClasses(ctx: CallContext, classIds: string[]): Promise<ClassInfo[]>;
// ===== 健康检查 =====
checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}>;
}

View File

@@ -0,0 +1,561 @@
// CoreEduClient gRPC 实现B2 裁决:首次实现即 gRPC
// core_edu.proto 现状 14 RPCExamService 5 + HomeworkService 4 + GradeService 5
// 未就绪 RPCClassService.GetClassesByTeacher 等)降级 mock + warning
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 { CoreEduClient } from "./core-edu-client.interface.js";
import type {
Exam,
Homework,
Grade,
ClassInfo,
StudentInfo,
CreateExamRequest,
CreateExamResponse,
UpdateExamRequest,
UpdateExamResponse,
DeleteExamResponse,
AssignHomeworkRequest,
AssignHomeworkResponse,
SubmitHomeworkResponse,
RecordGradeRequest,
RecordGradeResponse,
ListExamsResponse,
ListHomeworkResponse,
ListGradesResponse,
} from "./core-edu.types.js";
import { CoreEduMockClient } from "./core-edu-mock.client.js";
/** proto message 字段是 snake_casegRPC 返回需转 camelCase */
function mapExam(raw: Record<string, unknown>): Exam {
return {
id: String(raw.id ?? ""),
classId: String(raw.class_id ?? ""),
title: String(raw.title ?? ""),
description: String(raw.description ?? ""),
examDate: String(raw.exam_date ?? ""),
duration: String(raw.duration ?? ""),
totalScore: String(raw.total_score ?? ""),
status: String(raw.status ?? ""),
createdBy: String(raw.created_by ?? ""),
createdAt: String(raw.created_at ?? ""),
updatedAt: String(raw.updated_at ?? ""),
};
}
function mapHomework(raw: Record<string, unknown>): Homework {
return {
id: String(raw.id ?? ""),
classId: String(raw.class_id ?? ""),
title: String(raw.title ?? ""),
description: String(raw.description ?? ""),
dueDate: String(raw.due_date ?? ""),
status: String(raw.status ?? ""),
createdBy: String(raw.created_by ?? ""),
createdAt: String(raw.created_at ?? ""),
updatedAt: String(raw.updated_at ?? ""),
};
}
function mapGrade(raw: Record<string, unknown>): Grade {
return {
id: String(raw.id ?? ""),
studentId: String(raw.student_id ?? ""),
examId: String(raw.exam_id ?? ""),
homeworkId: String(raw.homework_id ?? ""),
score: String(raw.score ?? ""),
feedback: String(raw.feedback ?? ""),
gradedBy: String(raw.graded_by ?? ""),
createdAt: String(raw.created_at ?? ""),
updatedAt: String(raw.updated_at ?? ""),
};
}
@Injectable()
export class CoreEduGrpcClient
extends BaseDownstreamClient
implements CoreEduClient
{
readonly serviceName = "core-edu" as const;
private readonly mock: CoreEduMockClient;
constructor() {
super();
this.initLogger();
this.mock = new CoreEduMockClient();
}
// ===== ExamService =====
async createExam(
ctx: CallContext,
req: CreateExamRequest,
): Promise<CreateExamResponse> {
return this.callGrpc("CreateExam", async () => {
const client = getGrpcClient("core-edu", "ExamService") as unknown as {
createExam(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (err: grpc.ServiceError | null, res: { id: string }) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<CreateExamResponse>((resolve, reject) => {
client.createExam(
{
class_id: req.classId,
title: req.title,
description: req.description,
exam_date: req.examDate,
duration: req.duration,
total_score: req.totalScore,
created_by: req.createdBy,
},
meta,
(err, res) => {
if (err) reject(err);
else resolve({ id: res.id });
},
);
});
});
}
async getExam(ctx: CallContext, id: string): Promise<Exam> {
return this.callGrpc("GetExam", async () => {
const client = getGrpcClient("core-edu", "ExamService") as unknown as {
getExam(
req: { id: string },
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<Exam>((resolve, reject) => {
client.getExam({ id }, meta, (err, res) => {
if (err) reject(err);
else resolve(mapExam(res));
});
});
});
}
async listExamsByClass(
ctx: CallContext,
classId: string,
): Promise<ListExamsResponse> {
return this.callGrpc("ListExamsByClass", async () => {
const client = getGrpcClient("core-edu", "ExamService") as unknown as {
listExamsByClass(
req: { class_id: string },
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { exams?: unknown[] },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<ListExamsResponse>((resolve, reject) => {
client.listExamsByClass({ class_id: classId }, meta, (err, res) => {
if (err) reject(err);
else {
const exams = (res.exams ?? []) as Record<string, unknown>[];
resolve({ exams: exams.map(mapExam) });
}
});
});
});
}
async updateExam(
ctx: CallContext,
req: UpdateExamRequest,
): Promise<UpdateExamResponse> {
return this.callGrpc("UpdateExam", async () => {
const client = getGrpcClient("core-edu", "ExamService") as unknown as {
updateExam(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { success: boolean },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<UpdateExamResponse>((resolve, reject) => {
client.updateExam(
{
id: req.id,
title: req.title ?? "",
description: req.description ?? "",
exam_date: req.examDate ?? "",
duration: req.duration ?? "",
total_score: req.totalScore ?? "",
status: req.status ?? "",
},
meta,
(err, res) => {
if (err) reject(err);
else resolve({ success: res.success });
},
);
});
});
}
async deleteExam(ctx: CallContext, id: string): Promise<DeleteExamResponse> {
return this.callGrpc("DeleteExam", async () => {
const client = getGrpcClient("core-edu", "ExamService") as unknown as {
deleteExam(
req: { id: string },
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { success: boolean },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<DeleteExamResponse>((resolve, reject) => {
client.deleteExam({ id }, meta, (err, res) => {
if (err) reject(err);
else resolve({ success: res.success });
});
});
});
}
// ===== HomeworkService =====
async assignHomework(
ctx: CallContext,
req: AssignHomeworkRequest,
): Promise<AssignHomeworkResponse> {
return this.callGrpc("AssignHomework", async () => {
const client = getGrpcClient(
"core-edu",
"HomeworkService",
) as unknown as {
assignHomework(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (err: grpc.ServiceError | null, res: { id: string }) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<AssignHomeworkResponse>((resolve, reject) => {
client.assignHomework(
{
class_id: req.classId,
title: req.title,
description: req.description,
due_date: req.dueDate,
created_by: req.createdBy,
},
meta,
(err, res) => {
if (err) reject(err);
else resolve({ id: res.id });
},
);
});
});
}
async getHomework(ctx: CallContext, id: string): Promise<Homework> {
return this.callGrpc("GetHomework", async () => {
const client = getGrpcClient(
"core-edu",
"HomeworkService",
) as unknown as {
getHomework(
req: { id: string },
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<Homework>((resolve, reject) => {
client.getHomework({ id }, meta, (err, res) => {
if (err) reject(err);
else resolve(mapHomework(res));
});
});
});
}
async listHomeworkByClass(
ctx: CallContext,
classId: string,
): Promise<ListHomeworkResponse> {
return this.callGrpc("ListHomeworkByClass", async () => {
const client = getGrpcClient(
"core-edu",
"HomeworkService",
) as unknown as {
listHomeworkByClass(
req: { class_id: string },
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { homework?: unknown[] },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<ListHomeworkResponse>((resolve, reject) => {
client.listHomeworkByClass({ class_id: classId }, meta, (err, res) => {
if (err) reject(err);
else {
const homework = (res.homework ?? []) as Record<string, unknown>[];
resolve({ homework: homework.map(mapHomework) });
}
});
});
});
}
async submitHomework(
ctx: CallContext,
id: string,
): Promise<SubmitHomeworkResponse> {
return this.callGrpc("SubmitHomework", async () => {
const client = getGrpcClient(
"core-edu",
"HomeworkService",
) as unknown as {
submitHomework(
req: { id: string },
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { success: boolean },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<SubmitHomeworkResponse>((resolve, reject) => {
client.submitHomework({ id }, meta, (err, res) => {
if (err) reject(err);
else resolve({ success: res.success });
});
});
});
}
// ===== GradeService =====
async recordGrade(
ctx: CallContext,
req: RecordGradeRequest,
): Promise<RecordGradeResponse> {
return this.callGrpc("RecordGrade", async () => {
const client = getGrpcClient("core-edu", "GradeService") as unknown as {
recordGrade(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (err: grpc.ServiceError | null, res: { id: string }) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<RecordGradeResponse>((resolve, reject) => {
client.recordGrade(
{
student_id: req.studentId,
exam_id: req.examId ?? "",
homework_id: req.homeworkId ?? "",
score: req.score,
feedback: req.feedback ?? "",
graded_by: req.gradedBy,
},
meta,
(err, res) => {
if (err) reject(err);
else resolve({ id: res.id });
},
);
});
});
}
async getGrade(ctx: CallContext, id: string): Promise<Grade> {
return this.callGrpc("GetGrade", async () => {
const client = getGrpcClient("core-edu", "GradeService") as unknown as {
getGrade(
req: { id: string },
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<Grade>((resolve, reject) => {
client.getGrade({ id }, meta, (err, res) => {
if (err) reject(err);
else resolve(mapGrade(res));
});
});
});
}
async listGradesByStudent(
ctx: CallContext,
studentId: string,
): Promise<ListGradesResponse> {
return this.callGrpc("ListGradesByStudent", async () => {
const client = getGrpcClient("core-edu", "GradeService") as unknown as {
listGradesByStudent(
req: { student_id: string },
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { grades?: unknown[] },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<ListGradesResponse>((resolve, reject) => {
client.listGradesByStudent(
{ student_id: studentId },
meta,
(err, res) => {
if (err) reject(err);
else {
const grades = (res.grades ?? []) as Record<string, unknown>[];
resolve({ grades: grades.map(mapGrade) });
}
},
);
});
});
}
async listGradesByExam(
ctx: CallContext,
examId: string,
): Promise<ListGradesResponse> {
return this.callGrpc("ListGradesByExam", async () => {
const client = getGrpcClient("core-edu", "GradeService") as unknown as {
listGradesByExam(
req: { exam_id: string },
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { grades?: unknown[] },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<ListGradesResponse>((resolve, reject) => {
client.listGradesByExam({ exam_id: examId }, meta, (err, res) => {
if (err) reject(err);
else {
const grades = (res.grades ?? []) as Record<string, unknown>[];
resolve({ grades: grades.map(mapGrade) });
}
});
});
});
}
async listGradesByHomework(
ctx: CallContext,
homeworkId: string,
): Promise<ListGradesResponse> {
return this.callGrpc("ListGradesByHomework", async () => {
const client = getGrpcClient("core-edu", "GradeService") as unknown as {
listGradesByHomework(
req: { homework_id: string },
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { grades?: unknown[] },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<ListGradesResponse>((resolve, reject) => {
client.listGradesByHomework(
{ homework_id: homeworkId },
meta,
(err, res) => {
if (err) reject(err);
else {
const grades = (res.grades ?? []) as Record<string, unknown>[];
resolve({ grades: grades.map(mapGrade) });
}
},
);
});
});
}
// ===== ClassServiceproto 未定义,降级 mock + warning =====
async getClassesByTeacher(
ctx: CallContext,
teacherId: string,
): Promise<ClassInfo[]> {
this.log.warn(
{
rpc: "GetClassesByTeacher",
reason: "ClassService not in core_edu.proto yet",
},
"Downstream RPC not ready, falling back to mock",
);
return this.mock.getClassesByTeacher(ctx, teacherId);
}
async listStudentsByClass(
ctx: CallContext,
classId: string,
): Promise<StudentInfo[]> {
this.log.warn(
{
rpc: "ListStudentsByClass",
reason: "ClassService not in core_edu.proto yet",
},
"Downstream RPC not ready, falling back to mock",
);
return this.mock.listStudentsByClass(ctx, classId);
}
async batchGetClasses(
ctx: CallContext,
classIds: string[],
): Promise<ClassInfo[]> {
this.log.warn(
{
rpc: "BatchGetClasses",
reason: "ClassService not in core_edu.proto yet",
},
"Downstream RPC not ready, falling back to mock",
);
return this.mock.batchGetClasses(ctx, classIds);
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return checkGrpcHealth("core-edu");
}
}

View File

@@ -0,0 +1,336 @@
// CoreEduClient Mock 实现B8 裁决:上游就绪前的降级策略)
// DEV_MODE=true 时全部走 mockDEV_MODE=false 时仅未就绪 RPC 走 mock
// mock 数据对齐 contract §4.2 mock 策略
import { Injectable } from "@nestjs/common";
import { BaseDownstreamClient } from "../base.client.js";
import type { CallContext } from "../types.js";
import type { CoreEduClient } from "./core-edu-client.interface.js";
import type {
Exam,
Homework,
Grade,
ClassInfo,
StudentInfo,
CreateExamRequest,
CreateExamResponse,
UpdateExamRequest,
UpdateExamResponse,
DeleteExamResponse,
AssignHomeworkRequest,
AssignHomeworkResponse,
SubmitHomeworkResponse,
RecordGradeRequest,
RecordGradeResponse,
ListExamsResponse,
ListHomeworkResponse,
ListGradesResponse,
} from "./core-edu.types.js";
/** mock 考试数据contract §4.2:返回固定 5 场考试) */
const MOCK_EXAMS: Exam[] = [
{
id: "exam-001",
classId: "class-001",
title: "语文月考",
description: "三年级1班语文月考",
examDate: "2026-07-15",
duration: "90",
totalScore: "100",
status: "PUBLISHED",
createdBy: "teacher-001",
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-01T00:00:00Z",
},
{
id: "exam-002",
classId: "class-001",
title: "数学单元测验",
description: "三年级1班数学单元测验",
examDate: "2026-07-18",
duration: "60",
totalScore: "100",
status: "DRAFT",
createdBy: "teacher-001",
createdAt: "2026-07-02T00:00:00Z",
updatedAt: "2026-07-02T00:00:00Z",
},
{
id: "exam-003",
classId: "class-002",
title: "英语期末考试",
description: "三年级2班英语期末考试",
examDate: "2026-07-20",
duration: "120",
totalScore: "100",
status: "PUBLISHED",
createdBy: "teacher-001",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z",
},
{
id: "exam-004",
classId: "class-002",
title: "科学测验",
description: "三年级2班科学测验",
examDate: "2026-07-22",
duration: "45",
totalScore: "50",
status: "DRAFT",
createdBy: "teacher-001",
createdAt: "2026-07-04T00:00:00Z",
updatedAt: "2026-07-04T00:00:00Z",
},
{
id: "exam-005",
classId: "class-003",
title: "综合测试",
description: "三年级3班综合测试",
examDate: "2026-07-25",
duration: "90",
totalScore: "100",
status: "PUBLISHED",
createdBy: "teacher-001",
createdAt: "2026-07-05T00:00:00Z",
updatedAt: "2026-07-05T00:00:00Z",
},
];
/** mock 作业数据contract §4.2:返回固定 5 份作业) */
const MOCK_HOMEWORK: Homework[] = [
{
id: "hw-001",
classId: "class-001",
title: "语文作业1",
description: "阅读理解练习",
dueDate: "2026-07-16",
status: "PUBLISHED",
createdBy: "teacher-001",
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-01T00:00:00Z",
},
{
id: "hw-002",
classId: "class-001",
title: "数学作业1",
description: "加减法练习",
dueDate: "2026-07-17",
status: "PUBLISHED",
createdBy: "teacher-001",
createdAt: "2026-07-02T00:00:00Z",
updatedAt: "2026-07-02T00:00:00Z",
},
{
id: "hw-003",
classId: "class-002",
title: "英语作业1",
description: "单词默写",
dueDate: "2026-07-18",
status: "PUBLISHED",
createdBy: "teacher-001",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z",
},
{
id: "hw-004",
classId: "class-002",
title: "科学作业1",
description: "观察记录",
dueDate: "2026-07-19",
status: "PUBLISHED",
createdBy: "teacher-001",
createdAt: "2026-07-04T00:00:00Z",
updatedAt: "2026-07-04T00:00:00Z",
},
{
id: "hw-005",
classId: "class-003",
title: "综合作业1",
description: "期末复习",
dueDate: "2026-07-20",
status: "PUBLISHED",
createdBy: "teacher-001",
createdAt: "2026-07-05T00:00:00Z",
updatedAt: "2026-07-05T00:00:00Z",
},
];
/** mock 成绩数据contract §4.2:返回固定 10 条成绩) */
const MOCK_GRADES: Grade[] = Array.from({ length: 10 }, (_, i) => ({
id: `grade-${String(i + 1).padStart(3, "0")}`,
studentId: `student-${String(i + 1).padStart(3, "0")}`,
examId: i < 5 ? "exam-001" : "exam-003",
homeworkId: "",
score: String(80 + (i % 20)),
feedback: i % 2 === 0 ? "表现良好" : "需要努力",
gradedBy: "teacher-001",
createdAt: "2026-07-10T00:00:00Z",
updatedAt: "2026-07-10T00:00:00Z",
}));
/** mock 班级数据contract §4.2:返回固定 3 个 ClassInfo */
const MOCK_CLASSES: ClassInfo[] = [
{ id: "class-001", name: "三年级1班", gradeId: "grade-3", studentCount: 30 },
{ id: "class-002", name: "三年级2班", gradeId: "grade-3", studentCount: 28 },
{ id: "class-003", name: "三年级3班", gradeId: "grade-3", studentCount: 32 },
];
/** mock 学生数据contract §4.2:返回固定 30 个 StudentInfo */
const MOCK_STUDENTS: StudentInfo[] = Array.from({ length: 30 }, (_, i) => ({
id: `student-${String(i + 1).padStart(3, "0")}`,
name: `学生${String(i + 1).padStart(2, "0")}`,
classId: "class-001",
}));
@Injectable()
export class CoreEduMockClient
extends BaseDownstreamClient
implements CoreEduClient
{
readonly serviceName = "core-edu" as const;
constructor() {
super();
this.initLogger();
}
async createExam(
_ctx: CallContext,
req: CreateExamRequest,
): Promise<CreateExamResponse> {
this.log.debug({ req }, "Mock createExam");
return { id: `exam-${Date.now()}` };
}
async getExam(ctx: CallContext, id: string): Promise<Exam> {
this.log.debug({ userId: ctx.userId, id }, "Mock getExam");
return MOCK_EXAMS[0]!;
}
async listExamsByClass(
ctx: CallContext,
classId: string,
): Promise<ListExamsResponse> {
this.log.debug({ userId: ctx.userId, classId }, "Mock listExamsByClass");
return { exams: MOCK_EXAMS.filter((e) => e.classId === classId) };
}
async updateExam(
_ctx: CallContext,
_req: UpdateExamRequest,
): Promise<UpdateExamResponse> {
this.log.debug({ req: _req }, "Mock updateExam");
return { success: true };
}
async deleteExam(ctx: CallContext, id: string): Promise<DeleteExamResponse> {
this.log.debug({ userId: ctx.userId, id }, "Mock deleteExam");
return { success: true };
}
async assignHomework(
_ctx: CallContext,
req: AssignHomeworkRequest,
): Promise<AssignHomeworkResponse> {
this.log.debug({ req }, "Mock assignHomework");
return { id: `hw-${Date.now()}` };
}
async getHomework(ctx: CallContext, id: string): Promise<Homework> {
this.log.debug({ userId: ctx.userId, id }, "Mock getHomework");
return MOCK_HOMEWORK[0]!;
}
async listHomeworkByClass(
ctx: CallContext,
classId: string,
): Promise<ListHomeworkResponse> {
this.log.debug({ userId: ctx.userId, classId }, "Mock listHomeworkByClass");
return { homework: MOCK_HOMEWORK.filter((h) => h.classId === classId) };
}
async submitHomework(
ctx: CallContext,
id: string,
): Promise<SubmitHomeworkResponse> {
this.log.debug({ userId: ctx.userId, id }, "Mock submitHomework");
return { success: true };
}
async recordGrade(
_ctx: CallContext,
req: RecordGradeRequest,
): Promise<RecordGradeResponse> {
this.log.debug({ req }, "Mock recordGrade");
return { id: `grade-${Date.now()}` };
}
async getGrade(ctx: CallContext, id: string): Promise<Grade> {
this.log.debug({ userId: ctx.userId, id }, "Mock getGrade");
return MOCK_GRADES[0]!;
}
async listGradesByStudent(
ctx: CallContext,
studentId: string,
): Promise<ListGradesResponse> {
this.log.debug(
{ userId: ctx.userId, studentId },
"Mock listGradesByStudent",
);
return { grades: MOCK_GRADES.filter((g) => g.studentId === studentId) };
}
async listGradesByExam(
ctx: CallContext,
examId: string,
): Promise<ListGradesResponse> {
this.log.debug({ userId: ctx.userId, examId }, "Mock listGradesByExam");
return { grades: MOCK_GRADES.filter((g) => g.examId === examId) };
}
async listGradesByHomework(
ctx: CallContext,
homeworkId: string,
): Promise<ListGradesResponse> {
this.log.debug(
{ userId: ctx.userId, homeworkId },
"Mock listGradesByHomework",
);
return { grades: MOCK_GRADES.filter((g) => g.homeworkId === homeworkId) };
}
async getClassesByTeacher(
ctx: CallContext,
teacherId: string,
): Promise<ClassInfo[]> {
this.log.debug(
{ userId: ctx.userId, teacherId },
"Mock getClassesByTeacher",
);
return MOCK_CLASSES;
}
async listStudentsByClass(
ctx: CallContext,
classId: string,
): Promise<StudentInfo[]> {
this.log.debug({ userId: ctx.userId, classId }, "Mock listStudentsByClass");
return MOCK_STUDENTS.filter((s) => s.classId === classId);
}
async batchGetClasses(
ctx: CallContext,
classIds: string[],
): Promise<ClassInfo[]> {
this.log.debug({ userId: ctx.userId, classIds }, "Mock batchGetClasses");
return MOCK_CLASSES.filter((c) => classIds.includes(c.id));
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return { serving: true, latencyMs: 0 };
}
}

View File

@@ -0,0 +1,35 @@
// core-edu 模块B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
import { Module } from "@nestjs/common";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import { CORE_EDU_CLIENT } from "./core-edu-client.interface.js";
import { CoreEduGrpcClient } from "./core-edu-grpc.client.js";
import { CoreEduMockClient } from "./core-edu-mock.client.js";
@Module({
providers: [
{
provide: CORE_EDU_CLIENT,
useFactory: () => {
// DEV_MODE=true 或 CORE_EDU_GRPC_TARGET 未配置 → 使用 mock
if (env.TEACHER_BFF_DEV_MODE || !env.CORE_EDU_GRPC_TARGET) {
logger.warn(
{
devMode: env.TEACHER_BFF_DEV_MODE,
target: env.CORE_EDU_GRPC_TARGET,
},
"CoreEduClient using mock (DEV_MODE or target not configured)",
);
return new CoreEduMockClient();
}
logger.info(
{ devMode: false, target: env.CORE_EDU_GRPC_TARGET },
"CoreEduClient using gRPC",
);
return new CoreEduGrpcClient();
},
},
],
exports: [CORE_EDU_CLIENT],
})
export class CoreEduModule {}

View File

@@ -0,0 +1,136 @@
// core-edu 下游服务类型定义(对齐 core_edu.proto
// core_edu.proto 现状14 RPCExamService 5 + HomeworkService 4 + GradeService 5
// 待 coord 补全ClassServiceGetClassesByTeacher / ListStudentsByClass / BatchGetClasses+ AttendanceService
/** 考试(对齐 core_edu.proto Exam message */
export interface Exam {
id: string;
classId: string;
title: string;
description: string;
examDate: string;
duration: string;
totalScore: string;
status: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
/** 作业(对齐 core_edu.proto Homework message */
export interface Homework {
id: string;
classId: string;
title: string;
description: string;
dueDate: string;
status: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
/** 成绩(对齐 core_edu.proto Grade message */
export interface Grade {
id: string;
studentId: string;
examId: string;
homeworkId: string;
score: string;
feedback: string;
gradedBy: string;
createdAt: string;
updatedAt: string;
}
/** 班级信息(待 coord 补 ClassService.GetClassesByTeacher RPC */
export interface ClassInfo {
id: string;
name: string;
gradeId: string;
studentCount: number;
}
/** 学生信息(待 coord 补 ClassService.ListStudentsByClass RPC */
export interface StudentInfo {
id: string;
name: string;
classId: string;
}
// ===== Request 类型 =====
export interface CreateExamRequest {
classId: string;
title: string;
description: string;
examDate: string;
duration: string;
totalScore: string;
createdBy: string;
}
export interface UpdateExamRequest {
id: string;
title?: string;
description?: string;
examDate?: string;
duration?: string;
totalScore?: string;
status?: string;
}
export interface AssignHomeworkRequest {
classId: string;
title: string;
description: string;
dueDate: string;
createdBy: string;
}
export interface RecordGradeRequest {
studentId: string;
examId?: string;
homeworkId?: string;
score: string;
feedback?: string;
gradedBy: string;
}
// ===== Response 类型 =====
export interface CreateExamResponse {
id: string;
}
export interface UpdateExamResponse {
success: boolean;
}
export interface DeleteExamResponse {
success: boolean;
}
export interface AssignHomeworkResponse {
id: string;
}
export interface SubmitHomeworkResponse {
success: boolean;
}
export interface RecordGradeResponse {
id: string;
}
export interface ListExamsResponse {
exams: Exam[];
}
export interface ListHomeworkResponse {
homework: Homework[];
}
export interface ListGradesResponse {
grades: Grade[];
}

View File

@@ -0,0 +1,45 @@
// DataAnaClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P4 需要的 data-ana RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC
import type { CallContext } from "../types.js";
import type {
ClassPerformance,
StudentWeakness,
LearningTrend,
TeacherDashboardStats,
GetClassPerformanceRequest,
GetStudentWeaknessRequest,
GetLearningTrendRequest,
GetTeacherDashboardRequest,
} from "./data-ana.types.js";
/** DataAnaClient DI tokenNestJS 注入用) */
export const DATA_ANA_CLIENT = Symbol("DATA_ANA_CLIENT");
/** DataAnaClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
export interface DataAnaClient {
// ===== AnalyticsService =====
getClassPerformance(
ctx: CallContext,
req: GetClassPerformanceRequest,
): Promise<ClassPerformance>;
getStudentWeakness(
ctx: CallContext,
req: GetStudentWeaknessRequest,
): Promise<StudentWeakness>;
getLearningTrend(
ctx: CallContext,
req: GetLearningTrendRequest,
): Promise<LearningTrend>;
getTeacherDashboard(
ctx: CallContext,
req: GetTeacherDashboardRequest,
): Promise<TeacherDashboardStats>;
// ===== 健康检查 =====
checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}>;
}

View File

@@ -0,0 +1,257 @@
// DataAnaClient gRPC 实现B2 裁决:首次实现即 gRPC
// analytics.proto 现状 12 RPCAnalyticsServiceBFF 消费 4 个
// proto 字段是 snake_casegRPC 返回需转 camelCase
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 { DataAnaClient } from "./data-ana-client.interface.js";
import type {
ClassPerformance,
StudentWeakness,
LearningTrend,
TeacherDashboardStats,
StudentScore,
WeakPoint,
TrendPoint,
GetClassPerformanceRequest,
GetStudentWeaknessRequest,
GetLearningTrendRequest,
GetTeacherDashboardRequest,
} from "./data-ana.types.js";
/** proto message 字段是 snake_casegRPC 返回需转 camelCase */
function mapStudentScore(raw: Record<string, unknown>): StudentScore {
return {
studentId: String(raw.student_id ?? ""),
score: Number(raw.score ?? 0),
grade: String(raw.grade ?? ""),
};
}
function mapClassPerformance(raw: Record<string, unknown>): ClassPerformance {
const scores = (raw.scores ?? []) as Record<string, unknown>[];
return {
classId: String(raw.class_id ?? ""),
averageScore: Number(raw.average_score ?? 0),
passRate: Number(raw.pass_rate ?? 0),
totalStudents: Number(raw.total_students ?? 0),
scores: scores.map(mapStudentScore),
};
}
function mapWeakPoint(raw: Record<string, unknown>): WeakPoint {
return {
knowledgePointId: String(raw.knowledge_point_id ?? ""),
title: String(raw.title ?? ""),
mastery: Number(raw.mastery ?? 0),
errorCount: Number(raw.error_count ?? 0),
};
}
function mapStudentWeakness(raw: Record<string, unknown>): StudentWeakness {
const weakPoints = (raw.weak_points ?? []) as Record<string, unknown>[];
return {
studentId: String(raw.student_id ?? ""),
weakPoints: weakPoints.map(mapWeakPoint),
};
}
function mapTrendPoint(raw: Record<string, unknown>): TrendPoint {
return {
date: String(raw.date ?? ""),
score: Number(raw.score ?? 0),
};
}
function mapLearningTrend(raw: Record<string, unknown>): LearningTrend {
const points = (raw.points ?? []) as Record<string, unknown>[];
return {
studentId: String(raw.student_id ?? ""),
points: points.map(mapTrendPoint),
};
}
/** proto TeacherDashboard → BFF TeacherDashboardStats简化视图
* proto pending_homework_count → todayHomeworktotalExams/pendingGrading 不在 data-ana proto需 BFF 跨服务聚合,此处返 0 */
function mapTeacherDashboardStats(
raw: Record<string, unknown>,
): TeacherDashboardStats {
return {
totalExams: 0,
pendingGrading: 0,
todayHomework: Number(raw.pending_homework_count ?? 0),
};
}
@Injectable()
export class DataAnaGrpcClient
extends BaseDownstreamClient
implements DataAnaClient
{
readonly serviceName = "data-ana" as const;
constructor() {
super();
this.initLogger();
}
// ===== AnalyticsService =====
async getClassPerformance(
ctx: CallContext,
req: GetClassPerformanceRequest,
): Promise<ClassPerformance> {
return this.callGrpc("GetClassPerformance", async () => {
const client = getGrpcClient(
"data-ana",
"AnalyticsService",
) as unknown as {
getClassPerformance(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<ClassPerformance>((resolve, reject) => {
client.getClassPerformance(
{
class_id: req.classId,
subject_id: req.subjectId,
start_date: req.startDate,
end_date: req.endDate,
},
meta,
(err, res) => {
if (err) reject(err);
else resolve(mapClassPerformance(res));
},
);
});
});
}
async getStudentWeakness(
ctx: CallContext,
req: GetStudentWeaknessRequest,
): Promise<StudentWeakness> {
return this.callGrpc("GetStudentWeakness", async () => {
const client = getGrpcClient(
"data-ana",
"AnalyticsService",
) as unknown as {
getStudentWeakness(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<StudentWeakness>((resolve, reject) => {
client.getStudentWeakness(
{
student_id: req.studentId,
subject_id: req.subjectId,
},
meta,
(err, res) => {
if (err) reject(err);
else resolve(mapStudentWeakness(res));
},
);
});
});
}
async getLearningTrend(
ctx: CallContext,
req: GetLearningTrendRequest,
): Promise<LearningTrend> {
return this.callGrpc("GetLearningTrend", async () => {
const client = getGrpcClient(
"data-ana",
"AnalyticsService",
) as unknown as {
getLearningTrend(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<LearningTrend>((resolve, reject) => {
client.getLearningTrend(
{
student_id: req.studentId,
start_date: req.startDate,
end_date: req.endDate,
subject_id: req.subjectId,
},
meta,
(err, res) => {
if (err) reject(err);
else resolve(mapLearningTrend(res));
},
);
});
});
}
async getTeacherDashboard(
ctx: CallContext,
req: GetTeacherDashboardRequest,
): Promise<TeacherDashboardStats> {
return this.callGrpc("GetTeacherDashboard", async () => {
const client = getGrpcClient(
"data-ana",
"AnalyticsService",
) as unknown as {
getTeacherDashboard(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<TeacherDashboardStats>((resolve, reject) => {
client.getTeacherDashboard(
{
user_id: req.userId,
class_id: req.classId ?? "",
},
meta,
(err, res) => {
if (err) reject(err);
else resolve(mapTeacherDashboardStats(res));
},
);
});
});
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return checkGrpcHealth("data-ana");
}
}

View File

@@ -0,0 +1,124 @@
// DataAnaClient Mock 实现B8 裁决:上游就绪前的降级策略)
// DEV_MODE=true 时全部走 mockDEV_MODE=false 时走 gRPC
// mock 数据对齐 contract §4.2 mock 策略
import { Injectable } from "@nestjs/common";
import { BaseDownstreamClient } from "../base.client.js";
import type { CallContext } from "../types.js";
import type { DataAnaClient } from "./data-ana-client.interface.js";
import type {
ClassPerformance,
StudentWeakness,
LearningTrend,
TeacherDashboardStats,
GetClassPerformanceRequest,
GetStudentWeaknessRequest,
GetLearningTrendRequest,
GetTeacherDashboardRequest,
} from "./data-ana.types.js";
/** mock 班级成绩分析contract §4.2:返回固定分析数据) */
const MOCK_CLASS_PERFORMANCE: ClassPerformance = {
classId: "class-001",
averageScore: 82.5,
passRate: 0.85,
totalStudents: 30,
scores: Array.from({ length: 30 }, (_, i) => ({
studentId: `student-${String(i + 1).padStart(3, "0")}`,
score: 70 + ((i * 7) % 30),
grade: i % 3 === 0 ? "A" : i % 3 === 1 ? "B" : "C",
})),
};
/** mock 学生薄弱点contract §4.2:返回固定 3 个薄弱知识点) */
const MOCK_STUDENT_WEAKNESS: StudentWeakness = {
studentId: "student-001",
weakPoints: [
{
knowledgePointId: "kp-001",
title: "分数加减法",
mastery: 0.35,
errorCount: 8,
},
{
knowledgePointId: "kp-002",
title: "几何图形面积",
mastery: 0.42,
errorCount: 6,
},
{
knowledgePointId: "kp-003",
title: "应用题理解",
mastery: 0.5,
errorCount: 5,
},
],
};
/** mock 学习趋势contract §4.2:返回固定 12 个月趋势) */
const MOCK_LEARNING_TREND: LearningTrend = {
studentId: "student-001",
points: Array.from({ length: 12 }, (_, i) => ({
date: String(Date.now() - (11 - i) * 30 * 24 * 60 * 60),
score: 70 + i * 1.5,
})),
};
/** mock 教师仪表盘contract §4.2:返回固定仪表盘数据) */
const MOCK_TEACHER_DASHBOARD: TeacherDashboardStats = {
totalExams: 5,
pendingGrading: 3,
todayHomework: 2,
};
@Injectable()
export class DataAnaMockClient
extends BaseDownstreamClient
implements DataAnaClient
{
readonly serviceName = "data-ana" as const;
constructor() {
super();
this.initLogger();
}
async getClassPerformance(
ctx: CallContext,
req: GetClassPerformanceRequest,
): Promise<ClassPerformance> {
this.log.debug({ userId: ctx.userId, req }, "Mock getClassPerformance");
return MOCK_CLASS_PERFORMANCE;
}
async getStudentWeakness(
ctx: CallContext,
req: GetStudentWeaknessRequest,
): Promise<StudentWeakness> {
this.log.debug({ userId: ctx.userId, req }, "Mock getStudentWeakness");
return MOCK_STUDENT_WEAKNESS;
}
async getLearningTrend(
ctx: CallContext,
req: GetLearningTrendRequest,
): Promise<LearningTrend> {
this.log.debug({ userId: ctx.userId, req }, "Mock getLearningTrend");
return MOCK_LEARNING_TREND;
}
async getTeacherDashboard(
ctx: CallContext,
req: GetTeacherDashboardRequest,
): Promise<TeacherDashboardStats> {
this.log.debug({ userId: ctx.userId, req }, "Mock getTeacherDashboard");
return MOCK_TEACHER_DASHBOARD;
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return { serving: true, latencyMs: 0 };
}
}

View File

@@ -0,0 +1,35 @@
// data-ana 模块B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
import { Module } from "@nestjs/common";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import { DATA_ANA_CLIENT } from "./data-ana-client.interface.js";
import { DataAnaGrpcClient } from "./data-ana-grpc.client.js";
import { DataAnaMockClient } from "./data-ana-mock.client.js";
@Module({
providers: [
{
provide: DATA_ANA_CLIENT,
useFactory: () => {
// DEV_MODE=true 或 DATA_ANA_GRPC_TARGET 未配置 → 使用 mock
if (env.TEACHER_BFF_DEV_MODE || !env.DATA_ANA_GRPC_TARGET) {
logger.warn(
{
devMode: env.TEACHER_BFF_DEV_MODE,
target: env.DATA_ANA_GRPC_TARGET,
},
"DataAnaClient using mock (DEV_MODE or target not configured)",
);
return new DataAnaMockClient();
}
logger.info(
{ devMode: false, target: env.DATA_ANA_GRPC_TARGET },
"DataAnaClient using gRPC",
);
return new DataAnaGrpcClient();
},
},
],
exports: [DATA_ANA_CLIENT],
})
export class DataAnaModule {}

View File

@@ -0,0 +1,81 @@
// data-ana 下游服务类型定义(对齐 analytics.proto
// analytics.proto 现状12 RPCAnalyticsServiceISSUE-027 已补全 GetTeacherDashboard
// BFF 消费 4 个 RPCGetClassPerformance / GetStudentWeakness / GetLearningTrend / GetTeacherDashboard
// 裁决依据B8DownstreamClient 抽象)+ B2gRPC 首次实现)
/** 学生单科成绩(对齐 analytics.proto StudentScore message */
export interface StudentScore {
studentId: string;
score: number;
grade: string;
}
/** 班级成绩分析(对齐 analytics.proto ClassPerformance message */
export interface ClassPerformance {
classId: string;
averageScore: number;
passRate: number;
totalStudents: number;
scores: StudentScore[];
}
/** 薄弱知识点(对齐 analytics.proto WeakPoint message */
export interface WeakPoint {
knowledgePointId: string;
title: string;
mastery: number;
errorCount: number;
}
/** 学生薄弱点(对齐 analytics.proto StudentWeakness message */
export interface StudentWeakness {
studentId: string;
weakPoints: WeakPoint[];
}
/** 趋势数据点(对齐 analytics.proto TrendPoint messageint64 date 经 proto-loader 转为 string */
export interface TrendPoint {
date: string;
score: number;
}
/** 学习趋势(对齐 analytics.proto LearningTrend message */
export interface LearningTrend {
studentId: string;
points: TrendPoint[];
}
/** 教师仪表盘统计BFF 简化视图)
* proto TeacherDashboard 含 total_classes/total_students/class_avg_score/pending_homework_count 等字段,
* BFF 仪表盘仅需 exam/grading/homework 三项快速统计contract §4.2 mock 策略) */
export interface TeacherDashboardStats {
totalExams: number;
pendingGrading: number;
todayHomework: number;
}
// ===== Request 类型 =====
export interface GetClassPerformanceRequest {
classId: string;
subjectId: string;
startDate: string;
endDate: string;
}
export interface GetStudentWeaknessRequest {
studentId: string;
subjectId: string;
}
export interface GetLearningTrendRequest {
studentId: string;
startDate: string;
endDate: string;
subjectId: string;
}
export interface GetTeacherDashboardRequest {
userId: string;
classId?: string;
}

View File

@@ -1,11 +1,23 @@
// IamClient 接口 + DI tokenB8 裁决DownstreamClient 抽象) // IamClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P2+ 需要的 iam RPC,实现分 gRPC + mock 两种 // 接口定义所有 P2+ 需要的 iam RPC + REST 端点
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC(未就绪 RPC 降级 mock + warning // 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC/REST 真实调用
// 实现策略getUserInfo 走 gRPC其余走 RESTiam.proto 仅 6 RPC无 roles/permissions/audit
import type { CallContext } from "../types.js"; import type { CallContext } from "../types.js";
import type { import type {
UserInfo, UserInfo,
ViewportItem, ViewportItem,
EffectivePermissions, EffectivePermissions,
IamRole,
IamPermission,
IamAuditLog,
IamUser,
IamUserPage,
ListUsersRequest,
UpdateUserRequest,
QueryAuditLogRequest,
CreateRoleRequest,
UpdateRoleRequest,
UpdateRolePermissionsRequest,
} from "./iam.types.js"; } from "./iam.types.js";
/** IamClient DI tokenNestJS 注入用) */ /** IamClient DI tokenNestJS 注入用) */
@@ -13,15 +25,67 @@ export const IAM_CLIENT = Symbol("IAM_CLIENT");
/** IamClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */ /** IamClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
export interface IamClient { export interface IamClient {
// ===== gRPC 方法iam.proto 已声明) =====
/** 获取用户信息iam.proto GetUserInfo✅ 已就绪) */ /** 获取用户信息iam.proto GetUserInfo✅ 已就绪) */
getUserInfo(ctx: CallContext): Promise<UserInfo>; getUserInfo(ctx: CallContext): Promise<UserInfo>;
/** 获取教师导航菜单iam.proto GetViewports❌ 待 coord 补全 */ /** 获取教师导航菜单iam REST GET /v1/iam/viewports✅ 已就绪 */
getViewports(ctx: CallContext): Promise<ViewportItem[]>; getViewports(ctx: CallContext): Promise<ViewportItem[]>;
/** 获取有效权限集iam.proto GetEffectivePermissions❌ 待 coord 补全 */ /** 获取有效权限集iam REST GET /v1/iam/permissions/effective✅ 已就绪 */
getEffectivePermissions(ctx: CallContext): Promise<EffectivePermissions>; getEffectivePermissions(ctx: CallContext): Promise<EffectivePermissions>;
// ===== REST 方法admin 域需要) =====
/** 获取用户列表iam REST GET /v1/iam/users✅ 已就绪) */
listUsers(ctx: CallContext, req: ListUsersRequest): Promise<IamUserPage>;
/** 更新用户iam REST PATCH /v1/iam/users/:id✅ 已就绪) */
updateUser(
ctx: CallContext,
id: string,
input: UpdateUserRequest,
): Promise<IamUser>;
/** 切换用户状态iam REST PATCH /v1/iam/users/:id/status✅ 已就绪) */
toggleUserStatus(
ctx: CallContext,
id: string,
status: string,
): Promise<IamUser>;
/** 获取全部角色iam REST GET /v1/iam/roles✅ 已就绪) */
getAllRoles(ctx: CallContext): Promise<IamRole[]>;
/** 创建角色iam REST POST /v1/iam/roles✅ 已就绪) */
createRole(ctx: CallContext, input: CreateRoleRequest): Promise<IamRole>;
/** 更新角色iam REST PATCH /v1/iam/roles/:id✅ 已就绪) */
updateRole(
ctx: CallContext,
id: string,
input: UpdateRoleRequest,
): Promise<IamRole>;
/** 更新角色权限iam REST PATCH /v1/iam/roles/:id/permissions✅ 已就绪) */
updateRolePermissions(
ctx: CallContext,
roleId: string,
req: UpdateRolePermissionsRequest,
): Promise<IamRole>;
/** 获取全部权限点iam REST GET /v1/iam/permissions✅ 已就绪) */
getAllPermissions(ctx: CallContext): Promise<IamPermission[]>;
/** 查询审计日志iam REST GET /v1/iam/audit✅ 已就绪) */
queryAuditLog(
ctx: CallContext,
req: QueryAuditLogRequest,
): Promise<IamAuditLog[]>;
// ===== 健康检查 =====
/** 健康检查(/readyz 探针用) */ /** 健康检查(/readyz 探针用) */
checkHealth(): Promise<{ checkHealth(): Promise<{
serving: boolean; serving: boolean;

View File

@@ -1,6 +1,8 @@
// IamClient gRPC 实现B2 裁决:首次实现即 gRPC // IamClient gRPC + REST 实现B2 裁决:首次实现即 gRPCadmin 域补充 REST
// iam.proto 现状仅 GetUserInfoGetViewports/GetEffectivePermissions 待 coord 补全 // iam.proto 6 RPCRegister/Login/RefreshToken/Logout/GetUserInfo/GetEffectiveDataScope
// 未就绪 RPC 降级 mock + warning 日志president §2.6 降级模式 B // admin 域需要 roles/permissions/audit/users 等端点iam.proto 未声明,走 REST
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC/REST 真实调用
// 未就绪端点降级 mock + warning 日志president §2.6 降级模式 B
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import type * as grpc from "@grpc/grpc-js"; import type * as grpc from "@grpc/grpc-js";
import { BaseDownstreamClient } from "../base.client.js"; import { BaseDownstreamClient } from "../base.client.js";
@@ -8,16 +10,125 @@ import {
createGrpcMetadata, createGrpcMetadata,
getGrpcClient, getGrpcClient,
checkGrpcHealth, checkGrpcHealth,
isServiceConfigured,
} from "../grpc/grpc.factory.js"; } from "../grpc/grpc.factory.js";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import type { CallContext } from "../types.js"; import type { CallContext } from "../types.js";
import type { IamClient } from "./iam-client.interface.js"; import type { IamClient } from "./iam-client.interface.js";
import type { import type {
UserInfo, UserInfo,
ViewportItem, ViewportItem,
EffectivePermissions, EffectivePermissions,
IamRole,
IamPermission,
IamAuditLog,
IamUser,
IamUserPage,
ListUsersRequest,
UpdateUserRequest,
QueryAuditLogRequest,
CreateRoleRequest,
UpdateRoleRequest,
UpdateRolePermissionsRequest,
} from "./iam.types.js"; } from "./iam.types.js";
import { IamMockClient } from "./iam-mock.client.js"; import { IamMockClient } from "./iam-mock.client.js";
/** iam REST API 响应体通用结构iam 返回 { data: T } 或直接 T */
async function fetchIamRest<T>(
path: string,
init: RequestInit & { ctx?: CallContext } = {},
): Promise<T> {
const baseUrl = env.IAM_SERVICE_URL;
const url = `${baseUrl}${path}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(init.headers as Record<string, string> | undefined),
};
if (init.ctx) {
if (init.ctx.userId) headers["x-user-id"] = init.ctx.userId;
if (init.ctx.traceId) headers["x-request-id"] = init.ctx.traceId;
}
const resp = await fetch(url, { ...init, headers });
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`iam REST ${path} failed: ${resp.status} ${text}`);
}
const body = (await resp.json()) as T;
return body;
}
/** 将 iam REST 返回的 user 字段映射为 IamUser */
function mapIamUser(raw: Record<string, unknown>): IamUser {
return {
id: String(raw.id ?? ""),
email: String(raw.email ?? ""),
name: String(raw.name ?? ""),
status: String(raw.status ?? "active"),
roles: (raw.roles ?? []) as string[],
createdAt: String(raw.createdAt ?? raw.created_at ?? ""),
updatedAt: String(raw.updatedAt ?? raw.updated_at ?? ""),
};
}
/** 将 iam REST 返回的 role 字段映射为 IamRole */
function mapIamRole(raw: Record<string, unknown>): IamRole {
return {
id: String(raw.id ?? ""),
name: String(raw.name ?? ""),
description: raw.description == null ? null : String(raw.description),
roleType: String(raw.roleType ?? raw.role_type ?? "organization"),
createdAt: String(raw.createdAt ?? raw.created_at ?? ""),
updatedAt: String(raw.updatedAt ?? raw.updated_at ?? ""),
};
}
/** 将 iam REST 返回的 permission 字段映射为 IamPermission */
function mapIamPermission(raw: Record<string, unknown>): IamPermission {
return {
id: String(raw.id ?? ""),
code: String(raw.code ?? ""),
name: String(raw.name ?? ""),
description: raw.description == null ? null : String(raw.description),
resource: String(raw.resource ?? ""),
action: String(raw.action ?? ""),
isSystem: Boolean(raw.isSystem ?? raw.is_system ?? false),
createdAt: String(raw.createdAt ?? raw.created_at ?? ""),
updatedAt: String(raw.updatedAt ?? raw.updated_at ?? ""),
};
}
/** 将 iam REST 返回的 audit log 字段映射为 IamAuditLog */
function mapIamAuditLog(raw: Record<string, unknown>): IamAuditLog {
return {
id: String(raw.id ?? ""),
eventId: String(raw.eventId ?? raw.event_id ?? raw.id ?? ""),
createdAt: String(raw.createdAt ?? raw.created_at ?? ""),
actorUserId: String(raw.actorUserId ?? raw.actor_user_id ?? ""),
action: String(raw.action ?? ""),
resourceType: String(raw.resourceType ?? raw.resource_type ?? ""),
resourceId:
raw.resourceId == null ? null : String(raw.resourceId ?? raw.resource_id),
ip: raw.ip == null ? null : String(raw.ip),
userAgent:
raw.userAgent == null ? null : String(raw.userAgent ?? raw.user_agent),
beforeState:
raw.beforeState == null
? null
: typeof raw.beforeState === "string"
? raw.beforeState
: JSON.stringify(raw.beforeState),
afterState:
raw.afterState == null
? null
: typeof raw.afterState === "string"
? raw.afterState
: JSON.stringify(raw.afterState),
traceId: raw.traceId == null ? null : String(raw.traceId ?? raw.trace_id),
};
}
@Injectable() @Injectable()
export class IamGrpcClient extends BaseDownstreamClient implements IamClient { export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
readonly serviceName = "iam" as const; readonly serviceName = "iam" as const;
@@ -30,7 +141,16 @@ export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
this.mock = new IamMockClient(); this.mock = new IamMockClient();
} }
// ===== gRPC 方法 =====
async getUserInfo(ctx: CallContext): Promise<UserInfo> { async getUserInfo(ctx: CallContext): Promise<UserInfo> {
if (!isServiceConfigured("iam")) {
this.log.warn(
{ rpc: "GetUserInfo", reason: "IAM_GRPC_TARGET not configured" },
"Downstream gRPC target not configured, falling back to mock",
);
return this.mock.getUserInfo(ctx);
}
return this.callGrpc("GetUserInfo", async () => { return this.callGrpc("GetUserInfo", async () => {
const client = getGrpcClient("iam") as unknown as { const client = getGrpcClient("iam") as unknown as {
getUserInfo( getUserInfo(
@@ -50,23 +170,265 @@ export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
} }
async getViewports(ctx: CallContext): Promise<ViewportItem[]> { async getViewports(ctx: CallContext): Promise<ViewportItem[]> {
// iam.proto 暂无 GetViewports RPC降级 mock + warning待 coord 补全) // iam.proto 暂无 GetViewports RPC走 REST GET /v1/iam/viewports
this.log.warn( try {
{ rpc: "GetViewports", reason: "RPC not in iam.proto yet" }, const raw = await fetchIamRest<unknown[]>("/v1/iam/viewports", { ctx });
"Downstream RPC not ready, falling back to mock", const arr = Array.isArray(raw)
); ? raw
return this.mock.getViewports(ctx); : ((raw as { data?: unknown[] }).data ?? []);
return arr.map((r): ViewportItem => {
const item = r as Record<string, unknown>;
return {
key: String(item.key ?? ""),
label: String(item.label ?? ""),
route: String(item.route ?? ""),
icon: item.icon == null ? null : String(item.icon),
sortOrder: String(item.sortOrder ?? item.sort_order ?? "0"),
requiredPermission:
item.requiredPermission == null
? null
: String(
item.requiredPermission ?? item.required_permission ?? "",
),
};
});
} catch (err) {
this.log.warn(
{
err: (err as Error).message,
reason: "iam REST /v1/iam/viewports unavailable",
},
"getViewports falling back to mock",
);
return this.mock.getViewports(ctx);
}
} }
async getEffectivePermissions( async getEffectivePermissions(
ctx: CallContext, ctx: CallContext,
): Promise<EffectivePermissions> { ): Promise<EffectivePermissions> {
// iam.proto 暂无 GetEffectivePermissions RPC降级 mock + warning // iam.proto 暂无 GetEffectivePermissions RPC走 REST GET /v1/iam/permissions/effective
this.log.warn( try {
{ rpc: "GetEffectivePermissions", reason: "RPC not in iam.proto yet" }, const raw = await fetchIamRest<Record<string, unknown>>(
"Downstream RPC not ready, falling back to mock", "/v1/iam/permissions/effective",
{ ctx },
);
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
const permissions = (body.permissions ?? []) as string[];
const dataScope = String(body.dataScope ?? body.data_scope ?? "self");
return {
permissions,
// dataScope 转大写GraphQL enum 要求大写iam DB 存小写)
dataScope: dataScope.toUpperCase(),
};
} catch (err) {
this.log.warn(
{
err: (err as Error).message,
reason: "iam REST /v1/iam/permissions/effective unavailable",
},
"getEffectivePermissions falling back to mock",
);
return this.mock.getEffectivePermissions(ctx);
}
}
// ===== REST 方法admin 域需要) =====
async listUsers(
ctx: CallContext,
req: ListUsersRequest,
): Promise<IamUserPage> {
try {
const params = new URLSearchParams();
if (req.keyword) params.set("keyword", req.keyword);
if (req.status) params.set("status", req.status);
if (req.roleId) params.set("roleId", req.roleId);
params.set("page", String(req.page ?? 1));
params.set("pageSize", String(req.pageSize ?? 20));
const raw = await fetchIamRest<Record<string, unknown>>(
`/v1/iam/users?${params.toString()}`,
{ ctx },
);
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
const items = ((body.items ?? body.users ?? []) as unknown[]).map(
(r): IamUser => mapIamUser(r as Record<string, unknown>),
);
return {
items,
total: Number(body.total ?? items.length),
};
} catch (err) {
this.log.warn(
{
err: (err as Error).message,
reason: "iam REST /v1/iam/users unavailable",
},
"listUsers returning empty",
);
return { items: [], total: 0 };
}
}
async updateUser(
ctx: CallContext,
id: string,
input: UpdateUserRequest,
): Promise<IamUser> {
const raw = await fetchIamRest<Record<string, unknown>>(
`/v1/iam/users/${id}`,
{
method: "PATCH",
body: JSON.stringify(input),
ctx,
},
); );
return this.mock.getEffectivePermissions(ctx); const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
return mapIamUser(body);
}
async toggleUserStatus(
ctx: CallContext,
id: string,
status: string,
): Promise<IamUser> {
const raw = await fetchIamRest<Record<string, unknown>>(
`/v1/iam/users/${id}/status`,
{
method: "PATCH",
body: JSON.stringify({ status }),
ctx,
},
);
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
return mapIamUser(body);
}
async getAllRoles(ctx: CallContext): Promise<IamRole[]> {
try {
const raw = await fetchIamRest<unknown[]>("/v1/iam/roles", { ctx });
const arr = Array.isArray(raw)
? raw
: ((raw as { data?: unknown[] }).data ?? []);
return arr.map((r): IamRole => mapIamRole(r as Record<string, unknown>));
} catch (err) {
this.log.warn(
{
err: (err as Error).message,
reason: "iam REST /v1/iam/roles unavailable",
},
"getAllRoles returning empty",
);
return [];
}
}
async createRole(
ctx: CallContext,
input: CreateRoleRequest,
): Promise<IamRole> {
const raw = await fetchIamRest<Record<string, unknown>>("/v1/iam/roles", {
method: "POST",
body: JSON.stringify(input),
ctx,
});
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
return mapIamRole(body);
}
async updateRole(
ctx: CallContext,
id: string,
input: UpdateRoleRequest,
): Promise<IamRole> {
const raw = await fetchIamRest<Record<string, unknown>>(
`/v1/iam/roles/${id}`,
{
method: "PATCH",
body: JSON.stringify(input),
ctx,
},
);
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
return mapIamRole(body);
}
async updateRolePermissions(
ctx: CallContext,
roleId: string,
req: UpdateRolePermissionsRequest,
): Promise<IamRole> {
const raw = await fetchIamRest<Record<string, unknown>>(
`/v1/iam/roles/${roleId}/permissions`,
{
method: "PATCH",
body: JSON.stringify(req),
ctx,
},
);
const body = (raw as { data?: Record<string, unknown> }).data ?? raw;
return mapIamRole(body);
}
async getAllPermissions(ctx: CallContext): Promise<IamPermission[]> {
try {
const raw = await fetchIamRest<unknown[]>("/v1/iam/permissions", { ctx });
const arr = Array.isArray(raw)
? raw
: ((raw as { data?: unknown[] }).data ?? []);
return arr.map((r): IamPermission =>
mapIamPermission(r as Record<string, unknown>),
);
} catch (err) {
this.log.warn(
{
err: (err as Error).message,
reason: "iam REST /v1/iam/permissions unavailable",
},
"getAllPermissions returning empty",
);
return [];
}
}
async queryAuditLog(
ctx: CallContext,
req: QueryAuditLogRequest,
): Promise<IamAuditLog[]> {
try {
const params = new URLSearchParams();
if (req.actorUserId) params.set("actorUserId", req.actorUserId);
if (req.action) params.set("action", req.action);
if (req.resourceType) params.set("resourceType", req.resourceType);
if (req.resourceId) params.set("resourceId", req.resourceId);
if (req.startDate) params.set("startDate", req.startDate);
if (req.endDate) params.set("endDate", req.endDate);
if (req.limit) params.set("limit", String(req.limit));
if (req.offset) params.set("offset", String(req.offset));
const raw = await fetchIamRest<unknown>(
`/v1/iam/audit?${params.toString()}`,
{
ctx,
},
);
const arr = Array.isArray(raw)
? raw
: ((raw as { data?: unknown[] }).data ??
(raw as { items?: unknown[] }).items ??
[]);
return arr.map((r): IamAuditLog =>
mapIamAuditLog(r as Record<string, unknown>),
);
} catch (err) {
this.log.warn(
{
err: (err as Error).message,
reason: "iam REST /v1/iam/audit unavailable",
},
"queryAuditLog returning empty",
);
return [];
}
} }
async checkHealth(): Promise<{ async checkHealth(): Promise<{
@@ -77,3 +439,6 @@ export class IamGrpcClient extends BaseDownstreamClient implements IamClient {
return checkGrpcHealth("iam"); return checkGrpcHealth("iam");
} }
} }
// 避免未使用警告
void logger;

View File

@@ -9,6 +9,17 @@ import type {
UserInfo, UserInfo,
ViewportItem, ViewportItem,
EffectivePermissions, EffectivePermissions,
IamRole,
IamPermission,
IamAuditLog,
IamUser,
IamUserPage,
ListUsersRequest,
UpdateUserRequest,
QueryAuditLogRequest,
CreateRoleRequest,
UpdateRoleRequest,
UpdateRolePermissionsRequest,
} from "./iam.types.js"; } from "./iam.types.js";
/** 教师默认 mock 用户contract §4.1 mock 策略) */ /** 教师默认 mock 用户contract §4.1 mock 策略) */
@@ -27,6 +38,8 @@ const MOCK_TEACHER: UserInfo = {
"class:read", "class:read",
"student:read", "student:read",
], ],
dataScope: "SELF",
status: "active",
}; };
/** 教师默认 mock 视口L1 导航) */ /** 教师默认 mock 视口L1 导航) */
@@ -36,7 +49,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
label: "工作台", label: "工作台",
route: "/dashboard", route: "/dashboard",
icon: null, icon: null,
sortOrder: 1, sortOrder: "1",
requiredPermission: null, requiredPermission: null,
}, },
{ {
@@ -44,7 +57,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
label: "我的班级", label: "我的班级",
route: "/classes", route: "/classes",
icon: null, icon: null,
sortOrder: 2, sortOrder: "2",
requiredPermission: "class:read", requiredPermission: "class:read",
}, },
{ {
@@ -52,7 +65,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
label: "考试管理", label: "考试管理",
route: "/exams", route: "/exams",
icon: null, icon: null,
sortOrder: 3, sortOrder: "3",
requiredPermission: "exam:read", requiredPermission: "exam:read",
}, },
{ {
@@ -60,7 +73,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
label: "作业管理", label: "作业管理",
route: "/homework", route: "/homework",
icon: null, icon: null,
sortOrder: 4, sortOrder: "4",
requiredPermission: "homework:read", requiredPermission: "homework:read",
}, },
{ {
@@ -68,7 +81,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
label: "成绩管理", label: "成绩管理",
route: "/grades", route: "/grades",
icon: null, icon: null,
sortOrder: 5, sortOrder: "5",
requiredPermission: "grade:read", requiredPermission: "grade:read",
}, },
{ {
@@ -76,7 +89,7 @@ const MOCK_VIEWPORTS: ViewportItem[] = [
label: "学情分析", label: "学情分析",
route: "/analytics", route: "/analytics",
icon: null, icon: null,
sortOrder: 6, sortOrder: "6",
requiredPermission: "student:read", requiredPermission: "student:read",
}, },
]; ];
@@ -87,6 +100,52 @@ const MOCK_PERMISSIONS: EffectivePermissions = {
dataScope: "SELF", dataScope: "SELF",
}; };
/** mock 角色 */
const MOCK_ROLES: IamRole[] = [
{
id: "role-001",
name: "teacher",
description: "教师角色",
roleType: "system",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "role-002",
name: "admin",
description: "管理员角色",
roleType: "system",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
/** mock 权限点 */
const MOCK_PERMISSIONS_LIST: IamPermission[] = [
{
id: "perm-001",
code: "exam:read",
name: "查看考试",
description: "查看考试列表和详情",
resource: "exam",
action: "read",
isSystem: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "perm-002",
code: "exam:write",
name: "创建考试",
description: "创建和编辑考试",
resource: "exam",
action: "write",
isSystem: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
@Injectable() @Injectable()
export class IamMockClient extends BaseDownstreamClient implements IamClient { export class IamMockClient extends BaseDownstreamClient implements IamClient {
readonly serviceName = "iam" as const; readonly serviceName = "iam" as const;
@@ -114,6 +173,132 @@ export class IamMockClient extends BaseDownstreamClient implements IamClient {
return MOCK_PERMISSIONS; return MOCK_PERMISSIONS;
} }
async listUsers(
ctx: CallContext,
req: ListUsersRequest,
): Promise<IamUserPage> {
this.log.debug({ userId: ctx.userId, req }, "Mock listUsers");
const page = req.page ?? 1;
const pageSize = req.pageSize ?? 20;
const items: IamUser[] = [
{
id: "user-001",
email: "teacher@edu.test",
name: "张老师",
status: "active",
roles: ["teacher"],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
return {
items: items.slice((page - 1) * pageSize, page * pageSize),
total: items.length,
};
}
async updateUser(
ctx: CallContext,
id: string,
input: UpdateUserRequest,
): Promise<IamUser> {
this.log.debug({ userId: ctx.userId, id, input }, "Mock updateUser");
return {
id,
email: input.email ?? "updated@edu.test",
name: input.name ?? "Updated User",
status: input.status ?? "active",
roles: input.roleIds ?? [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: new Date().toISOString(),
};
}
async toggleUserStatus(
ctx: CallContext,
id: string,
status: string,
): Promise<IamUser> {
this.log.debug({ userId: ctx.userId, id, status }, "Mock toggleUserStatus");
return {
id,
email: "user@edu.test",
name: "User",
status,
roles: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: new Date().toISOString(),
};
}
async getAllRoles(ctx: CallContext): Promise<IamRole[]> {
this.log.debug({ userId: ctx.userId }, "Mock getAllRoles");
return MOCK_ROLES;
}
async createRole(
ctx: CallContext,
input: CreateRoleRequest,
): Promise<IamRole> {
this.log.debug({ userId: ctx.userId, input }, "Mock createRole");
return {
id: `role-${Date.now()}`,
name: input.name,
description: input.description ?? null,
roleType: input.roleType ?? "organization",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async updateRole(
ctx: CallContext,
id: string,
input: UpdateRoleRequest,
): Promise<IamRole> {
this.log.debug({ userId: ctx.userId, id, input }, "Mock updateRole");
return {
id,
name: input.name ?? "Updated Role",
description: input.description ?? null,
roleType: "organization",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: new Date().toISOString(),
};
}
async updateRolePermissions(
ctx: CallContext,
roleId: string,
req: UpdateRolePermissionsRequest,
): Promise<IamRole> {
this.log.debug(
{ userId: ctx.userId, roleId, req },
"Mock updateRolePermissions",
);
return {
id: roleId,
name: "Updated Role",
description: null,
roleType: "organization",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: new Date().toISOString(),
};
}
async getAllPermissions(ctx: CallContext): Promise<IamPermission[]> {
this.log.debug({ userId: ctx.userId }, "Mock getAllPermissions");
return MOCK_PERMISSIONS_LIST;
}
async queryAuditLog(
ctx: CallContext,
req: QueryAuditLogRequest,
): Promise<IamAuditLog[]> {
this.log.debug({ userId: ctx.userId, req }, "Mock queryAuditLog");
return [];
}
async checkHealth(): Promise<{ async checkHealth(): Promise<{
serving: boolean; serving: boolean;
latencyMs: number; latencyMs: number;

View File

@@ -1,30 +1,36 @@
// iam 下游服务类型定义(对齐 iam.proto + 待 coord 补全的 RPC // iam 下游服务类型定义(对齐 iam.proto + REST CRUD 端点
// iam.proto 现状:4 RPCRegister/Login/RefreshToken/GetUserInfo // iam.proto 现状:6 RPCRegister/Login/RefreshToken/Logout/GetUserInfo/GetEffectiveDataScope
// 待 coord 补全GetViewports / GetEffectivePermissions / GetEffectiveAccess / BatchGetUsers / GetChildrenByParent // REST 端点users / roles / permissions / viewports / audit 全部 CRUD
// BFF 通过 IamClient 接口统一访问gRPC 优先(仅 GetUserInfo其余走 REST
/** 用户信息(对齐 iam.proto UserInfo message */ /** 用户信息(对齐 iam.proto UserInfo message
* data_scope / status 字段对齐 protosnake_case → camelCase 由 proto-loader 转换) */
export interface UserInfo { export interface UserInfo {
id: string; id: string;
email: string; email: string;
name: string; name: string;
roles: string[]; roles: string[];
permissions: string[]; permissions: string[];
/** 数据范围self/subject/class/grade/school/all对齐 GraphQL enum DataScope 大写) */
dataScope?: string;
/** 用户状态active/disabled */
status?: string;
} }
/** 视口项L1 导航菜单,待 coord 补 GetViewports RPC 的 response message */ /** 视口项L1 导航菜单,对齐 iam REST GET /v1/iam/viewports */
export interface ViewportItem { export interface ViewportItem {
key: string; key: string;
label: string; label: string;
route: string; route: string;
icon: string | null; icon: string | null;
sortOrder: number; sortOrder: string;
requiredPermission: string | null; requiredPermission: string | null;
} }
/** 有效权限集(待 coord 补 GetEffectivePermissions RPC 的 response message */ /** 有效权限集(对齐 iam REST GET /v1/iam/permissions/effective */
export interface EffectivePermissions { export interface EffectivePermissions {
permissions: string[]; permissions: string[];
/** 数据范围(SELF / CLASS / GRADE / SCHOOL / DISTRICT / ALL对齐 GraphQL enum DataScope */ /** 数据范围(self/class/grade/school/all */
dataScope: string; dataScope: string;
} }
@@ -42,3 +48,117 @@ export interface GetViewportsRequest {
export interface GetEffectivePermissionsRequest { export interface GetEffectivePermissionsRequest {
userId: string; userId: string;
} }
// ===== REST 端点返回类型 =====
/** 角色(对齐 iam REST GET /v1/iam/roles */
export interface IamRole {
id: string;
name: string;
description: string | null;
/** 角色类型system/organization/temporary */
roleType: string;
createdAt: string;
updatedAt: string;
}
/** 权限点(对齐 iam REST GET /v1/iam/permissions */
export interface IamPermission {
id: string;
code: string;
name: string;
description: string | null;
resource: string;
action: string;
isSystem: boolean;
createdAt: string;
updatedAt: string;
}
/** 审计日志(对齐 iam REST GET /v1/iam/audit */
export interface IamAuditLog {
id: string;
/** 事件 IDUUID */
eventId: string;
/** 操作时间 ISO8601 */
createdAt: string;
/** 操作者用户 ID */
actorUserId: string;
/** 操作动作login/logout/create/update/delete/... */
action: string;
/** 资源类型user/role/permission/viewport/... */
resourceType: string;
/** 资源 ID */
resourceId: string | null;
/** IP 地址 */
ip: string | null;
/** User-Agent */
userAgent: string | null;
/** 操作前状态JSON 字符串) */
beforeState: string | null;
/** 操作后状态JSON 字符串) */
afterState: string | null;
/** traceId */
traceId: string | null;
}
/** 用户列表项(对齐 iam REST GET /v1/iam/users */
export interface IamUser {
id: string;
email: string;
name: string;
status: string;
roles: string[];
createdAt: string;
updatedAt: string;
}
/** 用户列表响应(对齐 iam REST GET /v1/iam/users 分页结构) */
export interface IamUserPage {
items: IamUser[];
total: number;
}
// ===== Request 类型 =====
export interface ListUsersRequest {
keyword?: string;
status?: string;
roleId?: string;
page?: number;
pageSize?: number;
}
export interface UpdateUserRequest {
name?: string;
email?: string;
status?: string;
roleIds?: string[];
}
export interface QueryAuditLogRequest {
actorUserId?: string;
action?: string;
resourceType?: string;
resourceId?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
}
export interface CreateRoleRequest {
name: string;
code?: string;
description?: string;
roleType?: string;
}
export interface UpdateRoleRequest {
name?: string;
description?: string;
}
export interface UpdateRolePermissionsRequest {
permissionCodes: string[];
}

View File

@@ -0,0 +1,39 @@
// MsgClient 接口 + DI tokenB8 裁决DownstreamClient 抽象)
// 接口定义所有 P5+ 需要的 msg RPC实现分 gRPC + mock 两种
// 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPC
import type { CallContext } from "../types.js";
import type {
ListNotificationsRequest,
ListNotificationsResponse,
SearchNotificationsRequest,
SearchNotificationsResponse,
MarkAsReadRequest,
MarkAsReadResponse,
} from "./msg.types.js";
/** MsgClient DI tokenNestJS 注入用) */
export const MSG_CLIENT = Symbol("MSG_CLIENT");
/** MsgClient 接口(所有 BFF 统一依赖此接口,不依赖具体实现) */
export interface MsgClient {
// ===== NotificationService =====
listNotifications(
ctx: CallContext,
req: ListNotificationsRequest,
): Promise<ListNotificationsResponse>;
searchNotifications(
ctx: CallContext,
req: SearchNotificationsRequest,
): Promise<SearchNotificationsResponse>;
markAsRead(
ctx: CallContext,
req: MarkAsReadRequest,
): Promise<MarkAsReadResponse>;
// ===== 健康检查 =====
checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}>;
}

View File

@@ -0,0 +1,186 @@
// MsgClient gRPC 实现B2 裁决:首次实现即 gRPC
// msg.proto 现状 NotificationService 9 RPCcontract §2.1.5 标 3 RPC 已就绪)
// proto 字段 snake_case 转 camelCase
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 { MsgClient } from "./msg-client.interface.js";
import type {
Notification,
ListNotificationsRequest,
ListNotificationsResponse,
SearchNotificationsRequest,
SearchNotificationsResponse,
MarkAsReadRequest,
MarkAsReadResponse,
} from "./msg.types.js";
/** proto message 字段是 snake_casegRPC 返回需转 camelCase */
function mapNotification(raw: Record<string, unknown>): Notification {
return {
id: String(raw.id ?? ""),
userId: String(raw.user_id ?? ""),
type: String(raw.type ?? ""),
title: String(raw.title ?? ""),
content: String(raw.content ?? ""),
channel: String(raw.channel ?? ""),
isRead: Boolean(raw.is_read ?? false),
createdAt: String(raw.created_at ?? ""),
status: String(raw.status ?? ""),
relatedEntityType: String(raw.related_entity_type ?? ""),
relatedEntityId: String(raw.related_entity_id ?? ""),
groupId: String(raw.group_id ?? ""),
senderId: String(raw.sender_id ?? ""),
templateId: String(raw.template_id ?? ""),
eventId: String(raw.event_id ?? ""),
readAt: String(raw.read_at ?? ""),
updatedAt: String(raw.updated_at ?? ""),
metadata: (raw.metadata as Record<string, string> | undefined) ?? {},
};
}
@Injectable()
export class MsgGrpcClient extends BaseDownstreamClient implements MsgClient {
readonly serviceName = "msg" as const;
constructor() {
super();
this.initLogger();
}
// ===== NotificationService =====
async listNotifications(
ctx: CallContext,
req: ListNotificationsRequest,
): Promise<ListNotificationsResponse> {
return this.callGrpc("ListNotifications", async () => {
const client = getGrpcClient("msg", "NotificationService") as unknown as {
listNotifications(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { notifications?: unknown[]; total?: number },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<ListNotificationsResponse>((resolve, reject) => {
client.listNotifications(
{
user_id: req.userId,
only_unread: req.onlyUnread,
type: req.type,
page: req.page,
page_size: req.pageSize,
},
meta,
(err, res) => {
if (err) reject(err);
else {
const notifications = (res.notifications ?? []) as Record<
string,
unknown
>[];
resolve({
notifications: notifications.map(mapNotification),
total: res.total ?? 0,
});
}
},
);
});
});
}
async searchNotifications(
ctx: CallContext,
req: SearchNotificationsRequest,
): Promise<SearchNotificationsResponse> {
return this.callGrpc("SearchNotifications", async () => {
const client = getGrpcClient("msg", "NotificationService") as unknown as {
searchNotifications(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: { notifications?: unknown[]; total?: number },
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<SearchNotificationsResponse>((resolve, reject) => {
client.searchNotifications(
{
user_id: req.userId,
query: req.query,
type: req.type,
page: req.page,
page_size: req.pageSize,
},
meta,
(err, res) => {
if (err) reject(err);
else {
const notifications = (res.notifications ?? []) as Record<
string,
unknown
>[];
resolve({
notifications: notifications.map(mapNotification),
total: res.total ?? 0,
});
}
},
);
});
});
}
async markAsRead(
ctx: CallContext,
req: MarkAsReadRequest,
): Promise<MarkAsReadResponse> {
return this.callGrpc("MarkAsRead", async () => {
const client = getGrpcClient("msg", "NotificationService") as unknown as {
markAsRead(
req: Record<string, unknown>,
meta: grpc.Metadata,
cb: (
err: grpc.ServiceError | null,
res: Record<string, unknown>,
) => void,
): void;
};
const meta = createGrpcMetadata(ctx.userId, ctx.traceId);
return new Promise<MarkAsReadResponse>((resolve, reject) => {
client.markAsRead(
{
id: req.id,
user_id: req.userId,
},
meta,
(err, _res) => {
if (err) reject(err);
else resolve({ success: true });
},
);
});
});
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return checkGrpcHealth("msg");
}
}

View File

@@ -0,0 +1,178 @@
// MsgClient Mock 实现B8 裁决:上游就绪前的降级策略)
// DEV_MODE=true 时全部走 mock
// mock 数据对齐 contract §2.1.5 mock 策略
import { Injectable } from "@nestjs/common";
import { BaseDownstreamClient } from "../base.client.js";
import type { CallContext } from "../types.js";
import type { MsgClient } from "./msg-client.interface.js";
import type {
Notification,
ListNotificationsRequest,
ListNotificationsResponse,
SearchNotificationsRequest,
SearchNotificationsResponse,
MarkAsReadRequest,
MarkAsReadResponse,
} from "./msg.types.js";
/** mock 通知数据contract §2.1.5:返回固定 5 条通知) */
const MOCK_NOTIFICATIONS: Notification[] = [
{
id: "notif-001",
userId: "teacher-001",
type: "SYSTEM",
title: "欢迎使用教学平台",
content: "您的账号已开通,请尽快完善个人信息。",
channel: "IN_APP",
isRead: false,
createdAt: "1782864000000",
status: "SENT",
relatedEntityType: "",
relatedEntityId: "",
groupId: "",
senderId: "system",
templateId: "tpl-system-welcome",
eventId: "",
readAt: "",
updatedAt: "1782864000000",
metadata: {},
},
{
id: "notif-002",
userId: "teacher-001",
type: "HOMEWORK",
title: "新作业提交提醒",
content: "三年级1班共有 5 名学生提交了数学作业1请及时批改。",
channel: "IN_APP",
isRead: false,
createdAt: "1782950400000",
status: "SENT",
relatedEntityType: "homework",
relatedEntityId: "hw-001",
groupId: "",
senderId: "core-edu",
templateId: "tpl-homework-submit",
eventId: "HomeworkSubmitted",
readAt: "",
updatedAt: "1782950400000",
metadata: {},
},
{
id: "notif-003",
userId: "teacher-001",
type: "EXAM",
title: "考试成绩已发布",
content: "语文月考成绩已发布,请查看详细分析报告。",
channel: "IN_APP",
isRead: true,
createdAt: "1783036800000",
status: "READ",
relatedEntityType: "exam",
relatedEntityId: "exam-001",
groupId: "",
senderId: "core-edu",
templateId: "tpl-exam-grade",
eventId: "GradeReleased",
readAt: "1783123200000",
updatedAt: "1783123200000",
metadata: {},
},
{
id: "notif-004",
userId: "teacher-001",
type: "ATTENDANCE",
title: "考勤异常提醒",
content: "三年级2班今日有 3 名学生缺勤,请关注。",
channel: "IN_APP",
isRead: true,
createdAt: "1783123200000",
status: "READ",
relatedEntityType: "attendance",
relatedEntityId: "att-2026-07-04",
groupId: "",
senderId: "core-edu",
templateId: "tpl-attendance-alert",
eventId: "AttendanceAnomaly",
readAt: "1783209600000",
updatedAt: "1783209600000",
metadata: {},
},
{
id: "notif-005",
userId: "teacher-001",
type: "SYSTEM",
title: "系统维护通知",
content: "平台将于本周日 02:00-04:00 进行系统维护,请提前保存工作。",
channel: "IN_APP",
isRead: false,
createdAt: "1783209600000",
status: "SENT",
relatedEntityType: "",
relatedEntityId: "",
groupId: "",
senderId: "system",
templateId: "tpl-system-maintenance",
eventId: "",
readAt: "",
updatedAt: "1783209600000",
metadata: {},
},
];
@Injectable()
export class MsgMockClient extends BaseDownstreamClient implements MsgClient {
readonly serviceName = "msg" as const;
constructor() {
super();
this.initLogger();
}
async listNotifications(
ctx: CallContext,
req: ListNotificationsRequest,
): Promise<ListNotificationsResponse> {
this.log.debug({ userId: ctx.userId, req }, "Mock listNotifications");
let list = MOCK_NOTIFICATIONS.filter((n) => n.userId === req.userId);
if (req.onlyUnread) {
list = list.filter((n) => !n.isRead);
}
if (req.type) {
list = list.filter((n) => n.type === req.type);
}
return { notifications: list, total: list.length };
}
async searchNotifications(
ctx: CallContext,
req: SearchNotificationsRequest,
): Promise<SearchNotificationsResponse> {
this.log.debug({ userId: ctx.userId, req }, "Mock searchNotifications");
let list = MOCK_NOTIFICATIONS.filter((n) => n.userId === req.userId);
if (req.query) {
list = list.filter(
(n) => n.title.includes(req.query) || n.content.includes(req.query),
);
}
if (req.type) {
list = list.filter((n) => n.type === req.type);
}
return { notifications: list, total: list.length };
}
async markAsRead(
ctx: CallContext,
req: MarkAsReadRequest,
): Promise<MarkAsReadResponse> {
this.log.debug({ userId: ctx.userId, req }, "Mock markAsRead");
return { success: true };
}
async checkHealth(): Promise<{
serving: boolean;
latencyMs: number;
error?: string;
}> {
return { serving: true, latencyMs: 0 };
}
}

View File

@@ -0,0 +1,32 @@
// msg 模块B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
import { Module } from "@nestjs/common";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import { MSG_CLIENT } from "./msg-client.interface.js";
import { MsgGrpcClient } from "./msg-grpc.client.js";
import { MsgMockClient } from "./msg-mock.client.js";
@Module({
providers: [
{
provide: MSG_CLIENT,
useFactory: () => {
// DEV_MODE=true 或 MSG_GRPC_TARGET 未配置 → 使用 mock
if (env.TEACHER_BFF_DEV_MODE || !env.MSG_GRPC_TARGET) {
logger.warn(
{ devMode: env.TEACHER_BFF_DEV_MODE, target: env.MSG_GRPC_TARGET },
"MsgClient using mock (DEV_MODE or target not configured)",
);
return new MsgMockClient();
}
logger.info(
{ devMode: false, target: env.MSG_GRPC_TARGET },
"MsgClient using gRPC",
);
return new MsgGrpcClient();
},
},
],
exports: [MSG_CLIENT],
})
export class MsgModule {}

View File

@@ -0,0 +1,64 @@
// msg 下游服务类型定义(对齐 msg.proto
// msg.proto 现状NotificationService 9 RPCP5 启用contract §2.1.5 标 3 RPC 已就绪)
// BFF 当前消费ListNotifications / SearchNotifications / MarkAsRead
/** 通知(对齐 msg.proto Notification message */
export interface Notification {
id: string;
userId: string;
type: string;
title: string;
content: string;
channel: string;
isRead: boolean;
createdAt: string;
status: string;
relatedEntityType: string;
relatedEntityId: string;
groupId: string;
senderId: string;
templateId: string;
eventId: string;
readAt: string;
updatedAt: string;
metadata: Record<string, string>;
}
// ===== Request 类型 =====
export interface ListNotificationsRequest {
userId: string;
onlyUnread: boolean;
type: string;
page: number;
pageSize: number;
}
export interface SearchNotificationsRequest {
userId: string;
query: string;
type: string;
page: number;
pageSize: number;
}
export interface MarkAsReadRequest {
id: string;
userId: string;
}
// ===== Response 类型 =====
export interface ListNotificationsResponse {
notifications: Notification[];
total: number;
}
export interface SearchNotificationsResponse {
notifications: Notification[];
total: number;
}
export interface MarkAsReadResponse {
success: boolean;
}

View File

@@ -23,13 +23,23 @@ const envSchema = z.object({
// Redis/readyz 探针 + 后续 DataLoader 缓存) // Redis/readyz 探针 + 后续 DataLoader 缓存)
REDIS_URL: z.string().default("redis://localhost:6379"), REDIS_URL: z.string().default("redis://localhost:6379"),
// 下游 gRPC targets按阶段启用,未启用者可留空字符串 // 下游 gRPC targetsv2 默认值对齐 Docker 服务名 + 端口分配
IAM_GRPC_TARGET: z.string().default("localhost:50052"), // 端口分配见 docs/architecture/port-allocation.md §5
CORE_EDU_GRPC_TARGET: z.string().default(""), // iam 50052 / core-edu 50053 / content 50054 / data-ana 50055 / msg 50056 / ai 50058
CONTENT_GRPC_TARGET: z.string().default(""), // Docker 部署:服务名解析通过 docker network本地开发覆盖为 localhost:<port>
DATA_ANA_GRPC_TARGET: z.string().default(""), // 选择策略TEACHER_BFF_DEV_MODE=true → mockfalse → gRPCtargets 默认已配置)
MSG_GRPC_TARGET: z.string().default(""), IAM_GRPC_TARGET: z.string().default("iam:50052"),
AI_GRPC_TARGET: z.string().default(""), CORE_EDU_GRPC_TARGET: z.string().default("core-edu:50053"),
CONTENT_GRPC_TARGET: z.string().default("content:50054"),
DATA_ANA_GRPC_TARGET: z.string().default("data-ana:50055"),
MSG_GRPC_TARGET: z.string().default("msg:50056"),
AI_GRPC_TARGET: z.string().default("ai:50058"),
// 下游 REST 服务 URLadmin 域 REST 调用 + ARB-008 公告降级 REST only
// iam RESTusers/roles/permissions/audit 端点
// msg REST/announcements 公告端点ARB-008 裁决)
IAM_SERVICE_URL: z.string().default("http://iam:3002"),
MSG_SERVICE_URL: z.string().default("http://msg:3007"),
// 可观测性OTEL_EXPORTER_OTLP_ENDPOINT 缺省则不启用 tracer // 可观测性OTEL_EXPORTER_OTLP_ENDPOINT 缺省则不启用 tracer
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),

View File

@@ -1,4 +1,4 @@
// GraphQL Yoga endpointB1 裁决P2 起直接 GraphQL // GraphQL Yoga endpointB1 裁决P2 起直接 GraphQLARB-001 扁平命名合并 admin resolvers
// POST /graphql — GraphQL query/mutation endpoint // POST /graphql — GraphQL query/mutation endpoint
// GET /graphql — GraphQL Playground开发环境/ schema introspection // GET /graphql — GraphQL Playground开发环境/ schema introspection
import { Controller, Post, Get, Req, Res, Inject } from "@nestjs/common"; import { Controller, Post, Get, Req, Res, Inject } from "@nestjs/common";
@@ -9,21 +9,30 @@ import { logger } from "../shared/observability/logger.js";
import { loadSchema } from "./schema-loader.js"; import { loadSchema } from "./schema-loader.js";
import { buildGraphQLContext } from "./context.js"; import { buildGraphQLContext } from "./context.js";
import { TeacherService } from "../teacher/teacher.service.js"; import { TeacherService } from "../teacher/teacher.service.js";
import { AdminService } from "../admin/admin.service.js";
import { buildResolvers } from "../teacher/teacher.resolver.js"; import { buildResolvers } from "../teacher/teacher.resolver.js";
import { buildAdminResolvers } from "../admin/admin.resolver.js";
import { mergeResolvers } from "./merge-resolvers.js";
@Controller("graphql") @Controller("graphql")
export class GraphQLController { export class GraphQLController {
// Yoga 实例类型复杂,用内联类型避免泛型不匹配
private readonly yoga: (req: Request, res: Response) => Promise<void>; private readonly yoga: (req: Request, res: Response) => Promise<void>;
constructor(@Inject(TeacherService) service: TeacherService) { constructor(
const resolvers = buildResolvers(service); @Inject(TeacherService) teacherService: TeacherService,
@Inject(AdminService) adminService: AdminService,
) {
// ARB-001扁平命名合并 teacher + admin resolvers
// 顺序admin 先 → teacher 后Object.assign 后者覆盖前者)
// teacher.updateUser 需覆盖 admin.updateUserschema 统一为 User! 返回类型id 可选)
const teacherResolvers = buildResolvers(teacherService);
const adminResolvers = buildAdminResolvers(adminService);
const resolvers = mergeResolvers(adminResolvers, teacherResolvers);
const schema = loadSchema(resolvers); const schema = loadSchema(resolvers);
const yoga = createYoga({ const yoga = createYoga({
schema, schema,
graphqlEndpoint: env.GRAPHQL_PATH, graphqlEndpoint: env.GRAPHQL_PATH,
// graphql-yoga v5request 是 WHATWG Requestheaders 需用 .get() 提取
context: ({ request }) => { context: ({ request }) => {
const req = { const req = {
headers: { headers: {
@@ -40,7 +49,6 @@ export class GraphQLController {
warn: (msg: unknown) => logger.warn({ yoga: msg }, "GraphQL Yoga"), warn: (msg: unknown) => logger.warn({ yoga: msg }, "GraphQL Yoga"),
error: (msg: unknown) => logger.error({ yoga: msg }, "GraphQL Yoga"), error: (msg: unknown) => logger.error({ yoga: msg }, "GraphQL Yoga"),
}, },
// GraphQL Playground开发环境启用
graphiql: env.NODE_ENV === "development", graphiql: env.NODE_ENV === "development",
}); });
@@ -51,7 +59,7 @@ export class GraphQLController {
logger.info( logger.info(
{ graphqlEndpoint: env.GRAPHQL_PATH }, { graphqlEndpoint: env.GRAPHQL_PATH },
"GraphQL Yoga endpoint initialized", "GraphQL Yoga endpoint initialized (teacher + admin resolvers merged)",
); );
} }

View File

@@ -1,10 +1,11 @@
// GraphQL 模块B1 裁决P2 起直接 GraphQL Yoga // GraphQL 模块B1 裁决P2 起 GraphQL YogaARB-001 扁平命名合并 admin resolvers
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { GraphQLController } from "./graphql.controller.js"; import { GraphQLController } from "./graphql.controller.js";
import { TeacherModule } from "../teacher/teacher.module.js"; import { TeacherModule } from "../teacher/teacher.module.js";
import { AdminModule } from "../admin/admin.module.js";
@Module({ @Module({
imports: [TeacherModule], imports: [TeacherModule, AdminModule],
controllers: [GraphQLController], controllers: [GraphQLController],
}) })
export class GraphQLModule {} export class GraphQLModule {}

View File

@@ -0,0 +1,19 @@
// 深合并多个 IResolvers 对象ARB-001teacher + admin resolvers 扁平命名合并)
// 避免浅展开覆盖 Query/Mutation 字典
import type { IResolvers } from "@graphql-tools/utils";
/** 深合并多个 resolvers 对象的 Query/Mutation 字典 */
export function mergeResolvers(...resolvers: IResolvers[]): IResolvers {
const merged: Record<string, Record<string, unknown>> = {};
for (const resolver of resolvers) {
for (const [typeKey, typeResolvers] of Object.entries(resolver)) {
if (!merged[typeKey]) {
merged[typeKey] = {};
}
Object.assign(merged[typeKey], typeResolvers);
}
}
return merged as IResolvers;
}

View File

@@ -0,0 +1,26 @@
// ai gRPC 健康探针(/readyz P5 必需依赖)
import { Injectable, Inject } from "@nestjs/common";
import { AI_CLIENT } from "../../clients/ai/ai-client.interface.js";
import type { AiClient } from "../../clients/ai/ai-client.interface.js";
import type { HealthProbe } from "./readiness.probe.js";
@Injectable()
export class AiGrpcHealthProbe implements HealthProbe {
readonly name = "ai-grpc";
readonly required = true;
constructor(@Inject(AI_CLIENT) private readonly ai: AiClient) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const result = await this.ai.checkHealth();
return {
status: result.serving ? "up" : "down",
latencyMs: result.latencyMs,
error: result.error,
};
}
}

View File

@@ -0,0 +1,28 @@
// content gRPC 健康探针(/readyz P4 必需依赖)
import { Injectable, Inject } from "@nestjs/common";
import { CONTENT_CLIENT } from "../../clients/content/content-client.interface.js";
import type { ContentClient } from "../../clients/content/content-client.interface.js";
import type { HealthProbe } from "./readiness.probe.js";
@Injectable()
export class ContentGrpcHealthProbe implements HealthProbe {
readonly name = "content-grpc";
readonly required = true;
constructor(
@Inject(CONTENT_CLIENT) private readonly content: ContentClient,
) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const result = await this.content.checkHealth();
return {
status: result.serving ? "up" : "down",
latencyMs: result.latencyMs,
error: result.error,
};
}
}

View File

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

View File

@@ -0,0 +1,28 @@
// data-ana gRPC 健康探针(/readyz P4 必需依赖)
import { Injectable, Inject } from "@nestjs/common";
import { DATA_ANA_CLIENT } from "../../clients/data-ana/data-ana-client.interface.js";
import type { DataAnaClient } from "../../clients/data-ana/data-ana-client.interface.js";
import type { HealthProbe } from "./readiness.probe.js";
@Injectable()
export class DataAnaGrpcHealthProbe implements HealthProbe {
readonly name = "data-ana-grpc";
readonly required = true;
constructor(
@Inject(DATA_ANA_CLIENT) private readonly dataAna: DataAnaClient,
) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const result = await this.dataAna.checkHealth();
return {
status: result.serving ? "up" : "down",
latencyMs: result.latencyMs,
error: result.error,
};
}
}

View File

@@ -0,0 +1,26 @@
// msg gRPC 健康探针(/readyz P5 必需依赖)
import { Injectable, Inject } from "@nestjs/common";
import { MSG_CLIENT } from "../../clients/msg/msg-client.interface.js";
import type { MsgClient } from "../../clients/msg/msg-client.interface.js";
import type { HealthProbe } from "./readiness.probe.js";
@Injectable()
export class MsgGrpcHealthProbe implements HealthProbe {
readonly name = "msg-grpc";
readonly required = true;
constructor(@Inject(MSG_CLIENT) private readonly msg: MsgClient) {}
async check(): Promise<{
status: "up" | "down";
latencyMs: number;
error?: string;
}> {
const result = await this.msg.checkHealth();
return {
status: result.serving ? "up" : "down",
latencyMs: result.latencyMs,
error: result.error,
};
}
}

View File

@@ -1,4 +1,4 @@
import pino from "pino"; import { pino } from "pino";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
export const logger = pino({ export const logger = pino({
@@ -16,4 +16,4 @@ export const logger = pino({
: undefined, : undefined,
}); });
export type Logger = typeof logger; export type Logger = ReturnType<typeof pino>;

View File

@@ -0,0 +1,122 @@
// Zod 验证 schemasP6 Nice to Have 补全:全 Controller 覆盖)
// 对齐 teacher-bff.schema.graphql Input 类型
import { z } from "zod";
/** 创建考试 Input 验证 */
export const createExamInputSchema = z.object({
classId: z.string().min(1, "classId is required"),
title: z.string().min(1, "title is required").max(200, "title too long"),
description: z.string().max(1000, "description too long").default(""),
examDate: z.string().min(1, "examDate is required"),
duration: z.string().min(1, "duration is required"),
totalScore: z.string().min(1, "totalScore is required"),
});
/** 布置作业 Input 验证 */
export const assignHomeworkInputSchema = z.object({
classId: z.string().min(1, "classId is required"),
title: z.string().min(1, "title is required").max(200, "title too long"),
description: z.string().max(1000, "description too long").default(""),
dueDate: z.string().min(1, "dueDate is required"),
});
/** 录入成绩 Input 验证 */
export const recordGradeInputSchema = z.object({
studentId: z.string().min(1, "studentId is required"),
examId: z.string().optional(),
homeworkId: z.string().optional(),
score: z.string().min(1, "score is required"),
feedback: z.string().max(500, "feedback too long").optional(),
});
/** AI 出题 Input 验证 */
export const generateQuestionInputSchema = z.object({
prompt: z.string().min(1, "prompt is required").max(2000, "prompt too long"),
subject: z.string().min(1, "subject is required"),
difficulty: z.enum(["easy", "medium", "hard"]),
knowledgePointIds: z.array(z.string()).default([]),
questionType: z
.enum([
"single_choice",
"multi_choice",
"fill_blank",
"short_answer",
"essay",
])
.default("short_answer"),
count: z.number().int().min(1).max(10).default(1),
});
/** admin 创建用户 Input 验证 */
export const adminCreateUserInputSchema = z.object({
email: z.string().email("invalid email"),
name: z.string().min(1, "name is required").max(50, "name too long"),
roles: z.array(z.string()).min(1, "at least one role required"),
password: z
.string()
.min(8, "password must be at least 8 characters")
.max(128, "password too long"),
});
/** admin 更新用户 Input 验证 */
export const adminUpdateUserInputSchema = z.object({
name: z.string().min(1).max(50).optional(),
roles: z.array(z.string()).optional(),
status: z.enum(["ACTIVE", "INACTIVE", "SUSPENDED"]).optional(),
});
/** admin 创建角色 Input 验证 */
export const adminCreateRoleInputSchema = z.object({
name: z.string().min(1, "name is required").max(50, "name too long"),
description: z
.string()
.max(200, "description too long")
.optional()
.default(""),
permissions: z.array(z.string()).min(1, "at least one permission required"),
});
/** admin 更新学校 Input 验证 */
export const adminUpdateSchoolInputSchema = z.object({
name: z.string().min(1, "name is required").max(100, "name too long"),
address: z.string().max(200, "address too long").optional().default(""),
phone: z.string().max(20, "phone too long").optional().default(""),
email: z.string().email("invalid email").optional().or(z.literal("")),
});
/** admin 创建组织 Input 验证 */
export const adminCreateOrganizationInputSchema = z.object({
name: z.string().min(1, "name is required").max(100, "name too long"),
type: z.enum(["DEPARTMENT", "TEACHING_GROUP", "GRADE", "CLASS"]),
parentId: z.string().optional().default(""),
});
/** 日期范围 Input 验证 */
export const dateRangeInputSchema = z.object({
start: z.string().min(1, "start date is required"),
end: z.string().min(1, "end date is required"),
});
/** 验证失败错误类型 */
export interface ValidationError {
field: string;
message: string;
}
/** 执行验证并返回结构化错误data 类型为 schema 的 output 类型) */
export function validateInput<S extends z.ZodSchema>(
schema: S,
input: unknown,
):
| { success: true; data: z.output<S> }
| { success: false; errors: ValidationError[] } {
const result = schema.safeParse(input);
if (result.success) {
return { success: true, data: result.data };
}
const errors: ValidationError[] = result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
}));
return { success: false, errors };
}

View File

@@ -0,0 +1,64 @@
// SSE 端点P5ai.StreamChat gRPC stream → BFF → 前端 EventSource
// 路由GET /ai/chat/stream
// 裁决依据02-architecture-design.md §10
import { Controller, Get, Query, Res, Req } from "@nestjs/common";
import type { Request, Response } from "express";
import { TeacherService } from "./teacher.service.js";
import type { CallContext } from "../clients/types.js";
import type { ChatMessage } from "../clients/ai/ai.types.js";
@Controller("/ai/chat")
export class AiChatSseController {
constructor(private readonly teacherService: TeacherService) {}
/**
* SSE 流式聊天端点
* 前端使用 EventSource 连接BFF 透传 ai.StreamChat gRPC stream
*/
@Get("stream")
async streamChat(
@Req() req: Request,
@Res() res: Response,
@Query("prompt") prompt: string,
): Promise<void> {
// 设置 SSE 响应头
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); // 禁用 Nginx 缓冲
// 构造 CallContext从 req.headers 提取 x-user-id 和 x-request-id
const ctx: CallContext = {
userId: (req.headers["x-user-id"] as string) ?? "dev-user",
traceId: (req.headers["x-request-id"] as string) ?? "",
};
// 构造聊天消息
const messages: ChatMessage[] = [{ role: "user", content: prompt }];
try {
const stream = await this.teacherService.streamChat(ctx, messages);
for await (const chunk of stream) {
const data = JSON.stringify({
content: chunk.content,
done: chunk.done,
});
res.write(`data: ${data}\n\n`);
if (chunk.done) {
break;
}
}
res.write("event: done\ndata: {}\n\n");
} catch (err) {
const errorData = JSON.stringify({
error: err instanceof Error ? err.message : "Unknown error",
});
res.write(`event: error\ndata: ${errorData}\n\n`);
} finally {
res.end();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,54 @@
// TeacherService — GraphQL Resolver 的业务逻辑层(B1 裁决P2 起 GraphQL // TeacherService — GraphQL Resolver 的业务逻辑层(v2 P3+ 全量实现
// 通过 IamClient 调下游 iam gRPCB2 裁决:首次实现即 gRPC // 通过 5 个 DownstreamClient 调下游 gRPCB2 裁决:首次实现即 gRPC
// P2: 仅 iam 数据P3+ 扩展 core-edu / content / data-ana / msg / ai // 裁决依据:
// - B1P2 起 GraphQL
// - B2gRPC 下游通信)
// - B8DownstreamClient 抽象)
// - president §2.6(降级模式方案 B下游不可用时返回空 + warning
// - president §2.9(越权防御 DEV_MODE 放行)
// 实现:
// - P2 核心 5 Querydashboard / viewports / me / classes / class
// - P3 考试/作业/成绩查询 + 创建core-edu
// - P4 知识路径/班级学情/学生薄弱/学习趋势content + data-ana
// - P5 通知/AI 生成题目msg + ai
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { IAM_CLIENT } from "../clients/iam/iam-client.interface.js"; import { IAM_CLIENT } from "../clients/iam/iam-client.interface.js";
import type { IamClient } from "../clients/iam/iam-client.interface.js"; import type { IamClient } from "../clients/iam/iam-client.interface.js";
import { CORE_EDU_CLIENT } from "../clients/core-edu/core-edu-client.interface.js";
import type { CoreEduClient } from "../clients/core-edu/core-edu-client.interface.js";
import { CONTENT_CLIENT } from "../clients/content/content-client.interface.js";
import type { ContentClient } from "../clients/content/content-client.interface.js";
import { DATA_ANA_CLIENT } from "../clients/data-ana/data-ana-client.interface.js";
import type { DataAnaClient } from "../clients/data-ana/data-ana-client.interface.js";
import { MSG_CLIENT } from "../clients/msg/msg-client.interface.js";
import type { MsgClient } from "../clients/msg/msg-client.interface.js";
import { AI_CLIENT } from "../clients/ai/ai-client.interface.js";
import type { AiClient } from "../clients/ai/ai-client.interface.js";
import type { CallContext } from "../clients/types.js"; import type { CallContext } from "../clients/types.js";
import type { import type {
UserInfo, UserInfo,
ViewportItem, ViewportItem,
EffectivePermissions, EffectivePermissions,
UpdateUserRequest,
} from "../clients/iam/iam.types.js"; } from "../clients/iam/iam.types.js";
import type {
Exam,
Homework,
Grade,
StudentInfo,
} from "../clients/core-edu/core-edu.types.js";
import type { KnowledgePoint } from "../clients/content/content.types.js";
import type {
ClassPerformance,
StudentWeakness as StudentWeaknessDTO,
LearningTrend as LearningTrendDTO,
} from "../clients/data-ana/data-ana.types.js";
import type { Notification } from "../clients/msg/msg.types.js";
import type {
GeneratedQuestion,
ChatMessage,
StreamChatChunk,
} from "../clients/ai/ai.types.js";
import { logger } from "../shared/observability/logger.js"; import { logger } from "../shared/observability/logger.js";
/** GraphQL User 类型(聚合 UserInfo + EffectivePermissions */ /** GraphQL User 类型(聚合 UserInfo + EffectivePermissions */
@@ -34,23 +73,105 @@ export interface DashboardData {
} | null; } | null;
} }
/** GraphQL Class 类型(P2 mockP3+ core-edu 真实数据 */ /** GraphQL Class 类型(v2来自 core-edu */
export interface ClassInfo { export interface ClassInfo {
id: string; id: string;
name: string; name: string;
gradeId: string; gradeId: string;
studentCount?: number;
} }
/** P2 mock 班级数据president §3.5P2 班级列表来自 iam 数据P3+ core-edu */ /** GraphQL Student 类型classStudents 查询返回 */
const MOCK_CLASSES: ClassInfo[] = [ export interface GraphQLStudent {
{ id: "class-001", name: "三年级1班", gradeId: "grade-3" }, id: string;
{ id: "class-002", name: "三年级2班", gradeId: "grade-3" }, name: string;
{ id: "class-003", name: "三年级3班", gradeId: "grade-3" }, email: string;
]; avatarUrl: string | null;
}
/** GraphQL ExamDetail 类型examDetail 查询返回) */
export interface GraphQLExamDetail {
id: string;
classId: string;
title: string;
description: string | null;
examDate: string;
duration: number;
totalScore: number;
status: string;
questions: unknown[];
}
/** GraphQL HomeworkDetail 类型homeworkDetail 查询返回) */
export interface GraphQLHomeworkDetail {
id: string;
classId: string;
title: string;
description: string | null;
dueDate: string;
status: string;
submissions: unknown[];
}
/** GraphQL GeneratedLessonPlan 类型 */
export interface GraphQLGeneratedLessonPlan {
id: string;
content: string;
summary: string;
degraded: boolean;
degradedReason: string;
}
/** GraphQL GeneratedReport 类型 */
export interface GraphQLGeneratedReport {
id: string;
content: string;
summary: string;
recommendations: string[];
degraded: boolean;
degradedReason: string;
}
/** GraphQL KnowledgeGraph 类型 */
export interface GraphQLKnowledgeGraph {
nodes: unknown[];
edges: unknown[];
}
/** GraphQL ClassAnalytics 类型 */
export interface GraphQLClassAnalytics {
classId: string;
className: string;
avgScore: number;
passRate: number;
avgTrend: number[];
topStudents: unknown[];
weakPoints: string[];
}
/** GraphQL StudentAnalytics 类型 */
export interface GraphQLStudentAnalytics {
studentId: string;
studentName: string;
avgScore: number;
trend: number[];
weakPoints: string[];
strongPoints: string[];
masteryRate: number;
}
@Injectable() @Injectable()
export class TeacherService { export class TeacherService {
constructor(@Inject(IAM_CLIENT) private readonly iam: IamClient) {} constructor(
@Inject(IAM_CLIENT) private readonly iam: IamClient,
@Inject(CORE_EDU_CLIENT) private readonly coreEdu: CoreEduClient,
@Inject(CONTENT_CLIENT) private readonly content: ContentClient,
@Inject(DATA_ANA_CLIENT) private readonly dataAna: DataAnaClient,
@Inject(MSG_CLIENT) private readonly msg: MsgClient,
@Inject(AI_CLIENT) private readonly ai: AiClient,
) {}
// ===== P2 核心 5 Query =====
/** 获取当前用户信息(聚合 iam.GetUserInfo + GetEffectivePermissions */ /** 获取当前用户信息(聚合 iam.GetUserInfo + GetEffectivePermissions */
async getCurrentUser(ctx: CallContext): Promise<GraphQLUser> { async getCurrentUser(ctx: CallContext): Promise<GraphQLUser> {
@@ -74,9 +195,10 @@ export class TeacherService {
} }
/** /**
* 获取 Dashboard 聚合数据(president §2.8P2 仅调 iam gRPC * 获取 Dashboard 聚合数据(v2聚合 iam + core-edu + data-ana
* P2: user + viewports 有数据classes 返回 mockstats 返回 null + warning * - user + viewports: iam
* P3+: classes 来自 core-edustats 来自 data-ana.GetTeacherDashboard * - classes: core-edu.GetClassesByTeacher降级返回空数组
* - stats: data-ana.GetTeacherDashboard降级返回 null
*/ */
async getDashboard(ctx: CallContext): Promise<DashboardData> { async getDashboard(ctx: CallContext): Promise<DashboardData> {
const [user, viewports] = await Promise.all([ const [user, viewports] = await Promise.all([
@@ -84,37 +206,602 @@ export class TeacherService {
this.iam.getViewports(ctx), this.iam.getViewports(ctx),
]); ]);
// P2: classes 返回 mock 数据P3+ 替换为 core-edu.GetClassesByTeacher // v2: classes 来自 core-edustats 来自 data-ana
// P2: stats 返回 nullP4+ 替换为 data-ana.GetTeacherDashboard let classes: unknown[] = [];
logger.warn( let stats: {
{ userId: ctx.userId, phase: "P2" }, totalExams: number;
"Dashboard P2: classes using mock, stats unavailable (field_unavailable_in_p2)", pendingGrading: number;
); todayHomework: number;
} | null = null;
try {
const classList = await this.coreEdu.getClassesByTeacher(ctx, ctx.userId);
classes = classList;
} catch (err) {
logger.warn(
{ userId: ctx.userId, err: (err as Error).message },
"Dashboard: core-edu.GetClassesByTeacher failed, returning empty classes",
);
}
try {
stats = await this.dataAna.getTeacherDashboard(ctx, {
userId: ctx.userId,
});
} catch (err) {
logger.warn(
{ userId: ctx.userId, err: (err as Error).message },
"Dashboard: data-ana.GetTeacherDashboard failed, returning null stats",
);
}
return { user, viewports, classes, stats };
}
/** 获取教师班级列表v2core-edu.GetClassesByTeacher */
async getClasses(ctx: CallContext): Promise<ClassInfo[]> {
try {
const list = await this.coreEdu.getClassesByTeacher(ctx, ctx.userId);
return list.map((c) => ({
id: c.id,
name: c.name,
gradeId: c.gradeId,
studentCount: c.studentCount,
}));
} catch (err) {
logger.warn(
{ userId: ctx.userId, err: (err as Error).message },
"getClasses: core-edu unavailable, returning empty list",
);
return [];
}
}
/** 获取单个班级详情v2core-edu */
async getClass(ctx: CallContext, classId: string): Promise<ClassInfo | null> {
try {
const list = await this.coreEdu.getClassesByTeacher(ctx, ctx.userId);
const found = list.find((c) => c.id === classId);
return found
? {
id: found.id,
name: found.name,
gradeId: found.gradeId,
studentCount: found.studentCount,
}
: null;
} catch (err) {
logger.warn(
{ userId: ctx.userId, classId, err: (err as Error).message },
"getClass: core-edu unavailable, returning null",
);
return null;
}
}
// ===== P3 考试/作业/成绩 =====
/** 获取班级考试列表v2core-edu.ListExamsByClass */
async getExamsByClass(ctx: CallContext, classId: string): Promise<Exam[]> {
try {
const res = await this.coreEdu.listExamsByClass(ctx, classId);
return res.exams;
} catch (err) {
logger.warn(
{ userId: ctx.userId, classId, err: (err as Error).message },
"getExamsByClass: core-edu unavailable, returning empty list",
);
return [];
}
}
/** 获取班级作业列表v2core-edu.ListHomeworkByClass */
async getHomeworkByClass(
ctx: CallContext,
classId: string,
): Promise<Homework[]> {
try {
const res = await this.coreEdu.listHomeworkByClass(ctx, classId);
return res.homework;
} catch (err) {
logger.warn(
{ userId: ctx.userId, classId, err: (err as Error).message },
"getHomeworkByClass: core-edu unavailable, returning empty list",
);
return [];
}
}
/** 获取考试成绩列表v2core-edu.ListGradesByExam */
async getGradesByExam(ctx: CallContext, examId: string): Promise<Grade[]> {
try {
const res = await this.coreEdu.listGradesByExam(ctx, examId);
return res.grades;
} catch (err) {
logger.warn(
{ userId: ctx.userId, examId, err: (err as Error).message },
"getGradesByExam: core-edu unavailable, returning empty list",
);
return [];
}
}
/** 创建考试v2core-edu.CreateExam */
async createExam(
ctx: CallContext,
input: {
classId: string;
title: string;
subject?: string;
scheduledAt?: string;
},
): Promise<{ id: string }> {
const res = await this.coreEdu.createExam(ctx, {
classId: input.classId,
title: input.title,
description: "",
examDate: input.scheduledAt ?? new Date().toISOString(),
duration: "60",
totalScore: "100",
createdBy: ctx.userId,
});
return { id: res.id };
}
/** 布置作业v2core-edu.AssignHomework */
async assignHomework(
ctx: CallContext,
input: { classId: string; title: string; dueDate: string },
): Promise<{ id: string }> {
const res = await this.coreEdu.assignHomework(ctx, {
classId: input.classId,
title: input.title,
description: "",
dueDate: input.dueDate,
createdBy: ctx.userId,
});
return { id: res.id };
}
/** 记录成绩v2core-edu.RecordGrade */
async recordGrade(
ctx: CallContext,
input: { examId: string; studentId: string; score: number },
): Promise<{ id: string }> {
const res = await this.coreEdu.recordGrade(ctx, {
studentId: input.studentId,
examId: input.examId,
score: String(input.score),
feedback: "",
gradedBy: ctx.userId,
});
return { id: res.id };
}
// ===== P4 知识路径/学情分析 =====
/** 获取知识路径v2content.GetLearningPath */
async getKnowledgePath(
ctx: CallContext,
knowledgePointId: string,
): Promise<{
knowledgePointId: string;
name: string;
prerequisites: KnowledgePoint[];
}> {
try {
// content.GetPrerequisites 返回前置知识点
const prereqRes = await this.content.getPrerequisites(ctx, {
knowledgePointId,
});
return {
knowledgePointId,
name: knowledgePointId,
prerequisites: prereqRes.points,
};
} catch (err) {
logger.warn(
{ userId: ctx.userId, knowledgePointId, err: (err as Error).message },
"getKnowledgePath: content unavailable, returning empty prerequisites",
);
return { knowledgePointId, name: knowledgePointId, prerequisites: [] };
}
}
/** 班级学情分析v2data-ana.GetClassPerformance */
async getClassPerformance(
ctx: CallContext,
classId: string,
): Promise<ClassPerformance> {
try {
const now = new Date();
const startDate = new Date(
now.getTime() - 30 * 24 * 60 * 60 * 1000,
).toISOString();
const endDate = now.toISOString();
return await this.dataAna.getClassPerformance(ctx, {
classId,
subjectId: "",
startDate,
endDate,
});
} catch (err) {
logger.warn(
{ userId: ctx.userId, classId, err: (err as Error).message },
"getClassPerformance: data-ana unavailable, returning empty stats",
);
return {
classId,
averageScore: 0,
passRate: 0,
totalStudents: 0,
scores: [],
};
}
}
/** 学生薄弱点v2data-ana.GetStudentWeakness */
async getStudentWeakness(
ctx: CallContext,
studentId: string,
): Promise<StudentWeaknessDTO> {
try {
return await this.dataAna.getStudentWeakness(ctx, {
studentId,
subjectId: "",
});
} catch (err) {
logger.warn(
{ userId: ctx.userId, studentId, err: (err as Error).message },
"getStudentWeakness: data-ana unavailable, returning empty weak points",
);
return { studentId, weakPoints: [] };
}
}
/** 学习趋势v2data-ana.GetLearningTrend */
async getLearningTrend(
ctx: CallContext,
studentId: string,
dateRange?: { start: string; end: string },
): Promise<LearningTrendDTO> {
try {
const now = new Date();
const endDate = dateRange?.end ?? now.toISOString();
const startDate =
dateRange?.start ??
new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString();
return await this.dataAna.getLearningTrend(ctx, {
studentId,
startDate,
endDate,
subjectId: "",
});
} catch (err) {
logger.warn(
{ userId: ctx.userId, studentId, err: (err as Error).message },
"getLearningTrend: data-ana unavailable, returning empty trend",
);
return { studentId, points: [] };
}
}
// ===== P5 通知 + AI =====
/** 通知列表v2msg.ListNotifications */
async getNotifications(
ctx: CallContext,
unreadOnly?: boolean,
): Promise<Notification[]> {
try {
const res = await this.msg.listNotifications(ctx, {
userId: ctx.userId,
onlyUnread: unreadOnly ?? false,
type: "",
page: 1,
pageSize: 50,
});
return res.notifications;
} catch (err) {
logger.warn(
{ userId: ctx.userId, err: (err as Error).message },
"getNotifications: msg unavailable, returning empty list",
);
return [];
}
}
/** 标记通知已读v2msg.MarkAsRead */
async markNotificationAsRead(
ctx: CallContext,
id: string,
): Promise<{ success: boolean }> {
try {
const res = await this.msg.markAsRead(ctx, { id, userId: ctx.userId });
return { success: res.success };
} catch (err) {
logger.warn(
{ userId: ctx.userId, id, err: (err as Error).message },
"markNotificationAsRead: msg unavailable",
);
return { success: false };
}
}
/** AI 生成题目v2ai.GenerateQuestion */
async generateQuestion(
ctx: CallContext,
input: {
subject: string;
gradeLevel?: string;
difficulty: string;
knowledgePointIds: string[];
},
): Promise<GeneratedQuestion> {
try {
return await this.ai.generateQuestion(ctx, {
prompt: "",
subject: input.subject,
difficulty: input.difficulty,
grade: input.gradeLevel,
knowledgePointIds: input.knowledgePointIds,
});
} catch (err) {
logger.warn(
{ userId: ctx.userId, err: (err as Error).message },
"generateQuestion: ai unavailable, returning degraded response",
);
return {
question: "",
answer: "",
explanation: "",
questionType: "",
difficulty: input.difficulty,
knowledgePointIds: input.knowledgePointIds,
degraded: true,
degradedReason: `AI service unavailable: ${(err as Error).message}`,
};
}
}
/** AI 流式聊天v2ai.StreamChatSSE 透传) */
async streamChat(
ctx: CallContext,
messages: ChatMessage[],
): Promise<AsyncIterable<StreamChatChunk>> {
return this.ai.streamChat(ctx, {
messages,
model: "",
temperature: 0.7,
userId: ctx.userId,
sessionId: ctx.traceId,
});
}
// ===== P3-P5 扩展 Service 方法 =====
/** 班级学生列表v2core-edu.ListStudentsByClass */
async getClassStudents(
ctx: CallContext,
classId: string,
): Promise<GraphQLStudent[]> {
try {
const students = await this.coreEdu.listStudentsByClass(ctx, classId);
return students.map((s: StudentInfo) => ({
id: s.id,
name: s.name,
email: "",
avatarUrl: null,
}));
} catch (err) {
logger.warn(
{ userId: ctx.userId, classId, err: (err as Error).message },
"getClassStudents: core-edu unavailable, returning empty list",
);
return [];
}
}
/** 考试详情v2core-edu.GetExamquestions 暂空) */
async getExamDetail(
ctx: CallContext,
id: string,
): Promise<GraphQLExamDetail | null> {
try {
const exam = await this.coreEdu.getExam(ctx, id);
return {
id: exam.id,
classId: exam.classId,
title: exam.title,
description: exam.description || null,
examDate: exam.examDate,
duration: Number(exam.duration) || 0,
totalScore: Number(exam.totalScore) || 0,
status: exam.status,
questions: [],
};
} catch (err) {
logger.warn(
{ userId: ctx.userId, id, err: (err as Error).message },
"getExamDetail: core-edu unavailable, returning null",
);
return null;
}
}
/** 作业详情v2core-edu.GetHomeworksubmissions 暂空) */
async getHomeworkDetail(
ctx: CallContext,
id: string,
): Promise<GraphQLHomeworkDetail | null> {
try {
const hw = await this.coreEdu.getHomework(ctx, id);
return {
id: hw.id,
classId: hw.classId,
title: hw.title,
description: hw.description || null,
dueDate: hw.dueDate,
status: hw.status,
submissions: [],
};
} catch (err) {
logger.warn(
{ userId: ctx.userId, id, err: (err as Error).message },
"getHomeworkDetail: core-edu unavailable, returning null",
);
return null;
}
}
/** 更新用户信息v2iam.UpdateUserid 缺省时更新当前用户) */
async updateUser(
ctx: CallContext,
input: UpdateUserRequest,
id?: string,
): Promise<GraphQLUser> {
const targetId = id ?? ctx.userId;
try {
await this.iam.updateUser(ctx, targetId, input);
} catch (err) {
logger.warn(
{ userId: ctx.userId, targetId, err: (err as Error).message },
"updateUser: iam unavailable, returning current user snapshot",
);
}
// 返回最新用户信息(即使更新失败也返回当前快照,避免阻塞前端)
return this.getCurrentUser(ctx);
}
/** 生成教案ai.GenerateLessonPlan 暂未实现,返回降级响应) */
async generateLessonPlan(
ctx: CallContext,
input: {
classId: string;
subject: string;
topic: string;
objectives: string;
},
): Promise<GraphQLGeneratedLessonPlan> {
logger.warn(
{ userId: ctx.userId, input },
"generateLessonPlan: ai.GenerateLessonPlan not implemented, returning degraded response",
);
return { return {
user, id: `lesson-plan-${Date.now()}`,
viewports, content: "",
classes: MOCK_CLASSES, summary: "",
stats: null, degraded: true,
degradedReason: "AI GenerateLessonPlan RPC not yet available",
}; };
} }
/** 获取教师班级列表P2: mockP3+: core-edu.GetClassesByTeacher */ /** 生成报告ai.GenerateReport 暂未实现,返回降级响应 */
async getClasses(ctx: CallContext): Promise<ClassInfo[]> { async generateReport(
ctx: CallContext,
input: { classId: string; reportType: string; studentId?: string },
): Promise<GraphQLGeneratedReport> {
logger.warn( logger.warn(
{ userId: ctx.userId, phase: "P2" }, { userId: ctx.userId, input },
"Classes P2: using mock data (core-edu not ready, field_unavailable_in_p2)", "generateReport: ai.GenerateReport not implemented, returning degraded response",
); );
return MOCK_CLASSES; return {
id: `report-${Date.now()}`,
content: "",
summary: "",
recommendations: [],
degraded: true,
degradedReason: "AI GenerateReport RPC not yet available",
};
} }
/** 获取单个班级详情P2: mockP3+: core-edu */ // ===== P4 扩展 Service 方法 =====
async getClass(ctx: CallContext, classId: string): Promise<ClassInfo | null> {
/** 知识图谱content.GetKnowledgeGraph 暂未实现,返回降级空图) */
async getKnowledgeGraph(
ctx: CallContext,
_classId?: string,
_subject?: string,
): Promise<GraphQLKnowledgeGraph> {
logger.warn( logger.warn(
{ userId: ctx.userId, classId, phase: "P2" }, { userId: ctx.userId },
"Class P2: using mock data (core-edu not ready, field_unavailable_in_p2)", "getKnowledgeGraph: content.GetKnowledgeGraph not implemented, returning empty graph",
); );
return MOCK_CLASSES.find((c) => c.id === classId) ?? null; return { nodes: [], edges: [] };
}
/** 班级学情分析(聚合 data-ana.GetClassPerformance + class name */
async getClassAnalytics(
ctx: CallContext,
classId: string,
): Promise<GraphQLClassAnalytics> {
try {
const perf = await this.getClassPerformance(ctx, classId);
return {
classId,
className: "",
avgScore: perf.averageScore,
passRate: perf.passRate,
avgTrend: [],
topStudents: [],
weakPoints: [],
};
} catch (err) {
logger.warn(
{ userId: ctx.userId, classId, err: (err as Error).message },
"getClassAnalytics: data-ana unavailable, returning empty analytics",
);
return {
classId,
className: "",
avgScore: 0,
passRate: 0,
avgTrend: [],
topStudents: [],
weakPoints: [],
};
}
}
/** 学生学情分析(聚合 data-ana.GetStudentWeakness + GetLearningTrend */
async getStudentAnalytics(
ctx: CallContext,
studentId: string,
): Promise<GraphQLStudentAnalytics> {
try {
const [weakness, trend] = await Promise.all([
this.getStudentWeakness(ctx, studentId),
this.getLearningTrend(ctx, studentId),
]);
const trendScores = trend.points.map((p) => p.score);
const avgScore =
trendScores.length > 0
? trendScores.reduce((a, b) => a + b, 0) / trendScores.length
: 0;
return {
studentId,
studentName: "",
avgScore,
trend: trendScores,
weakPoints: weakness.weakPoints.map((w) => w.title),
strongPoints: [],
masteryRate: 0,
};
} catch (err) {
logger.warn(
{ userId: ctx.userId, studentId, err: (err as Error).message },
"getStudentAnalytics: data-ana unavailable, returning empty analytics",
);
return {
studentId,
studentName: "",
avgScore: 0,
trend: [],
weakPoints: [],
strongPoints: [],
masteryRate: 0,
};
}
} }
} }