# student-bff 对接契约

> 负责人：ai04
> 阶段：P3（批次 2 启动）
> 版本：v2（对齐 coord-final-decisions B1-B8 + president-final-rulings §2.2/2.3/2.4/2.6/2.7/2.8/2.9）
> 日期：2026-07-10
> 关联文档：
>
> - [matrix.md](../matrix.md)
> - [coord-final-decisions.md](../../coord-final-decisions.md) §2 BFF 专项裁决 B1-B8
> - [president-final-rulings.md](../../president-final-rulings.md) §2.2 GraphQL schema 仲裁机制
> - [iam.proto](../../../../packages/shared-proto/proto/iam.proto)
> - [core_edu.proto](../../../../packages/shared-proto/proto/core_edu.proto)
> - [content.proto](../../../../packages/shared-proto/proto/content.proto)
> - [analytics.proto](../../../../packages/shared-proto/proto/analytics.proto)
> - [msg.proto](../../../../packages/shared-proto/proto/msg.proto)

---

## §0 裁决对齐声明

本文件已对齐以下裁决（无遗漏）：

| 裁决编号 | 内容                                       | 本文件章节     |
| -------- | ------------------------------------------ | -------------- |
| B1       | P2 起直接 GraphQL（GraphQL Yoga + DataLoader） | §1.2 / §1.3    |
| B2       | 首次实现即 gRPC 调用下游                    | §2.1 / §2.4    |
| B3       | BFF 豁免 @RequirePermission                 | §1.6 / §1.7    |
| B4       | 全部 BFF 强制自我越权防御                   | §1.7           |
| B5       | 错误码前缀 `BFF_STUDENT_`                   | §1.6           |
| B6       | Redis 5-30s 短缓存                          | §7             |
| B7       | P2-P4 不订阅 Kafka，P5 后再订阅             | §1.5 / §2.2    |
| B8       | DownstreamClient 抽象回写 teacher-bff       | §2.4           |
| G14      | 错误码前缀服务名大写                       | §1.6           |
| F4       | i18n key `error.bffStudent.<code_snake>`    | §1.6           |
| F7       | 权限点命名 `<RESOURCE>_<ACTION>[_<SCOPE>]`  | §1.3.4         |
| F9       | 前端用 urql/apollo 消费 GraphQL             | §1.2           |
| §2.2     | GraphQL schema 仲裁机制                     | §1.3.2 / §1.3.3 |
| §2.3     | BFF 跨阶段扩展例外                          | §3             |
| §2.4     | /readyz 探针按阶段扩展                      | §4             |
| §2.6     | 降级模式方案 B（data 内 degraded 字段）     | §1.4.2         |
| §2.7     | BFF 错误码语义区分（3 类）                  | §1.6 / §1.7.3  |
| §2.8     | Dashboard Query Resolver P2 实现方式        | §1.3.5         |
| §2.9     | 越权防御 P2 实现方式（DEV_MODE 放行）       | §1.7.4         |
| §5.1     | admin-portal 复用 teacher-bff，不占 student-bff 命名空间 | §1.3.6 |

---

## §1 我提供什么（对外接口）

### 1.1 服务基础信息

| 项目          | 值                                            |
| ------------- | --------------------------------------------- |
| 服务名        | student-bff                                   |
| 服务类型      | BFF 聚合层（无 DB，无 Outbox）                |
| HTTP 端口     | 3009                                          |
| gRPC 端口     | 不暴露（BFF 仅对下游走 gRPC，不对外提供 gRPC） |
| 路由前缀      | `/student/*`（api-gateway 透传）              |
| 部署目录      | `services/student-bff/`                       |
| 角色要求      | `student`（JWT 必需 + student 角色）          |

### 1.2 HTTP 端点

| Method | Path       | 用途                                       | 认证                         | 实现    |
| ------ | ---------- | ------------------------------------------ | ---------------------------- | ------- |
| POST   | /graphql   | 学生 BFF GraphQL 端点（B1 裁决，GraphQL Yoga） | JWT 必需 + student 角色      | P3      |
| GET    | /graphql   | GraphQL Playground（开发环境）              | 开发环境公开                 | P3      |
| GET    | /healthz   | 健康检查（liveness）                        | 公开                         | P3      |
| GET    | /readyz    | 就绪检查（readiness，按阶段扩展探针）       | 公开                         | P3      |
| GET    | /metrics   | Prometheus 指标端点                         | 公开（生产限内网）           | P3      |

> **B1 裁决**：P2 起直接 GraphQL（GraphQL Yoga + DataLoader），禁止 REST → GraphQL 渐进式过渡。
> **B2 裁决**：对下游通信首次实现即 gRPC，禁止 HTTP fetch → gRPC 渐进式过渡。
> **F9 裁决**：前端 student-portal 用 urql/apollo 消费 GraphQL。

### 1.3 GraphQL schema（核心契约）

#### 1.3.1 schema 存放路径

**强制路径**：`packages/shared-ts/contracts/graphql/student-bff.schema.graphql`

> 依据 president-final-rulings §2.2.1：3 个 BFF schema 统一存放于 `packages/shared-ts/contracts/graphql/`，由各 BFF 负责 AI 起草、coord 仲裁。student-bff schema 不放在 `services/student-bff/src/` 下，避免前端 AI 难以发现。

#### 1.3.2 起草与仲裁流程

依据 president-final-rulings §2.2.6：

1. **起草方**：ai04 在批次 1 等待期起草 student-bff GraphQL schema 草案
2. **仲裁方**：coord 在**批次 2 启动前**仲裁第一版 schema
3. **消费方**：ai14（student-portal）基于仲裁版 schema 消费
4. **变更流程**：
   - schema 变更需 PR + ai04（BFF）+ ai14（前端）双方 review
   - 重大变更（删除字段 / 修改类型）需 coord 仲裁
   - 新增字段允许，无需 coord 仲裁，但需通知 ai14
5. **版本管理**：SDL-first（`schema.graphql` 文件），配合 `graphql-codegen` 生成 TS 类型

#### 1.3.3 schema 设计规范

依据 president-final-rulings §2.2.5：

| 规范项         | 规则                                                              |
| -------------- | ----------------------------------------------------------------- |
| 命名风格       | Query/Mutation 用 camelCase（如 `studentDashboard` / `myClasses`） |
| 分页规范       | **Relay Cursor Connections**（`{ edges, pageInfo, totalCount }`） |
| 错误响应       | GraphQL errors 数组 + `extensions.code` + `extensions.traceId`    |
| 权限点标注     | 注释形式 `# @permission: DASHBOARD_VIEW`（F7 规范）               |
| DataScope 标注 | 注释形式 `# @dataScope: OWN`（学生数据隔离 SELF）                 |
| 字段命名       | Type 用 PascalCase，字段用 camelCase，枚举用 UPPER_SNAKE_CASE    |
| 非空与可选     | 必填字段用 `!`，可空字段不标 `!`（避免破坏性变更）                |

#### 1.3.4 核心 Query / Mutation 域

> 完整 SDL 定义见 `packages/shared-ts/contracts/graphql/student-bff.schema.graphql`（ai04 起草，coord 仲裁）。

**Query 域**：

| Query                  | 用途                     | 聚合下游 RPC                                                | 权限点标注                  | DataScope |
| ---------------------- | ------------------------ | ----------------------------------------------------------- | --------------------------- | --------- |
| `currentUser`          | 当前学生信息 + 权限 + 视口 | iam.GetUserInfo + GetEffectivePermissions + GetViewports   | `# @permission: AUTH_READ`  | OWN       |
| `myClasses`            | 我的班级列表             | core-edu.ClassService.GetClass + ListStudentsByClass       | `# @permission: CLASS_READ` | OWN       |
| `myExams`              | 我的考试列表             | core-edu.ExamService.ListExamsByClass                      | `# @permission: EXAM_READ`  | OWN       |
| `myHomework`           | 我的作业列表             | core-edu.HomeworkService.ListHomeworkByClass               | `# @permission: HOMEWORK_READ` | OWN    |
| `myGrades`             | 我的成绩列表             | core-edu.GradeService.ListGradesByStudent                  | `# @permission: GRADE_READ` | OWN       |
| `myAttendance`         | 我的考勤记录             | core-edu.AttendanceService.ListAttendanceByStudent         | `# @permission: ATTENDANCE_READ` | OWN   |
| `textbooks`            | 教材列表                 | content.TextbookService.ListTextbooks                      | `# @permission: TEXTBOOK_READ` | OWN    |
| `chapters`             | 章节列表                 | content.ChapterService.ListChapters                        | `# @permission: CHAPTER_READ` | OWN     |
| `learningPath`         | 学习路径推荐             | content.KnowledgeGraphService.GetLearningPath              | `# @permission: LEARNING_PATH_READ` | OWN |
| `studentDashboard`     | 学生仪表盘               | data-ana.AnalyticsService.GetStudentDashboard              | `# @permission: DASHBOARD_VIEW` | OWN   |
| `myWeakness`           | 我的薄弱点               | data-ana.AnalyticsService.GetStudentWeakness               | `# @permission: WEAKNESS_READ` | OWN    |
| `myTrend`              | 学习趋势                 | data-ana.AnalyticsService.GetLearningTrend                 | `# @permission: TREND_READ` | OWN       |
| `myNotifications`      | 我的通知列表             | msg.NotificationService.ListNotifications                  | `# @permission: NOTIFICATION_READ` | OWN |
| `myNotificationUnreadCount` | 通知未读数           | msg.NotificationService.GetUnreadCount                      | `# @permission: NOTIFICATION_READ` | OWN |

**Mutation 域**：

| Mutation               | 用途             | 聚合下游 RPC                              | 权限点标注                  | DataScope |
| ---------------------- | ---------------- | ----------------------------------------- | --------------------------- | --------- |
| `submitHomework`       | 提交作业         | core-edu.HomeworkService.SubmitHomework   | `# @permission: HOMEWORK_SUBMIT` | OWN   |
| `markNotificationAsRead` | 标记通知已读   | msg.NotificationService.MarkAsRead        | `# @permission: NOTIFICATION_UPDATE` | OWN |

> **分页**：所有列表 Query（myClasses / myExams / myHomework / myGrades / myAttendance / textbooks / chapters / myNotifications）使用 Relay Cursor Connections 规范，返回 `{ edges, pageInfo, totalCount }`。
> **DataScope=OWN**：学生数据隔离为 SELF，所有 Query 透传 `x-user-id` 给下游，下游 Repository 按 DataScope=SELF 过滤。

#### 1.3.5 Dashboard Query Resolver 实现方式（§2.8 裁决）

依据 president-final-rulings §2.8：

1. **GraphQL schema 设计完整**（含全部 Query/Mutation 字段定义），P3 即定型，后续不重构
2. **Resolver 实现**：
   - **P3 阶段**：`studentDashboard` Query Resolver 内部调 iam + core-edu gRPC，返回学生基础信息 + 班级列表 + 考试列表 + 作业列表 + 成绩列表
   - **P4 扩展**：增加 content（教材/章节）+ data-ana（仪表盘/薄弱点/趋势）数据源，将 null 字段替换为真实数据
   - **P5 扩展**：增加 msg（通知未读数）+ ai（AI 助教入口）数据源
   - 未启用的下游字段返回 `null` + `extensions.warning = "field_unavailable_in_p3"`
3. **前端配合**：ai14 student-portal 对 null 字段做 UI 降级展示（如"数据加载中"或隐藏模块）
4. **此方案不违反"不分阶段"**：schema 即最终方案，Resolver 内部数据源扩展属"跨阶段扩展例外"（见 §3）

#### 1.3.6 admin-portal 命名空间

依据 president-final-rulings §5.1：

- **admin-portal 复用 teacher-bff GraphQL endpoint**，不新建 admin-bff 服务
- **student-bff 不预留 admin schema 命名空间**（admin 操作走 teacher-bff 的 `admin.*` 命名空间）
- ai04 无需为 admin-portal 做任何 schema 预留

### 1.4 GraphQL 错误响应格式

#### 1.4.1 GraphQL errors 数组 + ActionState 扩展

依据 president-final-rulings §2.2.3 + G8 裁决：

GraphQL 错误响应遵循标准 errors 数组格式，扩展 ActionState 字段：

```json
{
  "errors": [
    {
      "message": "学生身份验证失败",
      "extensions": {
        "code": "BFF_STUDENT_UNAUTHORIZED",
        "traceId": "abc-123-def-456",
        "i18nKey": "error.bffStudent.unauthorized",
        "severity": "error"
      }
    }
  ],
  "data": null
}
```

| 字段                | 类型   | 说明                                            |
| ------------------- | ------ | ----------------------------------------------- |
| `extensions.code`   | string | 错误码（BFF_STUDENT_* 前缀，见 §1.6）           |
| `extensions.traceId`| string | 全链路追踪 ID（由 Gateway 注入 X-Request-Id）   |
| `extensions.i18nKey`| string | i18n key（F4 规范：`error.bffStudent.<code_snake>`） |
| `extensions.severity` | string | `error` / `warning` / `info`                  |

#### 1.4.2 降级模式（方案 B，§2.6 裁决）

依据 president-final-rulings §2.6：当下游服务不可用但需返回部分数据时，采用**方案 B**（success=true + error=null + data 内 degraded 字段）：

```json
{
  "data": {
    "studentDashboard": {
      "user": { "id": "stu-001", "name": "李同学" },
      "classes": [{ "id": "cls-001", "name": "高三1班" }],
      "weakness": null,
      "degraded": true,
      "degradedReason": "data_ana_unavailable",
      "degradedFields": ["weakness"]
    }
  }
}
```

**规则**：

1. 降级时 HTTP 200，GraphQL `data` 非 null
2. 降级字段返回 `null`，并在父对象内加 `degraded: true` + `degradedReason: string` + `degradedFields: string[]`
3. 前端检查 `data.degraded` 判断降级，对 `degradedFields` 内字段做 UI 降级展示
4. 降级场景示例：
   - data-ana gRPC 不可用 → `studentDashboard.weakness` / `myTrend` / `myWeakness` 降级
   - content gRPC 不可用 → `textbooks` / `chapters` / `learningPath` 降级
   - msg gRPC 不可用 → `myNotifications` / `myNotificationUnreadCount` 降级

### 1.5 Kafka 事件发布

**无**。student-bff 是纯聚合层，不发布 Kafka 事件，不写 Outbox。

### 1.6 错误码前缀与列表

依据 B5 + G14 + F4 + §2.7 裁决：

**错误码前缀**：`BFF_STUDENT_`（统一 BFF_ 前缀，服务名大写）

**i18n key 规范**：`error.bffStudent.<code_snake>`（F4 裁决）

**错误码清单**：

| 错误码                                | HTTP | 场景                                           | i18n key                                      |
| ------------------------------------- | ---- | ---------------------------------------------- | --------------------------------------------- |
| `BFF_STUDENT_UNAUTHORIZED`            | 401  | x-user-id 缺失或无效                           | `error.bffStudent.unauthorized`               |
| `BFF_STUDENT_FORBIDDEN_RESOURCE`      | 403  | 学生越权访问他人数据（场景 A）                 | `error.bffStudent.forbidden_resource`         |
| `BFF_STUDENT_IDENTITY_MISMATCH`       | 403  | JWT userId 与请求 body userId 不一致（场景 B） | `error.bffStudent.identity_mismatch`          |
| `BFF_STUDENT_BAD_GATEWAY`             | 502  | 下游 gRPC 调用失败（非业务错误）               | `error.bffStudent.bad_gateway`                |
| `BFF_STUDENT_UPSTREAM_UNAVAILABLE`    | 503  | 下游服务不可用（降级模式触发）                 | `error.bffStudent.upstream_unavailable`       |
| `BFF_STUDENT_AGGREGATION_FAILED`      | 500  | 聚合逻辑异常（未知错误）                       | `error.bffStudent.aggregation_failed`         |
| `BFF_STUDENT_VALIDATION_ERROR`        | 400  | 输入参数校验失败（Zod 校验）                   | `error.bffStudent.validation_error`           |
| `BFF_STUDENT_GRAPHQL_PARSE_ERROR`     | 400  | GraphQL 语法解析错误                           | `error.bffStudent.graphql_parse_error`        |
| `BFF_STUDENT_GRAPHQL_VALIDATION_ERROR`| 400  | GraphQL 字段类型校验错误                       | `error.bffStudent.graphql_validation_error`   |
| `BFF_STUDENT_RATE_LIMITED`            | 429  | 限流触发（Gateway 层处理，BFF 兜底）           | `error.bffStudent.rate_limited`               |

> **B3 裁决澄清**：BFF 豁免 `@RequirePermission` 指不做"功能权限决策"（如"能否查看仪表盘"），但必须做"数据权限防御"（如"只能看自己的数据"），见 §1.7。

### 1.7 BFF 越权防御契约（B4 裁决）

#### 1.7.1 越权防御场景

依据 B4 + §2.7 裁决，student-bff 强制自我越权防御：

| 场景 | 描述                                           | 防御方式                                            |
| ---- | ---------------------------------------------- | --------------------------------------------------- |
| A    | 学生查询他人数据（如 query 传入非自己 userId） | AuthorizationGuard 比对 `x-user-id` 与查询参数      |
| B    | JWT userId 与请求 body userId 不一致           | Mutation 入参校验，拒绝不一致请求                   |

#### 1.7.2 AuthorizationGuard 接口

依据 §2.9 裁决：

```typescript
// services/student-bff/src/middleware/authorization.guard.ts
export interface AuthorizationGuard {
  /**
   * 校验学生是否有权访问指定资源
   * @param currentUserId 从 x-user-id header 获取
   * @param resourceUserId 查询参数中的 userId
   * @returns true 允许访问，false 拒绝
   */
  canAccessSelfData(currentUserId: string, resourceUserId: string): Promise<boolean>;
}
```

#### 1.7.3 3 类错误码语义（§2.7 裁决）

| 错误码                           | HTTP | 场景                                                 | 触发条件                                  |
| -------------------------------- | ---- | ---------------------------------------------------- | ----------------------------------------- |
| `BFF_STUDENT_UNAUTHORIZED`       | 401  | x-user-id 缺失或无效                                 | header 无 x-user-id 或为空                |
| `BFF_STUDENT_FORBIDDEN_RESOURCE` | 403  | 学生越权访问他人数据（场景 A）                       | canAccessSelfData 返回 false              |
| `BFF_STUDENT_IDENTITY_MISMATCH`  | 403  | JWT userId 与请求 body userId 不一致（场景 B）       | Mutation submitHomework 等 body userId 不匹配 |

#### 1.7.4 P3 实现方式（§2.9 裁决）

依据 §2.9 裁决：

1. **P3 抽象 AuthorizationGuard 接口**（`canAccessSelfData`，见 §1.7.2）
2. **P3 内部实现为"DEV_MODE 放行 + 生产拒绝"**（保守策略）：
   - `DEV_MODE=true`：放行所有请求，仅记录 warn 日志
   - `DEV_MODE=false`：严格校验，越权返回 `BFF_STUDENT_FORBIDDEN_RESOURCE`
3. **Redis 缓存**（P3 后期接入）：
   - key: `authz:student:{userId}`
   - value: 用户基础信息（userId / roles / classIds）
   - TTL: 5min
   - AuthorizationGuard 优先查缓存，缓存未命中调 iam gRPC
4. **此方案不违反"不分阶段"**：Guard 接口即最终方案，P3→P3 后期仅替换内部实现（属"跨阶段扩展例外"，见 §3）

**P3 阶段生产环境**：Guard 全部拒绝时，student-bff P3 端到端验证仅限 DEV_MODE。生产环境 P3 不接入流量（仅 dev 测试），P3 后期接入真实校验后正式上线。

---

## §2 我消费什么（依赖上游）

### 2.1 gRPC 调用（同步）

依据 B2 裁决，student-bff 对下游全部走 gRPC（禁止 HTTP fetch）。

| 被调用方        | Service.RPC                               | 用途             | 启用阶段 | mock 策略                                   |
| --------------- | ----------------------------------------- | ---------------- | -------- | ------------------------------------------- |
| iam (ai06)      | IamService.GetUserInfo                    | 获取当前学生信息 | P3       | iam 就绪前返回固定 UserInfo（student 角色） |
| iam (ai06)      | IamService.GetEffectivePermissions        | 权限校验         | P3       | iam 就绪前返回学生权限集                    |
| iam (ai06)      | IamService.GetViewports                   | 学生导航菜单     | P3       | iam 就绪前返回固定视口列表                  |
| iam (ai06)      | IamService.GetEffectiveDataScope          | DataScope 透传   | P3       | iam 就绪前返回 `SELF`                       |
| core-edu (ai08) | ClassService.GetClass                     | 我的班级详情     | P3       | core-edu 就绪前返回固定 ClassInfo           |
| core-edu (ai08) | ClassService.ListStudentsByClass          | 班级同学名单     | P3       | core-edu 就绪前返回固定 30 个 StudentInfo   |
| core-edu (ai08) | ExamService.ListExamsByClass              | 我的考试         | P3       | core-edu 就绪前返回固定 2 个 Exam           |
| core-edu (ai08) | HomeworkService.ListHomeworkByClass       | 我的作业         | P3       | core-edu 就绪前返回固定 3 个 Homework       |
| core-edu (ai08) | HomeworkService.SubmitHomework            | 提交作业         | P3       | core-edu 就绪前返回 success=true            |
| core-edu (ai08) | GradeService.ListGradesByStudent          | 我的成绩         | P3       | core-edu 就绪前返回固定 5 个 Grade          |
| core-edu (ai08) | AttendanceService.ListAttendanceByStudent | 我的考勤         | P3       | core-edu 就绪前返回固定 10 条 Attendance    |
| content (ai09)  | TextbookService.ListTextbooks             | 教材列表         | P4       | content 就绪前返回固定 5 个教材             |
| content (ai09)  | ChapterService.ListChapters               | 章节列表         | P4       | content 就绪前返回固定章节树                |
| content (ai09)  | KnowledgeGraphService.GetLearningPath     | 学习路径         | P4       | content 就绪前返回固定 8 个知识点推荐顺序   |
| data-ana (ai11) | AnalyticsService.GetStudentDashboard      | 学生仪表盘       | P4       | data-ana 就绪前返回固定仪表盘               |
| data-ana (ai11) | AnalyticsService.GetStudentWeakness       | 我的薄弱点       | P4       | data-ana 就绪前返回固定 3 个 weak_points    |
| data-ana (ai11) | AnalyticsService.GetLearningTrend         | 学习趋势         | P4       | data-ana 就绪前返回固定趋势数据             |
| msg (ai10)      | NotificationService.ListNotifications     | 学生通知         | P5       | msg 就绪前返回固定 10 条通知                |
| msg (ai10)      | NotificationService.GetUnreadCount        | 通知未读数       | P5       | msg 就绪前返回固定 count=3                  |
| msg (ai10)      | NotificationService.MarkAsRead            | 标记已读         | P5       | msg 就绪前返回 success=true                 |

> **命名对齐**（coord §5.2）：
> - `AnalyticsService.GetStudentDashboard`（无 Stats 后缀，统一命名）
> - `KnowledgeGraphService.GetLearningPath`（禁用 ContentService 命名）

### 2.2 Kafka 事件订阅（异步）

依据 B7 裁决：

- **P3-P4 阶段**：**不订阅 Kafka**（仅同步 gRPC 聚合）
- **P5 阶段**：push-gateway 落地后，评估是否订阅 Kafka（如 `edu.identity.user.role_changed` 用于权限缓存失效）

**P5 可选订阅的 topic**（待 P5 评估）：

| Topic                           | 用途                       | 触发动作                              |
| ------------------------------- | -------------------------- | ------------------------------------- |
| `edu.identity.user.role_changed` | 学生角色变更，失效权限缓存 | iam 发布，student-bff 清除 Redis 缓存 |

> **P3-P4 降级方案**：权限缓存用短 TTL（5min）兜底，不订阅 Kafka 事件。

### 2.3 HTTP 调用

**无**。依据 B2 裁决，student-bff 对下游全部走 gRPC，禁止 HTTP fetch。

### 2.4 DownstreamClient 抽象层（B8 裁决）

依据 B8 裁决：

1. **回写 teacher-bff**：DownstreamClient 作为 BFF 模式 v2 标准抽象，回写到 teacher-bff，3 个 BFF（teacher-bff / student-bff / parent-bff）统一使用
2. **抽象位置**：`packages/shared-ts/src/bff/downstream-client.ts`（coord 维护）
3. **核心能力**：

```typescript
export class DownstreamClient {
  /**
   * gRPC 调用封装
   * @param service 下游服务名（如 'iam' / 'core-edu'）
   * @param method RPC 方法名（如 'GetUserInfo'）
   * @param request 请求 message
   * @param options 超时 / 重试 / traceId 透传
   */
  call<TRequest, TResponse>(
    service: string,
    method: string,
    request: TRequest,
    options?: CallOptions,
  ): Promise<TResponse>;
}

interface CallOptions {
  timeoutMs?: number;       // 默认 5000ms
  retryCount?: number;      // 默认 2
  retryBackoffMs?: number;  // 默认 100ms，指数退避
  traceId?: string;         // 从 x-request-id header 获取
  metadata?: Record<string, string>; // gRPC metadata（含 x-user-id / x-user-roles / x-dataScope）
}
```

4. **student-bff 使用方式**：

```typescript
// services/student-bff/src/auth/auth.service.ts
import { DownstreamClient } from '@edu/shared-ts/bff/downstream-client';

@Injectable()
export class AuthService {
  constructor(private readonly downstream: DownstreamClient) {}

  async getCurrentUser(userId: string): Promise<UserInfo> {
    return this.downstream.call('iam', 'GetUserInfo', { userId }, {
      metadata: { 'x-user-id': userId },
    });
  }
}
```

5. **回写义务**：ai04 在 P3 实现时，将 DownstreamClient 抽象回写到 teacher-bff（替换 teacher-bff 现有的散落 fetch 调用），保证 3 个 BFF 统一。

---

## §3 跨阶段扩展例外规则（§2.3 裁决）

依据 president-final-rulings §2.3：

### 3.1 允许扩展（无需 coord 仲裁）

1. 新增下游 gRPC 调用（新增 RPC 方法到 DownstreamClient）
2. 新增下游配置（gRPC endpoint 配置）
3. 新增 /readyz 探针（按阶段启用，见 §4）
4. Dashboard Query Resolver 内部数据源扩展（null 字段 → 真实数据）
5. AuthorizationGuard 内部实现替换（DEV_MODE 放行 → 真实 gRPC 校验 + Redis 缓存）

### 3.2 禁止变更（需 coord 仲裁）

1. 修改已有 RPC 调用的签名或返回类型
2. 删除已实现的 RPC 调用（除非下游服务下线）
3. 修改 GraphQL schema 已有字段的类型（新增字段允许，无需仲裁）
4. 修改 /readyz 已有探针的检查项（只能新增，不能修改）

### 3.3 验收标准

扩展时必须：

1. 更新 student-bff 02-architecture-design.md 下游调用矩阵（§7）
2. 更新 `packages/shared-ts/contracts/graphql/student-bff.schema.graphql`
3. 运行 `pnpm run arch:scan` 更新 arch.db
4. coord 在批次验收时检查上述 3 项

---

## §4 /readyz 探针按阶段扩展规则（§2.4 裁决）

依据 president-final-rulings §2.4 + G2 裁决：

### 4.1 探针列表（按阶段）

student-bff 无 DB，探针仅检查 Redis + 下游 gRPC 可达性：

| 阶段 | 探针列表                                              | 数量 |
| ---- | ----------------------------------------------------- | ---- |
| P3   | Redis PING + iam gRPC 50052 + core-edu gRPC 50053    | 3    |
| P4   | + content gRPC 50054 + data-ana gRPC 50055           | 5    |
| P5   | + ai gRPC 50058 + msg gRPC 50056                     | 7    |

**实现方式**：`DownstreamHealthCheck` 注册表模式，每个下游注册独立探针，按阶段启用（通过 ENV 过滤）：

```typescript
const checks: HealthCheck[] = [
  checkRedis(),
  checkGrpc('iam', 50052),
  checkGrpc('core-edu', 50053),
];
if (env.CONTENT_GRPC_ENABLED) checks.push(checkGrpc('content', 50054));
if (env.DATA_ANA_GRPC_ENABLED) checks.push(checkGrpc('data-ana', 50055));
if (env.AI_GRPC_ENABLED) checks.push(checkGrpc('ai', 50058));
if (env.MSG_GRPC_ENABLED) checks.push(checkGrpc('msg', 50056));
```

### 4.2 软失败规则

依据 §2.4.3：

- **必需依赖**（Redis + 已启用 gRPC 下游）：失败返回 503，触发 Pod 重启
- **可选依赖**（未启用 gRPC 下游 / Kafka 订阅）：失败仅告警，返回 200 + body `degraded: true`

**student-bff 软失败场景**：

| 依赖                | 类型   | 失败行为                                    |
| ------------------- | ------ | ------------------------------------------- |
| Redis               | 必需   | 503（缓存失效会影响性能，但 BFF 仍可降级运行） |
| iam gRPC 50052      | 必需   | 503（无 iam 无法做身份校验）                |
| core-edu gRPC 50053 | 必需   | 503（核心数据源）                           |
| content gRPC 50054  | 可选（P4 启用前） | 200 + degraded=true                |
| data-ana gRPC 50055 | 可选（P4 启用前） | 200 + degraded=true                |
| ai gRPC 50058       | 可选（P5 启用前） | 200 + degraded=true                |
| msg gRPC 50056      | 可选（P5 启用前） | 200 + degraded=true                |

> **P3 阶段**：content / data-ana / ai / msg 均为可选，P3 /readyz 仅检查 Redis + iam + core-edu（3 项）。

---

## §5 就绪信号

### 5.1 我依赖的上游就绪标志

| 上游依赖         | 就绪标志                                    | 责任方 | 完成期限      |
| ---------------- | ------------------------------------------- | ------ | ------------- |
| iam gRPC 50052   | iam /readyz 返回 200 + GetUserInfo RPC 可调 | ai06   | 批次 1 完成   |
| core-edu gRPC 50053 | core-edu /readyz 返回 200 + ListExamsByClass RPC 可调 | ai08 | 批次 2 完成 |
| content gRPC 50054 | content /readyz 返回 200 + ListTextbooks RPC 可调 | ai09 | 批次 3 完成 |
| data-ana gRPC 50055 | data-ana /readyz 返回 200 + GetStudentDashboard RPC 可调 | ai11 | 批次 3 完成 |
| msg gRPC 50056   | msg /readyz 返回 200 + ListNotifications RPC 可调 | ai10 | 批次 4 完成 |
| GraphQL schema 仲裁 | coord 仲裁 student-bff schema 第一版        | coord  | 批次 2 启动前 |
| DownstreamClient 抽象 | packages/shared-ts/src/bff/downstream-client.ts 就绪 | coord | 批次 1 启动前 |
| shared-go 包     | packages/shared-go/ 骨架就绪                | coord  | 批次 0.14     |

### 5.2 我的就绪标志（供下游消费）

| 就绪标志                                  | 完成标准                                                    | 完成阶段 |
| ----------------------------------------- | ----------------------------------------------------------- | -------- |
| student-bff GraphQL :3009 启用            | /healthz 返回 200                                           | P3       |
| /readyz 返回 200（P3 探针 3 项）          | Redis + iam gRPC + core-edu gRPC 全部通过                   | P3       |
| GraphQL schema 可内省                     | POST /graphql `{ query: "{ __schema { types { name } } }" }` 返回 schema | P3 |
| 核心 Query 可执行                         | currentUser / myClasses / myExams / myHomework / myGrades 返回真实数据 | P3 |
| 核心 Mutation 可执行                      | submitHomework 可执行                                       | P3       |
| AuthorizationGuard 接口就绪               | canAccessSelfData 接口已实现（DEV_MODE 放行）              | P3       |
| DownstreamClient 回写 teacher-bff 完成    | teacher-bff 已切换为 DownstreamClient 抽象                  | P3       |
| /readyz 扩展 P4 探针（5 项）              | + content gRPC + data-ana gRPC                              | P4       |
| /readyz 扩展 P5 探针（7 项）              | + ai gRPC + msg gRPC                                        | P5       |

---

## §6 Mock 策略

### 6.1 我提供的 mock（供 student-portal 消费）

在 student-bff 真实就绪前，为 ai14（student-portal）提供以下 mock：

**GraphQL mock 实现方式**：GraphQL Yoga 内置 mock 模式（`graphql-yoga` 的 `mocking` 配置）或 MSW 拦截 POST /graphql

| Query/Mutation              | mock 返回                                                    |
| --------------------------- | ------------------------------------------------------------ |
| `currentUser`               | 固定学生（id="student-001", name="李同学", roles=["student"]） |
| `myClasses`                 | 固定 1 个班级（id="cls-001", name="高三1班"）                |
| `myExams`                   | 固定 2 个考试                                                |
| `myHomework`                | 固定 3 个作业（1 个待提交）                                  |
| `myGrades`                  | 固定 5 个成绩（avg_score=85.0）                              |
| `myAttendance`              | 固定 10 条考勤                                               |
| `studentDashboard`          | 固定仪表盘（avg_score=85.0, class_rank=5）                   |
| `myNotifications`           | 固定 10 条通知（3 条未读）                                   |
| `myNotificationUnreadCount` | 固定 count=3                                                 |
| `submitHomework`            | 返回 success=true                                            |
| `markNotificationAsRead`    | 返回 success=true                                            |

> **P4 字段降级**：P3 阶段 `studentDashboard.weakness` / `myTrend` / `textbooks` / `chapters` / `learningPath` 返回 null + `extensions.warning = "field_unavailable_in_p3"`

### 6.2 我消费的 mock（上游未就绪时）

在真实上游就绪前，student-bff 使用以下 mock（详见 §2.1 mock 策略列）：

| 上游     | mock 实现                                                     |
| -------- | ------------------------------------------------------------ |
| iam      | 固定 UserInfo + 学生权限集 + 固定视口 + DataScope=SELF       |
| core-edu | 固定班级/同学/考试/作业/成绩/考勤                            |
| content  | 固定教材/章节/学习路径（P4 启用前）                          |
| data-ana | 固定仪表盘/薄弱点/趋势（P4 启用前）                          |
| msg      | 固定通知列表 + GetUnreadCount + MarkAsRead success（P5 启用前） |

> **mock 实现方式**：通过 DownstreamClient 的 gRPC client 拦截器实现，上游就绪后移除拦截器切换真实调用。具体：在 `DownstreamClient.call()` 内部检查 `env.MOCK_UPSTREAM=true`，若为 true 则返回 mock 数据，否则走真实 gRPC。

---

## §7 缓存策略（B6 裁决）

依据 B6 裁决 + 004 §6.2 BFF 混合读策略：

### 7.1 Redis 短缓存

| 缓存对象                | key 模式                            | TTL   | 失效策略                |
| ----------------------- | ----------------------------------- | ----- | ----------------------- |
| currentUser 聚合结果    | `bff:student:user:{userId}`         | 30s   | TTL 过期                |
| myClasses 聚合结果      | `bff:student:classes:{userId}`      | 30s   | TTL 过期                |
| studentDashboard 聚合结果 | `bff:student:dashboard:{userId}`  | 5s    | TTL 过期（实时性要求高）|
| 权限列表                | `authz:student:{userId}`            | 5min  | P5 订阅 Kafka 事件失效  |
| 视口配置                | `bff:student:viewports:{userId}`    | 5min  | TTL 过期                |

### 7.2 缓存规则

1. **仅缓存 Query**：Mutation 不缓存
2. **缓存粒度**：按 Query + userId 维度缓存（学生数据隔离 SELF）
3. **降级策略**：Redis 不可用时，直接走 gRPC 调用（不缓存），返回数据 + `degraded: true`（标记缓存降级）
4. **缓存击穿防护**：使用 `ioredis` 的 `GETSET` 或单飞模式（同一 key 并发请求只发一个 gRPC 调用）

---

## §8 变更记录

| 日期       | 版本 | 变更                                                                 | 负责人 |
| ---------- | ---- | -------------------------------------------------------------------- | ------ |
| 2026-07-09 | v1   | 初始创建                                                             | ai04   |
| 2026-07-10 | v2   | 对齐 coord B1-B8 + president §2.2-2.9 裁决（schema 路径/错误响应/越权防御/DownstreamClient/跨阶段扩展/readyz 探针/降级模式/缓存策略） | ai04   |
