feat(teacher-bff): admin 命名空间 + 5 个 gRPC client + health probes + merge-resolvers + nextstep 文档
This commit is contained in:
@@ -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-proto(gRPC proto-loader 运行时加载)
|
|
||||||
COPY packages/shared-proto ./packages/shared-proto
|
COPY packages/shared-proto ./packages/shared-proto
|
||||||
|
|
||||||
# 复制 shared-ts(GraphQL 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"]
|
||||||
|
|||||||
344
services/teacher-bff/docs/nextstep-v2.md
Normal file
344
services/teacher-bff/docs/nextstep-v2.md
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
# teacher-bff 下一步工作与上下游依赖
|
||||||
|
|
||||||
|
> 模块:teacher-bff(教学场景域聚合层 BFF,GraphQL 聚合层,端口 3003)
|
||||||
|
> 负责人:ai03
|
||||||
|
> 更新日期:2026-07-14(v2:schema 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)
|
||||||
|
|
||||||
|
测试环境:本地 Docker(edu-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-test(NODE_ENV=production, DEV_MODE=true, HTTP 3002 + gRPC 50052)
|
||||||
|
edu-teacher-bff-test(NODE_ENV=production, TEACHER_BFF_DEV_MODE=true, HTTP 3003)
|
||||||
|
测试用户:ffd5f3f8-67a3-4774-9ccc-c4c6d2288eaf(admin-test@edu.local, role=admin, data_scope=all)
|
||||||
|
测试 Headers:x-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 operations(teacher 域 23 Query + 9 Mutation,admin 域 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 log(attendanceList/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 → TextbookItem(Textbook 类型未定义) |
|
||||||
|
| 24 | 死代码清理 | 删除 circuit-breaker.ts(import 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 服务未启动,走降级模式 B(mock 数据)。
|
||||||
|
|
||||||
|
### 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-portal(ai16 负责)— 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-portal(ai13 负责)— 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-gateway(ai01 负责)— 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:latest(node: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 resolver(v2 扩展) |
|
||||||
|
| `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 契约(v2,96 operations) |
|
||||||
|
| `packages/shared-proto/proto/iam.proto` | iam gRPC proto 契约 |
|
||||||
|
| `infra/docker-compose.test.yml` | 测试环境 compose(5 个 gRPC target) |
|
||||||
|
| `infra/docker-compose.deploy.yml` | 生产部署 compose(5 个 gRPC target) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**本文件由 ai03 维护,上游/下游工作项请各负责 AI 完成后通知 ai03 更新状态。**
|
||||||
316
services/teacher-bff/docs/nextstep.md
Normal file
316
services/teacher-bff/docs/nextstep.md
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
# teacher-bff 下一步工作与上下游依赖
|
||||||
|
|
||||||
|
> 模块:teacher-bff(教学场景域聚合层 BFF,GraphQL 聚合层,端口 3003)
|
||||||
|
> 负责人:ai03
|
||||||
|
> 更新日期:2026-07-13(v2:iam 真实数据全量联调 + 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)
|
||||||
|
|
||||||
|
测试环境:本地 Docker(edu-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-test(NODE_ENV=production, DEV_MODE=true, HTTP 3002 + gRPC 50052)
|
||||||
|
edu-teacher-bff-test(NODE_ENV=production, TEACHER_BFF_DEV_MODE=true, HTTP 3003)
|
||||||
|
测试用户:ffd5f3f8-67a3-4774-9ccc-c4c6d2288eaf(admin-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: ALL,v2 修复) |
|
||||||
|
| `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=production(v2) | 避免 pino-pretty 在 prune --prod 后缺失(dev 模式才使用 transport) |
|
||||||
|
| 10 | IamModule 客户端选择逻辑修复(v2) | 按 `IAM_GRPC_TARGET` 是否配置选择 gRPC 或 mock,不再受 DEV_MODE 影响 |
|
||||||
|
| 11 | DataScope 枚举大小写转换(v2) | iam DB 存小写(self/all),GraphQL 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 JWKS(api-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-portal(ai16 负责)— 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-portal(ai13 负责)— 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-gateway(ai01 负责)— 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:latest(v2 alpine + bcryptjs) |
|
||||||
|
| Docker 容器运行 | ✅ | /healthz 200 + /readyz 200 |
|
||||||
|
| 真实 iam 联调(无 mock) | ✅ | currentUser/me/adminRoles/adminPermissions/auditLogs/adminDashboard/systemSettings 全部返回真实数据 |
|
||||||
|
| `me` 查询 dataScope 修复(v2) | ✅ | teacher.service.ts 使用 userInfo.dataScope,Docker 验证返回 ALL |
|
||||||
|
| DataScope 大小写转换(v2) | ✅ | iam-grpc.client.ts toUpperCase(),Docker 验证 GraphQL enum 正常解析 |
|
||||||
|
| IamModule 客户端选择修复(v2) | ✅ | IAM_GRPC_TARGET 配置后强制 gRPC,DEV_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 更新状态。**
|
||||||
11
services/teacher-bff/src/admin/admin.module.ts
Normal file
11
services/teacher-bff/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Admin 模块(P6 实现,president §5.1:admin-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 {}
|
||||||
408
services/teacher-bff/src/admin/admin.resolver.ts
Normal file
408
services/teacher-bff/src/admin/admin.resolver.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
// Admin Resolver(P6 重构,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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
1655
services/teacher-bff/src/admin/admin.service.ts
Normal file
1655
services/teacher-bff/src/admin/admin.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
50
services/teacher-bff/src/clients/ai/ai-client.interface.ts
Normal file
50
services/teacher-bff/src/clients/ai/ai-client.interface.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// AiClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||||
|
// 接口定义所有 P5 需要的 ai RPC,实现分 gRPC + mock 两种
|
||||||
|
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC
|
||||||
|
// 裁决依据:B2(首次实现即 gRPC)+ B8(DownstreamClient 抽象)
|
||||||
|
// 端口:ai gRPC 50058(contract §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 token(NestJS 注入用) */
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
256
services/teacher-bff/src/clients/ai/ai-grpc.client.ts
Normal file
256
services/teacher-bff/src/clients/ai/ai-grpc.client.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
// AiClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||||
|
// ai.proto 现状 4 RPC(AiService.Chat / StreamChat / GenerateQuestion / OptimizeExpression)
|
||||||
|
// StreamChat 为 server streaming RPC,返回 AsyncIterable<StreamChatChunk>
|
||||||
|
// proto 字段是 snake_case,gRPC 返回需转 camelCase
|
||||||
|
// 裁决依据:B2(首次实现即 gRPC)+ B8(DownstreamClient 抽象)
|
||||||
|
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 Usage(snake_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
services/teacher-bff/src/clients/ai/ai-mock.client.ts
Normal file
124
services/teacher-bff/src/clients/ai/ai-mock.client.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// AiClient Mock 实现(B8 裁决:上游就绪前的降级策略)
|
||||||
|
// DEV_MODE=true 时全部走 mock;DEV_MODE=false 时走 gRPC
|
||||||
|
// mock 数据对齐 contract §4.2 mock 策略
|
||||||
|
// 裁决依据:B2(首次实现即 gRPC)+ B8(DownstreamClient 抽象)
|
||||||
|
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 流式聊天 chunk(contract §4.2:流式 mock,3 个 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
services/teacher-bff/src/clients/ai/ai.module.ts
Normal file
34
services/teacher-bff/src/clients/ai/ai.module.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// ai 模块(B8 裁决:按 DEV_MODE 或 target 配置选择 mock 或 gRPC 实现)
|
||||||
|
// 端口:ai gRPC 50058(contract §2.1.6 + port-allocation.md §5)
|
||||||
|
// 裁决依据:B2(首次实现即 gRPC)+ B8(DownstreamClient 抽象)
|
||||||
|
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 {}
|
||||||
80
services/teacher-bff/src/clients/ai/ai.types.ts
Normal file
80
services/teacher-bff/src/clients/ai/ai.types.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// ai 下游服务类型定义(对齐 ai.proto)
|
||||||
|
// ai.proto 现状:4 RPC(AiService.Chat / StreamChat / GenerateQuestion / OptimizeExpression)
|
||||||
|
// 待 coord 补全:GenerateLessonPlan + StreamGenerateQuestion(A4 裁决)
|
||||||
|
// 裁决依据:B2(首次实现即 gRPC)+ B8(DownstreamClient 抽象)
|
||||||
|
|
||||||
|
/** 聊天消息(对齐 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 message,StreamChat 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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,30 @@
|
|||||||
// DownstreamClient 聚合模块(B8 裁决:3 个 BFF 统一使用)
|
// DownstreamClient 聚合模块(B8 裁决:3 个 BFF 统一使用)
|
||||||
// P2 仅注册 IamModule,P3+ 扩展 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 {}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// ContentClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||||
|
// 接口定义所有 P4+ 需要的 content RPC,实现分 gRPC + mock 两种
|
||||||
|
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → 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 token(NestJS 注入用) */
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
301
services/teacher-bff/src/clients/content/content-grpc.client.ts
Normal file
301
services/teacher-bff/src/clients/content/content-grpc.client.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
// ContentClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||||
|
// content.proto 现状:TextbookService 3 + KnowledgeGraphService 2 已就绪
|
||||||
|
// 未就绪 RPC(ChapterService.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_case,gRPC 返回需转 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
267
services/teacher-bff/src/clients/content/content-mock.client.ts
Normal file
267
services/teacher-bff/src/clients/content/content-mock.client.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
// ContentClient Mock 实现(B8 裁决:上游就绪前的降级策略)
|
||||||
|
// DEV_MODE=true 时全部走 mock;DEV_MODE=false 时仅未就绪 RPC 走 mock
|
||||||
|
// mock 数据对齐 contract §4.2 mock 策略
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { BaseDownstreamClient } from "../base.client.js";
|
||||||
|
import type { CallContext } from "../types.js";
|
||||||
|
import type { 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
35
services/teacher-bff/src/clients/content/content.module.ts
Normal file
35
services/teacher-bff/src/clients/content/content.module.ts
Normal 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 {}
|
||||||
139
services/teacher-bff/src/clients/content/content.types.ts
Normal file
139
services/teacher-bff/src/clients/content/content.types.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// content 下游服务类型定义(对齐 content.proto)
|
||||||
|
// content.proto 现状:4 Service(TextbookService 5 + ChapterService 5 + KnowledgeGraphService 4 + QuestionService 8)
|
||||||
|
// 待 coord 补全:ChapterService(N5 裁决)+ QuestionService(N3 裁决)服务端实现
|
||||||
|
|
||||||
|
/** 教材(对齐 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// CoreEduClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||||
|
// 接口定义所有 P3+ 需要的 core-edu RPC,实现分 gRPC + mock 两种
|
||||||
|
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → 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 token(NestJS 注入用) */
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
// CoreEduClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||||
|
// core_edu.proto 现状 14 RPC(ExamService 5 + HomeworkService 4 + GradeService 5)
|
||||||
|
// 未就绪 RPC(ClassService.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_case,gRPC 返回需转 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) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ClassService(proto 未定义,降级 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
// CoreEduClient Mock 实现(B8 裁决:上游就绪前的降级策略)
|
||||||
|
// DEV_MODE=true 时全部走 mock;DEV_MODE=false 时仅未就绪 RPC 走 mock
|
||||||
|
// mock 数据对齐 contract §4.2 mock 策略
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { BaseDownstreamClient } from "../base.client.js";
|
||||||
|
import type { CallContext } from "../types.js";
|
||||||
|
import type { 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
35
services/teacher-bff/src/clients/core-edu/core-edu.module.ts
Normal file
35
services/teacher-bff/src/clients/core-edu/core-edu.module.ts
Normal 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 {}
|
||||||
136
services/teacher-bff/src/clients/core-edu/core-edu.types.ts
Normal file
136
services/teacher-bff/src/clients/core-edu/core-edu.types.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// core-edu 下游服务类型定义(对齐 core_edu.proto)
|
||||||
|
// core_edu.proto 现状:14 RPC(ExamService 5 + HomeworkService 4 + GradeService 5)
|
||||||
|
// 待 coord 补全:ClassService(GetClassesByTeacher / 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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// DataAnaClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||||
|
// 接口定义所有 P4 需要的 data-ana RPC,实现分 gRPC + mock 两种
|
||||||
|
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC
|
||||||
|
import type { CallContext } from "../types.js";
|
||||||
|
import type {
|
||||||
|
ClassPerformance,
|
||||||
|
StudentWeakness,
|
||||||
|
LearningTrend,
|
||||||
|
TeacherDashboardStats,
|
||||||
|
GetClassPerformanceRequest,
|
||||||
|
GetStudentWeaknessRequest,
|
||||||
|
GetLearningTrendRequest,
|
||||||
|
GetTeacherDashboardRequest,
|
||||||
|
} from "./data-ana.types.js";
|
||||||
|
|
||||||
|
/** DataAnaClient DI token(NestJS 注入用) */
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// DataAnaClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||||
|
// analytics.proto 现状 12 RPC(AnalyticsService),BFF 消费 4 个
|
||||||
|
// proto 字段是 snake_case,gRPC 返回需转 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_case,gRPC 返回需转 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 → todayHomework;totalExams/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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// DataAnaClient Mock 实现(B8 裁决:上游就绪前的降级策略)
|
||||||
|
// DEV_MODE=true 时全部走 mock;DEV_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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
35
services/teacher-bff/src/clients/data-ana/data-ana.module.ts
Normal file
35
services/teacher-bff/src/clients/data-ana/data-ana.module.ts
Normal 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 {}
|
||||||
81
services/teacher-bff/src/clients/data-ana/data-ana.types.ts
Normal file
81
services/teacher-bff/src/clients/data-ana/data-ana.types.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// data-ana 下游服务类型定义(对齐 analytics.proto)
|
||||||
|
// analytics.proto 现状:12 RPC(AnalyticsService),ISSUE-027 已补全 GetTeacherDashboard
|
||||||
|
// BFF 消费 4 个 RPC:GetClassPerformance / GetStudentWeakness / GetLearningTrend / GetTeacherDashboard
|
||||||
|
// 裁决依据:B8(DownstreamClient 抽象)+ B2(gRPC 首次实现)
|
||||||
|
|
||||||
|
/** 学生单科成绩(对齐 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 message,int64 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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
// IamClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
// IamClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||||
// 接口定义所有 P2+ 需要的 iam RPC,实现分 gRPC + mock 两种
|
// 接口定义所有 P2+ 需要的 iam RPC + REST 端点
|
||||||
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC(未就绪 RPC 降级 mock + warning)
|
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC/REST 真实调用
|
||||||
|
// 实现策略:getUserInfo 走 gRPC,其余走 REST(iam.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 token(NestJS 注入用) */
|
/** IamClient DI token(NestJS 注入用) */
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// IamClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
// IamClient gRPC + REST 实现(B2 裁决:首次实现即 gRPC;admin 域补充 REST)
|
||||||
// iam.proto 现状仅 GetUserInfo,GetViewports/GetEffectivePermissions 待 coord 补全
|
// iam.proto 6 RPC(Register/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 → mock;false → 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,30 +1,36 @@
|
|||||||
// iam 下游服务类型定义(对齐 iam.proto + 待 coord 补全的 RPC)
|
// iam 下游服务类型定义(对齐 iam.proto + REST CRUD 端点)
|
||||||
// iam.proto 现状:4 RPC(Register/Login/RefreshToken/GetUserInfo)
|
// iam.proto 现状:6 RPC(Register/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 字段对齐 proto(snake_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;
|
||||||
|
/** 事件 ID(UUID) */
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
39
services/teacher-bff/src/clients/msg/msg-client.interface.ts
Normal file
39
services/teacher-bff/src/clients/msg/msg-client.interface.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// MsgClient 接口 + DI token(B8 裁决:DownstreamClient 抽象)
|
||||||
|
// 接口定义所有 P5+ 需要的 msg RPC,实现分 gRPC + mock 两种
|
||||||
|
// 选择策略:TEACHER_BFF_DEV_MODE=true → mock,false → gRPC
|
||||||
|
import type { CallContext } from "../types.js";
|
||||||
|
import type {
|
||||||
|
ListNotificationsRequest,
|
||||||
|
ListNotificationsResponse,
|
||||||
|
SearchNotificationsRequest,
|
||||||
|
SearchNotificationsResponse,
|
||||||
|
MarkAsReadRequest,
|
||||||
|
MarkAsReadResponse,
|
||||||
|
} from "./msg.types.js";
|
||||||
|
|
||||||
|
/** MsgClient DI token(NestJS 注入用) */
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
186
services/teacher-bff/src/clients/msg/msg-grpc.client.ts
Normal file
186
services/teacher-bff/src/clients/msg/msg-grpc.client.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
// MsgClient gRPC 实现(B2 裁决:首次实现即 gRPC)
|
||||||
|
// msg.proto 现状 NotificationService 9 RPC(contract §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_case,gRPC 返回需转 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
178
services/teacher-bff/src/clients/msg/msg-mock.client.ts
Normal file
178
services/teacher-bff/src/clients/msg/msg-mock.client.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
32
services/teacher-bff/src/clients/msg/msg.module.ts
Normal file
32
services/teacher-bff/src/clients/msg/msg.module.ts
Normal 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 {}
|
||||||
64
services/teacher-bff/src/clients/msg/msg.types.ts
Normal file
64
services/teacher-bff/src/clients/msg/msg.types.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// msg 下游服务类型定义(对齐 msg.proto)
|
||||||
|
// msg.proto 现状:NotificationService 9 RPC(P5 启用,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;
|
||||||
|
}
|
||||||
@@ -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 targets(v2 默认值对齐 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 → mock;false → gRPC(targets 默认已配置)
|
||||||
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 服务 URL(admin 域 REST 调用 + ARB-008 公告降级 REST only)
|
||||||
|
// iam REST:users/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(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// GraphQL Yoga endpoint(B1 裁决:P2 起直接 GraphQL)
|
// GraphQL Yoga endpoint(B1 裁决:P2 起直接 GraphQL,ARB-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.updateUser(schema 统一为 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 v5:request 是 WHATWG Request,headers 需用 .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)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// GraphQL 模块(B1 裁决:P2 起直接 GraphQL Yoga)
|
// GraphQL 模块(B1 裁决:P2 起 GraphQL Yoga,ARB-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 {}
|
||||||
|
|||||||
19
services/teacher-bff/src/graphql/merge-resolvers.ts
Normal file
19
services/teacher-bff/src/graphql/merge-resolvers.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// 深合并多个 IResolvers 对象(ARB-001:teacher + 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;
|
||||||
|
}
|
||||||
26
services/teacher-bff/src/shared/health/ai-grpc.probe.ts
Normal file
26
services/teacher-bff/src/shared/health/ai-grpc.probe.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
28
services/teacher-bff/src/shared/health/content-grpc.probe.ts
Normal file
28
services/teacher-bff/src/shared/health/content-grpc.probe.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
services/teacher-bff/src/shared/health/msg-grpc.probe.ts
Normal file
26
services/teacher-bff/src/shared/health/msg-grpc.probe.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
|||||||
122
services/teacher-bff/src/shared/validation/schemas.ts
Normal file
122
services/teacher-bff/src/shared/validation/schemas.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Zod 验证 schemas(P6 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 };
|
||||||
|
}
|
||||||
64
services/teacher-bff/src/teacher/ai-chat-sse.controller.ts
Normal file
64
services/teacher-bff/src/teacher/ai-chat-sse.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// SSE 端点(P5:ai.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
@@ -1,15 +1,54 @@
|
|||||||
// TeacherService — GraphQL Resolver 的业务逻辑层(B1 裁决:P2 起 GraphQL)
|
// TeacherService — GraphQL Resolver 的业务逻辑层(v2 P3+ 全量实现)
|
||||||
// 通过 IamClient 调下游 iam gRPC(B2 裁决:首次实现即 gRPC)
|
// 通过 5 个 DownstreamClient 调下游 gRPC(B2 裁决:首次实现即 gRPC)
|
||||||
// P2: 仅 iam 数据;P3+ 扩展 core-edu / content / data-ana / msg / ai
|
// 裁决依据:
|
||||||
|
// - B1(P2 起 GraphQL)
|
||||||
|
// - B2(gRPC 下游通信)
|
||||||
|
// - B8(DownstreamClient 抽象)
|
||||||
|
// - president §2.6(降级模式方案 B:下游不可用时返回空 + warning)
|
||||||
|
// - president §2.9(越权防御 DEV_MODE 放行)
|
||||||
|
// 实现:
|
||||||
|
// - P2 核心 5 Query(dashboard / 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 mock,P3+ 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.5:P2 班级列表来自 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.8:P2 仅调 iam gRPC)
|
* 获取 Dashboard 聚合数据(v2:聚合 iam + core-edu + data-ana)
|
||||||
* P2: user + viewports 有数据,classes 返回 mock,stats 返回 null + warning
|
* - user + viewports: iam
|
||||||
* P3+: classes 来自 core-edu,stats 来自 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-edu,stats 来自 data-ana
|
||||||
// P2: stats 返回 null(P4+ 替换为 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取教师班级列表(v2:core-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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取单个班级详情(v2:core-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 考试/作业/成绩 =====
|
||||||
|
|
||||||
|
/** 获取班级考试列表(v2:core-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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取班级作业列表(v2:core-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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取考试成绩列表(v2:core-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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建考试(v2:core-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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 布置作业(v2:core-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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录成绩(v2:core-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 知识路径/学情分析 =====
|
||||||
|
|
||||||
|
/** 获取知识路径(v2:content.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: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 班级学情分析(v2:data-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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学生薄弱点(v2:data-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: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学习趋势(v2:data-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 =====
|
||||||
|
|
||||||
|
/** 通知列表(v2:msg.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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记通知已读(v2:msg.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 生成题目(v2:ai.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 流式聊天(v2:ai.StreamChat,SSE 透传) */
|
||||||
|
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 方法 =====
|
||||||
|
|
||||||
|
/** 班级学生列表(v2:core-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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 考试详情(v2:core-edu.GetExam,questions 暂空) */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 作业详情(v2:core-edu.GetHomework,submissions 暂空) */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新用户信息(v2:iam.UpdateUser;id 缺省时更新当前用户) */
|
||||||
|
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: mock;P3+: 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: mock;P3+: 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user