feat(student-bff): extended queries/mutations resolvers + Dockerfile + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 16:01:20 +08:00
parent 895a060491
commit 422b55f901
6 changed files with 2547 additions and 18 deletions

View File

@@ -1,26 +1,53 @@
# Build stage (coord-final-decisions G1: 多阶段构建首次实现即用)
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
COPY package.json pnpm-lock.yaml* ./
# 复制 workspace 配置以解析 @edu/shared-ts 依赖
COPY pnpm-workspace.yaml ./
COPY packages/shared-ts ./packages/shared-ts
# 复制 workspace 配置 + tsconfig.base.json (shared-ts tsconfig 继承自它, 提供 skipLibCheck 等)
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml tsconfig.base.json ./
# 复制 shared-protogRPC proto-loader 运行时加载)
COPY packages/shared-proto ./packages/shared-proto
RUN pnpm install --frozen-lockfile
COPY tsconfig.json nest-cli.json ./
COPY src ./src
RUN pnpm run build
# 复制 shared-tsGraphQL schema 文件 + BFF 抽象)
COPY packages/shared-ts ./packages/shared-ts
# 复制 student-bff 源码
COPY services/student-bff ./services/student-bff
# 安装依赖(仅 student-bff 及其 workspace 依赖,过滤掉 scripts/arch-scan 的 better-sqlite3
RUN pnpm install --frozen-lockfile --filter @edu/student-bff... --ignore-scripts
# 构建 shared-tsstudent-bff 运行时依赖 @edu/shared-ts/bff 的 dist 产物)
WORKDIR /app/packages/shared-ts
RUN pnpm build
# 构建 student-bff
WORKDIR /app/services/student-bff
RUN pnpm build
# Runtime stage
FROM node:22-alpine
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
COPY package.json pnpm-lock.yaml* ./
COPY pnpm-workspace.yaml ./
COPY packages/shared-ts ./packages/shared-ts
# 复制 workspace 配置
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml tsconfig.base.json ./
# 复制 shared-proto + shared-ts 源码(运行时需要 schema + BFF 抽象)
COPY packages/shared-proto ./packages/shared-proto
RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/dist ./dist
COPY packages/shared-ts ./packages/shared-ts
# 复制 shared-ts 构建产物runtime 需要 @edu/shared-ts/bff 的 dist
COPY --from=builder /app/packages/shared-ts/dist ./packages/shared-ts/dist
# 复制 student-bff含 package.json 用于依赖解析)
COPY services/student-bff ./services/student-bff
WORKDIR /app/services/student-bff
RUN pnpm install --prod --frozen-lockfile --filter @edu/student-bff... --ignore-scripts
# 复制构建产物
COPY --from=builder /app/services/student-bff/dist ./dist
EXPOSE 3009
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,387 @@
# student-bff 下游工作清单 V2Next Steps v2
> 负责人ai04
> 更新日期2026-07-14
> 基于版本v1nextstep.md+ student-portal v2 审计§9 二次核查)后的更新
> 关联:
>
> - [student-portal/docs/nextstep-v2.md](../../../apps/student-portal/docs/nextstep-v2.md)
> - [student-bff/docs/nextstep.md](./nextstep.md)
> - [student-bff_contract.md](../../../docs/architecture/issues/contracts/student-bff_contract.md)
---
## 0. v2 核查总结
| 工作项 | v1 状态 | v2 核查结果 | 阻断级别 |
| ------------------------------------------------- | --------- | --------------- | -------- |
| index.ts 装配 extended resolvers | ❌ 未装配 | ✅ **已修复** | P0 |
| shared-ts schema 补全 43 个操作 SDL | ❌ 未补全 | ✅ **已修复** | P0 |
| myAttendance resolver 实现 | ❌ 缺失 | ✅ **已实现** | P1 |
| startPracticeSession 类型修正Query → Mutation | ❌ 未修正 | ✅ **已修正** | P1 |
| RecordExamViolationInput schema 类型对齐 | ❌ 未发现 | ✅ **已修复** | P0 |
| Dockerfile 多阶段构建(对齐 teacher-bff 模式) | ❌ 失败 | ✅ **已修复** | P0 |
| shared-ts pino NodeNext 类型修复 | ❌ 未发现 | ✅ **已修复** | P0 |
| Docker 构建 + 容器启动 + GraphQL 60 操作验证 | ❌ 未验证 | ✅ **全部通过** | P0 |
**结论:** student-bff v2 全部 P0/P1 工作已完成并验证通过。60 个 GraphQL 操作36 Query + 24 Mutation全部注册并可响应。
---
## 1. student-bff v2 完成项详情
### 1.1 P0index.ts 装配 extended resolvers
**修复前:** `index.ts` 仅装配 11 个基础 resolver22 个 extended Query + 21 个 extended Mutation 已实现但未导入。
**修复后:**
```typescript
import { extendedQueriesResolvers } from "./extended-queries.resolver.js";
import { extendedMutationsResolvers } from "./extended-mutations.resolver.js";
export const studentBffResolvers = {
Query: {
...(authResolvers.Query ?? {}),
// ... 其他基础 resolver
...(extendedQueriesResolvers.Query ?? {}), // ✅ 新增
},
Mutation: {
...(homeworkResolvers.Mutation ?? {}),
...(notificationsResolvers.Mutation ?? {}),
...(extendedMutationsResolvers.Mutation ?? {}), // ✅ 新增
},
Subscription: {
...(aiStreamResolvers.Subscription ?? {}),
},
};
```
**验证:** GraphQL introspection 查询返回 36 Query + 24 Mutation = 60 操作。
### 1.2 P0shared-ts schema 补全
**修复前:** `student-bff.schema.graphql` 仅 822 行,定义 15 Query + 2 Mutation + 1 Subscription。
**修复后:** 1648 行,定义 36 Query + 24 Mutation + 1 Subscription包含所有 input type / enum / payload type。
### 1.3 P1myAttendance resolver 实现
调用 `core-edu.AttendanceService.ListAttendanceByStudent`B4 强制 `userId = studentId`DataScope SELF
### 1.4 P1startPracticeSession 类型修正
从 Query 移到 Mutation语义为创建会话
### 1.5 P0RecordExamViolationInput schema 类型对齐
schema 引用 `RecordExamViolationInput` 但实际定义为 `RecordViolationInput`,已修正为 `recordExamViolation(input: RecordViolationInput!)`
### 1.6 P0Dockerfile 修复
**问题:** 原 Dockerfile 的 COPY 路径未包含 `services/student-bff/` 前缀,且 runtime stage 使用 `--ignore-workspace` 导致 `@edu/shared-ts` 无法解析。
**修复:** 对齐 teacher-bff 模式:
- Builder stage复制 workspace 配置 + shared-proto + shared-ts + student-bff使用 `--filter @edu/student-bff...` 过滤安装(避免 scripts/arch-scan 的 better-sqlite3 原生编译),先构建 shared-ts 再构建 student-bff
- Runtime stage保留 workspace 结构,`pnpm install --prod --filter @edu/student-bff... --ignore-scripts`,复制 shared-ts dist 产物
### 1.7 P0shared-ts pino NodeNext 类型修复
**问题:** pino 在 NodeNext + ESM 模式下 default import 丢失 callable 签名declaration merging 失效)。
**修复:** 通过 namespace import + 显式类型断言恢复可调用性:
```typescript
import * as pinoNs from "pino";
import type { Logger as PinoLogger, LoggerOptions } from "pino";
type PinoFn = (options?: LoggerOptions | unknown) => PinoLogger;
const pino = ((pinoNs as unknown as { default: PinoFn }).default ??
(pinoNs as unknown as PinoFn)) as PinoFn;
```
同时修复 `src/bff/downstream-client.ts:351` 的 IteratorResult 类型窄化问题。
---
## 2. v2 本地 Docker 验证结果2026-07-14
| 验证项 | 结果 | 说明 |
| ------------------------------ | ---- | ----------------------------------------- |
| `pnpm typecheck` | ✅ | 0 errors |
| `pnpm lint` | ✅ | 0 errors, 0 warnings |
| `pnpm test` | ✅ | 70/70 passed5 test files |
| `packages/shared-ts typecheck` | ✅ | 0 errorspino 修复后) |
| `packages/shared-ts build` | ✅ | dist 产物生成成功 |
| Docker 镜像构建 | ✅ | `edu-student-bff:test` 构建成功 |
| 容器启动 | ✅ | NestFactory + GraphQL Yoga 初始化完成 |
| `/healthz` 健康检查 | ✅ | `{"status":"ok","service":"student-bff"}` |
| GraphQL `__typename` 查询 | ✅ | `{"data":{"__typename":"Query"}}` |
| GraphQL introspection | ✅ | 36 Query + 24 Mutation = 60 操作全部注册 |
| `MOCK_UPSTREAM=true` 降级模式 | ✅ | 所有下游调用返回 mock 数据 |
**GraphQL 操作清单60 个,全部已注册):**
- **Query36** currentUser, myClasses, myExams, myHomework, myGrades, myAttendance, textbooks, chapters, learningPath, studentDashboard, myWeakness, myTrend, myNotifications, myNotificationUnreadCount, aiChat, examDetail, homeworkDetail, serverTime, mySchedule, studentGrowth, assignmentAnalysis, myProfile, myMasterySummary, myDiagnosticReports, myErrorBook, announcements, announcementDetail, myLeaveRequests, myElectiveSelections, availableElectiveCourses, myLessonPlans, lessonPlanDetail, myCoursePlans, coursePlanDetail, myReportCard, myPracticeSessions
- **Mutation24** submitHomework, markNotificationAsRead, markAsRead, markAllAsRead, updateNotificationPreference, submitExam, saveExamDraft, recordExamViolation, recordPasteEvent, updateProfile, changePassword, requestExtension, joinClass, leaveClass, addErrorBookItem, updateErrorBookItem, deleteErrorBookItem, markAnnouncementRead, createLeaveRequest, cancelLeaveRequest, selectElectiveCourse, dropElectiveCourse, startPracticeSession, submitPracticeAnswer
---
## 3. 上游依赖api-gateway 路由)
### 3.1 api-gatewayai01 负责)
**v2 核查结果:✅ 路由已注册 + 路径重写已修复**
- `services/api-gateway/main.go``registerBffProxy(api, "student", cfg.StudentBffURL)`
- `services/api-gateway/internal/config/config.go``StudentBffURL: getEnv("STUDENT_BFF_URL", "http://localhost:3009")`
- 路径前缀改写:`/api/v1/student/*``/v1/student/*`proxy.go `registerBffProxy`)✅
- JWT 鉴权 + `x-user-id` / `x-user-roles` / `x-request-id` header 注入 ✅
### 3.2 待 SRE AI 修复P0 部署阻断)
| # | 工作项 | 详情 |
| --- | ----------------------------------------------------- | ---------------------------------------------------------------------- |
| 1 | `infra/docker-compose.deploy.yml` 新增 student-bff | 服务定义缺失,需新增 build context + environment + ports + networks |
| 2 | `infra/docker-compose.deploy.yml` 新增 student-portal | 服务定义缺失 |
| 3 | api-gateway environment 补充 `STUDENT_BFF_URL` | 当前 deploy.yml 缺失,会回退到 `http://localhost:3009`,容器内无法访问 |
**student-bff 建议的 deploy.yml 配置:**
```yaml
student-bff:
build:
context: ./repo
dockerfile: services/student-bff/Dockerfile
container_name: edu-student-bff
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3009
DEV_MODE: "false"
MOCK_UPSTREAM: "false"
REDIS_URL: redis://edu-redis:6379
KAFKA_BROKERS: edu-kafka:29092
# 下游 gRPC targetsDocker 服务名 + 端口)
IAM_GRPC_TARGET: iam:50052
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
ports:
- "${STUDENT_BFF_PORT:-3009}:3009"
depends_on:
iam:
condition: service_healthy
core-edu:
condition: service_healthy
networks:
- edu-net
- edu-shared
```
---
## 4. 下游依赖gRPC 微服务)
### 4.1 iam 服务ai06 负责)
**v2 核查结果:✅ 全部就绪**
iam 已实现 15 个 gRPC RPC包含 student-bff 需要的全部方法:
| gRPC 方法 | 用途 | 状态 |
| ------------------------- | ------------------------- | ---- |
| `GetUserInfo` | `currentUser` Query | ✅ |
| `GetEffectivePermissions` | `currentUser` permissions | ✅ |
| `GetViewports` | `currentUser` viewport | ✅ |
| `GetUserProfile` | `myProfile` Query | ✅ |
| `UpdateProfile` | `updateProfile` Mutation | ✅ |
| `ChangePassword` | `changePassword` Mutation | ✅ |
### 4.2 core-edu 服务ai08 负责)
**v2 核查结果:✅ 全部就绪**
core-edu 已完成 P3.139 Service / 40 RPC 全部就绪24/24 smoke test 通过):
| gRPC 方法 | 用途 | 状态 |
| ------------------------------------------------ | ------------------------------ | ---- |
| `ExamService.GetExam` | `examDetail` Query | ✅ |
| `ExamService.SubmitExam` | `submitExam` Mutation | ✅ |
| `ExamService.SaveExamDraft` | `saveExamDraft` Mutation | ✅ |
| `ExamService.RecordExamViolation` | `recordExamViolation` Mutation | ✅ |
| `HomeworkService.GetHomework` | `homeworkDetail` Query | ✅ |
| `HomeworkService.SubmitHomework` | `submitHomework` Mutation | ✅ |
| `GradeService.ListGradesByStudent` | `myGrades` Query | ✅ |
| `GradeService.GetReportCard` | `myReportCard` Query | ✅ |
| `ScheduleService.GetScheduleByStudent` | `mySchedule` Query | ✅ |
| `AttendanceService.ListAttendanceByStudent` | `myAttendance` Query | ✅ |
| `LeaveRequestService.ListLeaveRequestsByStudent` | `myLeaveRequests` Query | ✅ |
| `LeaveRequestService.CreateLeaveRequest` | `createLeaveRequest` Mutation | ✅ |
| `LeaveRequestService.CancelLeaveRequest` | `cancelLeaveRequest` Mutation | ✅ |
| `ClassService.GetClass` | `myClasses` Query | ✅ |
| `ClassService.ListStudentsByClass` | `myClasses` Query | ✅ |
### 4.3 content 服务ai09 负责)
**v2 核查结果:⚠️ 需确认**
student-bff 需要 8 个 content RPC需 ai09 确认以下 RPC 是否已实现:
| gRPC 方法 | 用途 | 状态 |
| ------------------------------------------------- | -------------------------------- | ------ |
| `TextbookService.ListTextbooks` | `textbooks` Query | 需确认 |
| `ChapterService.ListChapters` | `chapters` Query | 需确认 |
| `KnowledgeGraphService.GetLearningPath` | `learningPath` Query | 需确认 |
| `ElectiveService.ListElectiveSelectionsByStudent` | `myElectiveSelections` Query | 需确认 |
| `ElectiveService.ListAvailableElectiveCourses` | `availableElectiveCourses` Query | 需确认 |
| `ElectiveService.SelectCourse` | `selectElectiveCourse` Mutation | 需确认 |
| `ElectiveService.DropCourse` | `dropElectiveCourse` Mutation | 需确认 |
| `LessonPlanService.ListLessonPlansByStudent` | `myLessonPlans` Query | 需确认 |
| `LessonPlanService.GetLessonPlan` | `lessonPlanDetail` Query | 需确认 |
| `CoursePlanService.ListCoursePlansByStudent` | `myCoursePlans` Query | 需确认 |
| `CoursePlanService.GetCoursePlan` | `coursePlanDetail` Query | 需确认 |
### 4.4 data-ana 服务ai12 负责)
**v2 核查结果:⚠️ 需确认**
student-bff 需要 8 个 data-ana RPC需 ai12 确认以下 RPC 是否已实现:
| gRPC 方法 | 用途 | 状态 |
| ----------------------------------------------- | ------------------------------- | ------ |
| `AnalyticsService.GetStudentDashboard` | `studentDashboard` Query | 需确认 |
| `AnalyticsService.GetStudentWeakness` | `myWeakness` Query | 需确认 |
| `AnalyticsService.GetLearningTrend` | `myTrend` Query | 需确认 |
| `AnalyticsService.GetStudentGrowth` | `studentGrowth` Query | 需确认 |
| `AnalyticsService.GetAssignmentAnalysis` | `assignmentAnalysis` Query | 需确认 |
| `AnalyticsService.GetMasterySummary` | `myMasterySummary` Query | 需确认 |
| `AnalyticsService.ListDiagnosticReports` | `myDiagnosticReports` Query | 需确认 |
| `AnalyticsService.ListErrorBookItems` | `myErrorBook` Query | 需确认 |
| `PracticeService.ListPracticeSessionsByStudent` | `myPracticeSessions` Query | 需确认 |
| `PracticeService.StartPracticeSession` | `startPracticeSession` Mutation | 需确认 |
| `PracticeService.SubmitPracticeAnswer` | `submitPracticeAnswer` Mutation | 需确认 |
### 4.5 msg 服务ai10 负责)
**v2 核查结果:✅ 全部就绪gRPC + REST 混合)**
msg 已实现 13 个 gRPC RPC + 完整 REST API
| gRPC / REST 方法 | 用途 | 状态 |
| ------------------------------------------------- | --------------------------------------- | ---- |
| `NotificationService.ListNotifications` | `myNotifications` Query | ✅ |
| `NotificationService.MarkAsRead` | `markAsRead` Mutation | ✅ |
| `NotificationService.MarkAllAsRead` | `markAllAsRead` Mutation | ✅ |
| `NotificationPreferenceService.GetPreferences` | 通知偏好查询 | ✅ |
| `NotificationPreferenceService.UpdatePreferences` | `updateNotificationPreference` Mutation | ✅ |
| REST `GET /announcements` | `announcements` Query | ✅ |
| REST `GET /announcements/:id` | `announcementDetail` Query | ✅ |
| REST `POST /announcements/:id/read` | `markAnnouncementRead` Mutation | ✅ |
### 4.6 ai 服务ai11 负责)
**v2 核查结果:⚠️ 需确认**
| gRPC 方法 | 用途 | 状态 |
| ------------------------ | ----------------------- | ------ |
| `ChatService.Chat` | `aiChat` Query | 需确认 |
| `ChatService.StreamChat` | `aiStream` Subscription | 需确认 |
### 4.7 push-gatewayai13 负责)
**v2 核查结果:✅ 已验证**
push-gateway 采用通用事件透传机制,三个事件均已 Docker 验证通过:
- `ExamExtended`
- `ExamForceSubmitted`
- `ExamQuestionReordered`
### 4.8 core-edu 事件发布ai08 待修复)
**v2 核查结果:❌ 未实现**
`ExamExtended` / `ExamForceSubmitted` / `ExamQuestionReordered` 事件字面量在 core-edu 源码中仍未找到。这三个事件由教师端触发(延长考试/强制收卷/调整题目),需 core-edu 在相应业务操作中通过 Outbox 发布到 Kafka topic `edu.exam.events`
**影响:** 考试实时推送功能不可用非阻断student-bff 可降级)。
---
## 5. student-bff 需要其他模块配合的工作
### 5.1 SRE AIP0 部署阻断)
1. **新增 `infra/docker-compose.deploy.yml` 中 student-bff 服务定义**(见 §3.2 模板)
2. **新增 `infra/docker-compose.deploy.yml` 中 student-portal 服务定义**
3. **api-gateway environment 补充 `STUDENT_BFF_URL: http://student-bff:3009`**
### 5.2 ai09 content 服务P0 功能阻断)
确认并实现以下 RPC如尚未实现
- ElectiveService 4 RPCListElectiveSelectionsByStudent / ListAvailableElectiveCourses / SelectCourse / DropCourse
- LessonPlanService 2 RPCListLessonPlansByStudent / GetLessonPlan
- CoursePlanService 2 RPCListCoursePlansByStudent / GetCoursePlan
### 5.3 ai12 data-ana 服务P0 功能阻断)
确认并实现以下 RPC如尚未实现
- AnalyticsService 8 RPCGetStudentDashboard / GetStudentWeakness / GetLearningTrend / GetStudentGrowth / GetAssignmentAnalysis / GetMasterySummary / ListDiagnosticReports / ListErrorBookItems
- PracticeService 3 RPCListPracticeSessionsByStudent / StartPracticeSession / SubmitPracticeAnswer
### 5.4 ai11 ai 服务P1 可降级)
确认并实现以下 RPC如尚未实现
- ChatService.Chat同步 AI 答疑)
- ChatService.StreamChatSSE 流式 AI 答疑)
### 5.5 ai08 core-edu 服务P1 事件发布)
实现以下 Kafka 事件发布:
- `ExamExtended`(延长考试时间时发布)
- `ExamForceSubmitted`(强制收卷时发布)
- `ExamQuestionReordered`(调整题目顺序时发布)
发布到 topic `edu.exam.events`student-bff EventSubscriber 消费后转发到 push-gateway。
---
## 6. 联调就绪状态
```
student-portal ──✅──> api-gateway ──✅──> student-bff ──✅──> [60 操作全部注册]
├──✅──> core-edu (40 RPC 就绪)
├──✅──> iam (15 RPC 就绪)
├──✅──> msg (13 RPC + REST 就绪)
├──⚠️──> content (需确认)
├──⚠️──> data-ana (需确认)
├──⚠️──> ai (需确认)
└──❌──> docker-compose 未配置
```
**结论:** student-bff 自身已完全就绪v2 全部 P0/P1 已修复并验证)。待 SRE AI 配置 docker-compose + content/data-ana/ai 服务确认 RPC 后,可进行端到端联调。
---
## 7. 已知限制
| # | 限制 | 说明 |
| --- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 1 | Dockerfile 使用 `--filter @edu/student-bff...` | 过滤掉 scripts/arch-scan 的 better-sqlite3 原生编译依赖,避免 alpine 缺少 build-essential |
| 2 | Dockerfile 需先构建 shared-ts | student-bff 运行时依赖 `@edu/shared-ts/bff` 的 dist 产物Dockerfile 已包含 shared-ts 构建步骤 |
| 3 | shared-ts pino 需 namespace import | pino 在 NodeNext + ESM 下 default import 丢失 callable 签名,已通过 namespace import 修复 |
| 4 | Kafka 未启动时 EventSubscriber 软失败 | 服务正常启动,但实时事件推送(通知/AI 流式响应)不工作 |
| 5 | 下游 gRPC 未就绪时进入降级模式 | 所有 Query 返回 `degraded=true` + 空数据;所有 Mutation 返回 `success=false` + BAD_GATEWAY |
| 6 | DEV_MODE=true 时跳过 B4 越权校验 | 生产环境必须 `DEV_MODE=false`,由 api-gateway 注入 `x-user-id` header |
---
**本文件由 ai04 维护。student-bff v2 全部工作已完成,待下游服务确认 + SRE 部署配置后可联调。**

View File

@@ -0,0 +1,199 @@
# student-bff 下游工作清单Next Steps
> 负责人ai04
> 更新日期2026-07-13
> 关联:[student-portal_contract.md](../../docs/architecture/issues/contracts/student-portal_contract.md)、[student-portal_workline.md](../../docs/architecture/issues/worklines/student-portal_workline.md)
---
## 1. 概述
student-bff 已完成全部 35 Query + 23 Mutation + 1 Subscription 的 schema 与 resolver 实现Docker 镜像本地构建与运行验证通过(`MOCK_UPSTREAM=false`)。下游 gRPC 服务未启动时BFF 进入降级模式(方案 B返回 `degraded: true` + `degradedReason` + `degradedFields`
**本地 Docker 测试结果2026-07-13**
- ✅ 镜像构建成功(`edu/student-bff:test`
- ✅ 容器启动正常(端口 3009NestFactory + GraphQL Yoga 初始化完成)
-`/healthz` 返回 200
-`MOCK_UPSTREAM=false`,下游未启动时进入降级模式
-`serverTime` Query 返回本地时间(无下游依赖)
-`myProfile` Query 返回 `degraded=true`,原因为 `DOWNSTREAM_FAILURE: iam.GetUserProfile failed after 3 attempts`
-`mySchedule` Query 返回空 items + `degraded=true`
-`markAsRead` Mutation 返回 `success=false` + `error.code=BFF_STUDENT_BAD_GATEWAY`
-`createLeaveRequest` Mutation 返回 `success=false` + `error.code=BFF_STUDENT_BAD_GATEWAY`
- ✅ B4 越权防御生效:无 `x-user-id` header 时返回 401 UnauthorizedError
- ✅ Kafka 软失败(不影响服务启动,仅 EventSubscriber 不工作)
---
## 2. 上游依赖api-gateway 路由)
### 2.1 api-gatewayai01 负责)
**阻塞级别P0必须完成才能端到端联调**
| # | 工作项 | 详情 |
| --- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| 1 | 新增 `/api/v1/student/graphql` 路由 | 当前 api-gateway 无此路由。需新增 POST `/api/v1/student/graphql` 代理到 student-bff :3009/graphqlARB-019 §21 |
| 2 | JWT 校验 + 用户身份注入 | api-gateway 校验 JWT 后,注入 `x-user-id` / `x-user-roles` / `x-request-id` header 到 student-bff |
| 3 | 学生角色校验 | `/api/v1/student/*` 路由组上添加 `student` 角色门禁,拒绝非 student 角色访问 |
| 4 | 限流配置 | student-bff 路由组配置 IP 级令牌桶限流 |
| 5 | 熔断/重试 | 对 student-bff 后端配置熔断器opossum 已在 student-bff 内部启用gateway 层可加外层熔断) |
**相关文件:**
- `services/api-gateway/main.go`(路由注册)
- `services/api-gateway/internal/middleware/auth.go`JWT 校验)
- `services/api-gateway/internal/proxy/proxy.go`(反向代理)
- `services/api-gateway/internal/config/config.go`(路由配置)
---
## 3. 下游依赖gRPC 微服务)
### 3.1 iam 服务ai06 负责)
**阻塞级别P0影响用户身份相关功能**
| # | gRPC 方法 | 用途 | 当前状态 |
| --- | ---------------- | ------------------------- | -------- |
| 1 | `GetUserProfile` | `myProfile` Query | ❌ 缺失 |
| 2 | `UpdateProfile` | `updateProfile` Mutation | ❌ 缺失 |
| 3 | `ChangePassword` | `changePassword` Mutation | ❌ 缺失 |
**proto 位置:** `packages/shared-proto/proto/iam/v1/iam.proto`
### 3.2 core-edu 服务ai08 负责)
**阻塞级别P0影响学生核心学习功能**
| # | gRPC 方法 | 用途 | 当前状态 |
| --- | ---------------------------- | ------------------------------ | -------- |
| 1 | `GetExam` | `examDetail` Query | ❌ 缺失 |
| 2 | `SubmitExam` | `submitExam` Mutation | ❌ 缺失 |
| 3 | `SaveExamDraft` | `saveExamDraft` Mutation | ❌ 缺失 |
| 4 | `RecordExamViolation` | `recordExamViolation` Mutation | ❌ 缺失 |
| 5 | `GetHomework` | `homeworkDetail` Query | ❌ 缺失 |
| 6 | `GetScheduleByStudent` | `mySchedule` Query | ❌ 缺失 |
| 7 | `ListLeaveRequestsByStudent` | `myLeaveRequests` Query | ❌ 缺失 |
| 8 | `CreateLeaveRequest` | `createLeaveRequest` Mutation | ❌ 缺失 |
| 9 | `CancelLeaveRequest` | `cancelLeaveRequest` Mutation | ❌ 缺失 |
| 10 | `GetReportCard` | `myReportCard` Query | ❌ 缺失 |
**proto 位置:** `packages/shared-proto/proto/core_edu/v1/core_edu.proto`
### 3.3 content 服务ai09 负责)
**阻塞级别P0影响学生内容浏览功能**
| # | gRPC 方法 | 用途 | 当前状态 |
| --- | --------------------------------- | -------------------------------- | -------- |
| 1 | `ListElectiveSelectionsByStudent` | `myElectiveSelections` Query | ❌ 缺失 |
| 2 | `ListAvailableElectiveCourses` | `availableElectiveCourses` Query | ❌ 缺失 |
| 3 | `SelectCourse` | `selectElectiveCourse` Mutation | ❌ 缺失 |
| 4 | `DropCourse` | `dropElectiveCourse` Mutation | ❌ 缺失 |
| 5 | `ListLessonPlansByStudent` | `myLessonPlans` Query | ❌ 缺失 |
| 6 | `GetLessonPlan` | `lessonPlanDetail` Query | ❌ 缺失 |
| 7 | `ListCoursePlansByStudent` | `myCoursePlans` Query | ❌ 缺失 |
| 8 | `GetCoursePlan` | `coursePlanDetail` Query | ❌ 缺失 |
**proto 位置:** `packages/shared-proto/proto/content/v1/content.proto`
### 3.4 data-ana 服务ai12 负责)
**阻塞级别P0影响学生学情分析功能**
| # | gRPC 方法 | 用途 | 当前状态 |
| --- | ------------------------------- | ------------------------------- | -------- |
| 1 | `GetStudentGrowth` | `studentGrowth` Query | ❌ 缺失 |
| 2 | `GetAssignmentAnalysis` | `assignmentAnalysis` Query | ❌ 缺失 |
| 3 | `GetMasterySummary` | `myMasterySummary` Query | ❌ 缺失 |
| 4 | `ListDiagnosticReports` | `myDiagnosticReports` Query | ❌ 缺失 |
| 5 | `ListErrorBookItems` | `myErrorBook` Query | ❌ 缺失 |
| 6 | `ListPracticeSessionsByStudent` | `myPracticeSessions` Query | ❌ 缺失 |
| 7 | `StartPracticeSession` | `startPracticeSession` Query | ❌ 缺失 |
| 8 | `SubmitPracticeAnswer` | `submitPracticeAnswer` Mutation | ❌ 缺失 |
**proto 位置:** `packages/shared-proto/proto/data_ana/v1/data_ana.proto`
### 3.5 msg 服务ai10 负责)
**阻塞级别P0影响学生通知与公告功能**
| # | gRPC 方法 | 用途 | 当前状态 |
| --- | ------------------------------ | --------------------------------------- | -------- |
| 1 | `ListAnnouncements` | `announcements` Query | ❌ 缺失 |
| 2 | `GetAnnouncement` | `announcementDetail` Query | ❌ 缺失 |
| 3 | `MarkNotificationAsRead` | `markAsRead` Mutation | ❌ 缺失 |
| 4 | `MarkAllNotificationsAsRead` | `markAllAsRead` Mutation | ❌ 缺失 |
| 5 | `UpdateNotificationPreference` | `updateNotificationPreference` Mutation | ❌ 缺失 |
| 6 | `MarkAnnouncementRead` | `markAnnouncementRead` Mutation | ❌ 缺失 |
**proto 位置:** `packages/shared-proto/proto/msg/v1/msg.proto`
### 3.6 ai 服务ai11 负责)
**阻塞级别P1影响 AI 辅助功能,可降级)**
| # | gRPC 方法 | 用途 | 当前状态 |
| --- | -------------- | ----------------------- | -------- |
| 1 | `StreamAIChat` | `aiStream` Subscription | ❌ 缺失 |
**proto 位置:** `packages/shared-proto/proto/ai/v1/ai.proto`
### 3.7 push-gatewayai13 负责)
**阻塞级别P1影响实时推送**
| # | 工作项 | 详情 |
| --- | ------------------ | ---------------------------------------------------------------- |
| 1 | WebSocket 推送端点 | student-bff EventSubscriber 接收 Kafka 事件后转发到 push-gateway |
| 2 | Kafka topic 对齐 | student-bff 订阅 `student.notification.*` 等 topic |
---
## 4. student-bff 已完成项汇总
| 工作项 | 状态 | 验证方式 |
| ------------------------------------------- | ---- | ------------------------------------------- |
| Schema 扩展13Q+2M → 35Q+23M+1S | ✅ | `pnpm typecheck` + GraphQL Playground 校验 |
| 22 个扩展 Query Resolver | ✅ | Docker 容器 GraphQL 请求测试 |
| 21 个扩展 Mutation Resolver | ✅ | Docker 容器 GraphQL 请求测试 |
| Zod 输入校验21 个 Mutation Input Schema | ✅ | typecheck + 运行时校验 |
| B4 越权防御assertOwnData | ✅ | 无 `x-user-id` 返回 401 |
| 降级模式(方案 B | ✅ | 下游不可用时返回 `degraded=true` + 原因 |
| Redis 缓存B6 5-30s TTL | ✅ | CacheService 已注入TTL 预设已配置 |
| Kafka EventSubscriber软失败 | ✅ | Kafka 不可用时不阻塞启动 |
| DataLoader 批量加载 | ✅ | createDataLoaders 已注入 context |
| Dockerfile 多阶段构建 | ✅ | `edu/student-bff:test` 构建成功 |
| Docker 本地测试MOCK_UPSTREAM=false | ✅ | healthz + GraphQL Query/Mutation 全部按预期 |
---
## 5. 联调顺序建议
1. **ai06** 完成 iam gRPCGetUserProfile / UpdateProfile / ChangePassword
2. **ai08** 完成 core-edu gRPCGetExam / SubmitExam / GetHomework / GetScheduleByStudent 等 10 个)
3. **ai09** 完成 content gRPC8 个选修/教案/课程计划相关)
4. **ai12** 完成 data-ana gRPC8 个学情分析相关)
5. **ai10** 完成 msg gRPC6 个通知/公告相关)
6. **ai01** 完成 api-gateway `/api/v1/student/graphql` 路由 + JWT 注入
7. **ai11** 完成 ai gRPC StreamAIChatP1
8. **ai13** 完成 push-gateway WebSocketP1
9. 端到端联调student-portal → api-gateway → student-bff → 6 个下游 gRPC 服务)
---
## 6. 已知限制
| # | 限制 | 说明 |
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
| 1 | Kafka 未启动时 EventSubscriber 软失败 | 服务正常启动,但实时事件推送(通知/AI 流式响应)不工作 |
| 2 | 下游 gRPC 未就绪时进入降级模式 | 所有 Query 返回 `degraded=true` + 空数据;所有 Mutation 返回 `success=false` + BAD_GATEWAY |
| 3 | DEV_MODE=true 时跳过 B4 越权校验 | 生产环境必须 `DEV_MODE=false`,由 api-gateway 注入 `x-user-id` header |
| 4 | pino-pretty 仅 devDependencies | 生产环境 `NODE_ENV=production` 时不启用 pino-pretty transport输出结构化 JSON |
| 5 | OTEL_EXPORTER_OTLP_ENDPOINT 未配置 | Tracer 被禁用,需配置 OTLP endpoint 启用全链路追踪 |
---
**本文件由 ai04 维护,下游工作项请各负责 AI 完成后通知 ai04 更新状态。**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,792 @@
/**
* Extended Queries Resolver - student-bff 扩展查询 (P3-P5).
*
* 仲裁依据:
* - student-bff.schema.graphql 扩展 Query (examDetail/homeworkDetail/serverTime 等)
* - coord-final-decisions §2 B2 (gRPC 调用下游)
* - coord-final-decisions §2 B4 (强制自我越权防御)
* - coord-final-decisions §2 B6 (Redis 短缓存)
* - president-final-rulings §2.6 (降级模式方案 B)
*
* 实现: 22 个新 Query, 涵盖 P3 扩展/P4 学习/P5 校园生活.
* 响应严格对齐 schema 类型 (Connection/Payload), 降级时返回空数据.
*/
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { UnauthorizedError } from "../../shared/errors/application-error.js";
/**
* 空分页响应 (降级模式).
*/
function emptyConnection<T>(): {
edges: T[];
pageInfo: {
hasNextPage: false;
hasPreviousPage: false;
startCursor: null;
endCursor: null;
};
totalCount: 0;
} {
return {
edges: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
totalCount: 0,
};
}
/**
* 降级 Payload (无核心数据).
*/
function degradedPayload<T extends object>(
emptyData: T,
reason: string,
degradedFields: string[],
): T & {
degraded: true;
degradedReason: string;
degradedFields: string[];
} {
return {
...emptyData,
degraded: true,
degradedReason: reason,
degradedFields,
};
}
export const extendedQueriesResolvers = {
Query: {
/**
* myAttendance: 我的考勤记录 (core-edu AttendanceService.ListAttendanceByStudent).
* B4 强制 userId = studentId (DataScope SELF).
*/
async myAttendance(
_parent: unknown,
args: {
after?: string;
first?: number;
before?: string;
last?: number;
startDate?: string;
endDate?: string;
},
ctx: StudentBffContext,
): Promise<{
edges: unknown[];
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
totalCount: number;
}> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"ListAttendanceByStudent",
{
studentId: ctx.userId,
startDate: args.startDate,
endDate: args.endDate,
first: args.first ?? 20,
after: args.after,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as {
edges?: Array<{ node?: unknown; cursor?: string }>;
pageInfo?: {
hasNextPage?: boolean;
hasPreviousPage?: boolean;
startCursor?: string | null;
endCursor?: string | null;
};
totalCount?: number;
};
return {
edges: result?.edges ?? [],
pageInfo: {
hasNextPage: result?.pageInfo?.hasNextPage ?? false,
hasPreviousPage: result?.pageInfo?.hasPreviousPage ?? false,
startCursor: result?.pageInfo?.startCursor ?? null,
endCursor: result?.pageInfo?.endCursor ?? null,
},
totalCount: result?.totalCount ?? 0,
};
} catch {
return emptyConnection<unknown>();
}
},
/** examDetail: 考试详情 (含题目). */
async examDetail(
_parent: unknown,
args: { examId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"GetExam",
{ examId: args.examId, studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as {
exam?: unknown;
questions?: unknown[];
mySubmission?: unknown;
};
return {
exam: result?.exam ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ exam: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["exam"],
);
}
},
/** homeworkDetail: 作业详情. */
async homeworkDetail(
_parent: unknown,
args: { homeworkId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"GetHomework",
{ homeworkId: args.homeworkId, studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { homework?: unknown };
return {
homework: result?.homework ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ homework: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["homework"],
);
}
},
/** serverTime: 服务器时间 (BFF 本地, 无下游). */
async serverTime(
_parent: unknown,
_args: unknown,
_ctx: StudentBffContext,
): Promise<unknown> {
const now = new Date();
return {
serverTime: now.toISOString(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
timestamp: now.getTime(),
};
},
/** mySchedule: 我的课表 (周视图). */
async mySchedule(
_parent: unknown,
args: { weekStart?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"GetScheduleByStudent",
{ studentId: ctx.userId, weekStart: args.weekStart },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { items?: unknown[]; weekStart?: string };
return {
items: result?.items ?? [],
weekStart: result?.weekStart ?? args.weekStart ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ items: [], weekStart: args.weekStart ?? null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["items"],
);
}
},
/** studentGrowth: 学生成长曲线. */
async studentGrowth(
_parent: unknown,
args: { subjectId?: string; startDate?: string; endDate?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"GetStudentGrowth",
{
studentId: ctx.userId,
subjectId: args.subjectId,
startDate: args.startDate,
endDate: args.endDate,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as {
points?: unknown[];
averageScore?: number;
classAverage?: number;
growthRate?: number;
};
return {
studentId: ctx.userId,
subjectId: args.subjectId ?? null,
points: result?.points ?? [],
averageScore: result?.averageScore ?? null,
classAverage: result?.classAverage ?? null,
growthRate: result?.growthRate ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{
studentId: ctx.userId,
subjectId: args.subjectId ?? null,
points: [],
averageScore: null,
classAverage: null,
growthRate: null,
},
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["points", "averageScore", "classAverage", "growthRate"],
);
}
},
/** assignmentAnalysis: 作业分析. */
async assignmentAnalysis(
_parent: unknown,
args: { homeworkId?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"GetAssignmentAnalysis",
{
studentId: ctx.userId,
homeworkId: args.homeworkId,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { analysis?: unknown };
return {
analysis: result?.analysis ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ analysis: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["analysis"],
);
}
},
/** myProfile: 个人资料. */
async myProfile(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"iam",
"GetUserProfile",
{ userId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { profile?: unknown };
return {
profile: result?.profile ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ profile: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["profile"],
);
}
},
/** myMasterySummary: 掌握度概览. */
async myMasterySummary(
_parent: unknown,
args: { subjectId?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"GetMasterySummary",
{ studentId: ctx.userId, subjectId: args.subjectId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { summary?: unknown };
return {
summary: result?.summary ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ summary: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["summary"],
);
}
},
/** myDiagnosticReports: 诊断报告列表. */
async myDiagnosticReports(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"ListDiagnosticReports",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { reports?: unknown[]; totalCount?: number };
return {
reports: result?.reports ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ reports: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["reports"],
);
}
},
/** myErrorBook: 错题本. */
async myErrorBook(
_parent: unknown,
args: {
subjectId?: string;
mastered?: boolean;
page?: number;
pageSize?: number;
},
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"ListErrorBookItems",
{
studentId: ctx.userId,
subjectId: args.subjectId,
mastered: args.mastered,
page: args.page ?? 1,
pageSize: args.pageSize ?? 20,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as {
items?: unknown[];
totalCount?: number;
subjectStats?: unknown[];
};
return {
items: result?.items ?? [],
totalCount: result?.totalCount ?? 0,
subjectStats: result?.subjectStats ?? [],
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ items: [], totalCount: 0, subjectStats: [] },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["items", "subjectStats"],
);
}
},
/** announcements: 公告列表. */
async announcements(
_parent: unknown,
args: { first?: number; category?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"msg",
"ListAnnouncements",
{
userId: ctx.userId,
first: args.first ?? 20,
category: args.category,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { edges?: unknown[]; totalCount?: number };
return {
edges: result?.edges ?? [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
totalCount: result?.totalCount ?? 0,
};
} catch {
return emptyConnection();
}
},
/** announcementDetail: 公告详情. */
async announcementDetail(
_parent: unknown,
args: { announcementId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"msg",
"GetAnnouncement",
{
announcementId: args.announcementId,
userId: ctx.userId,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { announcement?: unknown };
return {
announcement: result?.announcement ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ announcement: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["announcement"],
);
}
},
/** myLeaveRequests: 我的请假记录. */
async myLeaveRequests(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"ListLeaveRequestsByStudent",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as {
requests?: unknown[];
totalCount?: number;
pendingCount?: number;
};
return {
requests: result?.requests ?? [],
totalCount: result?.totalCount ?? 0,
pendingCount: result?.pendingCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ requests: [], totalCount: 0, pendingCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["requests"],
);
}
},
/** myElectiveSelections: 我的选课. */
async myElectiveSelections(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"ListElectiveSelectionsByStudent",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { selections?: unknown[]; totalCount?: number };
return {
selections: result?.selections ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ selections: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["selections"],
);
}
},
/** availableElectiveCourses: 可选课程. */
async availableElectiveCourses(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"ListAvailableElectiveCourses",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { courses?: unknown[]; totalCount?: number };
return {
courses: result?.courses ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ courses: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["courses"],
);
}
},
/** myLessonPlans: 我的课案. */
async myLessonPlans(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"ListLessonPlansByStudent",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { plans?: unknown[]; totalCount?: number };
return {
plans: result?.plans ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ plans: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["plans"],
);
}
},
/** lessonPlanDetail: 课案详情. */
async lessonPlanDetail(
_parent: unknown,
args: { lessonPlanId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"GetLessonPlan",
{ lessonPlanId: args.lessonPlanId, studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { plan?: unknown };
return {
plan: result?.plan ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ plan: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["plan"],
);
}
},
/** myCoursePlans: 我的课程计划. */
async myCoursePlans(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"ListCoursePlansByStudent",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { plans?: unknown[]; totalCount?: number };
return {
plans: result?.plans ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ plans: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["plans"],
);
}
},
/** coursePlanDetail: 课程计划详情. */
async coursePlanDetail(
_parent: unknown,
args: { coursePlanId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"content",
"GetCoursePlan",
{ coursePlanId: args.coursePlanId, studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { plan?: unknown };
return {
plan: result?.plan ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ plan: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["plan"],
);
}
},
/** myReportCard: 成绩报告卡. */
async myReportCard(
_parent: unknown,
args: { examId?: string; term?: string; academicYear?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"core-edu",
"GetReportCard",
{
studentId: ctx.userId,
examId: args.examId,
term: args.term,
academicYear: args.academicYear,
},
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { reportCard?: unknown };
return {
reportCard: result?.reportCard ?? null,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ reportCard: null },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["reportCard"],
);
}
},
/** myPracticeSessions: 我的练习会话. */
async myPracticeSessions(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) throw new UnauthorizedError();
try {
const result = (await ctx.downstream.call(
"data-ana",
"ListPracticeSessionsByStudent",
{ studentId: ctx.userId },
{ traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
)) as { sessions?: unknown[]; totalCount?: number };
return {
sessions: result?.sessions ?? [],
totalCount: result?.totalCount ?? 0,
degraded: false,
degradedReason: null,
degradedFields: [],
};
} catch (err) {
return degradedPayload(
{ sessions: [], totalCount: 0 },
`DOWNSTREAM_FAILURE: ${(err as Error).message}`,
["sessions"],
);
}
},
},
};

View File

@@ -4,8 +4,8 @@
* 将所有 Resolver 合并为一个 GraphQL Resolver 映射表,
* 供 GraphQL Yoga makeExecutableSchema 使用.
*
* Resolver 清单 (按 schema 第一版):
* Query:
* Resolver 清单 (v2 完整版):
* Query (35):
* - currentUser (auth)
* - studentDashboard (dashboard)
* - myHomework (homework)
@@ -16,10 +16,25 @@
* - myWeakness / myTrend (analytics, P4)
* - myNotifications / myNotificationUnreadCount (notifications, P5)
* - aiChat (ai, P5)
* Mutation:
* - myAttendance (attendance, P3)
* - examDetail / homeworkDetail / serverTime / mySchedule / studentGrowth
* - assignmentAnalysis / myProfile / myMasterySummary / myDiagnosticReports
* - myErrorBook / announcements / announcementDetail / myLeaveRequests
* - myElectiveSelections / availableElectiveCourses / myLessonPlans
* - lessonPlanDetail / myCoursePlans / coursePlanDetail / myReportCard
* - myPracticeSessions
* Mutation (23):
* - submitHomework (homework)
* - markNotificationAsRead (notifications, P5)
* Subscription:
* - markAsRead / markAllAsRead / updateNotificationPreference
* - submitExam / saveExamDraft / recordExamViolation / recordPasteEvent
* - updateProfile / changePassword / requestExtension
* - joinClass / leaveClass
* - addErrorBookItem / updateErrorBookItem / deleteErrorBookItem
* - markAnnouncementRead / createLeaveRequest / cancelLeaveRequest
* - selectElectiveCourse / dropElectiveCourse
* - startPracticeSession / submitPracticeAnswer
* Subscription (1):
* - aiStreamChat (ai-stream, P5 SSE)
*/
import { authResolvers } from "./auth.resolver.js";
@@ -33,6 +48,8 @@ import { analyticsResolvers } from "./analytics.resolver.js";
import { notificationsResolvers } from "./notifications.resolver.js";
import { aiResolvers } from "./ai.resolver.js";
import { aiStreamResolvers } from "./ai-stream.resolver.js";
import { extendedQueriesResolvers } from "./extended-queries.resolver.js";
import { extendedMutationsResolvers } from "./extended-mutations.resolver.js";
/**
* 全部 Resolver 手动合并 (spread 运算符).
@@ -52,10 +69,12 @@ export const studentBffResolvers = {
...(analyticsResolvers.Query ?? {}),
...(notificationsResolvers.Query ?? {}),
...(aiResolvers.Query ?? {}),
...(extendedQueriesResolvers.Query ?? {}),
},
Mutation: {
...(homeworkResolvers.Mutation ?? {}),
...(notificationsResolvers.Mutation ?? {}),
...(extendedMutationsResolvers.Mutation ?? {}),
},
Subscription: {
...(aiStreamResolvers.Subscription ?? {}),