feat(parent-bff): graphql schema 扩展 + extended-resolvers + grpc factory + nextstep 文档
This commit is contained in:
@@ -1,21 +1,40 @@
|
|||||||
# 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
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
|
||||||
# pnpm 11 默认拒绝 build scripts,独立安装时无法读取 workspace 的 allowBuilds 配置
|
# 先复制所有源码(含 tsconfig.json / nest-cli.json / proto / graphql schema)
|
||||||
# 使用 --ignore-scripts 跳过(@nestjs/core/protobufjs 的 postinstall 非功能必需)
|
COPY packages/shared-proto ./packages/shared-proto
|
||||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
COPY packages/shared-ts ./packages/shared-ts
|
||||||
COPY tsconfig.json nest-cli.json ./
|
COPY services/parent-bff ./services/parent-bff
|
||||||
COPY src ./src
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json ./
|
||||||
|
|
||||||
|
# 安装依赖(含 devDependencies 用于构建,--ignore-scripts 跳过 husky)
|
||||||
|
WORKDIR /app/services/parent-bff
|
||||||
|
RUN pnpm install --no-frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
# 构建
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM node:22-alpine
|
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
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
|
||||||
RUN pnpm install --prod --frozen-lockfile --ignore-scripts
|
# 复制 parent-bff package.json(独立安装生产依赖)
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY services/parent-bff/package.json ./services/parent-bff/package.json
|
||||||
|
WORKDIR /app/services/parent-bff
|
||||||
|
RUN pnpm install --prod --no-frozen-lockfile --ignore-workspace --ignore-scripts
|
||||||
|
|
||||||
|
# 复制 shared-proto + shared-ts(运行时 proto/schema 加载需要)
|
||||||
|
WORKDIR /app
|
||||||
|
COPY packages/shared-proto ./packages/shared-proto
|
||||||
|
COPY packages/shared-ts ./packages/shared-ts
|
||||||
|
|
||||||
|
# 复制构建产物(保持 services/parent-bff/dist 结构)
|
||||||
|
COPY --from=builder /app/services/parent-bff/dist ./services/parent-bff/dist
|
||||||
|
|
||||||
|
WORKDIR /app/services/parent-bff
|
||||||
EXPOSE 3010
|
EXPOSE 3010
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
420
services/parent-bff/docs/nextstep-v2.md
Normal file
420
services/parent-bff/docs/nextstep-v2.md
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
# parent-bff 模块上下游依赖与工作清单(Next Steps v2)
|
||||||
|
|
||||||
|
> 版本:v2
|
||||||
|
> 日期:2026-07-14
|
||||||
|
> 负责人:ai04
|
||||||
|
> 关联:
|
||||||
|
>
|
||||||
|
> - [parent-portal nextstep-v2.md](../../../apps/parent-portal/docs/nextstep-v2.md)
|
||||||
|
> - [api-gateway nextstep.md](../../api-gateway/docs/nextstep.md)
|
||||||
|
> - [parent-bff.graphql SDL](../../../packages/shared-ts/contracts/graphql/parent-bff.graphql)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
parent-bff 是家长端聚合层 BFF(Backend For Frontend),端口 3010,基于 NestJS + GraphQL Yoga。负责聚合 iam / core-edu / data-ana / msg 四个下游微服务的 gRPC 接口,为 parent-portal 前端提供场景化 GraphQL API。
|
||||||
|
|
||||||
|
**路由路径(ARB-022 §24.4 ISSUE-003 方案 A 双 /v1 前缀):**
|
||||||
|
|
||||||
|
```
|
||||||
|
parent-portal
|
||||||
|
→ POST /api/v1/parent/v1/graphql (前端调用路径)
|
||||||
|
→ api-gateway registerBffProxy("parent") (剥离 /api/v1/parent)
|
||||||
|
→ parent-bff:3010 /v1/graphql (BFF 接收路径)
|
||||||
|
→ GraphqlController @Controller("v1/graphql")
|
||||||
|
```
|
||||||
|
|
||||||
|
**本地 Docker 测试结果(2026-07-14,DEV_MODE=false,不使用 mock):**
|
||||||
|
|
||||||
|
- ✅ TypeScript 编译零错误(`tsc --noEmit -p tsconfig.test.json`)
|
||||||
|
- ✅ ESLint 零错误(`eslint src test`)
|
||||||
|
- ✅ 单元测试 + 集成测试全通过(128 tests, 13 test files)
|
||||||
|
- ✅ Docker 镜像构建成功(`edu/parent-bff:test`)
|
||||||
|
- ✅ 容器启动正常(端口 3010,DEV_MODE=false 生产模式)
|
||||||
|
- ✅ `/healthz` 返回 200(liveness 通过)
|
||||||
|
- ✅ `/readyz` 返回 200(status=degraded,下游 gRPC 不可达但 Redis up)
|
||||||
|
- ✅ `/v1/graphql` GraphQL 查询正常响应(`{ __typename }` → `{ data: { __typename: "Query" } }`)
|
||||||
|
- ✅ `/v1/graphql` 扩展查询正常(`{ myChildren { id } }` → `{ data: { myChildren: [] } }`)
|
||||||
|
- ✅ `/v1/graphql` Mutation 正常(`mutation { markAllAsRead { count } }` → `{ data: { markAllAsRead: { count: 0 } } }`)
|
||||||
|
- ✅ `/metrics` 返回 Prometheus 指标(graphql_requests_total + graphql_duration_seconds)
|
||||||
|
- ✅ 优雅降级:下游 gRPC 不可达时 resolver 返回空数据而非崩溃
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 已完成工作(v2 全部完成)
|
||||||
|
|
||||||
|
### 2.1 P0 阻塞项(已解决)
|
||||||
|
|
||||||
|
| # | 工作项 | 状态 | 实现详情 |
|
||||||
|
| --- | ------------------------------------------------------------------ | ---- | ------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | GraphQL 端点路径修复(`/graphql` → `/v1/graphql`) | ✅ | controller + yoga + module 三处路径改为 `v1/graphql`,对齐 api-gateway `registerBffProxy` 剥离策略 |
|
||||||
|
| 2 | GraphQL Schema 扩展至 32 Query + 6 Mutation | ✅ | `packages/shared-ts/contracts/graphql/parent-bff.graphql` 已扩展,对齐 parent-portal 全部 operations |
|
||||||
|
| 3 | TypeScript 类型定义扩展(37+ 个新类型) | ✅ | `src/graphql/types.ts` 新增 ChildBrief/ChildSummary/ChildDetail/ChildGrade/AttendanceRecord 等 37+ 类型 |
|
||||||
|
| 4 | 全部新 Query/Mutation resolver 实现(37 个 resolver builder) | ✅ | `src/graphql/resolvers/extended-resolvers.ts` 实现 21 个 Query + 6 个 Mutation resolver |
|
||||||
|
| 5 | Legacy resolver 修复(children/child/selectChild/Child.analytics) | ✅ | `index.ts` + `select-child.resolver.ts` + `child.resolver.ts` 恢复并接通真实下游 |
|
||||||
|
| 6 | JSON scalar 支持 | ✅ | `resolvers/index.ts` 新增 JSONScalar(用于 NotificationPreferences.preferences 等动态结构) |
|
||||||
|
| 7 | Response Mapper 更新 | ✅ | `mapParent` 增加 permissions/schoolId,新增 `mapChildBrief` |
|
||||||
|
| 8 | Notification resolver 兼容旧版/新版 schema | ✅ | `notification.resolver.ts` + `notification-preference.resolver.ts` 同时填充两组字段 |
|
||||||
|
| 9 | Dockerfile 构建修复 | ✅ | 构建上下文改为 repo root,复制 shared-proto + shared-ts + tsconfig.base.json |
|
||||||
|
| 10 | Proto/Schema 路径解析修复 | ✅ | `grpc.factory.ts` + `schema.ts` 改用 `process.cwd()` 解析,兼容开发/生产模式 |
|
||||||
|
| 11 | pino 导入修复 | ✅ | `logger.ts` 从 `import pino from "pino"` 改为 `import { pino } from "pino"`(ESM 兼容) |
|
||||||
|
|
||||||
|
### 2.2 关键修复详情
|
||||||
|
|
||||||
|
**GraphQL 端点路径(ARB-022 §24.4 ISSUE-003 方案 A):**
|
||||||
|
|
||||||
|
| 文件 | 路径配置 |
|
||||||
|
| --------------------------------- | -------------------------------- |
|
||||||
|
| `src/entry/graphql.controller.ts` | `@Controller("v1/graphql")` |
|
||||||
|
| `src/graphql/yoga.ts` | `graphqlEndpoint: "/v1/graphql"` |
|
||||||
|
| `src/graphql/graphql.module.ts` | `forRoutes("v1/graphql")` |
|
||||||
|
|
||||||
|
**Proto/Schema 路径解析(process.cwd() 方式):**
|
||||||
|
|
||||||
|
| 文件 | 路径解析 |
|
||||||
|
| ---------------------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| `src/graphql/schema.ts` | `process.cwd() + ../../packages/shared-ts/contracts/graphql/parent-bff.graphql` |
|
||||||
|
| `src/clients/grpc/grpc.factory.ts` | `process.cwd() + ../../packages/shared-proto/proto` |
|
||||||
|
|
||||||
|
**Dockerfile 关键修复:**
|
||||||
|
|
||||||
|
- 构建上下文改为 repo root(访问 packages/shared-proto + packages/shared-ts)
|
||||||
|
- 复制 `tsconfig.base.json`(`tsconfig.json` extends `../../tsconfig.base.json`)
|
||||||
|
- runtime stage:`--ignore-workspace --ignore-scripts`(避免 workspace 解析 + 跳过 native 构建)
|
||||||
|
- 运行时保留 `packages/shared-proto` + `packages/shared-ts` 目录结构
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 上游依赖(调用 parent-bff 的模块)
|
||||||
|
|
||||||
|
### 3.1 api-gateway(ai01 负责)— P0
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | ------------------------------------------ | ---------------------------------------------------------------------------------- | ---- |
|
||||||
|
| 1 | `/api/v1/parent/*` 反向代理路由 | 前端请求经 api-gateway 代理到 parent-bff:3010 | ✅ |
|
||||||
|
| 2 | `registerBffProxy("parent", ...)` 路径重写 | 剥离 `/api/v1/parent`,转发剩余路径(`/v1/graphql`)到 parent-bff | ✅ |
|
||||||
|
| 3 | JWT 鉴权 + x-user-* 头注入 | api-gateway 校验 JWT 后注入 `x-user-id`/`x-user-roles`/`x-data-scope`/`x-trace-id` | ✅ |
|
||||||
|
| 4 | CORS 白名单 | `CORS_ORIGINS` 环境变量配置 | ✅ |
|
||||||
|
| 5 | 限流(IP 级令牌桶) | 100 rps,突发 20 | ✅ |
|
||||||
|
| 6 | 熔断(下游 5xx 触发) | `CircuitBreaker("downstream")` | ✅ |
|
||||||
|
|
||||||
|
**验证要点:**
|
||||||
|
|
||||||
|
- api-gateway 入站 `/api/v1/parent/v1/graphql` → 剥离 `/api/v1/parent` → 转发 `/v1/graphql` 到 parent-bff:3010
|
||||||
|
- parent-bff GraphqlController 注册在 `/v1/graphql`,接收路径匹配
|
||||||
|
- 经 api-gateway 代理访问 `http://api-gateway:8080/api/v1/parent/v1/graphql` 应返回 200
|
||||||
|
|
||||||
|
### 3.2 parent-portal(ai15 负责)— P0
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | -------------------------------------- | --------------------------------------------------- | ---- |
|
||||||
|
| 1 | `NEXT_PUBLIC_GRAPHQL_ENDPOINT` 配置 | 前端 GraphQL 客户端调用 `/api/v1/parent/v1/graphql` | ✅ |
|
||||||
|
| 2 | 32 个 Query + 6 个 Mutation operations | 前端定义的 GraphQL 操作,需 parent-bff schema 对齐 | ✅ |
|
||||||
|
| 3 | Mock 数据禁用 | `NEXT_PUBLIC_API_MOCKING=disabled`,使用真实后端 | ✅ |
|
||||||
|
|
||||||
|
**前端调用路径:**
|
||||||
|
|
||||||
|
```
|
||||||
|
parent-portal → POST /api/v1/parent/v1/graphql
|
||||||
|
→ api-gateway 剥离 /api/v1/parent
|
||||||
|
→ parent-bff:3010/v1/graphql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 下游依赖(parent-bff 调用的模块)
|
||||||
|
|
||||||
|
### 4.1 iam 服务(ai06 负责,gRPC :50052)— P0
|
||||||
|
|
||||||
|
| # | RPC 方法 | 用途 | 状态 | 说明 |
|
||||||
|
| --- | --------------------------------- | ------------------------------ | ---- | ---------------------------------------------------------- |
|
||||||
|
| 1 | `getUserInfo(userId)` | 获取家长个人信息 | ✅ | `GrpcIamClient.getUserInfo`,用于 `me`/`currentUser` Query |
|
||||||
|
| 2 | `getChildrenByParent(parentId)` | 获取家长绑定的孩子列表 | ✅ | `GrpcIamClient.getChildrenByParent`,ChildGuard 缓存 30s |
|
||||||
|
| 3 | `getViewports(userId)` | 获取家长可见视口 | ✅ | `GrpcIamClient.getViewports` |
|
||||||
|
| 4 | `getEffectivePermissions(userId)` | 获取家长有效权限 | ✅ | `GrpcIamClient.getEffectivePermissions` |
|
||||||
|
| 5 | `GET /healthz` 端点 | /readyz 下游健康检查 | ⏳ | iam 服务容器未运行,/readyz 报告 down |
|
||||||
|
| 6 | `GET /.well-known/jwks.json` | RS256 公钥集(api-gateway 用) | ⏳ | iam 服务容器未运行 |
|
||||||
|
|
||||||
|
**环境变量:** `IAM_GRPC_TARGET=iam:50052`(Docker 网络)/ `localhost:50052`(本地开发)
|
||||||
|
|
||||||
|
**影响:** iam 不可达时,`me`/`currentUser`/`children`/`myChildren` 等核心 Query 降级返回空数据。
|
||||||
|
|
||||||
|
### 4.2 core-edu 服务(ai07 负责,gRPC :50053)— P0
|
||||||
|
|
||||||
|
| # | RPC 方法 | 用途 | 状态 | 说明 |
|
||||||
|
| --- | -------------------------------- | -------------------- | ---- | ------------------------------------------------- |
|
||||||
|
| 1 | `listGradesByStudent(studentId)` | 孩子成绩列表 | ✅ | 用于 `childGrades`/`childSummary`/`childDetail` |
|
||||||
|
| 2 | `listHomeworkByClass(classId)` | 班级作业列表 | ✅ | 用于 `childHomework`/`childSummary`/`childDetail` |
|
||||||
|
| 3 | `listExamsByClass(classId)` | 班级考试列表 | ✅ | 用于 `childExams`/`childSummary`/`childDetail` |
|
||||||
|
| 4 | `getClass(classId)` | 班级信息 | ⚠️ | ISSUE-008: proto 缺 ClassService,当前返回默认值 |
|
||||||
|
| 5 | `GET /healthz` 端点 | /readyz 下游健康检查 | ⏳ | core-edu 服务容器未运行 |
|
||||||
|
|
||||||
|
**环境变量:** `CORE_EDU_GRPC_TARGET=core-edu:50053`(Docker 网络)/ `localhost:50053`(本地开发)
|
||||||
|
|
||||||
|
**降级查询:** `childAttendance`/`childExamResult`/`childReportCard`/`childLeaveRequests`/`academicYears`/`childClasses`/`createLeaveRequest`/`exportChildGrades` 在 RPC 未就绪时降级返回空数据。
|
||||||
|
|
||||||
|
### 4.3 data-ana 服务(ai09 负责,gRPC :50055)— P1
|
||||||
|
|
||||||
|
| # | RPC 方法 | 用途 | 状态 | 说明 |
|
||||||
|
| --- | ---------------------------------------------- | -------------------- | ---- | ------------------------------------- |
|
||||||
|
| 1 | `getStudentWeakness(studentId, subjectId)` | 学生薄弱知识点 | ✅ | 用于 `childWeakness`/`childAnalytics` |
|
||||||
|
| 2 | `getLearningTrend(studentId, start, end)` | 学习趋势 | ✅ | 用于 `childTrend`/`childAnalytics` |
|
||||||
|
| 3 | `getClassPerformance(classId, subjectId, ...)` | 班级绩效 | ✅ | 用于 `classRank`/`classAverage` 计算 |
|
||||||
|
| 4 | `GET /healthz` 端点 | /readyz 下游健康检查 | ⏳ | data-ana 服务容器未运行 |
|
||||||
|
|
||||||
|
**环境变量:** `DATA_ANA_GRPC_TARGET=data-ana:50055`(Docker 网络)/ `localhost:50055`(本地开发)
|
||||||
|
|
||||||
|
**降级查询:** `childGrowthArchive`/`childLearningPath`/`childErrorBookStats`/`childTopWrongQuestions`/`childWeakKps`/`childMasterySummary`/`childDiagnosticReports`/`childPracticeStats`/`childPracticeSessions` 在 RPC 未就绪时降级返回空数据。
|
||||||
|
|
||||||
|
### 4.4 msg 服务(ai08 负责,gRPC :50056)— P1
|
||||||
|
|
||||||
|
| # | RPC 方法 | 用途 | 状态 | 说明 |
|
||||||
|
| --- | -------------------------------------- | -------------------- | ---- | ------------------------------------------ |
|
||||||
|
| 1 | `listNotifications(parentId, unread)` | 通知列表 | ✅ | 用于 `myNotifications`/`notifications` |
|
||||||
|
| 2 | `markAsRead(notificationId)` | 标记已读 | ✅ | 用于 `markAsRead`/`markAllAsRead` Mutation |
|
||||||
|
| 3 | `getNotificationPreferences(parentId)` | 通知偏好 | ⚠️ | proto 未定义 RPC,gRPC 实现返回默认值 |
|
||||||
|
| 4 | `updateNotificationPreferences(...)` | 更新通知偏好 | ⚠️ | proto 未定义 RPC,gRPC 实现返回输入 |
|
||||||
|
| 5 | `GET /healthz` 端点 | /readyz 下游健康检查 | ⏳ | msg 服务容器未运行 |
|
||||||
|
|
||||||
|
**环境变量:** `MSG_GRPC_TARGET=msg:50056`(Docker 网络)/ `localhost:50056`(本地开发)
|
||||||
|
|
||||||
|
### 4.5 Redis(基础设施)— P0
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | ------------------ | --------------------------------------------------- | ---- |
|
||||||
|
| 1 | `redis://...:6379` | ChildGuard 缓存 + dashboard/grades/permissions 缓存 | ✅ |
|
||||||
|
|
||||||
|
**环境变量:** `REDIS_URL=redis://edu-redis:6379`(Docker 网络)/ `redis://localhost:6379`(本地开发)
|
||||||
|
|
||||||
|
**验证结果:** 容器内 Redis 连接成功(`/readyz` 报告 redis:up,latency_ms=30)。
|
||||||
|
|
||||||
|
### 4.6 降级模式说明
|
||||||
|
|
||||||
|
以下 Query 在下游 RPC 未就绪时降级返回空数据(不阻塞前端渲染):
|
||||||
|
|
||||||
|
| Query | 降级行为 | 待补全的下游 RPC |
|
||||||
|
| ------------------------ | ----------------- | ----------------------------- |
|
||||||
|
| `childAttendance` | 返回空数组 | core-edu AttendanceService |
|
||||||
|
| `childExamResult` | 返回 null | core-edu ExamResultService |
|
||||||
|
| `childReportCard` | 返回 null | core-edu ReportCardService |
|
||||||
|
| `childGrowthArchive` | 返回空 dataPoints | data-ana GrowthArchiveService |
|
||||||
|
| `childLearningPath` | 返回空数组 | data-ana LearningPathService |
|
||||||
|
| `childErrorBookStats` | 返回零值 | data-ana ErrorBookService |
|
||||||
|
| `childTopWrongQuestions` | 返回空数组 | data-ana ErrorBookService |
|
||||||
|
| `childWeakKps` | 返回空数组 | data-ana WeakKpsService |
|
||||||
|
| `childMasterySummary` | 返回零值 | data-ana MasteryService |
|
||||||
|
| `childDiagnosticReports` | 返回空数组 | data-ana DiagnosticService |
|
||||||
|
| `childPracticeStats` | 返回零值 | data-ana PracticeService |
|
||||||
|
| `childPracticeSessions` | 返回空数组 | data-ana PracticeService |
|
||||||
|
| `childCoursePlans` | 返回空数组 | content CoursePlanService |
|
||||||
|
| `childCoursePlanDetail` | 返回 null | content CoursePlanService |
|
||||||
|
| `childLessonPlans` | 返回空数组 | content LessonPlanService |
|
||||||
|
| `childLessonPlanDetail` | 返回 null | content LessonPlanService |
|
||||||
|
| `childElective` | 返回空数组 | content ElectiveService |
|
||||||
|
| `childLeaveRequests` | 返回空数组 | core-edu LeaveRequestService |
|
||||||
|
| `academicYears` | 返回空数组 | core-edu AcademicYearService |
|
||||||
|
| `childClasses` | 返回空数组 | classes ClassService |
|
||||||
|
| `createLeaveRequest` | 返回 PENDING 状态 | core-edu LeaveRequestService |
|
||||||
|
| `exportChildGrades` | 返回临时 URL | core-edu ExportService |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Docker 本地测试
|
||||||
|
|
||||||
|
### 5.1 镜像构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在仓库根目录执行(需要访问 packages/shared-proto + packages/shared-ts + tsconfig.base.json)
|
||||||
|
docker build -t edu/parent-bff:test -f services/parent-bff/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 容器启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 加入 edu-full_default 网络(与 Redis/MySQL/Kafka 等基础设施同网络)
|
||||||
|
docker run -d \
|
||||||
|
--name edu-parent-bff-test \
|
||||||
|
--network edu-full_default \
|
||||||
|
-p 3010:3010 \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
-e DEV_MODE=false \
|
||||||
|
-e PORT=3010 \
|
||||||
|
-e REDIS_URL=redis://edu-redis:6379 \
|
||||||
|
-e IAM_GRPC_TARGET=iam:50052 \
|
||||||
|
-e CORE_EDU_GRPC_TARGET=core-edu:50053 \
|
||||||
|
-e DATA_ANA_GRPC_TARGET=data-ana:50055 \
|
||||||
|
-e MSG_GRPC_TARGET=msg:50056 \
|
||||||
|
-e CORS_ORIGINS=http://localhost:4002 \
|
||||||
|
-e GRAPHQL_INTROSPECTION_ENABLED=true \
|
||||||
|
edu/parent-bff:test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 健康检查验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# liveness(返回 200)
|
||||||
|
curl http://localhost:3010/healthz
|
||||||
|
# {"status":"ok","service":"parent-bff","timestamp":"..."}
|
||||||
|
|
||||||
|
# readiness(返回 200,status=degraded 因为下游 gRPC 不可达)
|
||||||
|
curl http://localhost:3010/readyz
|
||||||
|
# {"status":"degraded","checks":{"iam":{"status":"down",...},"redis":{"status":"up",...}}}
|
||||||
|
|
||||||
|
# Prometheus 指标
|
||||||
|
curl http://localhost:3010/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 GraphQL 端点验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 必须携带 x-user-* 头(api-gateway 注入)
|
||||||
|
curl -X POST http://localhost:3010/v1/graphql \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-user-id: parent-001" \
|
||||||
|
-H "x-user-roles: parent" \
|
||||||
|
-H "x-data-scope: CHILDREN:child-001" \
|
||||||
|
-H "x-trace-id: test-trace-001" \
|
||||||
|
-d '{"query":"{ __typename }"}'
|
||||||
|
# {"data":{"__typename":"Query"}}
|
||||||
|
|
||||||
|
# 扩展查询(优雅降级)
|
||||||
|
curl -X POST http://localhost:3010/v1/graphql \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-user-id: parent-001" \
|
||||||
|
-H "x-user-roles: parent" \
|
||||||
|
-d '{"query":"{ myChildren { id name } }"}'
|
||||||
|
# {"data":{"myChildren":[]}}
|
||||||
|
|
||||||
|
# Mutation
|
||||||
|
curl -X POST http://localhost:3010/v1/graphql \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-user-id: parent-001" \
|
||||||
|
-H "x-user-roles: parent" \
|
||||||
|
-d '{"query":"mutation { markAllAsRead { count } }"}'
|
||||||
|
# {"data":{"markAllAsRead":{"count":0}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 后续其他模块配合工作
|
||||||
|
|
||||||
|
### 6.1 待下游服务补全的 RPC(P1)
|
||||||
|
|
||||||
|
| 服务 | 待补全 RPC | 用途 | 影响 Query/Mutation |
|
||||||
|
| -------- | ---------------------------------- | --------------------- | ------------------------------------------------------------- |
|
||||||
|
| core-edu | `AttendanceService.ListAttendance` | 孩子考勤记录 | `childAttendance` |
|
||||||
|
| core-edu | `ExamResultService.GetExamResult` | 考试成绩详情 | `childExamResult` |
|
||||||
|
| core-edu | `ReportCardService.GetReportCard` | 成绩单 | `childReportCard` |
|
||||||
|
| core-edu | `LeaveRequestService.List/Create` | 请假记录 | `childLeaveRequests`/`createLeaveRequest` |
|
||||||
|
| core-edu | `AcademicYearService.List` | 学年列表 | `academicYears` |
|
||||||
|
| core-edu | `ClassService.GetClass` | 班级信息(ISSUE-008) | `childClasses` |
|
||||||
|
| core-edu | `ExportService.ExportGrades` | 成绩导出 | `exportChildGrades` |
|
||||||
|
| data-ana | `GrowthArchiveService.Get` | 成长档案 | `childGrowthArchive` |
|
||||||
|
| data-ana | `LearningPathService.List` | 学习路径 | `childLearningPath` |
|
||||||
|
| data-ana | `ErrorBookService.GetStats/List` | 错题本统计/列表 | `childErrorBookStats`/`childTopWrongQuestions` |
|
||||||
|
| data-ana | `WeakKpsService.List` | 薄弱知识点 | `childWeakKps` |
|
||||||
|
| data-ana | `MasteryService.GetSummary` | 掌握度汇总 | `childMasterySummary` |
|
||||||
|
| data-ana | `DiagnosticService.List` | 诊断报告 | `childDiagnosticReports` |
|
||||||
|
| data-ana | `PracticeService.GetStats/List` | 练习统计/会话 | `childPracticeStats`/`childPracticeSessions` |
|
||||||
|
| content | `CoursePlanService.List/Get` | 课程计划 | `childCoursePlans`/`childCoursePlanDetail` |
|
||||||
|
| content | `LessonPlanService.List/Get` | 备课计划 | `childLessonPlans`/`childLessonPlanDetail` |
|
||||||
|
| content | `ElectiveService.List` | 选修课 | `childElective` |
|
||||||
|
| msg | `NotificationPreferencesService` | 通知偏好查询/更新 | `myNotificationPreferences`/`updateMyNotificationPreferences` |
|
||||||
|
|
||||||
|
### 6.2 端到端联调待办(P1)
|
||||||
|
|
||||||
|
| # | 联调项 | 触发条件 |
|
||||||
|
| --- | ----------------------------------------------- | ---------------------------------------------------------- |
|
||||||
|
| 1 | iam 服务容器启动 + JWKS 端点就绪 | iam 服务 Docker 化 |
|
||||||
|
| 2 | core-edu 服务容器启动 + gRPC 端口就绪 | core-edu 服务 Docker 化 |
|
||||||
|
| 3 | data-ana 服务容器启动 + gRPC 端口就绪 | data-ana 服务 Docker 化 |
|
||||||
|
| 4 | msg 服务容器启动 + gRPC 端口就绪 | msg 服务 Docker 化 |
|
||||||
|
| 5 | parent-bff 加入 deploy compose | `infra/docker-compose.deploy.yml` 补充 parent-bff 服务定义 |
|
||||||
|
| 6 | parent-portal → api-gateway → parent-bff 端到端 | 所有服务容器就绪后执行 |
|
||||||
|
|
||||||
|
### 6.3 给下游模块的工作要求
|
||||||
|
|
||||||
|
**给 iam(ai06):**
|
||||||
|
|
||||||
|
- 补全 `getChildrenByParent` RPC(返回 ChildDto 列表,含 id/name/grade/classId/className/gradeId)
|
||||||
|
- 补全 `getViewports` RPC(返回 ViewportDto 列表)
|
||||||
|
- 补全 `getEffectivePermissions` RPC(返回 permissions 数组)
|
||||||
|
- 启动 `/healthz` 端点供 parent-bff /readyz 探测
|
||||||
|
|
||||||
|
**给 core-edu(ai07):**
|
||||||
|
|
||||||
|
- 补全 `ClassService.GetClass` RPC(ISSUE-008 仲裁)
|
||||||
|
- 补全 `AttendanceService`/`ExamResultService`/`ReportCardService`/`LeaveRequestService`/`AcademicYearService`/`ExportService`
|
||||||
|
- 启动 `/healthz` 端点供 parent-bff /readyz 探测
|
||||||
|
|
||||||
|
**给 data-ana(ai09):**
|
||||||
|
|
||||||
|
- 补全 `GrowthArchiveService`/`LearningPathService`/`ErrorBookService`/`WeakKpsService`/`MasteryService`/`DiagnosticService`/`PracticeService`
|
||||||
|
- 启动 `/healthz` 端点供 parent-bff /readyz 探测
|
||||||
|
|
||||||
|
**给 msg(ai08):**
|
||||||
|
|
||||||
|
- 补全 `NotificationPreferencesService`(Get/Update)
|
||||||
|
- 启动 `/healthz` 端点供 parent-bff /readyz 探测
|
||||||
|
|
||||||
|
**给 api-gateway(ai01):**
|
||||||
|
|
||||||
|
- 确认 `/api/v1/parent/*` 路由已注册(已完成)
|
||||||
|
- 确认 `registerBffProxy("parent", ...)` 路径重写剥离 `/api/v1/parent`(已完成)
|
||||||
|
|
||||||
|
**给 parent-portal(ai15):**
|
||||||
|
|
||||||
|
- 确认 `NEXT_PUBLIC_GRAPHQL_ENDPOINT=/api/v1/parent/v1/graphql`(已完成)
|
||||||
|
- 确认 32 Query + 6 Mutation operations 与 parent-bff.graphql schema 对齐(已完成)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 关键文件路径
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------- |
|
||||||
|
| `packages/shared-ts/contracts/graphql/parent-bff.graphql` | GraphQL Schema 契约(32Q + 6M,前后端共享唯一源) |
|
||||||
|
| `services/parent-bff/src/entry/graphql.controller.ts` | Controller(路由 `v1/graphql`) |
|
||||||
|
| `services/parent-bff/src/graphql/yoga.ts` | Yoga 实例(端点 `/v1/graphql`) |
|
||||||
|
| `services/parent-bff/src/graphql/graphql.module.ts` | Module(中间件路由 `v1/graphql`) |
|
||||||
|
| `services/parent-bff/src/graphql/schema.ts` | SDL 加载(process.cwd() 解析路径) |
|
||||||
|
| `services/parent-bff/src/graphql/resolvers/index.ts` | Resolver 注册(含 DateTime + JSON scalar) |
|
||||||
|
| `services/parent-bff/src/graphql/resolvers/extended-resolvers.ts` | 37 个新 resolver builder |
|
||||||
|
| `services/parent-bff/src/graphql/resolvers/child.resolver.ts` | legacy childAnalytics resolver |
|
||||||
|
| `services/parent-bff/src/graphql/resolvers/select-child.resolver.ts` | legacy selectChild Mutation resolver |
|
||||||
|
| `services/parent-bff/src/graphql/types.ts` | TypeScript 类型定义(37+ 个新类型) |
|
||||||
|
| `services/parent-bff/src/aggregation/response-mapper.ts` | DTO → GraphQL Type 映射 |
|
||||||
|
| `services/parent-bff/src/clients/grpc/grpc.factory.ts` | gRPC 客户端工厂(process.cwd() 解析 proto 路径) |
|
||||||
|
| `services/parent-bff/src/config/env.ts` | Zod 环境变量校验 |
|
||||||
|
| `services/parent-bff/Dockerfile` | Docker 构建(多阶段,repo root 上下文) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 已完成项汇总
|
||||||
|
|
||||||
|
| 工作项 | 状态 | 验证方式 |
|
||||||
|
| -------------------------------------------- | ---- | ------------------------------------------------------------- |
|
||||||
|
| GraphQL 端点路径修复(`/v1/graphql`) | ✅ | Docker 测试 `/v1/graphql` 返回 200 |
|
||||||
|
| GraphQL Schema 扩展(32Q + 6M) | ✅ | `{ __typename }` 返回 Query 类型 |
|
||||||
|
| TypeScript 类型定义扩展(37+ 个新类型) | ✅ | `tsc --noEmit` 零错误 |
|
||||||
|
| 全部 resolver 实现(37 个 resolver builder) | ✅ | `{ myChildren { id } }` 返回空数组(降级正常) |
|
||||||
|
| Legacy resolver 修复 | ✅ | selectChild/Child.analytics resolver 恢复 |
|
||||||
|
| JSON scalar 支持 | ✅ | NotificationPreferences.preferences 字段可用 |
|
||||||
|
| Response Mapper 更新 | ✅ | mapParent 含 permissions/schoolId,mapChildBrief 新增 |
|
||||||
|
| Notification resolver 兼容旧版/新版 schema | ✅ | 同时填充 type/content 和 eventType/body |
|
||||||
|
| Dockerfile 构建修复 | ✅ | `edu/parent-bff:test` 构建成功 |
|
||||||
|
| Proto/Schema 路径解析修复 | ✅ | 容器启动日志显示 gRPC client 创建成功 |
|
||||||
|
| pino 导入修复 | ✅ | 容器启动无错误 |
|
||||||
|
| TypeScript 编译零错误 | ✅ | `tsc --noEmit -p tsconfig.test.json` 通过 |
|
||||||
|
| ESLint 零错误 | ✅ | `eslint src test` 通过 |
|
||||||
|
| 单元测试 + 集成测试通过 | ✅ | 128 tests, 13 test files 全部通过 |
|
||||||
|
| Docker 镜像构建 | ✅ | `edu/parent-bff:test` 构建成功 |
|
||||||
|
| Docker 容器运行验证 | ✅ | /healthz 200 + /readyz 200 + /v1/graphql 200 + /metrics 200 |
|
||||||
|
| 优雅降级验证 | ✅ | 下游 gRPC 不可达时返回空数据而非崩溃 |
|
||||||
|
| 可观测性验证 | ✅ | /metrics 含 graphql_requests_total + graphql_duration_seconds |
|
||||||
|
| Mutation 验证 | ✅ | `markAllAsRead { count }` 返回 0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**本文件由 ai04 维护。parent-bff 已完成 v2 全部工作并通过本地 Docker 测试(DEV_MODE=false,连接真实 Redis,下游 gRPC 不可达时优雅降级)。等待下游应用服务(iam/core-edu/data-ana/msg)容器就绪后即可端到端联调。**
|
||||||
345
services/parent-bff/docs/nextstep.md
Normal file
345
services/parent-bff/docs/nextstep.md
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
# parent-bff 模块上下游依赖与工作清单(Next Steps)
|
||||||
|
|
||||||
|
> 版本:v3
|
||||||
|
> 日期:2026-07-13
|
||||||
|
> 负责人:ai04
|
||||||
|
> 关联:
|
||||||
|
>
|
||||||
|
> - [parent-portal nextstep.md](../../../apps/parent-portal/docs/nextstep.md)
|
||||||
|
> - [api-gateway nextstep.md](../../api-gateway/docs/nextstep.md)
|
||||||
|
> - [ARB-022 §24.4 ISSUE-003 方案 A](../../docs/architecture/issues/coord.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
parent-bff 是家长端聚合层 BFF(Backend For Frontend),端口 3010,基于 NestJS + GraphQL Yoga。负责聚合 iam / core-edu / data-ana / msg 四个下游微服务的 gRPC 接口,为 parent-portal 前端提供场景化 GraphQL API。
|
||||||
|
|
||||||
|
**路由路径(ARB-022 §24.4 ISSUE-003 方案 A 双 /v1 前缀):**
|
||||||
|
|
||||||
|
```
|
||||||
|
parent-portal
|
||||||
|
→ POST /api/v1/parent/v1/graphql (前端调用路径)
|
||||||
|
→ api-gateway registerBffProxy("parent") (剥离 /api/v1/parent)
|
||||||
|
→ parent-bff:3010 /v1/graphql (BFF 接收路径)
|
||||||
|
→ GraphqlController @Controller("v1/graphql")
|
||||||
|
```
|
||||||
|
|
||||||
|
**本地 Docker 测试结果(2026-07-13):**
|
||||||
|
|
||||||
|
- ✅ TypeScript 编译零错误(`tsc --noEmit -p tsconfig.test.json`)
|
||||||
|
- ✅ ESLint 零错误(`eslint src test`)
|
||||||
|
- ✅ 单元测试 + 集成测试全通过(128 tests, 13 test files)
|
||||||
|
- ✅ Docker 镜像构建成功(`edu/parent-bff:test`)
|
||||||
|
- ✅ 容器启动正常(端口 3010,DEV_MODE=false 生产模式)
|
||||||
|
- ✅ `/healthz` 返回 200(liveness 通过)
|
||||||
|
- ✅ `/readyz` 返回 200(status=degraded,下游 gRPC 不可达但 Redis up)
|
||||||
|
- ✅ `/v1/graphql` GraphQL 查询正常响应(`{ __typename }` → `{ data: { __typename: "Query" } }`)
|
||||||
|
- ✅ `/metrics` 返回 Prometheus 指标(graphql_requests_total + downstream_calls_total)
|
||||||
|
- ✅ 优雅降级:下游 gRPC 不可达时 resolver 返回空数据而非崩溃(`{ children { id } }` → `{ data: { children: [] } }`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 已完成工作
|
||||||
|
|
||||||
|
### 2.1 P0 阻塞项(已解决)
|
||||||
|
|
||||||
|
| # | 工作项 | 状态 | 实现详情 |
|
||||||
|
| --- | ------------------------------------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | GraphQL 端点路径修复(`/graphql` → `/v1/graphql`) | ✅ | controller + yoga + module 三处路径改为 `v1/graphql`,对齐 api-gateway `registerBffProxy` 剥离策略 |
|
||||||
|
| 2 | Dockerfile 构建修复(pnpm-lock.yaml + husky + better-sqlite3) | ✅ | `--no-frozen-lockfile --ignore-scripts`,runtime stage 用 `--ignore-workspace --ignore-scripts` |
|
||||||
|
| 3 | Proto 文件路径修复(4 级 → 5 级 `../`) | ✅ | `grpc.factory.ts` 改用 `process.cwd()` 解析,兼容开发模式和生产模式 |
|
||||||
|
| 4 | GraphQL Schema 扩展至 32 Query + 6 Mutation | ✅ | `packages/shared-ts/contracts/graphql/parent-bff.graphql` 已扩展,对齐 parent-portal 全部 operations |
|
||||||
|
| 5 | 全部新 Query/Mutation resolver 实现 | ✅ | `src/graphql/resolvers/extended-resolvers.ts` 37 个 resolver builder |
|
||||||
|
| 6 | Legacy resolver 修复(children/child/selectChild/Child.analytics) | ✅ | `index.ts` + `select-child.resolver.ts` + `child.resolver.ts` 恢复并接通真实下游 |
|
||||||
|
| 7 | TypeScript 类型定义扩展 | ✅ | `src/graphql/types.ts` 新增 `ChildType`,`ChildBriefType` 使用 `classId`/`className` |
|
||||||
|
| 8 | Response Mapper 更新 | ✅ | `mapParent` 增加 permissions/schoolId,新增 `mapChildBrief` |
|
||||||
|
| 9 | JSON scalar 支持 | ✅ | `resolvers/index.ts` 新增 JSONScalar |
|
||||||
|
| 10 | pnpm build 通过 | ✅ | `nest build` 成功,dist/ 产物正常 |
|
||||||
|
|
||||||
|
### 2.2 关键修复详情
|
||||||
|
|
||||||
|
**GraphQL 端点路径(ARB-022 §24.4 ISSUE-003 方案 A):**
|
||||||
|
|
||||||
|
| 文件 | 路径配置 |
|
||||||
|
| --------------------------------- | -------------------------------- |
|
||||||
|
| `src/entry/graphql.controller.ts` | `@Controller("v1/graphql")` |
|
||||||
|
| `src/graphql/yoga.ts` | `graphqlEndpoint: "/v1/graphql"` |
|
||||||
|
| `src/graphql/graphql.module.ts` | `forRoutes("v1/graphql")` |
|
||||||
|
|
||||||
|
**Dockerfile 关键修复:**
|
||||||
|
|
||||||
|
- builder stage:`pnpm install --no-frozen-lockfile --ignore-scripts`(跳过 husky prepare)
|
||||||
|
- runtime stage:`pnpm install --prod --no-frozen-lockfile --ignore-workspace --ignore-scripts`(避免 workspace 解析 + 跳过 native 构建)
|
||||||
|
- 运行时保留 `packages/shared-proto` + `packages/shared-ts` 目录结构(proto/schema 加载需要)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 上游依赖(调用 parent-bff 的模块)
|
||||||
|
|
||||||
|
### 3.1 api-gateway(ai01 负责)— P0
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | ------------------------------------------ | ---------------------------------------------------------------------------------- | ---- |
|
||||||
|
| 1 | `/api/v1/parent/*` 反向代理路由 | 前端请求经 api-gateway 代理到 parent-bff:3010 | ✅ |
|
||||||
|
| 2 | `registerBffProxy("parent", ...)` 路径重写 | 剥离 `/api/v1/parent`,转发剩余路径(`/v1/graphql`)到 parent-bff | ✅ |
|
||||||
|
| 3 | JWT 鉴权 + x-user-* 头注入 | api-gateway 校验 JWT 后注入 `x-user-id`/`x-user-roles`/`x-data-scope`/`x-trace-id` | ✅ |
|
||||||
|
| 4 | CORS 白名单 | `CORS_ORIGINS` 环境变量配置 | ✅ |
|
||||||
|
| 5 | 限流(IP 级令牌桶) | 100 rps,突发 20 | ✅ |
|
||||||
|
| 6 | 熔断(下游 5xx 触发) | `CircuitBreaker("downstream")` | ✅ |
|
||||||
|
|
||||||
|
**验证要点:**
|
||||||
|
|
||||||
|
- api-gateway 入站 `/api/v1/parent/v1/graphql` → 剥离 `/api/v1/parent` → 转发 `/v1/graphql` 到 parent-bff:3010
|
||||||
|
- parent-bff GraphqlController 注册在 `/v1/graphql`,接收路径匹配
|
||||||
|
- 经 api-gateway 代理访问 `http://api-gateway:8080/api/v1/parent/v1/graphql` 应返回 200
|
||||||
|
|
||||||
|
### 3.2 parent-portal(ai15 负责)— P0
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | -------------------------------------- | --------------------------------------------------- | ---- |
|
||||||
|
| 1 | `NEXT_PUBLIC_GRAPHQL_ENDPOINT` 配置 | 前端 GraphQL 客户端调用 `/api/v1/parent/v1/graphql` | ✅ |
|
||||||
|
| 2 | 32 个 Query + 6 个 Mutation operations | 前端定义的 GraphQL 操作,需 parent-bff schema 对齐 | ✅ |
|
||||||
|
| 3 | Mock 数据禁用 | `NEXT_PUBLIC_API_MOCKING=disabled`,使用真实后端 | ✅ |
|
||||||
|
|
||||||
|
**前端调用路径:**
|
||||||
|
|
||||||
|
```
|
||||||
|
parent-portal → POST /api/v1/parent/v1/graphql
|
||||||
|
→ api-gateway 剥离 /api/v1/parent
|
||||||
|
→ parent-bff:3010/v1/graphql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 下游依赖(parent-bff 调用的模块)
|
||||||
|
|
||||||
|
### 4.1 iam 服务(ai06 负责,gRPC :50052)— P0
|
||||||
|
|
||||||
|
| # | RPC 方法 | 用途 | 状态 | 说明 |
|
||||||
|
| --- | --------------------------------- | ------------------------------ | ---- | -------------------------------------------------------- |
|
||||||
|
| 1 | `getUserInfo(userId)` | 获取家长个人信息 | ✅ | `GrpcIamClient.getUserInfo`,用于 `me` Query |
|
||||||
|
| 2 | `getChildrenByParent(parentId)` | 获取家长绑定的孩子列表 | ✅ | `GrpcIamClient.getChildrenByParent`,ChildGuard 缓存 30s |
|
||||||
|
| 3 | `getViewports(userId)` | 获取家长可见视口 | ✅ | `GrpcIamClient.getViewports` |
|
||||||
|
| 4 | `getEffectivePermissions(userId)` | 获取家长有效权限 | ✅ | `GrpcIamClient.getEffectivePermissions` |
|
||||||
|
| 5 | `GET /healthz` 端点 | /readyz 下游健康检查 | ⏳ | iam 服务容器未运行,/readyz 报告 down |
|
||||||
|
| 6 | `GET /.well-known/jwks.json` | RS256 公钥集(api-gateway 用) | ⏳ | iam 服务容器未运行 |
|
||||||
|
|
||||||
|
**环境变量:** `IAM_GRPC_TARGET=iam:50052`(Docker 网络)/ `localhost:50052`(本地开发)
|
||||||
|
|
||||||
|
**影响:** iam 不可达时,`me`/`children`/`myNotifications` 等核心 Query 降级返回空数据。
|
||||||
|
|
||||||
|
### 4.2 core-edu 服务(ai07 负责,gRPC :50053)— P0
|
||||||
|
|
||||||
|
| # | RPC 方法 | 用途 | 状态 | 说明 |
|
||||||
|
| --- | -------------------------------- | -------------------- | ---- | ------------------------------------------------ |
|
||||||
|
| 1 | `listGradesByStudent(studentId)` | 孩子成绩列表 | ✅ | 用于 `childGrades`/`childSummary`/`childDetail` |
|
||||||
|
| 2 | `listHomeworkByClass(classId)` | 班级作业列表 | ✅ | 用于 `childHomework`/`childSummary` |
|
||||||
|
| 3 | `listExamsByClass(classId)` | 班级考试列表 | ✅ | 用于 `childExams`/`childSummary` |
|
||||||
|
| 4 | `getClass(classId)` | 班级信息 | ⚠️ | ISSUE-008: proto 缺 ClassService,当前返回默认值 |
|
||||||
|
| 5 | `GET /healthz` 端点 | /readyz 下游健康检查 | ⏳ | core-edu 服务容器未运行 |
|
||||||
|
|
||||||
|
**环境变量:** `CORE_EDU_GRPC_TARGET=core-edu:50053`(Docker 网络)/ `localhost:50053`(本地开发)
|
||||||
|
|
||||||
|
**降级查询:** `childAttendance`/`childExamResult`/`childReportCard`/`childLeaveRequests`/`academicYears`/`childClasses`/`createLeaveRequest`/`exportChildGrades` 在 RPC 未就绪时降级返回空数据。
|
||||||
|
|
||||||
|
### 4.3 data-ana 服务(ai09 负责,gRPC :50055)— P1
|
||||||
|
|
||||||
|
| # | RPC 方法 | 用途 | 状态 | 说明 |
|
||||||
|
| --- | ---------------------------------------------- | -------------------- | ---- | ------------------------------------- |
|
||||||
|
| 1 | `getStudentWeakness(studentId, subjectId)` | 学生薄弱知识点 | ✅ | 用于 `childWeakness`/`childAnalytics` |
|
||||||
|
| 2 | `getLearningTrend(studentId, start, end)` | 学习趋势 | ✅ | 用于 `childTrend`/`childAnalytics` |
|
||||||
|
| 3 | `getClassPerformance(classId, subjectId, ...)` | 班级绩效 | ✅ | 用于 `classRank`/`classAverage` 计算 |
|
||||||
|
| 4 | `GET /healthz` 端点 | /readyz 下游健康检查 | ⏳ | data-ana 服务容器未运行 |
|
||||||
|
|
||||||
|
**环境变量:** `DATA_ANA_GRPC_TARGET=data-ana:50055`(Docker 网络)/ `localhost:50055`(本地开发)
|
||||||
|
|
||||||
|
**降级查询:** `childGrowthArchive`/`childLearningPath`/`childErrorBookStats`/`childTopWrongQuestions`/`childWeakKps`/`childMasterySummary`/`childDiagnosticReports`/`childPracticeStats`/`childPracticeSessions` 在 RPC 未就绪时降级返回空数据。
|
||||||
|
|
||||||
|
### 4.4 msg 服务(ai08 负责,gRPC :50056)— P1
|
||||||
|
|
||||||
|
| # | RPC 方法 | 用途 | 状态 | 说明 |
|
||||||
|
| --- | -------------------------------------- | -------------------- | ---- | ------------------------------------------ |
|
||||||
|
| 1 | `listNotifications(parentId, unread)` | 通知列表 | ✅ | 用于 `myNotifications` |
|
||||||
|
| 2 | `markAsRead(notificationId)` | 标记已读 | ✅ | 用于 `markAsRead`/`markAllAsRead` Mutation |
|
||||||
|
| 3 | `getNotificationPreferences(parentId)` | 通知偏好 | ⚠️ | proto 未定义 RPC,gRPC 实现返回默认值 |
|
||||||
|
| 4 | `updateNotificationPreferences(...)` | 更新通知偏好 | ⚠️ | proto 未定义 RPC,gRPC 实现返回输入 |
|
||||||
|
| 5 | `GET /healthz` 端点 | /readyz 下游健康检查 | ⏳ | msg 服务容器未运行 |
|
||||||
|
|
||||||
|
**环境变量:** `MSG_GRPC_TARGET=msg:50056`(Docker 网络)/ `localhost:50056`(本地开发)
|
||||||
|
|
||||||
|
### 4.5 Redis(基础设施)— P0
|
||||||
|
|
||||||
|
| # | 依赖项 | 用途 | 状态 |
|
||||||
|
| --- | ------------------ | --------------------------------------------------- | ---- |
|
||||||
|
| 1 | `redis://...:6379` | ChildGuard 缓存 + dashboard/grades/permissions 缓存 | ✅ |
|
||||||
|
|
||||||
|
**环境变量:** `REDIS_URL=redis://edu-redis:6379`(Docker 网络)/ `redis://localhost:6379`(本地开发)
|
||||||
|
|
||||||
|
**验证结果:** 容器内 Redis 连接成功(`/readyz` 报告 redis:up,latency_ms=28)。
|
||||||
|
|
||||||
|
### 4.6 降级模式说明
|
||||||
|
|
||||||
|
以下 Query 在下游 RPC 未就绪时降级返回空数据(不阻塞前端渲染):
|
||||||
|
|
||||||
|
| Query | 降级行为 | 待补全的下游 RPC |
|
||||||
|
| ------------------------ | ----------------- | ----------------------------- |
|
||||||
|
| `childAttendance` | 返回空数组 | core-edu AttendanceService |
|
||||||
|
| `childExamResult` | 返回 null | core-edu ExamResultService |
|
||||||
|
| `childReportCard` | 返回 null | core-edu ReportCardService |
|
||||||
|
| `childGrowthArchive` | 返回空 dataPoints | data-ana GrowthArchiveService |
|
||||||
|
| `childLearningPath` | 返回空数组 | data-ana LearningPathService |
|
||||||
|
| `childErrorBookStats` | 返回零值 | data-ana ErrorBookService |
|
||||||
|
| `childTopWrongQuestions` | 返回空数组 | data-ana ErrorBookService |
|
||||||
|
| `childWeakKps` | 返回空数组 | data-ana WeakKpsService |
|
||||||
|
| `childMasterySummary` | 返回零值 | data-ana MasteryService |
|
||||||
|
| `childDiagnosticReports` | 返回空数组 | data-ana DiagnosticService |
|
||||||
|
| `childPracticeStats` | 返回零值 | data-ana PracticeService |
|
||||||
|
| `childPracticeSessions` | 返回空数组 | data-ana PracticeService |
|
||||||
|
| `childCoursePlans` | 返回空数组 | content CoursePlanService |
|
||||||
|
| `childCoursePlanDetail` | 返回 null | content CoursePlanService |
|
||||||
|
| `childLessonPlans` | 返回空数组 | content LessonPlanService |
|
||||||
|
| `childLessonPlanDetail` | 返回 null | content LessonPlanService |
|
||||||
|
| `childElective` | 返回空数组 | content ElectiveService |
|
||||||
|
| `childLeaveRequests` | 返回空数组 | core-edu LeaveRequestService |
|
||||||
|
| `academicYears` | 返回空数组 | core-edu AcademicYearService |
|
||||||
|
| `childClasses` | 返回空数组 | classes ClassService |
|
||||||
|
| `createLeaveRequest` | 返回 PENDING 状态 | core-edu LeaveRequestService |
|
||||||
|
| `exportChildGrades` | 返回临时 URL | core-edu ExportService |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Docker 本地测试
|
||||||
|
|
||||||
|
### 5.1 镜像构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在仓库根目录执行(需要访问 packages/shared-proto + packages/shared-ts)
|
||||||
|
docker build -t edu/parent-bff:test -f services/parent-bff/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 容器启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 加入 edu-full_default 网络(与 Redis/MySQL/Kafka 等基础设施同网络)
|
||||||
|
docker run -d \
|
||||||
|
--name edu-parent-bff-test \
|
||||||
|
--network edu-full_default \
|
||||||
|
-p 3010:3010 \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
-e DEV_MODE=false \
|
||||||
|
-e PORT=3010 \
|
||||||
|
-e REDIS_URL=redis://edu-redis:6379 \
|
||||||
|
-e IAM_GRPC_TARGET=iam:50052 \
|
||||||
|
-e CORE_EDU_GRPC_TARGET=core-edu:50053 \
|
||||||
|
-e DATA_ANA_GRPC_TARGET=data-ana:50055 \
|
||||||
|
-e MSG_GRPC_TARGET=msg:50056 \
|
||||||
|
-e CORS_ORIGINS=http://localhost:4002 \
|
||||||
|
-e GRAPHQL_INTROSPECTION_ENABLED=true \
|
||||||
|
edu/parent-bff:test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 健康检查验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# liveness(返回 200)
|
||||||
|
curl http://localhost:3010/healthz
|
||||||
|
# {"status":"ok","service":"parent-bff","timestamp":"..."}
|
||||||
|
|
||||||
|
# readiness(返回 200,status=degraded 因为下游 gRPC 不可达)
|
||||||
|
curl http://localhost:3010/readyz
|
||||||
|
# {"status":"degraded","checks":{"iam":{"status":"down",...},"core-edu":{"status":"down",...},"data-ana":{"status":"down",...},"redis":{"status":"up",...}}}
|
||||||
|
|
||||||
|
# Prometheus 指标
|
||||||
|
curl http://localhost:3010/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 GraphQL 端点验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 必须携带 x-user-* 头(api-gateway 注入)
|
||||||
|
curl -X POST http://localhost:3010/v1/graphql \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-user-id: parent-001" \
|
||||||
|
-H "x-user-roles: parent" \
|
||||||
|
-H "x-data-scope: CHILDREN:child-001" \
|
||||||
|
-H "x-trace-id: test-trace-001" \
|
||||||
|
-d '{"query":"{ __typename }"}'
|
||||||
|
# {"data":{"__typename":"Query"}}
|
||||||
|
|
||||||
|
# 优雅降级(下游不可达时返回空数据)
|
||||||
|
curl -X POST http://localhost:3010/v1/graphql \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-user-id: parent-001" \
|
||||||
|
-H "x-user-roles: parent" \
|
||||||
|
-d '{"query":"{ children { id name } }"}'
|
||||||
|
# {"data":{"children":[]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 测试容器管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止测试容器
|
||||||
|
docker rm -f edu-parent-bff-test
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker logs edu-parent-bff-test --tail 50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 关键文件路径
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------- |
|
||||||
|
| `packages/shared-ts/contracts/graphql/parent-bff.graphql` | GraphQL Schema 契约(32Q + 6M,前后端共享唯一源) |
|
||||||
|
| `services/parent-bff/src/entry/graphql.controller.ts` | Controller(路由 `v1/graphql`) |
|
||||||
|
| `services/parent-bff/src/graphql/yoga.ts` | Yoga 实例(端点 `/v1/graphql`) |
|
||||||
|
| `services/parent-bff/src/graphql/graphql.module.ts` | Module(中间件路由 `v1/graphql`) |
|
||||||
|
| `services/parent-bff/src/graphql/schema.ts` | SDL 加载(process.cwd() 解析路径) |
|
||||||
|
| `services/parent-bff/src/graphql/resolvers/index.ts` | Resolver 注册(含 DateTime + JSON scalar) |
|
||||||
|
| `services/parent-bff/src/graphql/resolvers/extended-resolvers.ts` | 37 个新 resolver builder |
|
||||||
|
| `services/parent-bff/src/graphql/resolvers/child.resolver.ts` | legacy childAnalytics resolver |
|
||||||
|
| `services/parent-bff/src/graphql/resolvers/select-child.resolver.ts` | legacy selectChild Mutation resolver |
|
||||||
|
| `services/parent-bff/src/graphql/types.ts` | TypeScript 类型定义 |
|
||||||
|
| `services/parent-bff/src/aggregation/response-mapper.ts` | DTO → GraphQL Type 映射 |
|
||||||
|
| `services/parent-bff/src/clients/grpc/grpc.factory.ts` | gRPC 客户端工厂(process.cwd() 解析 proto 路径) |
|
||||||
|
| `services/parent-bff/src/config/env.ts` | Zod 环境变量校验 |
|
||||||
|
| `services/parent-bff/Dockerfile` | Docker 构建(多阶段,--ignore-scripts) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 遗留事项
|
||||||
|
|
||||||
|
| # | 事项 | 优先级 | 说明 |
|
||||||
|
| --- | ------------------------------------ | ------ | ----------------------------------------------------------- |
|
||||||
|
| 1 | 补全 core-edu ClassService proto | P1 | ISSUE-008: `getClass` 当前返回默认值 |
|
||||||
|
| 2 | 补全 msg NotificationPreferences RPC | P2 | 通知偏好查询/更新当前降级 |
|
||||||
|
| 3 | 补全 data-ana 各分析 RPC | P2 | 练习/诊断/错题本/掌握度等分析类查询当前降级 |
|
||||||
|
| 4 | 补全 content 课程/备课/选修 RPC | P2 | 课程计划/备课/选修查询当前降级 |
|
||||||
|
| 5 | 端到端联调 | P1 | 待 iam/core-edu/data-ana/msg 服务容器就绪后执行 |
|
||||||
|
| 6 | parent-bff 加入 deploy compose | P1 | `infra/docker-compose.deploy.yml` 未包含 parent-bff,需补充 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 已完成项汇总
|
||||||
|
|
||||||
|
| 工作项 | 状态 | 验证方式 |
|
||||||
|
| ------------------------------------- | ---- | ----------------------------------------------------------- |
|
||||||
|
| GraphQL 端点路径修复(`/v1/graphql`) | ✅ | Docker 测试 `/v1/graphql` 返回 200 |
|
||||||
|
| Dockerfile 构建修复 | ✅ | `edu/parent-bff:test` 镜像构建成功 |
|
||||||
|
| Proto 文件路径修复 | ✅ | 容器启动日志显示 gRPC client 创建成功 |
|
||||||
|
| GraphQL Schema 扩展(32Q + 6M) | ✅ | `{ __typename }` 返回 Query 类型 |
|
||||||
|
| 全部 resolver 实现 | ✅ | `{ children { id } }` 返回空数组(降级正常) |
|
||||||
|
| Legacy resolver 修复 | ✅ | selectChild/Child.analytics resolver 恢复 |
|
||||||
|
| TypeScript 编译零错误 | ✅ | `tsc --noEmit -p tsconfig.test.json` 通过 |
|
||||||
|
| ESLint 零错误 | ✅ | `eslint src test` 通过 |
|
||||||
|
| 单元测试 + 集成测试通过 | ✅ | 128 tests, 13 test files 全部通过 |
|
||||||
|
| Docker 镜像构建 | ✅ | `edu/parent-bff:test` 构建成功 |
|
||||||
|
| Docker 容器运行验证 | ✅ | /healthz 200 + /readyz 200 + /v1/graphql 200 + /metrics 200 |
|
||||||
|
| 优雅降级验证 | ✅ | 下游 gRPC 不可达时返回空数据而非崩溃 |
|
||||||
|
| 可观测性验证 | ✅ | /metrics 含 graphql_requests_total + downstream_calls_total |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**本文件由 ai04 维护。parent-bff 已完成全部 P0 工作并通过本地 Docker 测试(DEV_MODE=false,连接真实 Redis,下游 gRPC 不可达时优雅降级)。等待下游应用服务(iam/core-edu/data-ana/msg)容器就绪后即可端到端联调。**
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
} from "../clients/dtos.js";
|
} from "../clients/dtos.js";
|
||||||
import type {
|
import type {
|
||||||
ChildAnalyticsType,
|
ChildAnalyticsType,
|
||||||
|
ChildBriefType,
|
||||||
ChildType,
|
ChildType,
|
||||||
ClassInfoType,
|
ClassInfoType,
|
||||||
ExamType,
|
ExamType,
|
||||||
@@ -41,7 +42,23 @@ export function mapParent(dto: UserInfoDto): ParentType {
|
|||||||
name: dto.name,
|
name: dto.name,
|
||||||
avatar: null,
|
avatar: null,
|
||||||
roles: dto.roles,
|
roles: dto.roles,
|
||||||
|
permissions: dto.permissions,
|
||||||
dataScope: "CHILDREN",
|
dataScope: "CHILDREN",
|
||||||
|
schoolId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChildDto → ChildBriefType(扁平结构,用于 myChildren / childSummary 等扩展查询)。
|
||||||
|
*/
|
||||||
|
export function mapChildBrief(dto: ChildDto): ChildBriefType {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
name: dto.name,
|
||||||
|
grade: dto.grade,
|
||||||
|
classId: dto.classId,
|
||||||
|
className: dto.className,
|
||||||
|
avatar: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { fileURLToPath } from "node:url";
|
import { resolve } from "node:path";
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import {
|
import {
|
||||||
credentials,
|
credentials,
|
||||||
loadPackageDefinition,
|
loadPackageDefinition,
|
||||||
@@ -8,12 +7,14 @@ import {
|
|||||||
import protoLoader from "@grpc/proto-loader";
|
import protoLoader from "@grpc/proto-loader";
|
||||||
import { logger } from "../../shared/observability/logger.js";
|
import { logger } from "../../shared/observability/logger.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
/**
|
||||||
|
* 使用 process.cwd() 解析 proto 路径,兼容开发模式(cwd=services/parent-bff)
|
||||||
const PROTO_ROOT = resolve(
|
* 和生产模式(cwd=/app/services/parent-bff)。
|
||||||
__dirname,
|
*
|
||||||
"../../../../packages/shared-proto/proto",
|
* 开发模式:services/parent-bff → ../../packages/shared-proto/proto
|
||||||
);
|
* 生产模式:/app/services/parent-bff → ../../packages/shared-proto/proto → /app/packages/shared-proto/proto
|
||||||
|
*/
|
||||||
|
const PROTO_ROOT = resolve(process.cwd(), "../../packages/shared-proto/proto");
|
||||||
|
|
||||||
const LOADER_OPTIONS: protoLoader.Options = {
|
const LOADER_OPTIONS: protoLoader.Options = {
|
||||||
keepCase: false,
|
keepCase: false,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { YogaInstance } from "../graphql/yoga.js";
|
|||||||
*
|
*
|
||||||
* graphql-yoga v5:Yoga 实例本身是 callable handler,直接调用 yoga(req, res)。
|
* graphql-yoga v5:Yoga 实例本身是 callable handler,直接调用 yoga(req, res)。
|
||||||
*/
|
*/
|
||||||
@Controller("graphql")
|
@Controller("v1/graphql")
|
||||||
export class GraphqlController {
|
export class GraphqlController {
|
||||||
private readonly yoga: YogaInstance;
|
private readonly yoga: YogaInstance;
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,6 @@ import { buildResolvers, type ResolverDeps } from "./resolvers/index.js";
|
|||||||
})
|
})
|
||||||
export class GraphqlModule implements NestModule {
|
export class GraphqlModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
consumer.apply(contextMiddleware).forRoutes("graphql");
|
consumer.apply(contextMiddleware).forRoutes("v1/graphql");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1109
services/parent-bff/src/graphql/resolvers/extended-resolvers.ts
Normal file
1109
services/parent-bff/src/graphql/resolvers/extended-resolvers.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,42 @@ import {
|
|||||||
buildNotificationPreferencesQueryResolver,
|
buildNotificationPreferencesQueryResolver,
|
||||||
buildUpdateNotificationPreferencesMutationResolver,
|
buildUpdateNotificationPreferencesMutationResolver,
|
||||||
} from "./notification-preference.resolver.js";
|
} from "./notification-preference.resolver.js";
|
||||||
|
import {
|
||||||
|
buildAcademicYearsQueryResolver,
|
||||||
|
buildChildAttendanceQueryResolver,
|
||||||
|
buildChildClassesQueryResolver,
|
||||||
|
buildChildCoursePlanDetailQueryResolver,
|
||||||
|
buildChildCoursePlansQueryResolver,
|
||||||
|
buildChildDetailQueryResolver,
|
||||||
|
buildChildDiagnosticReportsQueryResolver,
|
||||||
|
buildChildElectiveQueryResolver,
|
||||||
|
buildChildErrorBookStatsQueryResolver,
|
||||||
|
buildChildExamResultQueryResolver,
|
||||||
|
buildChildGrowthArchiveQueryResolver,
|
||||||
|
buildChildLeaveRequestsQueryResolver,
|
||||||
|
buildChildLearningPathQueryResolver,
|
||||||
|
buildChildLessonPlanDetailQueryResolver,
|
||||||
|
buildChildLessonPlansQueryResolver,
|
||||||
|
buildChildMasterySummaryQueryResolver,
|
||||||
|
buildChildPracticeSessionsQueryResolver,
|
||||||
|
buildChildPracticeStatsQueryResolver,
|
||||||
|
buildChildReportCardQueryResolver,
|
||||||
|
buildChildSummaryQueryResolver,
|
||||||
|
buildChildTopWrongQuestionsQueryResolver,
|
||||||
|
buildChildTrendQueryResolver,
|
||||||
|
buildChildWeakKpsQueryResolver,
|
||||||
|
buildChildWeaknessQueryResolver,
|
||||||
|
buildCreateLeaveRequestMutationResolver,
|
||||||
|
buildCurrentUserQueryResolver,
|
||||||
|
buildExportChildGradesMutationResolver,
|
||||||
|
buildMarkAllAsReadMutationResolver,
|
||||||
|
buildMarkAsReadMutationResolver,
|
||||||
|
buildMyChildrenQueryResolver,
|
||||||
|
buildMyNotificationPreferencesQueryResolver,
|
||||||
|
buildMyNotificationsQueryResolver,
|
||||||
|
buildSwitchChildMutationResolver,
|
||||||
|
buildUpdateMyNotificationPreferencesMutationResolver,
|
||||||
|
} from "./extended-resolvers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolver 依赖注入容器。
|
* Resolver 依赖注入容器。
|
||||||
@@ -96,6 +132,47 @@ const DateTimeScalar = new GraphQLScalarType({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 标量:任意 JSON 值(用于 NotificationPreferences.preferences 等动态结构)。
|
||||||
|
*/
|
||||||
|
const JSONScalar = new GraphQLScalarType({
|
||||||
|
name: "JSON",
|
||||||
|
description: "Arbitrary JSON value",
|
||||||
|
|
||||||
|
serialize(value: unknown): unknown {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
|
||||||
|
parseValue(value: unknown): unknown {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
|
||||||
|
parseLiteral(ast): unknown {
|
||||||
|
switch (ast.kind) {
|
||||||
|
case Kind.STRING:
|
||||||
|
case Kind.BOOLEAN:
|
||||||
|
return ast.value;
|
||||||
|
case Kind.INT:
|
||||||
|
return Number(ast.value);
|
||||||
|
case Kind.FLOAT:
|
||||||
|
return Number(ast.value);
|
||||||
|
case Kind.LIST:
|
||||||
|
return ast.values.map((v) => JSONScalar.parseLiteral(v));
|
||||||
|
case Kind.OBJECT: {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
for (const field of ast.fields) {
|
||||||
|
obj[field.name.value] = JSONScalar.parseLiteral(field.value);
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
case Kind.NULL:
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建 GraphQL resolvers(对齐 02-architecture-design.md §4.2 SDL)。
|
* 构建 GraphQL resolvers(对齐 02-architecture-design.md §4.2 SDL)。
|
||||||
*
|
*
|
||||||
@@ -111,8 +188,10 @@ const DateTimeScalar = new GraphQLScalarType({
|
|||||||
export function buildResolvers(deps: ResolverDeps): IResolvers {
|
export function buildResolvers(deps: ResolverDeps): IResolvers {
|
||||||
return {
|
return {
|
||||||
DateTime: DateTimeScalar,
|
DateTime: DateTimeScalar,
|
||||||
|
JSON: JSONScalar,
|
||||||
|
|
||||||
Query: {
|
Query: {
|
||||||
|
// Legacy(11 个)
|
||||||
dashboard: buildDashboardResolver(deps),
|
dashboard: buildDashboardResolver(deps),
|
||||||
viewports: buildViewportsQueryResolver(deps),
|
viewports: buildViewportsQueryResolver(deps),
|
||||||
me: buildMeQueryResolver(deps),
|
me: buildMeQueryResolver(deps),
|
||||||
@@ -122,19 +201,56 @@ export function buildResolvers(deps: ResolverDeps): IResolvers {
|
|||||||
childHomework: buildChildHomeworkQueryResolver(deps),
|
childHomework: buildChildHomeworkQueryResolver(deps),
|
||||||
childExams: buildChildExamsQueryResolver(deps),
|
childExams: buildChildExamsQueryResolver(deps),
|
||||||
childAnalytics: buildChildAnalyticsQueryResolver(deps),
|
childAnalytics: buildChildAnalyticsQueryResolver(deps),
|
||||||
|
|
||||||
// P5 通知 resolvers
|
|
||||||
notifications: buildNotificationsQueryResolver(deps),
|
notifications: buildNotificationsQueryResolver(deps),
|
||||||
notificationPreferences: buildNotificationPreferencesQueryResolver(deps),
|
notificationPreferences: buildNotificationPreferencesQueryResolver(deps),
|
||||||
|
|
||||||
|
// Extended(21 个)
|
||||||
|
currentUser: buildCurrentUserQueryResolver(deps),
|
||||||
|
myChildren: buildMyChildrenQueryResolver(deps),
|
||||||
|
childSummary: buildChildSummaryQueryResolver(deps),
|
||||||
|
childDetail: buildChildDetailQueryResolver(deps),
|
||||||
|
childAttendance: buildChildAttendanceQueryResolver(deps),
|
||||||
|
childExamResult: buildChildExamResultQueryResolver(deps),
|
||||||
|
childClasses: buildChildClassesQueryResolver(deps),
|
||||||
|
childReportCard: buildChildReportCardQueryResolver(deps),
|
||||||
|
childGrowthArchive: buildChildGrowthArchiveQueryResolver(deps),
|
||||||
|
childWeakness: buildChildWeaknessQueryResolver(deps),
|
||||||
|
childTrend: buildChildTrendQueryResolver(deps),
|
||||||
|
childLearningPath: buildChildLearningPathQueryResolver(deps),
|
||||||
|
childErrorBookStats: buildChildErrorBookStatsQueryResolver(deps),
|
||||||
|
childTopWrongQuestions: buildChildTopWrongQuestionsQueryResolver(deps),
|
||||||
|
childWeakKps: buildChildWeakKpsQueryResolver(deps),
|
||||||
|
childMasterySummary: buildChildMasterySummaryQueryResolver(deps),
|
||||||
|
childDiagnosticReports: buildChildDiagnosticReportsQueryResolver(deps),
|
||||||
|
childPracticeStats: buildChildPracticeStatsQueryResolver(deps),
|
||||||
|
childPracticeSessions: buildChildPracticeSessionsQueryResolver(deps),
|
||||||
|
childCoursePlans: buildChildCoursePlansQueryResolver(deps),
|
||||||
|
childCoursePlanDetail: buildChildCoursePlanDetailQueryResolver(deps),
|
||||||
|
childLessonPlans: buildChildLessonPlansQueryResolver(deps),
|
||||||
|
childLessonPlanDetail: buildChildLessonPlanDetailQueryResolver(deps),
|
||||||
|
childElective: buildChildElectiveQueryResolver(deps),
|
||||||
|
childLeaveRequests: buildChildLeaveRequestsQueryResolver(deps),
|
||||||
|
academicYears: buildAcademicYearsQueryResolver(deps),
|
||||||
|
myNotifications: buildMyNotificationsQueryResolver(deps),
|
||||||
|
myNotificationPreferences:
|
||||||
|
buildMyNotificationPreferencesQueryResolver(deps),
|
||||||
},
|
},
|
||||||
|
|
||||||
Mutation: {
|
Mutation: {
|
||||||
|
// Legacy(3 个)
|
||||||
selectChild: buildSelectChildMutationResolver(deps),
|
selectChild: buildSelectChildMutationResolver(deps),
|
||||||
|
|
||||||
// P5 通知 mutations
|
|
||||||
markNotificationRead: buildMarkNotificationReadMutationResolver(deps),
|
markNotificationRead: buildMarkNotificationReadMutationResolver(deps),
|
||||||
updateNotificationPreferences:
|
updateNotificationPreferences:
|
||||||
buildUpdateNotificationPreferencesMutationResolver(deps),
|
buildUpdateNotificationPreferencesMutationResolver(deps),
|
||||||
|
|
||||||
|
// Extended(6 个)
|
||||||
|
markAsRead: buildMarkAsReadMutationResolver(deps),
|
||||||
|
markAllAsRead: buildMarkAllAsReadMutationResolver(deps),
|
||||||
|
switchChild: buildSwitchChildMutationResolver(deps),
|
||||||
|
updateMyNotificationPreferences:
|
||||||
|
buildUpdateMyNotificationPreferencesMutationResolver(deps),
|
||||||
|
createLeaveRequest: buildCreateLeaveRequestMutationResolver(deps),
|
||||||
|
exportChildGrades: buildExportChildGradesMutationResolver(deps),
|
||||||
},
|
},
|
||||||
|
|
||||||
Child: {
|
Child: {
|
||||||
|
|||||||
@@ -26,13 +26,33 @@ const CHANNEL_MAP: Record<string, NotificationChannel> = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO → GraphQL Type 映射。
|
* DTO → GraphQL Type 映射。
|
||||||
|
*
|
||||||
|
* 旧版 schema 使用 channels/eventTypes 顶层字段,
|
||||||
|
* 新版 schema 使用 preferences/defaults 嵌套结构。
|
||||||
|
* 此处同时填充两组字段以兼容 legacy NotificationPreferences 和新版 MyNotificationPreferences。
|
||||||
*/
|
*/
|
||||||
function mapPreferences(
|
function mapPreferences(
|
||||||
dto: NotificationPreferencesDto,
|
dto: NotificationPreferencesDto,
|
||||||
|
parentId: string,
|
||||||
): NotificationPreferencesType {
|
): NotificationPreferencesType {
|
||||||
|
const channels = dto.channels.map((c) => CHANNEL_MAP[c] ?? "APP");
|
||||||
|
const eventTypes = { ...dto.eventTypes };
|
||||||
return {
|
return {
|
||||||
channels: dto.channels.map((c) => CHANNEL_MAP[c] ?? "APP"),
|
parentId,
|
||||||
eventTypes: { ...dto.eventTypes },
|
preferences: { channels, eventTypes },
|
||||||
|
defaults: {
|
||||||
|
channels: ["APP" as NotificationChannel],
|
||||||
|
eventTypes: {
|
||||||
|
gradeReleased: true,
|
||||||
|
homeworkGraded: true,
|
||||||
|
examPublished: true,
|
||||||
|
attendanceAlert: true,
|
||||||
|
schoolAnnouncement: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatedAt: null,
|
||||||
|
channels,
|
||||||
|
eventTypes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +83,29 @@ export function buildNotificationPreferencesQueryResolver(deps: {
|
|||||||
if (!dto) {
|
if (!dto) {
|
||||||
// 下游失败且无缓存,返回默认偏好
|
// 下游失败且无缓存,返回默认偏好
|
||||||
return {
|
return {
|
||||||
channels: ["APP"],
|
parentId,
|
||||||
|
preferences: {
|
||||||
|
channels: ["APP" as NotificationChannel],
|
||||||
|
eventTypes: {
|
||||||
|
gradeReleased: true,
|
||||||
|
homeworkGraded: true,
|
||||||
|
examPublished: true,
|
||||||
|
attendanceAlert: true,
|
||||||
|
schoolAnnouncement: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
channels: ["APP" as NotificationChannel],
|
||||||
|
eventTypes: {
|
||||||
|
gradeReleased: true,
|
||||||
|
homeworkGraded: true,
|
||||||
|
examPublished: true,
|
||||||
|
attendanceAlert: true,
|
||||||
|
schoolAnnouncement: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatedAt: null,
|
||||||
|
channels: ["APP" as NotificationChannel],
|
||||||
eventTypes: {
|
eventTypes: {
|
||||||
gradeReleased: true,
|
gradeReleased: true,
|
||||||
homeworkGraded: true,
|
homeworkGraded: true,
|
||||||
@@ -73,7 +115,7 @@ export function buildNotificationPreferencesQueryResolver(deps: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return mapPreferences(dto);
|
return mapPreferences(dto, parentId);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +181,6 @@ export function buildUpdateNotificationPreferencesMutationResolver(deps: {
|
|||||||
"Notification preferences updated",
|
"Notification preferences updated",
|
||||||
);
|
);
|
||||||
|
|
||||||
return mapPreferences(updated);
|
return mapPreferences(updated, parentId);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { MsgClient } from "../../clients/msg.client.js";
|
import type { MsgClient } from "../../clients/msg.client.js";
|
||||||
import type { NotificationDto } from "../../clients/dtos.js";
|
import type { NotificationDto } from "../../clients/dtos.js";
|
||||||
import type { NotificationItem, NotificationType } from "../types.js";
|
import type { NotificationItem } from "../types.js";
|
||||||
import type { GraphqlContext } from "../context.js";
|
import type { GraphqlContext } from "../context.js";
|
||||||
import { CacheKeys } from "../../shared/cache/cache-key.builder.js";
|
import { CacheKeys } from "../../shared/cache/cache-key.builder.js";
|
||||||
import {
|
import {
|
||||||
@@ -10,29 +10,26 @@ import {
|
|||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
import { logger } from "../../shared/observability/logger.js";
|
import { logger } from "../../shared/observability/logger.js";
|
||||||
|
|
||||||
const NOTIFICATION_TYPE_MAP: Record<string, NotificationType> = {
|
|
||||||
SYSTEM: "SYSTEM",
|
|
||||||
EXAM: "EXAM",
|
|
||||||
HOMEWORK: "HOMEWORK",
|
|
||||||
GRADE: "GRADE",
|
|
||||||
ATTENDANCE: "ATTENDANCE",
|
|
||||||
ANNOUNCEMENT: "ANNOUNCEMENT",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO → GraphQL Type 映射。
|
* DTO → GraphQL Type 映射。
|
||||||
*
|
*
|
||||||
* createdAt: proto int64(epoch ms)→ ISO 8601 字符串(DateTime scalar)
|
* createdAt: proto int64(epoch ms)→ ISO 8601 字符串(DateTime scalar)
|
||||||
|
* 旧版 schema 使用 type/content,新版 schema 使用 eventType/body,
|
||||||
|
* 此处同时填充两组字段以兼容 legacy Notification type 和新版 MyNotification。
|
||||||
*/
|
*/
|
||||||
function mapNotification(dto: NotificationDto): NotificationItem {
|
function mapNotification(dto: NotificationDto): NotificationItem {
|
||||||
return {
|
return {
|
||||||
id: dto.id,
|
id: dto.id,
|
||||||
type: NOTIFICATION_TYPE_MAP[dto.type] ?? "SYSTEM",
|
type: dto.type || "SYSTEM",
|
||||||
|
eventType: dto.type || "SYSTEM",
|
||||||
title: dto.title,
|
title: dto.title,
|
||||||
content: dto.content,
|
content: dto.content,
|
||||||
|
body: dto.content,
|
||||||
read: dto.isRead,
|
read: dto.isRead,
|
||||||
childId: dto.childId ?? null,
|
childId: dto.childId ?? null,
|
||||||
createdAt: new Date(dto.createdAt).toISOString(),
|
createdAt: new Date(dto.createdAt).toISOString(),
|
||||||
|
actionUrl: null,
|
||||||
|
pinned: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,11 +110,15 @@ export function buildMarkNotificationReadMutationResolver(deps: {
|
|||||||
return {
|
return {
|
||||||
id: args.notificationId,
|
id: args.notificationId,
|
||||||
type: "SYSTEM",
|
type: "SYSTEM",
|
||||||
|
eventType: "SYSTEM",
|
||||||
title: "",
|
title: "",
|
||||||
content: "",
|
content: "",
|
||||||
|
body: "",
|
||||||
read: true,
|
read: true,
|
||||||
childId: null,
|
childId: null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
actionUrl: null,
|
||||||
|
pinned: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return mapNotification(updated);
|
return mapNotification(updated);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { resolve } from "node:path";
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import { makeExecutableSchema } from "@graphql-tools/schema";
|
import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||||
import type { IResolvers } from "@graphql-tools/utils";
|
import type { IResolvers } from "@graphql-tools/utils";
|
||||||
|
|
||||||
@@ -9,11 +8,13 @@ import type { IResolvers } from "@graphql-tools/utils";
|
|||||||
*
|
*
|
||||||
* 文件位置:packages/shared-ts/contracts/graphql/parent-bff.graphql
|
* 文件位置:packages/shared-ts/contracts/graphql/parent-bff.graphql
|
||||||
* 该文件是 parent-bff GraphQL 端点的契约唯一源,前后端共享。
|
* 该文件是 parent-bff GraphQL 端点的契约唯一源,前后端共享。
|
||||||
|
*
|
||||||
|
* 使用 process.cwd() 解析路径,兼容开发模式(cwd=services/parent-bff)
|
||||||
|
* 和生产模式(cwd=/app/services/parent-bff)。
|
||||||
*/
|
*/
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const SDL_PATH = resolve(
|
const SDL_PATH = resolve(
|
||||||
__dirname,
|
process.cwd(),
|
||||||
"../../../../packages/shared-ts/contracts/graphql/parent-bff.graphql",
|
"../../packages/shared-ts/contracts/graphql/parent-bff.graphql",
|
||||||
);
|
);
|
||||||
|
|
||||||
const typeDefs = readFileSync(SDL_PATH, "utf-8");
|
const typeDefs = readFileSync(SDL_PATH, "utf-8");
|
||||||
|
|||||||
@@ -8,13 +8,17 @@
|
|||||||
export type DataScope =
|
export type DataScope =
|
||||||
"SELF" | "CHILDREN" | "CLASS" | "GRADE" | "SCHOOL" | "DISTRICT" | "ALL";
|
"SELF" | "CHILDREN" | "CLASS" | "GRADE" | "SCHOOL" | "DISTRICT" | "ALL";
|
||||||
|
|
||||||
|
// ============ Parent / Viewport ============
|
||||||
|
|
||||||
export interface ParentType {
|
export interface ParentType {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
|
permissions?: string[];
|
||||||
dataScope: DataScope;
|
dataScope: DataScope;
|
||||||
|
schoolId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ViewportItemType {
|
export interface ViewportItemType {
|
||||||
@@ -26,6 +30,8 @@ export interface ViewportItemType {
|
|||||||
requiredPermission?: string | null;
|
requiredPermission?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Legacy Child / Grade / Homework / Exam ============
|
||||||
|
|
||||||
export interface ClassInfoType {
|
export interface ClassInfoType {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -98,6 +104,7 @@ export interface ChildAnalyticsType {
|
|||||||
classAverage?: number | null;
|
classAverage?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 旧版 Child 类型(含嵌套 class 对象,用于 legacy Child field resolver) */
|
||||||
export interface ChildType {
|
export interface ChildType {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -110,17 +117,373 @@ export interface ChildType {
|
|||||||
analytics?: ChildAnalyticsType;
|
analytics?: ChildAnalyticsType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationType =
|
// ============ Extended: Child Brief / Summary / Detail ============
|
||||||
"SYSTEM" | "EXAM" | "HOMEWORK" | "GRADE" | "ATTENDANCE" | "ANNOUNCEMENT";
|
|
||||||
|
export interface ChildBriefType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
grade: string;
|
||||||
|
classId: string | null;
|
||||||
|
className: string | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildSummaryType {
|
||||||
|
childId: string;
|
||||||
|
avgScore: number | null;
|
||||||
|
classRank: number | null;
|
||||||
|
classSize: number | null;
|
||||||
|
attendanceRate: number | null;
|
||||||
|
pendingHomeworkCount: number;
|
||||||
|
recentGradeTrend: number | null;
|
||||||
|
recentScores: ChildGradeType[];
|
||||||
|
upcomingEvents: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
dueDate: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildDetailType {
|
||||||
|
childId: string;
|
||||||
|
basicInfo: {
|
||||||
|
name: string;
|
||||||
|
avatar: string | null;
|
||||||
|
grade: string;
|
||||||
|
className: string;
|
||||||
|
schoolName: string;
|
||||||
|
relation: string;
|
||||||
|
};
|
||||||
|
todaySchedule: unknown[];
|
||||||
|
weeklySchedule: unknown[];
|
||||||
|
homeworkSummary: {
|
||||||
|
pendingCount: number;
|
||||||
|
overdueCount: number;
|
||||||
|
submittedCount: number;
|
||||||
|
gradedCount: number;
|
||||||
|
};
|
||||||
|
gradeSummary: {
|
||||||
|
avgScore: number;
|
||||||
|
classRank: number | null;
|
||||||
|
classSize: number | null;
|
||||||
|
trend: number | null;
|
||||||
|
};
|
||||||
|
examResults: {
|
||||||
|
upcoming: number;
|
||||||
|
completed: number;
|
||||||
|
avgScore: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Extended: Child Grade / Homework / Exam ============
|
||||||
|
|
||||||
|
export interface ChildGradeType {
|
||||||
|
examId: string;
|
||||||
|
examName: string;
|
||||||
|
examDate: string;
|
||||||
|
subject: string;
|
||||||
|
studentScore: number;
|
||||||
|
classAverage: number | null;
|
||||||
|
classMax: number | null;
|
||||||
|
classMin: number | null;
|
||||||
|
gradeLevel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildHomeworkType {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subject: string;
|
||||||
|
className: string;
|
||||||
|
assignedDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
status: string;
|
||||||
|
score: number | null;
|
||||||
|
maxScore: number | null;
|
||||||
|
feedback: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildExamType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
status: string;
|
||||||
|
startsAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
durationSeconds: number | null;
|
||||||
|
questionCount: number | null;
|
||||||
|
totalScore: number | null;
|
||||||
|
submittedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendanceRecordType {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
status: string;
|
||||||
|
checkInTime: string | null;
|
||||||
|
checkOutTime: string | null;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamResultType {
|
||||||
|
examId: string;
|
||||||
|
childId: string;
|
||||||
|
score: number | null;
|
||||||
|
rank: number | null;
|
||||||
|
subjectScores: Array<{
|
||||||
|
subject: string;
|
||||||
|
score: number;
|
||||||
|
fullScore: number;
|
||||||
|
}>;
|
||||||
|
feedback: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildClassType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
homeroomTeacher: string | null;
|
||||||
|
studentCount: number | null;
|
||||||
|
grade: string;
|
||||||
|
year: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportCardType {
|
||||||
|
childId: string;
|
||||||
|
academicYearId: string;
|
||||||
|
semester: number;
|
||||||
|
subjects: Array<{
|
||||||
|
subject: string;
|
||||||
|
score: number;
|
||||||
|
grade: string;
|
||||||
|
teacherComment: string | null;
|
||||||
|
}>;
|
||||||
|
overallComment: string | null;
|
||||||
|
classRank: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrowthArchiveType {
|
||||||
|
childId: string;
|
||||||
|
subject: string | null;
|
||||||
|
dataPoints: Array<{
|
||||||
|
date: string;
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
evidence: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Extended: Analytics ============
|
||||||
|
|
||||||
|
export interface WeaknessItemType {
|
||||||
|
id: string;
|
||||||
|
knowledgePoint: string;
|
||||||
|
masteryLevel: number;
|
||||||
|
subject: string;
|
||||||
|
recommendation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrendPeriod = "WEEK" | "MONTH" | "SEMESTER" | "YEAR";
|
||||||
|
|
||||||
|
export interface ChildTrendType {
|
||||||
|
childId: string;
|
||||||
|
period: TrendPeriod;
|
||||||
|
dataPoints: Array<{
|
||||||
|
date: string;
|
||||||
|
score: number;
|
||||||
|
subject: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LearningPathItemType {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subject: string;
|
||||||
|
order: number;
|
||||||
|
masteryLevel: number;
|
||||||
|
resources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorBookStatsType {
|
||||||
|
childId: string;
|
||||||
|
totalCount: number;
|
||||||
|
newCount: number;
|
||||||
|
learningCount: number;
|
||||||
|
masteredCount: number;
|
||||||
|
dueReviewCount: number;
|
||||||
|
masteredRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WrongQuestionType {
|
||||||
|
id: string;
|
||||||
|
questionId: string;
|
||||||
|
subject: string;
|
||||||
|
content: string;
|
||||||
|
wrongAnswer: string;
|
||||||
|
correctAnswer: string;
|
||||||
|
addedAt: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeakKpType {
|
||||||
|
id: string;
|
||||||
|
knowledgePoint: string;
|
||||||
|
subject: string;
|
||||||
|
masteryLevel: number;
|
||||||
|
recommendation: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterySummaryType {
|
||||||
|
childId: string;
|
||||||
|
overallMastery: number;
|
||||||
|
subjectMastery: Array<{
|
||||||
|
subject: string;
|
||||||
|
mastery: number;
|
||||||
|
totalKps: number;
|
||||||
|
masteredKps: number;
|
||||||
|
}>;
|
||||||
|
totalKps: number;
|
||||||
|
masteredKps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosticReportType {
|
||||||
|
id: string;
|
||||||
|
childId: string;
|
||||||
|
subject: string;
|
||||||
|
reportDate: string;
|
||||||
|
summary: string;
|
||||||
|
recommendations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PracticeStatsType {
|
||||||
|
childId: string;
|
||||||
|
totalSessions: number;
|
||||||
|
completedSessions: number;
|
||||||
|
totalQuestionsAnswered: number;
|
||||||
|
overallAccuracy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PracticeSessionType {
|
||||||
|
id: string;
|
||||||
|
childId: string;
|
||||||
|
subject: string;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
questionCount: number;
|
||||||
|
correctCount: number;
|
||||||
|
accuracy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Extended: Course / Lesson / Elective ============
|
||||||
|
|
||||||
|
export interface CoursePlanType {
|
||||||
|
id: string;
|
||||||
|
childId: string;
|
||||||
|
subject: string;
|
||||||
|
title: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoursePlanDetailType {
|
||||||
|
id: string;
|
||||||
|
childId: string;
|
||||||
|
subject: string;
|
||||||
|
title: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
progress: number;
|
||||||
|
lessons: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
completed: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonPlanType {
|
||||||
|
id: string;
|
||||||
|
childId: string;
|
||||||
|
subject: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
teacherName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonPlanDetailType {
|
||||||
|
id: string;
|
||||||
|
childId: string;
|
||||||
|
subject: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
teacherName: string;
|
||||||
|
objectives: string[];
|
||||||
|
content: string;
|
||||||
|
homework: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectiveCourseType {
|
||||||
|
id: string;
|
||||||
|
childId: string;
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
teacher: string;
|
||||||
|
schedule: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Extended: Leave Request ============
|
||||||
|
|
||||||
|
export interface LeaveRequestInput {
|
||||||
|
childId: string;
|
||||||
|
type: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveRequestItem {
|
||||||
|
id: string;
|
||||||
|
childId: string;
|
||||||
|
childName: string | null;
|
||||||
|
className: string | null;
|
||||||
|
type: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
reason: string;
|
||||||
|
status: string;
|
||||||
|
submittedAt: string;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
reviewerName: string | null;
|
||||||
|
reviewComment: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Extended: Academic Year ============
|
||||||
|
|
||||||
|
export interface AcademicYearType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Extended: Notification ============
|
||||||
|
|
||||||
export interface NotificationItem {
|
export interface NotificationItem {
|
||||||
id: string;
|
id: string;
|
||||||
type: NotificationType;
|
childId: string | null;
|
||||||
|
eventType: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
body: string;
|
||||||
read: boolean;
|
read: boolean;
|
||||||
childId?: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
actionUrl: string | null;
|
||||||
|
pinned: boolean;
|
||||||
|
/** 旧版字段(legacy schema 用) */
|
||||||
|
type?: string;
|
||||||
|
content?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationChannel = "APP" | "SMS" | "EMAIL" | "WECHAT";
|
export type NotificationChannel = "APP" | "SMS" | "EMAIL" | "WECHAT";
|
||||||
@@ -134,10 +497,50 @@ export interface NotificationEventTypes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationPreferencesType {
|
export interface NotificationPreferencesType {
|
||||||
channels: NotificationChannel[];
|
parentId: string;
|
||||||
eventTypes: NotificationEventTypes;
|
preferences: {
|
||||||
|
channels?: NotificationChannel[];
|
||||||
|
eventTypes?: Partial<NotificationEventTypes>;
|
||||||
|
};
|
||||||
|
defaults: {
|
||||||
|
channels: NotificationChannel[];
|
||||||
|
eventTypes: NotificationEventTypes;
|
||||||
|
};
|
||||||
|
updatedAt: string | null;
|
||||||
|
/** 旧版字段(legacy schema 用) */
|
||||||
|
channels?: NotificationChannel[];
|
||||||
|
eventTypes?: NotificationEventTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateNotificationPreferencesResultType {
|
||||||
|
parentId: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkAsReadResultType {
|
||||||
|
id: string;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkAllAsReadResultType {
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Extended: Mutation Results ============
|
||||||
|
|
||||||
|
export interface SwitchChildResultType {
|
||||||
|
childId: string;
|
||||||
|
childName: string;
|
||||||
|
selectedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportChildGradesResultType {
|
||||||
|
downloadUrl: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Legacy Types(保留旧版兼容) ============
|
||||||
|
|
||||||
export interface DashboardDataType {
|
export interface DashboardDataType {
|
||||||
parent: ParentType | null;
|
parent: ParentType | null;
|
||||||
children: ChildType[];
|
children: ChildType[];
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function createYogaInstance(
|
|||||||
|
|
||||||
return createYoga({
|
return createYoga({
|
||||||
schema,
|
schema,
|
||||||
graphqlEndpoint: "/graphql",
|
graphqlEndpoint: "/v1/graphql",
|
||||||
graphiql:
|
graphiql:
|
||||||
env.NODE_ENV === "development" && env.GRAPHQL_INTROSPECTION_ENABLED,
|
env.NODE_ENV === "development" && env.GRAPHQL_INTROSPECTION_ENABLED,
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import pino from "pino";
|
import { pino } from "pino";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user