chore(student-bff): merge student-bff full implementation into main

This commit is contained in:
SpecialX
2026-07-10 19:18:58 +08:00
55 changed files with 7141 additions and 252 deletions

View File

@@ -569,6 +569,30 @@
| Mock 客户端 markAsRead 类型 | `MOCK_NOTIFICATIONS[parentId]` 可能 undefined需 `if (!list) continue``list[idx]` 可能 undefined需 `const target = list[idx]; if (target)` 守卫 |
| msg.client callWithMetrics | callWithMetrics 签名是 `(client, method, request, serviceName)`,不是 `("msg", "X", () => fn)` |
### 2.14.1 student-bffTS/NestJS/GraphQLP3
> student-bff 是学生场景域 BFF 聚合层GraphQL Yoga + gRPC 下游通信。本节仅记录 student-bff **特有**的"场景→技术"映射,通用 NestJS 映射见 §1.4。
| 场景 | 技术/规则 |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| P3 API 风格 | **GraphQL Yoga**coord §B1 裁决P2 起直接 GraphQL禁止 REST 渐进schema 存放 `packages/shared-ts/contracts/graphql/student-bff.schema.graphql`president §2.2.1 |
| P3 下游通信 | **gRPC 首次即用**coord §B2 裁决,@grpc/grpc-js + @grpc/proto-loader禁止 HTTP fetchDownstreamClient 抽象复用 shared-tscoord §B83 BFF 统一) |
| P3 错误码前缀 | `BFF_STUDENT_`coord §B5 裁决BFF 在前,非 `STUDENT_BFF_`i18n key `error.bffStudent.<code_snake>`coord §F4 |
| P3 越权防御 | AuthorizationGuard 强制 `studentId = userId`coord §B4场景 A 抛 `ForbiddenResourceError`,场景 B 抛 `IdentityMismatchError`president §2.7DEV_MODE 放行president §2.9 方案 D|
| P3 降级模式 | 方案 Bpresident §2.6`success=true + data 内 degraded=true + degradedFields`,下游部分失败仍返回部分数据 |
| P3 Redis 缓存 | 5-30s 短缓存coord §B6TTL ±20% 随机抖动防雪崩CacheService 封装 get/set/invalidate/invalidateByPrefix |
| P3 ESM 相对 import 路径深度 | 嵌套目录(如 `src/student/events/`、`src/student/guards/`)引用 shared 层需 `../../shared/` 前缀(非 `../shared/`NodeNext + .js 后缀 |
| P5 GraphQL Subscription SSE | GraphQL Yoga 原生 SSE 传输AsyncGenerator 桥接 gRPC server-streamingchunkQueue + resolveWait 机制将 Node ReadableStream 'data'/'end'/'error' 事件转为 async iterable |
| P5 DownstreamClient.callStream| gRPC server-streaming 调用 `callFn.call(client, request, meta, { deadline })`,返回 Node ReadableStream需手动转换为 AsyncIterablefinally 块调用 `stream.destroy?.()` 清理 |
| P5 Push-gateway 模块化 | 从 EventSubscriber 解耦推送逻辑为独立 PushGatewayServiceDI 注入fetch POST + AbortSignal.timeout(3000),失败软处理(仅 warn 日志,不抛异常) |
| P5 Kafka 事件订阅 | coord §B7 裁决P2-P4 不订阅P5 起订阅 7 个 topic消费者组 `student-bff-event-subscriber`Redis SETNX `event_id` 幂等去重 |
| P6 熔断器设计 | opossum 库,每下游服务一个独立 CircuitBreaker 实例Map 缓存action 函数闭包捕获动态参数;状态变更监听同步 Prometheus 指标student_bff_circuit_state Gauge |
| P6 熔断器配置 | timeout 5000mserrorThresholdPercentage 50resetTimeout 30000volumeThreshold 10rollingCountTimeout 60000coord §G11 |
| Mock 模式 | `env.MOCK_UPSTREAM=true` 时 DownstreamClient 返回固定数据config/mock-data.tscallStream 产出单个 mock chunk 后结束 |
| /readyz 探针 | 检查 6 个下游服务可达性iam/classes/core-edu/content/msg/ai/data-ana必需失败返回 503可选软失败返回 200 + degraded=true |
| ActionState 信封 | coord §G8 裁决:成功 `{success: true, data, meta?}`,失败 `{success: false, error: {code, message, i18nKey, details?, traceId?}}`ok()/fail()/degraded() 工具函数 |
| GraphQL Resolver 测试 Mock | vi.mock 模拟 env/metrics/loggervi.stubGlobal 模拟 fetchmockContext 工厂函数注入 downstream/redis mock 对象Vitest 覆盖率 ≥ 80% |
### 2.15 student-portal学生端微前端 RemoteP3
> student-portal 复用 teacher-portal Shell 暴露的 AppShell / usePermission / ApiClient / ErrorBoundary / 设计令牌 / A11y 工具集,本节仅记录 student-portal **特有**的"场景→技术"映射,通用前端映射见 §2.12。

View File

@@ -0,0 +1,805 @@
# student-bff GraphQL Schema (v1)
#
# 负责人: ai04
# 仲裁依据: coord-final-decisions §2 B1-B8 + president-final-rulings §2.2
# 存放路径: packages/shared-ts/contracts/graphql/student-bff.schema.graphql (president §2.2.1)
# 起草与仲裁流程: ai04 起草 → coord 在批次 2 启动前仲裁第一版 → ai14 (student-portal) 消费
#
# 设计规范 (president §2.2.5):
# - Query/Mutation 用 camelCase
# - 分页采用 Relay Cursor Connections 规范 ({ edges, pageInfo, totalCount })
# - 错误响应: GraphQL errors 数组 + extensions.code + extensions.traceId
# - 权限点标注: # @permission: <RESOURCE>_<ACTION>
# - DataScope 标注: # @dataScope: OWN (学生数据隔离 SELF)
# - Type 用 PascalCase, 字段用 camelCase, 枚举用 UPPER_SNAKE_CASE
# - 必填字段用 !, 可空字段不标 ! (避免破坏性变更)
#
# 降级模式 (president §2.6 方案 B):
# - 下游不可用时 success=true + error=null + data 内 degraded=true
# - 降级字段返回 null, 父对象加 degraded/degradedReason/degradedFields
scalar DateTime
scalar JSON
# ============================================================================
# Relay Cursor Connections 规范
# ============================================================================
interface Node {
id: ID!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# ============================================================================
# 错误扩展 (GraphQL errors 数组 + extensions)
# ============================================================================
"""
GraphQL errors 数组中 extensions 字段规范 (president §2.2.3 + G8 裁决).
GlobalErrorFilter 注入, 不在 schema 中显式暴露.
"""
type GraphQLErrorExtension {
code: String! # BFF_STUDENT_* 前缀 (B5 裁决)
traceId: String! # 全链路追踪 ID (Gateway 注入 X-Request-Id)
i18nKey: String! # i18n key: error.bffStudent.<code_snake> (F4 裁决)
severity: String! # error / warning / info
}
# ============================================================================
# 通用类型
# ============================================================================
"""
降级标记 (president §2.6 方案 B).
当下游服务不可用但需返回部分数据时, 父对象包含此接口字段.
"""
interface Degradable {
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
type DegradationInfo {
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# 用户与权限 (下游: iam)
# ============================================================================
type UserProfile {
id: ID!
email: String!
name: String!
avatar: String
roles: [String!]!
permissions: [String!]!
}
type ViewportItem {
key: String!
label: String!
route: String!
icon: String
sortOrder: Int!
requiredPermission: String
}
type ViewportConfig {
navigation: [ViewportItem!]!
dataScope: StudentDataScope!
}
type StudentDataScope {
showHistoryGrades: Boolean!
showClassRanking: Boolean!
enableAIChat: Boolean!
}
type CurrentUserPayload implements Degradable {
user: UserProfile
viewports: ViewportConfig
effectivePermissions: [String!]!
dataScope: String # SELF (学生固定)
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# 班级 (下游: core-edu ClassService)
# ============================================================================
type StudentClass {
id: ID!
name: String!
gradeId: String!
gradeName: String
subjects: [String!]!
homeroomTeacher: TeacherBrief
}
type TeacherBrief {
id: ID!
name: String!
avatar: String
}
type StudentClassConnection {
edges: [StudentClassEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type StudentClassEdge {
node: StudentClass!
cursor: String!
}
type StudentClassPayload implements Degradable {
classes: [StudentClass!]!
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# 考试 (下游: core-edu ExamService)
# ============================================================================
enum ExamStatus {
DRAFT
PUBLISHED
IN_PROGRESS
COMPLETED
CANCELLED
ARCHIVED
}
type Exam {
id: ID!
classId: ID!
title: String!
description: String
examDate: DateTime!
duration: Int! # 考试时长(分钟)
totalScore: Float!
status: ExamStatus!
createdBy: ID!
createdAt: DateTime!
updatedAt: DateTime!
daysLeft: Int # 距考试天数(负数表示已过)
}
type ExamConnection {
edges: [ExamEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ExamEdge {
node: Exam!
cursor: String!
}
type ExamListPayload implements Degradable {
exams: [Exam!]!
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# 作业 (下游: core-edu HomeworkService)
# ============================================================================
enum HomeworkStatus {
ASSIGNED
SUBMITTED
GRADED
OVERDUE
RETURNED
}
type Homework {
id: ID!
classId: ID!
title: String!
description: String
dueDate: DateTime!
status: HomeworkStatus!
createdBy: ID!
createdAt: DateTime!
updatedAt: DateTime!
submission: HomeworkSubmission # 当前学生的提交
}
type HomeworkSubmission {
id: ID!
homeworkId: ID!
studentId: ID!
status: HomeworkStatus!
submittedAt: DateTime
gradedAt: DateTime
score: Float
feedback: String
}
type HomeworkConnection {
edges: [HomeworkEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type HomeworkEdge {
node: Homework!
cursor: String!
}
type HomeworkListPayload implements Degradable {
homework: [Homework!]!
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# 成绩 (下游: core-edu GradeService)
# ============================================================================
type Grade {
id: ID!
studentId: ID!
examId: ID
homeworkId: ID
examTitle: String
homeworkTitle: String
subject: String
score: Float!
totalScore: Float!
feedback: String
gradedBy: ID!
gradedAt: DateTime!
createdAt: DateTime!
updatedAt: DateTime!
}
type GradeConnection {
edges: [GradeEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type GradeEdge {
node: Grade!
cursor: String!
}
type GradeListPayload implements Degradable {
grades: [Grade!]!
averageScore: Float
totalCount: Int!
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# 考勤 (下游: core-edu AttendanceService, P3 预留)
# ============================================================================
enum AttendanceStatus {
PRESENT
ABSENT
LATE
EARLY_LEAVE
EXCUSED
}
type AttendanceRecord {
id: ID!
studentId: ID!
classId: ID!
date: DateTime!
status: AttendanceStatus!
remark: String
recordedBy: ID!
createdAt: DateTime!
}
type AttendanceConnection {
edges: [AttendanceEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type AttendanceEdge {
node: AttendanceRecord!
cursor: String!
}
type AttendanceListPayload implements Degradable {
records: [AttendanceRecord!]!
presentCount: Int!
absentCount: Int!
lateCount: Int!
totalCount: Int!
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# 教材与章节 (下游: content, P4)
# ============================================================================
type Textbook {
id: ID!
title: String!
subjectId: ID!
subjectName: String
gradeId: ID!
gradeName: String
version: String!
coverImage: String
chapters: [Chapter!]!
}
type Chapter {
id: ID!
textbookId: ID!
title: String!
description: String
sortOrder: Int!
parentId: ID # 父章节(支持章节树)
knowledgePoints: [KnowledgePoint!]!
}
type KnowledgePoint {
id: ID!
title: String!
description: String
mastery: Float # 0-1 掌握度
}
type TextbookConnection {
edges: [TextbookEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type TextbookEdge {
node: Textbook!
cursor: String!
}
type TextbookListPayload implements Degradable {
textbooks: [Textbook!]!
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
type ChapterListPayload implements Degradable {
chapters: [Chapter!]!
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# 学习路径 (下游: content KnowledgeGraphService, P4)
# ============================================================================
type LearningPath {
studentId: ID!
subjectId: ID!
points: [KnowledgePoint!]!
recommendedOrder: [String!]! # 知识点 id 顺序
estimatedHours: Float
}
type LearningPathPayload implements Degradable {
path: LearningPath
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# 学情分析 (下游: data-ana, P4)
# ============================================================================
type StudentDashboard {
studentId: ID!
averageScore: Float
classRank: Int
totalStudents: Int
pendingHomeworkCount: Int!
upcomingExamCount: Int!
unreadNotificationCount: Int!
lastGrade: Grade
weakness: WeakPointSummary # P4 data-ana 启用后填充
trend: LearningTrendSummary # P4 data-ana 启用后填充
}
type WeakPointSummary {
knowledgePointId: ID!
title: String!
mastery: Float!
subject: String
}
type StudentWeakness {
studentId: ID!
subjectId: ID
weakPoints: [WeakPoint!]!
}
type WeakPoint {
knowledgePointId: ID!
title: String!
mastery: Float!
subject: String
lastAssessedAt: DateTime
}
type LearningTrend {
studentId: ID!
subjectId: ID
points: [TrendPoint!]!
}
type TrendPoint {
date: DateTime!
score: Float!
subject: String
}
type StudentDashboardPayload implements Degradable {
dashboard: StudentDashboard
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
type WeaknessPayload implements Degradable {
weakness: StudentWeakness
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
type TrendPayload implements Degradable {
trend: LearningTrend
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# 通知 (下游: msg, P5)
# ============================================================================
enum NotificationType {
HOMEWORK_ASSIGNED
HOMEWORK_GRADED
EXAM_PUBLISHED
EXAM_UPDATED
GRADE_RECORDED
SYSTEM
ANNOUNCEMENT
}
enum NotificationChannel {
IN_APP
EMAIL
SMS
PUSH
}
type Notification {
id: ID!
userId: ID!
type: NotificationType!
title: String!
content: String!
channel: NotificationChannel!
isRead: Boolean!
createdAt: DateTime!
readAt: DateTime
metadata: JSON
}
type NotificationConnection {
edges: [NotificationEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type NotificationEdge {
node: Notification!
cursor: String!
}
type NotificationListPayload implements Degradable {
notifications: [Notification!]!
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
type UnreadCountPayload implements Degradable {
count: Int!
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# AI 答疑 (下游: ai, P5)
# ============================================================================
type AIChatResponse {
content: String!
model: String!
usage: AIUsage!
}
type AIUsage {
promptTokens: Int!
completionTokens: Int!
totalTokens: Int!
}
type AIChatPayload implements Degradable {
response: AIChatResponse
degraded: Boolean!
degradedReason: String
degradedFields: [String!]
}
# ============================================================================
# Mutation 结果
# ============================================================================
type SubmitHomeworkResult {
success: Boolean!
submissionId: ID
homeworkId: ID!
submittedAt: DateTime
status: HomeworkStatus
error: MutationError
}
type MarkNotificationReadResult {
success: Boolean!
notificationId: ID!
readAt: DateTime
error: MutationError
}
type MutationError {
code: String! # BFF_STUDENT_* 前缀
message: String!
traceId: String
i18nKey: String
}
# ============================================================================
# Query 根类型
# ============================================================================
type Query {
"""当前学生信息 + 权限 + 视口 (聚合 iam.GetUserInfo + GetEffectivePermissions + GetViewports)"""
# @permission: AUTH_READ
# @dataScope: OWN
currentUser: CurrentUserPayload!
"""我的班级列表 (core-edu.ClassService.GetClass + ListStudentsByClass)"""
# @permission: CLASS_READ
# @dataScope: OWN
myClasses(
after: String
first: Int = 20
before: String
last: Int
): StudentClassConnection!
"""我的考试列表 (core-edu.ExamService.ListExamsByClass)"""
# @permission: EXAM_READ
# @dataScope: OWN
myExams(
after: String
first: Int = 20
before: String
last: Int
status: ExamStatus
): ExamConnection!
"""我的作业列表 (core-edu.HomeworkService.ListHomeworkByClass)"""
# @permission: HOMEWORK_READ
# @dataScope: OWN
myHomework(
after: String
first: Int = 20
before: String
last: Int
status: HomeworkStatus
): HomeworkConnection!
"""我的成绩列表 (core-edu.GradeService.ListGradesByStudent, B4 强制 userId 比对)"""
# @permission: GRADE_READ
# @dataScope: OWN
myGrades(
after: String
first: Int = 20
before: String
last: Int
subject: String
startDate: DateTime
endDate: DateTime
): GradeConnection!
"""我的考勤记录 (core-edu.AttendanceService.ListAttendanceByStudent)"""
# @permission: ATTENDANCE_READ
# @dataScope: OWN
myAttendance(
after: String
first: Int = 20
before: String
last: Int
startDate: DateTime
endDate: DateTime
): AttendanceConnection!
"""教材列表 (content.TextbookService.ListTextbooks)"""
# @permission: TEXTBOOK_READ
# @dataScope: OWN
textbooks(
after: String
first: Int = 20
before: String
last: Int
subjectId: ID
gradeId: ID
): TextbookConnection!
"""章节列表 (content.ChapterService.ListChapters)"""
# @permission: CHAPTER_READ
# @dataScope: OWN
chapters(
after: String
first: Int = 20
before: String
last: Int
textbookId: ID!
): ChapterListPayload!
"""学习路径推荐 (content.KnowledgeGraphService.GetLearningPath)"""
# @permission: LEARNING_PATH_READ
# @dataScope: OWN
learningPath(subjectId: ID!): LearningPathPayload!
"""学生仪表盘 (data-ana.AnalyticsService.GetStudentDashboard)"""
# @permission: DASHBOARD_VIEW
# @dataScope: OWN
studentDashboard: StudentDashboardPayload!
"""我的薄弱点 (data-ana.AnalyticsService.GetStudentWeakness)"""
# @permission: WEAKNESS_READ
# @dataScope: OWN
myWeakness(subjectId: ID): WeaknessPayload!
"""学习趋势 (data-ana.AnalyticsService.GetLearningTrend)"""
# @permission: TREND_READ
# @dataScope: OWN
myTrend(
subjectId: ID
startDate: DateTime
endDate: DateTime
): TrendPayload!
"""我的通知列表 (msg.NotificationService.ListNotifications)"""
# @permission: NOTIFICATION_READ
# @dataScope: OWN
myNotifications(
after: String
first: Int = 20
before: String
last: Int
onlyUnread: Boolean = false
): NotificationConnection!
"""通知未读数 (msg.NotificationService.GetUnreadCount)"""
# @permission: NOTIFICATION_READ
# @dataScope: OWN
myNotificationUnreadCount: UnreadCountPayload!
}
# ============================================================================
# Mutation 根类型
# ============================================================================
type Mutation {
"""提交作业 (core-edu.HomeworkService.SubmitHomework, B4 强制 userId 比对)"""
# @permission: HOMEWORK_SUBMIT
# @dataScope: OWN
submitHomework(
input: SubmitHomeworkInput!
): SubmitHomeworkResult!
"""标记通知已读 (msg.NotificationService.MarkAsRead)"""
# @permission: NOTIFICATION_UPDATE
# @dataScope: OWN
markNotificationAsRead(
input: MarkNotificationReadInput!
): MarkNotificationReadResult!
}
# ============================================================================
# Input 类型
# ============================================================================
input SubmitHomeworkInput {
homeworkId: ID!
studentId: ID! # 必须与 x-user-id 一致 (B4 越权防御)
answers: [HomeworkAnswerInput!]!
}
input HomeworkAnswerInput {
questionId: ID!
content: String! # 最大 5000 字符
attachments: [String!] # URL 列表, 最多 5 个
}
input MarkNotificationReadInput {
notificationId: ID!
userId: ID! # 必须与 x-user-id 一致 (B4 越权防御)
}
# ============================================================================
# AI 流式答疑 (SSE Subscription, P5)
# ============================================================================
type AIStreamChunk {
content: String! # 本次分块内容
done: Boolean! # 是否结束
model: String
usage: AIUsage # done=true 时填充
}
input AIStreamChatInput {
messages: [AIChatMessageInput!]!
model: String = "gpt-4o-mini"
context: AIChatContextInput
}
input AIChatMessageInput {
role: String! # user / assistant
content: String!
}
input AIChatContextInput {
subject: String
knowledgePointId: ID
}
# ============================================================================
# Subscription 根类型 (SSE 传输, GraphQL Yoga 原生支持)
# ============================================================================
type Subscription {
"""AI 答疑流式响应 (ai.StreamChat, SSE 传输)"""
# @permission: STUDENT_AI_CHAT
# @dataScope: OWN
aiStreamChat(input: AIStreamChatInput!): AIStreamChunk!
}

View File

@@ -7,6 +7,10 @@
"./outbox": {
"types": "./src/outbox/index.ts",
"default": "./src/outbox/index.ts"
},
"./bff": {
"types": "./src/bff/index.ts",
"default": "./src/bff/index.ts"
}
},
"scripts": {
@@ -14,6 +18,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@grpc/grpc-js": "^1.12.0",
"@grpc/proto-loader": "^0.7.13",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@paralleldrive/cuid2": "^2.2.2",
@@ -25,6 +31,7 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"pino-pretty": "^11.2.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,693 @@
/**
* DownstreamClient - BFF 模式 v2 标准下游调用抽象.
*
* 仲裁依据: coord-final-decisions §2 B8 (回写 teacher-bff, 3 个 BFF 统一使用)
* coord-final-decisions §2 B2 (首次实现即 gRPC, 禁止 HTTP fetch)
*
* 核心能力:
* - gRPC 调用封装 (@grpc/grpc-js + @grpc/proto-loader)
* - mock 模式 (env.MOCK_UPSTREAM=true 时返回固定数据, 上游就绪后移除)
* - 超时/重试/traceId 透传
* - 错误归一化 (下游 gRPC 错误 → BFF_STUDENT_BAD_GATEWAY)
* - 熔断器集成点 (P6 接入 opossum)
*
* 使用方式:
* const client = new DownstreamClient(env);
* const userInfo = await client.call('iam', 'GetUserInfo', { userId }, { metadata: { 'x-user-id': userId } });
*/
import { promises } from 'node:fs';
import path from 'node:path';
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import type { PackageDefinition, ServiceClientConstructor } from '@grpc/grpc-js';
import { logger } from './logger.js';
/**
* 下游调用配置.
*/
export interface CallOptions {
/** 超时时间(毫秒), 默认 5000ms */
timeoutMs?: number;
/** 重试次数, 默认 2 */
retryCount?: number;
/** 重试退避基数(毫秒), 默认 100ms, 指数退避 */
retryBackoffMs?: number;
/** 全链路追踪 ID (从 x-request-id header 获取) */
traceId?: string;
/** gRPC metadata (含 x-user-id / x-user-roles / x-dataScope) */
metadata?: Record<string, string>;
}
/**
* 下游调用结果(成功).
*/
export interface DownstreamResult<T> {
success: true;
data: T;
}
/**
* 下游调用结果(失败).
*/
export interface DownstreamFailure {
success: false;
error: {
code: string;
message: string;
service: string;
method: string;
status?: number;
traceId?: string;
};
}
export type DownstreamResponse<T> = DownstreamResult<T> | DownstreamFailure;
/**
* 下游服务配置.
*/
export interface DownstreamServiceConfig {
/** 服务名 (iam / core-edu / content / data-ana / msg / ai) */
name: string;
/** gRPC 端点 (如 'localhost:50052') */
grpcUrl: string;
/** proto 文件路径 (相对项目根) */
protoPath: string;
/** proto package 名 (如 'next_edu_cloud.iam.v1') */
packageName: string;
/** 是否启用该下游 (按阶段扩展, 见 president §2.4) */
enabled: boolean;
/** 是否为必需依赖 (失败返回 503, 否则软失败) */
required: boolean;
}
/**
* DownstreamClient 配置.
*/
export interface DownstreamClientConfig {
/** 是否启用 mock 模式 (上游未就绪时) */
mockUpstream: boolean;
/** 是否开发模式 (DEV_MODE 放行越权等) */
devMode: boolean;
/** 下游服务配置列表 */
services: DownstreamServiceConfig[];
/** 默认超时(ms) */
defaultTimeoutMs: number;
/** 默认重试次数 */
defaultRetryCount: number;
/** 默认重试退避基数(ms) */
defaultRetryBackoffMs: number;
}
/**
* Mock 数据提供器接口.
* 各 BFF 自行实现, 提供各 RPC 的 mock 数据.
*/
export type MockDataProvider = (
service: string,
method: string,
request: unknown,
) => unknown | undefined;
/**
* DownstreamClient - BFF 模式 v2 标准下游调用抽象.
*
* 3 个 BFF (teacher-bff / student-bff / parent-bff) 统一使用 (B8 裁决).
*/
export class DownstreamClient {
private readonly clients = new Map<string, grpc.Client>();
private readonly serviceDefs = new Map<string, DownstreamServiceConfig>();
private readonly packageDefs = new Map<string, PackageDefinition>();
private mockProvider: MockDataProvider | null = null;
constructor(private readonly config: DownstreamClientConfig) {
for (const svc of config.services) {
this.serviceDefs.set(svc.name, svc);
}
}
/**
* 设置 mock 数据提供器.
* 各 BFF 在初始化时注入自己的 mock 数据实现.
*/
setMockProvider(provider: MockDataProvider): void {
this.mockProvider = provider;
}
/**
* gRPC 调用封装.
*
* @param service 下游服务名 (如 'iam' / 'core-edu')
* @param method RPC 方法名 (如 'GetUserInfo')
* @param request 请求 message
* @param options 调用配置
* @returns 响应数据, 失败时抛出 DownstreamError
*/
async call<TRequest, TResponse>(
service: string,
method: string,
request: TRequest,
options?: CallOptions,
): Promise<TResponse> {
const svc = this.serviceDefs.get(service);
if (!svc) {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_UNKNOWN_SERVICE',
message: `Unknown downstream service: ${service}`,
service,
method,
});
}
if (!svc.enabled) {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_DISABLED',
message: `Downstream service ${service} is not enabled in current stage`,
service,
method,
});
}
// mock 模式: 上游未就绪时返回固定数据
if (this.config.mockUpstream && this.mockProvider) {
const mockData = this.mockProvider(service, method, request);
if (mockData !== undefined) {
logger.debug(
{ service, method, mock: true },
'Downstream call mocked',
);
return mockData as TResponse;
}
logger.warn(
{ service, method },
'No mock data provider for downstream call, falling through to gRPC',
);
}
const timeoutMs = options?.timeoutMs ?? this.config.defaultTimeoutMs;
const retryCount = options?.retryCount ?? this.config.defaultRetryCount;
const retryBackoffMs =
options?.retryBackoffMs ?? this.config.defaultRetryBackoffMs;
let lastError: unknown = null;
for (let attempt = 0; attempt <= retryCount; attempt++) {
try {
const data = await this.invokeGrpc<TRequest, TResponse>(
svc,
method,
request,
timeoutMs,
options?.metadata,
);
return data;
} catch (err) {
lastError = err;
if (attempt < retryCount) {
const backoff = retryBackoffMs * Math.pow(2, attempt);
logger.warn(
{ service, method, attempt: attempt + 1, retryCount, backoff, err },
'Downstream call failed, retrying',
);
await sleep(backoff);
}
}
}
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_BAD_GATEWAY',
message: `Downstream ${service}.${method} failed after ${retryCount + 1} attempts`,
service,
method,
traceId: options?.traceId,
cause: lastError,
});
}
/**
* gRPC server-streaming 调用封装.
*
* 用于流式 RPC (如 ai.StreamChat), 返回 AsyncIterable 逐块产出.
* mock 模式下产出单个 mock chunk 后结束.
*
* @param service 下游服务名
* @param method RPC 方法名 (必须是 server-streaming)
* @param request 请求 message
* @param options 调用配置 (timeoutMs / metadata / traceId)
* @returns AsyncIterable<TResponse>, 每个元素为一个流块
*/
async *callStream<TRequest, TResponse>(
service: string,
method: string,
request: TRequest,
options?: CallOptions,
): AsyncIterable<TResponse> {
const svc = this.serviceDefs.get(service);
if (!svc) {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_UNKNOWN_SERVICE',
message: `Unknown downstream service: ${service}`,
service,
method,
});
}
if (!svc.enabled) {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_DISABLED',
message: `Downstream service ${service} is not enabled in current stage`,
service,
method,
});
}
// mock 模式: 产出单个 mock chunk 后结束
if (this.config.mockUpstream && this.mockProvider) {
const mockData = this.mockProvider(service, method, request);
if (mockData !== undefined) {
logger.debug(
{ service, method, mock: true },
'Downstream stream call mocked',
);
yield mockData as TResponse;
return;
}
}
const timeoutMs = options?.timeoutMs ?? this.config.defaultTimeoutMs;
const client = await this.getOrCreateClient(svc);
const meta = new grpc.Metadata();
if (options?.metadata) {
for (const [key, value] of Object.entries(options.metadata)) {
meta.set(key, value);
}
}
const deadline = Date.now() + timeoutMs;
const callFn = (client as unknown as Record<string, Function>)[method];
if (typeof callFn !== 'function') {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_METHOD_NOT_FOUND',
message: `Method ${method} not found on service ${svc.name}`,
service: svc.name,
method,
});
}
// 发起 server-streaming 调用
const stream = callFn.call(client, request, meta, { deadline });
// 将 Node ReadableStream 转换为 AsyncIterable
try {
let streamDone = false;
let streamError: Error | null = null;
const chunkQueue: TResponse[] = [];
let resolveWait: ((v: { done: true } | { done: false; value: TResponse }) => void) | null = null;
stream.on('data', (chunk: TResponse) => {
if (resolveWait) {
const r = resolveWait;
resolveWait = null;
r({ done: false, value: chunk });
} else {
chunkQueue.push(chunk);
}
});
stream.on('end', () => {
streamDone = true;
if (resolveWait) {
const r = resolveWait;
resolveWait = null;
r({ done: true });
}
});
stream.on('error', (err: Error) => {
streamError = err;
streamDone = true;
if (resolveWait) {
const r = resolveWait;
resolveWait = null;
r({ done: true });
}
});
while (!streamDone || chunkQueue.length > 0) {
if (chunkQueue.length > 0) {
yield chunkQueue.shift()!;
continue;
}
if (streamDone) break;
const result = await new Promise<{ done: true } | { done: false; value: TResponse }>(
(resolve) => {
resolveWait = resolve;
},
);
if (result.done) break;
yield result.value;
}
if (streamError) {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_STREAM_ERROR',
message: streamError.message,
service: svc.name,
method,
traceId: options?.traceId,
cause: streamError,
});
}
} finally {
// 确保流被销毁
stream.destroy?.();
}
}
/**
* 并行调用多个下游服务, 部分失败容错 (Promise.allSettled).
* 用于 Dashboard 类聚合场景 (president §2.6 方案 B 降级).
*
* @returns 每个调用的结果 (success 或 failure), 不抛出异常
*/
async callAll<T extends readonly DownstreamCallSpec[]>(
calls: T,
): Promise<{ -readonly [K in keyof T]: DownstreamResponse<unknown> }> {
const results = await Promise.allSettled(
calls.map((c) =>
this.call(c.service, c.method, c.request, c.options).then(
(data): DownstreamResponse<unknown> => ({ success: true, data }),
),
),
);
return results.map((r, idx) => {
if (r.status === 'fulfilled') {
return r.value;
}
const spec = calls[idx];
const err =
r.reason instanceof DownstreamError
? r.reason
: new DownstreamError({
code: 'BFF_DOWNSTREAM_UNKNOWN_ERROR',
message: String(r.reason),
service: spec.service,
method: spec.method,
});
return {
success: false,
error: {
code: err.code,
message: err.message,
service: err.service,
method: err.method,
traceId: spec.options?.traceId,
},
};
}) as { -readonly [K in keyof T]: DownstreamResponse<unknown> };
}
/**
* 关闭所有 gRPC 连接 (优雅关闭时调用).
*/
async close(): Promise<void> {
for (const [name, client] of this.clients) {
client.close();
logger.debug({ service: name }, 'gRPC client closed');
}
this.clients.clear();
}
/**
* 检查下游服务可达性 (用于 /readyz 探针, §2.4).
*/
async checkHealth(service: string): Promise<boolean> {
const svc = this.serviceDefs.get(service);
if (!svc || !svc.enabled) {
return false;
}
if (this.config.mockUpstream) {
return true;
}
try {
const client = await this.getOrCreateClient(svc);
return new Promise<boolean>((resolve) => {
client.waitForReady(
Date.now() + 2000,
(err) => resolve(!err),
);
});
} catch {
return false;
}
}
/**
* 获取所有已启用的必需下游服务名 (用于 /readyz 探针).
*/
getRequiredServices(): string[] {
return Array.from(this.serviceDefs.values())
.filter((s) => s.enabled && s.required)
.map((s) => s.name);
}
/**
* 获取所有已启用的下游服务名.
*/
getEnabledServices(): string[] {
return Array.from(this.serviceDefs.values())
.filter((s) => s.enabled)
.map((s) => s.name);
}
/**
* 内部: 执行 gRPC 调用.
*/
private async invokeGrpc<TRequest, TResponse>(
svc: DownstreamServiceConfig,
method: string,
request: TRequest,
timeoutMs: number,
metadata?: Record<string, string>,
): Promise<TResponse> {
const client = await this.getOrCreateClient(svc);
const meta = new grpc.Metadata();
if (metadata) {
for (const [key, value] of Object.entries(metadata)) {
meta.set(key, value);
}
}
return new Promise<TResponse>((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
const call = (client as unknown as Record<string, Function>)[method];
if (typeof call !== 'function') {
reject(
new DownstreamError({
code: 'BFF_DOWNSTREAM_METHOD_NOT_FOUND',
message: `Method ${method} not found on service ${svc.name}`,
service: svc.name,
method,
}),
);
return;
}
call.call(
client,
request,
meta,
{ deadline },
(err: grpc.ServiceError | null, response: TResponse) => {
if (err) {
reject(
new DownstreamError({
code: mapGrpcErrorCode(err.code),
message: err.message,
service: svc.name,
method,
status: err.code,
}),
);
return;
}
resolve(response);
},
);
});
}
/**
* 内部: 获取或创建 gRPC client (channel 复用).
*/
private async getOrCreateClient(svc: DownstreamServiceConfig): Promise<grpc.Client> {
let client = this.clients.get(svc.name);
if (client) {
return client;
}
const packageDef = await this.loadPackageDefinition(svc);
const proto = grpc.loadPackageDefinition(packageDef) as unknown as Record<
string,
unknown
>;
const packageObj = this.getNestedPackage(proto, svc.packageName);
const ServiceCtor = this.findServiceCtor(packageObj, svc);
client = new ServiceCtor(
svc.grpcUrl,
grpc.credentials.createInsecure(),
);
this.clients.set(svc.name, client);
return client;
}
/**
* 内部: 加载 proto package definition (缓存).
*/
private async loadPackageDefinition(
svc: DownstreamServiceConfig,
): Promise<PackageDefinition> {
let pkgDef = this.packageDefs.get(svc.name);
if (pkgDef) {
return pkgDef;
}
const fullPath = path.resolve(process.cwd(), svc.protoPath);
try {
await promises.access(fullPath);
} catch {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_PROTO_NOT_FOUND',
message: `Proto file not found: ${fullPath}`,
service: svc.name,
method: '<init>',
});
}
pkgDef = protoLoader.loadSync(fullPath, {
keepCase: false,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
this.packageDefs.set(svc.name, pkgDef);
return pkgDef;
}
/**
* 内部: 按 packageName 点号路径获取嵌套 package 对象.
*/
private getNestedPackage(
root: Record<string, unknown>,
packageName: string,
): Record<string, unknown> {
const parts = packageName.split('.');
let current: Record<string, unknown> = root;
for (const part of parts) {
const next = current[part];
if (typeof next !== 'object' || next === null) {
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_PACKAGE_NOT_FOUND',
message: `Package ${packageName} not found in proto (missing part: ${part})`,
service: '',
method: '<init>',
});
}
current = next as Record<string, unknown>;
}
return current;
}
/**
* 内部: 在 package 对象中查找第一个 service 构造器.
* proto-loader 将 service 暴露为 ServiceClientConstructor.
*/
private findServiceCtor(
packageObj: Record<string, unknown>,
svc: DownstreamServiceConfig,
): ServiceClientConstructor {
for (const [key, value] of Object.entries(packageObj)) {
if (
typeof value === 'function' &&
'service' in (value as object)
) {
return value as ServiceClientConstructor;
}
}
throw new DownstreamError({
code: 'BFF_DOWNSTREAM_SERVICE_NOT_FOUND',
message: `No gRPC service found in package ${svc.packageName} for service ${svc.name}`,
service: svc.name,
method: '<init>',
});
}
}
/**
* 并行调用规范 (用于 callAll).
*/
export interface DownstreamCallSpec {
service: string;
method: string;
request: unknown;
options?: CallOptions;
}
/**
* 下游调用错误.
*/
export class DownstreamError extends Error {
readonly code: string;
readonly service: string;
readonly method: string;
readonly status?: number;
readonly traceId?: string;
readonly cause?: unknown;
constructor(params: {
code: string;
message: string;
service: string;
method: string;
status?: number;
traceId?: string;
cause?: unknown;
}) {
super(params.message);
this.name = 'DownstreamError';
this.code = params.code;
this.service = params.service;
this.method = params.method;
this.status = params.status;
this.traceId = params.traceId;
this.cause = params.cause;
}
}
/**
* 将 gRPC status code 映射为 BFF 错误码.
*/
function mapGrpcErrorCode(code: grpc.status | number): string {
switch (code) {
case grpc.status.UNAVAILABLE:
return 'BFF_DOWNSTREAM_UNAVAILABLE';
case grpc.status.DEADLINE_EXCEEDED:
return 'BFF_DOWNSTREAM_TIMEOUT';
case grpc.status.UNAUTHENTICATED:
return 'BFF_DOWNSTREAM_UNAUTHENTICATED';
case grpc.status.PERMISSION_DENIED:
return 'BFF_DOWNSTREAM_PERMISSION_DENIED';
case grpc.status.NOT_FOUND:
return 'BFF_DOWNSTREAM_NOT_FOUND';
case grpc.status.INVALID_ARGUMENT:
return 'BFF_DOWNSTREAM_INVALID_ARGUMENT';
case grpc.status.UNIMPLEMENTED:
return 'BFF_DOWNSTREAM_UNIMPLEMENTED';
case grpc.status.INTERNAL:
return 'BFF_DOWNSTREAM_INTERNAL';
default:
return 'BFF_DOWNSTREAM_BAD_GATEWAY';
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,29 @@
/**
* BFF 模式 v2 标准抽象 (B8 裁决).
*
* 3 个 BFF (teacher-bff / student-bff / parent-bff) 统一使用本模块:
* - DownstreamClient: gRPC 调用封装 + mock + 重试 + 错误归一化
* - createBffLogger: pino logger 工厂 (各 BFF 注入 service 名)
* - DownstreamError + DownstreamResponse: 错误与响应类型
*
* 仲裁依据:
* - coord-final-decisions §2 B1 (P2 起直接 GraphQL)
* - coord-final-decisions §2 B2 (首次实现即 gRPC, 禁止 HTTP fetch)
* - coord-final-decisions §2 B8 (回写 teacher-bff, 3 BFF 统一)
* - coord-final-decisions §1 G4 (pino 结构化日志)
* - coord-final-decisions §1 G12 (ESM .js 后缀 import)
* - coord-final-decisions §1 G13 (import type)
*/
export { DownstreamClient, DownstreamError } from "./downstream-client.js";
export type {
CallOptions,
DownstreamClientConfig,
DownstreamServiceConfig,
DownstreamResult,
DownstreamFailure,
DownstreamResponse,
DownstreamCallSpec,
MockDataProvider,
} from "./downstream-client.js";
export { createBffLogger, logger } from "./logger.js";
export type { Logger } from "./logger.js";

View File

@@ -0,0 +1,57 @@
/**
* BFF 共享 logger (pino 实例).
*
* 仲裁依据: coord-final-decisions §2 B8 (回写 teacher-bff, 3 个 BFF 统一使用)
* coord-final-decisions §1 G4 (首次实现即结构化日志, 禁止 console.log)
*
* 各 BFF 在自身 logger.ts 中调用 createBffLogger('student-bff' | 'teacher-bff' | 'parent-bff')
* 获取带 service 标签的 pino 实例.
*
* 该 logger 也作为 DownstreamClient 默认 logger, 避免循环依赖.
*/
import pino, { type Logger as PinoLogger, type LoggerOptions } from "pino";
/**
* 默认日志级别 (可通过环境变量 LOG_LEVEL 覆盖).
*/
const DEFAULT_LEVEL = process.env.LOG_LEVEL ?? "info";
/**
* 是否开发模式 (启用 pino-pretty 美化输出).
*/
const IS_DEV = process.env.NODE_ENV === "development";
/**
* 创建 BFF 共享 logger.
*
* @param serviceName 服务名 (student-bff / teacher-bff / parent-bff)
* @param options 额外 pino 配置 (可选)
*/
export function createBffLogger(
serviceName: string,
options?: LoggerOptions,
): PinoLogger {
const opts: LoggerOptions = {
level: DEFAULT_LEVEL,
base: {
service: serviceName,
version: "0.1.0",
},
transport: IS_DEV
? {
target: "pino-pretty",
options: { colorize: true },
}
: undefined,
...options,
};
return pino(opts);
}
/**
* DownstreamClient 默认 logger (无 service 标签, 由调用方覆盖).
* 各 BFF 不应直接使用此 logger, 应使用自身 createBffLogger() 产出实例.
*/
export const logger: PinoLogger = createBffLogger("bff-shared");
export type Logger = PinoLogger;

View File

@@ -0,0 +1,26 @@
# Build stage (coord-final-decisions G1: 多阶段构建首次实现即用)
FROM node:22-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
# 复制 workspace 配置以解析 @edu/shared-ts 依赖
COPY pnpm-workspace.yaml ./
COPY packages/shared-ts ./packages/shared-ts
COPY packages/shared-proto ./packages/shared-proto
RUN pnpm install --frozen-lockfile
COPY tsconfig.json nest-cli.json ./
COPY src ./src
RUN pnpm run build
# Runtime stage
FROM node:22-alpine
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
COPY pnpm-workspace.yaml ./
COPY packages/shared-ts ./packages/shared-ts
COPY packages/shared-proto ./packages/shared-proto
RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/dist ./dist
EXPOSE 3009
CMD ["node", "dist/main.js"]

View File

@@ -3,8 +3,15 @@
> AI 标识ai04
> 阶段:阶段 1全局理解
> 日期2026-07-09
> 状态:待 coord 审核
> 关联文档:[ai-allocation §6 模板](../../docs/architecture/ai-allocation.md)、[004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md)、[pending-features P3](../../docs/architecture/roadmap/pending-features.md)
> 状态:已对齐仲裁裁决coord-final-decisions §2 B1-B8 + president-final-rulings §2.2-2.9
> 关联文档:[ai-allocation §6 模板](../../docs/architecture/ai-allocation.md)、[004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md)、[pending-features P3](../../docs/architecture/roadmap/pending-features.md)、[coord-final-decisions](../../docs/architecture/coord-final-decisions.md)、[president-final-rulings](../../docs/architecture/president-final-rulings.md)
>
> **仲裁对齐说明ISSUE-STU-001 修复)**
> - B1: API 风格 = GraphQL Yoga + DataLoaderP2 起直接 GraphQL禁止 REST 渐进)
> - B2: 下游通信 = gRPC 首次实现即用(@grpc/grpc-js + @grpc/proto-loader禁止 HTTP fetch
> - B5: 错误码前缀 = `BFF_STUDENT_`BFF 在前,非 `STUDENT_BFF_`
> - B8: DownstreamClient 抽象复用 shared-ts回写 teacher-bff3 BFF 统一)
> - 本文档中早期将通信方式写为 HTTP REST/fetch、错误码前缀写为 `STUDENT_BFF_` 的部分已修正,以本对齐说明为准。
---
@@ -15,8 +22,8 @@
| 层级 | **L4 BFF 聚合层**004 §3.1 六层架构) |
| 上游调用方 | api-gatewayGo Gin反向代理 `/api/v1/student/*` → student-bff:3009 |
| 下游被调用方 | iam、core-edu、content、data-ana按 004 §4 服务依赖图) |
| 通信方式(入) | HTTP RESTapi-gateway → student-bff当前阶段设计意图为 gRPC004 §4.1 |
| 通信方式(出) | HTTP fetch当前阶段对齐 teacher-bff 模式);设计意图为 gRPC004 §4.1 |
| 通信方式(入) | **GraphQL Yoga over HTTP**api-gateway → student-bff:3009B1 裁决P2 起直接 GraphQL禁止 REST 渐进 |
| 通信方式(出) | **gRPC**@grpc/grpc-js + @grpc/proto-loaderB2 裁决:首次实现即用 gRPC禁止 HTTP fetch |
| 微前端对接 | student-portalai07 负责P3 阶段)通过 api-gateway 调用 student-bff |
| 推送通道 | push-gatewayP5 阶段WebSocket/SSE 推送考试通知、成绩发布等) |
@@ -73,70 +80,107 @@ student-bff 是**纯聚合层**,不持有业务状态、不直接访问 DB
### 3.1 我消费的 proto message / 下游接口
> ⚠️ **重要差距**:当前阶段 BFF→Service 走 HTTP fetch对齐 teacher-bff 现状proto 仅作"契约文档"。gRPC 落地需 coord 在 buf.gen.yaml 补 gRPC 插件
> **B2 裁决落地**BFF→Service 首次实现即用 gRPC@grpc/grpc-js + @grpc/proto-loader通过 `DownstreamClient` 抽象B8 裁决,复用 shared-ts3 个 BFF 统一。proto 即契约,不再是"文档"
| 下游服务 | proto service(设计意图) | 当前 REST 端点(实际可用) | 用途 |
| ------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------ |
| iam | `IamService.GetUserInfo` | `GET /iam/me` | 获取学生个人信息 + roles + dataScope |
| iam | `IamService.GetViewports`proto 缺失) | `GET /iam/viewports` | 获取学生端导航视口 |
| iam | `IamService.GetEffectivePermissions`proto 缺失) | `GET /iam/permissions/effective` | 获取有效权限列表 |
| classescore-edu | `ClassService.GetClass` / `ListClasses` | `GET /classes` / `GET /classes/:id` | 查自己所在班级 |
| core-edu | `ExamService.GetExam` / `ListExamsByClass` | `GET /exams/class/:classId` | 查班级考试 |
| core-edu | `HomeworkService.GetHomework` / `ListHomeworkByClass` / `SubmitHomework` | `GET /homework/class/:classId` / `POST /homework/:id/submit` | 查作业 + 提交 |
| core-edu | `GradeService.GetGrade` / `ListGradesByStudent` | `GET /grades/student/:studentId` | 查自己成绩 |
| content | `TextbookService.GetTextbook` / `ListTextbooks` | `GET /textbooks` | 查教材 |
| content | `ChapterService`proto 缺失) | `GET /chapters` / `GET /chapters/:id` | 查章节 |
| content | `QuestionService`proto 缺失) | `GET /questions` | 查题库 |
| content | `KnowledgeGraphService.GetLearningPath` | `GET /knowledge-points/:id/learning-path` | 学习路径 |
| msg | `NotificationService.ListNotifications` / `MarkAsRead` / `SearchNotifications` | `GET /notifications` / `POST /notifications/:id/read` | 消息中心 |
| data-ana | `AnalyticsService.GetStudentWeakness` / `GetLearningTrend` | **REST 未实现** | 学情分析 |
| ai | `AiService.Chat` / `StreamChat` / `GenerateQuestion` | **REST 未实现** | AI 答疑 |
| 下游服务 | proto service | gRPC methodB2 裁决) | 用途 |
| ------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------ |
| iam | `IamService.GetUserInfo` | `iam.GetUserInfo` | 获取学生个人信息 + roles + dataScope |
| iam | `IamService.GetViewports`proto 缺失) | `iam.GetViewports` | 获取学生端导航视口 |
| iam | `IamService.GetEffectivePermissions`proto 缺失) | `iam.GetEffectivePermissions` | 获取有效权限列表 |
| classescore-edu | `ClassService.GetClass` / `ListClasses` | `classes.GetClass` / `classes.ListClasses` | 查自己所在班级 |
| core-edu | `ExamService.GetExam` / `ListExamsByClass` | `core-edu.GetExam` / `core-edu.ListExamsByClass` | 查班级考试 |
| core-edu | `HomeworkService.GetHomework` / `ListHomeworkByClass` / `SubmitHomework` | `core-edu.ListHomeworkByStudent` / `core-edu.SubmitHomework` | 查作业 + 提交 |
| core-edu | `GradeService.GetGrade` / `ListGradesByStudent` | `core-edu.ListGradesByStudent` | 查自己成绩 |
| content | `TextbookService.GetTextbook` / `ListTextbooks` | `content.ListTextbooks` | 查教材 |
| content | `ChapterService`proto 缺失) | `content.ListChapters` | 查章节 |
| content | `QuestionService`proto 缺失) | `content.ListQuestions` | 查题库 |
| content | `KnowledgeGraphService.GetLearningPath` | `content.GetLearningPath` | 学习路径 |
| msg | `NotificationService.ListNotifications` / `MarkAsRead` / `SearchNotifications` | `msg.ListNotifications` / `msg.MarkAsRead` | 消息中心 |
| data-ana | `AnalyticsService.GetStudentWeakness` / `GetLearningTrend` | `data-ana.GetStudentWeakness` / `data-ana.GetLearningTrend` | 学情分析 |
| ai | `AiService.Chat` / `StreamChat` / `GenerateQuestion` | `ai.Chat`(同步) / `ai.StreamChat`server-streamingB2 裁决) | AI 答疑 |
### 3.2 我暴露的 API 端点student-bff 对外)
### 3.2 我暴露的 GraphQL APIstudent-bff 对外)
> 路由前缀:`/student`(对齐 teacher-bff 用 `/teacher` 的命名规律BFF 用角色单数无 `-bff` 后缀)
> 网关路径:`/api/v1/student/*` → api-gateway 剥离 `/api/v1` 后代理到 student-bff:3009
> ✅ **B1 裁决落地**P2 起直接 GraphQL Yoga禁止 REST 渐进schema 存放于 `packages/shared-ts/contracts/graphql/student-bff.schema.graphql`president §2.2.1)。
> 网关路径:`/api/v1/student/*` → api-gateway 剥离 `/api/v1` 后代理到 student-bff:3009 GraphQL endpoint。
> 分页采用 Relay Cursor Connections 规范(`{ edges, pageInfo, totalCount }`)。
> 权限点标注于 schema 注释 `# @permission:`DataScope 固定 `OWN`(学生数据隔离 SELF
| method | path | 聚合下游 | 权限(透传给下游校验) | 说明 |
| ------ | --------------------------------- | -------------------- | ------------------------- | -------------------- |
| GET | `/student/dashboard` | iam + core-edu + msg | STUDENT_DASHBOARD_READ | 学生首页聚合 |
| GET | `/student/exams` | core-edu | STUDENT_EXAM_READ | 即将到来的考试 |
| GET | `/student/homework` | core-edu | STUDENT_HOMEWORK_READ | 我的作业列表 |
| POST | `/student/homework/:id/submit` | core-edu | STUDENT_HOMEWORK_SUBMIT | 提交作业 |
| GET | `/student/grades` | core-edu | STUDENT_GRADE_READ | 我的成绩 |
| GET | `/student/notifications` | msg | STUDENT_NOTIFICATION_READ | 消息列表 |
| POST | `/student/notifications/:id/read` | msg | STUDENT_NOTIFICATION_READ | 标记已读 |
| GET | `/student/textbooks` | content | STUDENT_CONTENT_READ | 教材列表 |
| GET | `/student/chapters/:textbookId` | content | STUDENT_CONTENT_READ | 章节树 |
| GET | `/student/questions` | content | STUDENT_CONTENT_READ | 题库(按知识点过滤) |
| GET | `/student/analytics/weakness` | data-ana | STUDENT_ANALYTICS_READ | 学情诊断 |
| GET | `/student/analytics/trend` | data-ana | STUDENT_ANALYTICS_READ | 学习趋势 |
| POST | `/student/ai/chat` | ai | STUDENT_AI_CHAT | AI 答疑(同步) |
| POST | `/student/ai/stream-chat` | ai | STUDENT_AI_CHAT | AI 答疑SSE 流式) |
#### Query14 个字段)
| Query 字段 | 聚合下游 | 权限点(注释标注) | 说明 |
| ------------------------- | -------------------- | ----------------------- | -------------------- |
| `currentUser` | iam | AUTH_READ | 学生信息 + 权限 + 视口 |
| `myClasses` | core-edu | CLASS_READ | 我的班级列表 |
| `myExams` | core-edu | EXAM_READ | 即将到来的考试 |
| `myHomework` | core-edu | HOMEWORK_READ | 我的作业列表 |
| `myGrades` | core-edu | GRADE_READ | 我的成绩B4 比对) |
| `myAttendance` | core-edu | ATTENDANCE_READ | 我的考勤记录 |
| `textbooks` | content | TEXTBOOK_READ | 教材列表 |
| `chapters` | content | CHAPTER_READ | 章节树 |
| `learningPath` | content | LEARNING_PATH_READ | 学习路径推荐 |
| `studentDashboard` | data-ana | DASHBOARD_VIEW | 学生仪表盘聚合 |
| `myWeakness` | data-ana | WEAKNESS_READ | 学情诊断(薄弱点) |
| `myTrend` | data-ana | TREND_READ | 学习趋势 |
| `myNotifications` | msg | NOTIFICATION_READ | 通知列表 |
| `myNotificationUnreadCount` | msg | NOTIFICATION_READ | 通知未读数 |
#### Mutation2 个字段)
| Mutation 字段 | 下游 | 权限点 | 说明 |
| ---------------------------- | --------------------- | ------------------- | ---------------------------- |
| `submitHomework` | core-edu.SubmitHomework | HOMEWORK_SUBMIT | 提交作业B4 强制 userId |
| `markNotificationAsRead` | msg.MarkAsRead | NOTIFICATION_UPDATE | 标记通知已读B4 强制 userId|
#### Subscription1 个字段SSE 传输)
| Subscription 字段 | 下游 | 权限点 | 说明 |
| ----------------- | ------------------- | --------------- | ----------------------------- |
| `aiStreamChat` | ai.StreamChat | STUDENT_AI_CHAT | AI 答疑流式响应gRPC server-streaming 透传) |
### 3.3 错误码前缀
| 前缀 | 用途 | 示例 |
| -------------- | ---------------------- | ----------------------------------------------------- |
| `STUDENT_BFF_` | student-bff 自身错误 | `STUDENT_BFF_UNAUTHORIZED``STUDENT_BFF_BAD_GATEWAY` |
| 下游错误透传 | 下游服务错误码原样返回 | `CLASSES_NOT_FOUND``IAM_USER_NOT_FOUND` |
> ✅ **B5 裁决**BFF 在前,统一 `BFF_STUDENT_` 前缀(非 `STUDENT_BFF_`)。
> **G14 裁决**:服务名大写前缀。**F4 裁决**i18n key 格式 `error.bffStudent.<code_snake>`。
错误类清单(对齐 teacher-bff application-error.ts
| 前缀 | 用途 | 示例 |
| -------------- | ---------------------- | ------------------------------------------------------------- |
| `BFF_STUDENT_` | student-bff 自身错误 | `BFF_STUDENT_UNAUTHORIZED``BFF_STUDENT_BAD_GATEWAY` |
| 下游错误透传 | 下游服务错误码原样返回 | `CLASSES_NOT_FOUND``IAM_USER_NOT_FOUND` |
- `UnauthorizedError(401)` — 缺失 `x-user-id`
- `BadGatewayError(502)` — 下游服务返回非 ok 或 fetch rejected
- `ValidationError(400)` — 入参校验失败BFF 层 Zod 校验)
- `InternalError(500)` — 未捕获异常
错误类清单(`shared/errors/application-error.ts`11 个类G8 ActionState 信封):
### 3.4 我订阅的 Kafka 事件(可选,用于实时推送)
| 错误类 | statusCode | code | 说明 |
| ------------------------------ | ---------- | --------------------------------- | ----------------------------- |
| `ValidationError` | 400 | `BFF_STUDENT_VALIDATION_ERROR` | Zod 校验失败 |
| `UnauthorizedError` | 401 | `BFF_STUDENT_UNAUTHORIZED` | 缺失 `x-user-id` 头 |
| `ForbiddenResourceError` | 403 | `BFF_STUDENT_FORBIDDEN_RESOURCE` | 场景 A资源无归属president §2.7 |
| `IdentityMismatchError` | 403 | `BFF_STUDENT_IDENTITY_MISMATCH` | 场景 BJWT/body userId 不一致president §2.7 |
| `NotFoundError` | 404 | `BFF_STUDENT_NOT_FOUND` | 资源不存在 |
| `ConflictError` | 409 | `BFF_STUDENT_CONFLICT` | 重复提交 / 状态冲突 |
| `BusinessError` | 422 | `BFF_STUDENT_BUSINESS_ERROR` | 业务规则违反 |
| `BadGatewayError` | 502 | `BFF_STUDENT_BAD_GATEWAY` | 下游 gRPC 失败 |
| `ServiceUnavailableError` | 503 | `BFF_STUDENT_SERVICE_UNAVAILABLE` | 熔断器开启P6 |
| `GatewayTimeoutError` | 504 | `BFF_STUDENT_GATEWAY_TIMEOUT` | 下游超时 |
| `InternalError` | 500 | `BFF_STUDENT_INTERNAL_ERROR` | 未捕获异常 |
| Topic | 事件 | 消费动作 |
| --------------------- | --------------------------------------- | -------------------------- |
| `edu.homework.events` | `homework.assigned` / `homework.graded` | 推送给学生push-gateway |
| `edu.exam.events` | `exam.published` / `exam.updated` | 考试提醒推送 |
| `edu.grade.events` | `grade.recorded` | 成绩发布推送 |
### 3.4 我订阅的 Kafka 事件P5 起订阅B7 裁决)
> ⚠️ Kafka 订阅在 P5 阶段 push-gateway 落地后才有意义P3 阶段 student-bff 可不消费事件,仅做同步聚合
> ✅ **B7 裁决**P2-P4 阶段不订阅 KafkaP5 起 student-bff 订阅事件用于实时推送
> 消费者组:`student-bff-event-subscriber`。幂等去重Redis SETNX `event_id`。
> 推送通道push-gateway HTTP POST `/push/user/:userId`(失败软处理,仅 warn 日志)。
| Topic | 事件 | 消费动作 |
| -------------------------- | --------------------------------------- | ------------------------------ |
| `edu.homework.events` | `homework.assigned` / `homework.graded` | 推送给学生push-gateway |
| `edu.exam.events` | `exam.published` / `exam.updated` | 考试提醒推送 |
| `edu.grade.events` | `grade.recorded` | 成绩发布推送 |
| `edu.notification.events` | `notification.created` | 通知推送 |
| `edu.attendance.events` | `attendance.recorded` | 考勤提醒推送 |
| `edu.class.events` | `class.updated` | 班级信息变更推送 |
| `edu.content.events` | `content.published` | 教材/章节更新推送 |
> ⚠️ P3-P4 阶段 student-bff 不消费事件,仅做同步聚合。事件订阅逻辑在 `src/student/events/event-subscriber.ts`。
---
@@ -151,22 +195,35 @@ student-bff 是**纯聚合层**,不持有业务状态、不直接访问 DB
| 可观测性日志 | pino | 对齐 classes/teacher-bff |
| 可观测性指标 | prom-client`/metrics` 端点) | 对齐 teacher-bff main.ts |
| 可观测性链路 | OpenTelemetry SDK + OTLP exporter | 对齐 teacher-bff tracer.ts |
| API 风格 | **HTTP REST**(当前阶段) | 对齐 teacher-bff 现状pending-features P2 设计意图为 GraphQL但 teacher-bff 实际未落地 GraphQL需 coord 仲裁是否在 student-bff 引入 |
| 输入校验 | Zod | 对齐 classes/teacher-bff |
| 错误处理 | GlobalErrorFilter + ApplicationError | 对齐 classes/teacher-bff |
| ESM 模式 | NodeNext + `.js` 后缀 import | 对齐 teacher-bff tsconfig |
| 测试框架 | Jest待定对齐 classes | 黄金模板要求测试覆盖率 ≥ 80% |
| API 风格 | **GraphQL Yoga**B1 裁决) | P2 起直接 GraphQL + DataLoader禁止 REST 渐进schema 存放 `packages/shared-ts/contracts/graphql/student-bff.schema.graphql` |
| 下游通信 | **gRPC**B2 裁决) | @grpc/grpc-js + @grpc/proto-loaderDownstreamClient 抽象B8 复用 shared-ts |
| 输入校验 | Zod | Resolver 层 `schema.safeParse(args.input)`G7 裁决) |
| 错误处理 | GlobalErrorFilter + ApplicationError | 11 个错误类ActionState 信封G8 裁决) |
| ESM 模式 | NodeNext + `.js` 后缀 import | 对齐 teacher-bff tsconfig |
| 测试框架 | **Vitest** | 覆盖率 ≥ 80%lines/functionsbranches ≥ 70% |
### 4.1 关于 GraphQL 设计决策(待 coord 仲裁
### 4.1 GraphQL 设计决策(已裁决 B1
**现状矛盾**
> ✅ **B1 裁决**student-bff 从 P2 起直接采用 GraphQL Yoga + DataLoader禁止 REST 渐进。
- 004 §11.3 BFF 聚合模式图示为 GraphQL Resolver + DataLoader + Redis 缓存
- pending-features P2 明确"Teacher BFFTS/GraphQL"用 GraphQL Yoga + DataLoader
- **实际**teacher-bff 当前是纯 REST + fetch无 GraphQL、无 DataLoader
- ai-allocation.md §5 ai04 设计重点提到"DataLoader 复用 teacher-bff 模式"
**裁决结论**
**ai04 倾向方案**P3 阶段 student-bff **先对齐 teacher-bff 现状REST + fetch + Promise.allSettled**,避免技术栈分裂;若 coord 决策统一升级到 GraphQL则在 P3 后期或 P4 阶段同步升级 teacher-bff + student-bff + parent-bff 三端。此决策需 coord 仲裁。
- **API 风格**GraphQL Yoga over HTTPSSE 传输 Subscription
- **DataLoader**:解决 N+1 查询问题,按下游服务分批聚合
- **schema 存放**`packages/shared-ts/contracts/graphql/student-bff.schema.graphql`president §2.2.1
- **分页规范**Relay Cursor Connections`{ edges, pageInfo, totalCount }`
- **降级模式**:方案 Bpresident §2.6`success=true + data 内 degraded=true + degradedFields`
- **越权防御**B4 强制自我越权防御AuthorizationGuard 拦截president §2.9 方案 DDEV_MODE 放行)
**已落地的 GraphQL 核心文件**
| 文件 | 职责 |
| --------------------------------------------- | ------------------------------------------------- |
| `src/shared/graphql/yoga.ts` | GraphQL Yoga 实例 + context 构建 |
| `src/shared/graphql/dataloader.ts` | DataLoader 工厂(按下游服务分批) |
| `src/student/resolvers/*.resolver.ts` | Query/Mutation/Subscription Resolver6 个文件) |
| `src/student/guards/authorization.guard.ts` | B4 越权防御assertOwnData + assertIdentityMatch|
| `packages/shared-ts/contracts/graphql/student-bff.schema.graphql` | GraphQL schema 定义 |
---
@@ -184,19 +241,19 @@ student-bff 是**纯聚合层**,不持有业务状态、不直接访问 DB
### 5.1 P3 阶段最小可行集合MVP
student-bff 在 P3 阶段不一定要实现全部 14端点,优先级:
student-bff 在 P3 阶段不一定要实现全部 17 GraphQL 字段,优先级:
| 优先级 | 端点 | P3 必需 | 说明 |
| ------ | ----------------------------------------------------------------- | ------- | ------------------------- |
| P0 | `/student/homework` GET | ✅ | 学生作答作业页面核心 |
| P0 | `/student/homework/:id/submit` POST | ✅ | 学生作答提交 |
| P0 | `/student/grades` GET | ✅ | 成绩查看 |
| P0 | `/student/dashboard` GET | ✅ | 学生首页 |
| P1 | `/student/exams` GET | ✅ | 考试日程 |
| P1 | `/student/notifications` GET | ⚠️ 可选 | P5 msg 服务落地后才有意义 |
| P2 | `/student/textbooks` / `/student/chapters` / `/student/questions` | ❌ P4 | content 服务 P4 才落地 |
| P2 | `/student/analytics/*` | ❌ P4 | data-ana 学情诊断 P4 |
| P2 | `/student/ai/*` | ❌ P5 | ai 服务 P5 |
| 优先级 | GraphQL 字段 | P3 必需 | 说明 |
| ------ | ------------------------------------------------- | ------- | ------------------------- |
| P0 | `Query.myHomework` + `Mutation.submitHomework` | ✅ | 学生作答作业页面核心 |
| P0 | `Query.myGrades` | ✅ | 成绩查看 |
| P0 | `Query.currentUser` | ✅ | 学生信息 + 权限 + 视口 |
| P0 | `Query.studentDashboard` | ✅ | 学生首页聚合 |
| P1 | `Query.myExams` | ✅ | 考试日程 |
| P1 | `Query.myNotifications` | ⚠️ 可选 | P5 msg 服务落地后才有意义 |
| P2 | `Query.textbooks` / `chapters` / `learningPath` | ❌ P4 | content 服务 P4 才落地 |
| P2 | `Query.myWeakness` / `myTrend` | ❌ P4 | data-ana 学情诊断 P4 |
| P2 | `Subscription.aiStreamChat` | ❌ P5 | ai 服务 P5 |
---
@@ -206,15 +263,15 @@ student-bff 在 P3 阶段不一定要实现全部 14 个端点,优先级:
| 对齐项 | classes 黄金模板 | student-bff 计划 | 备注 |
| ------------------------------- | ---------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- |
| 权限装饰器 `@RequirePermission` | ✅ 全部 Controller 方法 | ⚠️ **不对齐** | BFF 不做权限校验(对齐 teacher-bff透传 `x-user-id` 给下游校验 |
| 错误码前缀统一 | ✅ `CLASSES_` | ✅ `STUDENT_BFF_` | 对齐 teacher-bff 的 `TEACHER_BFF_` 模式 |
| 权限装饰器 `@RequirePermission` | ✅ 全部 Controller 方法 | ⚠️ **B3 豁免** | BFF 豁免 `@RequirePermission`B3 裁决但强制自我越权防御B4AuthorizationGuard |
| 错误码前缀统一 | ✅ `CLASSES_` | ✅ `BFF_STUDENT_` | B5 裁决BFF 在前,非 `STUDENT_BFF_` |
| loggerpino | ✅ `shared/observability/logger.ts` | ✅ 复制 teacher-bff 实现 | service 名改 `student-bff` |
| metricsprom-client | ✅ `/metrics` 端点 | ✅ 复制 teacher-bff main.ts 注册方式 | 指标名前缀 `student_bff_` |
| tracerOpenTelemetry | ✅ OTLP exporter + auto-instrumentations | ✅ 复制 teacher-bff tracer.ts | serviceName 改 `student-bff` |
| `/healthz` 健康检查 | ✅ liveness | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok |
| `/readyz` 健康检查 | ✅ Drizzle `SELECT 1` | ✅ 复制 teacher-bff | BFF 不查 DB直接返回 ok可选检查下游服务可达性 |
| 优雅关闭SIGTERM | ✅ LifecycleService 关闭 DB 连接池 | ✅ main.ts 注册 SIGTERM → `app.close()` + `shutdownTracer()` | BFF 无 DB 连接,仅需关闭 HTTP server + tracer |
| 测试覆盖率 ≥ 80% | ✅ Jest | ⚠️ **待补** | BFF 测试重点是 Service 层聚合逻辑 mock 下游 fetch |
| `/readyz` 健康检查 | ✅ Drizzle `SELECT 1` | ✅ 检查下游 6 个服务可达性 | 必需失败返回 503可选软失败返回 200 + degraded=true |
| 优雅关闭SIGTERM | ✅ LifecycleService 关闭 DB 连接池 | ✅ main.ts 注册 SIGTERM → `app.close()` + `shutdownTracer()` + `circuitBreaker.shutdown()` | BFF 无 DB 连接,关闭 HTTP server + tracer + 熔断器 |
| 测试覆盖率 ≥ 80% | ✅ Jest | **Vitest**(已落地) | 5 个测试文件action-state / application-error / authorization.guard / homework.resolver / push-gateway.service |
| Dockerfile 多阶段构建 | ✅ builder + runtime | ✅ 复制 teacher-bff Dockerfile | EXPOSE 改 3009 |
| Zod 输入验证 | ✅ Controller 层 `schema.parse(body)` | ✅ Controller 层校验 | 提交作业 body 需 Zod 校验 |
| GlobalErrorFilter | ✅ `@Catch()` 全局过滤器 | ✅ 复制 teacher-bff | 注册到 main.ts |
@@ -232,7 +289,7 @@ student-bff 在 P3 阶段不一定要实现全部 14 个端点,优先级:
| `src/teacher/` 目录名 | `teacher/` | `student/` |
| `@Controller("teacher")` | `"teacher"` | `"student"` |
| `health.controller.ts` `SERVICE_NAME` | `"teacher-bff"` | `"student-bff"` |
| `application-error.ts` 错误码前缀 | `TEACHER_BFF_` | `STUDENT_BFF_` |
| `application-error.ts` 错误码前缀 | `TEACHER_BFF_` | `BFF_STUDENT_`B5 裁决BFF 在前) |
| `metrics.ts` 指标名前缀 | `teacher_bff_` | `student_bff_` |
| `tracer.ts` serviceName | `"teacher-bff"` | `"student-bff"` |
| `logger.ts` service | `"teacher-bff"` | `"student-bff"` |
@@ -254,15 +311,24 @@ student-bff 在 P3 阶段不一定要实现全部 14 个端点,优先级:
| 出勤attendance全局缺失 | 学生端无法查出勤 | 推动 coord 在 core_edu.proto 补 Attendance 域P3 后期或 P4 |
| 学生-家长关联表缺失pending-features P2 提到 `parent_student_relations` | 影响 parent-bff不影响 student-bff | 报告给 coord由 ai02 在 iam 或 ai03 在 core-edu 补表 |
### 7.2 设计决策待仲裁
### 7.2 设计决策(已裁决 B1-B8
| 决策点 | 选项 | ai04 建议 |
| ------------------ | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| BFF API 风格 | A. REST对齐 teacher-bff 现状)<br/>B. GraphQL对齐 004 §11.3 设计意图 + pending-features P2 | **A**P3 阶段先 REST避免技术栈分裂后续统一升级 |
| BFF 是否做权限校验 | A. 不校验(对齐 teacher-bff透传 x-user-id<br/>B. 加 `@RequirePermission` 装饰器 | **A**BFF 是聚合层,权限由下游服务校验) |
| `/readyz` 检查逻辑 | A. 直接返回 ok对齐 teacher-bff<br/>B. 检查下游服务可达性 | **A**P3 阶段,下游可达性由 Prometheus 监控) |
| Kafka 事件订阅 | A. P3 不订阅(仅同步聚合)<br/>B. P3 订阅事件推送 | **A**push-gateway P5 才落地P3 无推送通道) |
| 端口分配 | 3009 | 对齐 full-stack-runbook 端口矩阵3001-3008 已用) |
> ✅ 全部 8 项决策已由 coord-final-decisions §2 B1-B8 + president-final-rulings §2.2-2.9 裁决。
| 决策点 | 裁决编号 | 裁决结论 |
| ------------------ | -------- | ---------------------------------------------------------------------------------------------- |
| BFF API 风格 | **B1** | GraphQL Yoga + DataLoaderP2 起直接 GraphQL禁止 REST 渐进) |
| 下游通信 | **B2** | gRPC 首次实现即用(@grpc/grpc-js + @grpc/proto-loader禁止 HTTP fetch |
| BFF 权限校验 | **B3** | BFF 豁免 `@RequirePermission`,但透传 `x-user-id` 给下游校验 |
| 自我越权防御 | **B4** | 强制 B4 越权防御AuthorizationGuard场景 A + 场景 Bpresident §2.9 方案 D DEV_MODE 放行) |
| 错误码前缀 | **B5** | `BFF_STUDENT_`BFF 在前,非 `STUDENT_BFF_` |
| Redis 缓存 | **B6** | 5-30s 短缓存TTL ±20% 随机抖动防雪崩 |
| Kafka 订阅 | **B7** | P2-P4 不订阅P5 起订阅 7 个 topicRedis SETNX `event_id` 幂等去重 |
| DownstreamClient | **B8** | 抽象复用 shared-ts回写 teacher-bff3 个 BFF 统一使用 |
| 降级模式 | president §2.6 | 方案 B`success=true + data 内 degraded=true + degradedFields` |
| 越权防御错误码 | president §2.7 | 3 类ForbiddenResourceError / IdentityMismatchError / DEV_MODE 放行 |
| GraphQL schema 存放 | president §2.2.1 | `packages/shared-ts/contracts/graphql/student-bff.schema.graphql` |
| 端口分配 | - | 3009HTTP无 gRPC 端口对外 |
### 7.3 跨模块协作需求(需提交 coord 协调)
@@ -274,7 +340,7 @@ student-bff 在 P3 阶段不一定要实现全部 14 个端点,优先级:
| 004 架构图状态更新 | coorddocs | student-bff 状态从"📐 需设计"改为"✅ 已实现" |
| shared-proto 补全 content.protoChapter/Question | coord | P4 阶段 content 服务落地前补全 |
| shared-proto 补全 iam.protoViewport/EffectivePermissions | coord | 推动 ai02 补 proto |
| buf.gen.yaml 补 gRPC 插件 | coord | 决定是否在 P3 升级到 gRPC 通信 |
| buf.gen.yaml 补 gRPC 插件 | coord | ✅ B2 裁决已落地TS 走 @grpc/proto-loader 动态加载,无需 buf generate gRPC 插件 |
---
@@ -291,7 +357,7 @@ student-bff 在 P3 阶段不一定要实现全部 14 个端点,优先级:
| 已读 shared-proto 全部 .proto | ✅ |
| 已识别 proto 契约缺口 | ✅(见 §7.1 |
| 已识别端口/路由预留情况 | ✅3009 可用,路由未预留) |
| 已识别设计决策待仲裁项 | ✅(见 §7.2 |
| 已识别设计决策待仲裁项 | ✅**已裁决**B1-B8 + president §2.2-2.9,见 §7.2 |
| 已识别跨模块协作需求 | ✅(见 §7.3 |
**ai04 阶段 1 交付完成,请 coord 审核。审核通过后进入阶段 2模块架构设计文档**
**ai04 阶段 1 交付完成,已对齐仲裁裁决coord-final-decisions B1-B8 + president-final-rulings §2.2-2.9。P3-P6 全部代码已实现**

View File

@@ -231,7 +231,7 @@ sequenceDiagram
| SSE Streamer | ❌ 无 | ✅ P5 引入(封装 ai 服务的 stream-chat | AI 答疑流式响应 |
| EventSubscriber | ❌ 无 | ✅ P5 引入(订阅 Kafka → push-gateway | 实时推送 |
> **改进点是否回写 teacher-bff 由 coord 仲裁**(避免技术栈分裂)。本文档定义的 DownstreamClient/Aggregator/Transformer 三层抽象可作为 BFF 模式 v2 的参考实现,待 coord 决策是否回写
> ✅ **B8 裁决**DownstreamClient 抽象回写 shared-ts`packages/shared-ts/src/bff/downstream-client.ts`3 个 BFFstudent-bff / teacher-bff / parent-bff统一使用避免技术栈分裂
---
@@ -482,35 +482,46 @@ export function filterByViewport<T>(
## 4. API 设计
### 4.1 端点全清单
### 4.1 GraphQL Schema 全清单B1 裁决)
> 路由前缀:`/student`(对齐 teacher-bff 用 `/teacher` 命名规律BFF 用角色单数无 `-bff` 后缀)
> 网关路径:`/api/v1/student/*` → api-gateway 剥离 `/api/v1` 后代理到 student-bff:3009
> 响应信封:统一 ActionState[004 §11.5](../../../docs/architecture/004_architecture_impact_map.md#115-统一响应信封actionstate)),成功 `{success: true, data, meta?}`,失败 `{success: false, error: {code, message, details?, traceId?}}`
> ✅ **B1 裁决**P2 起直接 GraphQL Yoga + DataLoader禁止 REST 渐进。
> Schema 存放:[`packages/shared-ts/contracts/graphql/student-bff.schema.graphql`](../../../packages/shared-ts/contracts/graphql/student-bff.schema.graphql)president §2.2.1
> 网关路径:`/api/v1/student/*` → api-gateway 剥离 `/api/v1` 后代理到 student-bff:3009 GraphQL endpoint
> 响应信封ActionStateG8 裁决),成功 `{success: true, data, meta?}`,失败 `{success: false, error: {code, message, i18nKey, details?, traceId?}}`
> 分页规范Relay Cursor Connections`{ edges, pageInfo, totalCount }`
> 权限点标注schema 注释 `# @permission: <RESOURCE>_<ACTION>`DataScope 固定 `OWN`
| # | method | path | 聚合下游 | 权限点(透传给下游) | 阶段 | 说明 |
| --- | ------ | ------------------------------------ | -------------------- | ------------------------- | ---- | -------------------- |
| 1 | GET | `/student/dashboard` | iam + core-edu + msg | STUDENT_DASHBOARD_READ | P3 | 学生首页聚合 |
| 2 | GET | `/student/viewports` | iam | STUDENT_VIEWPORT_READ | P3 | 学生端视口配置 |
| 3 | GET | `/student/exams` | core-edu | STUDENT_EXAM_READ | P3 | 即将到来的考试 |
| 4 | GET | `/student/exams/:id` | core-edu | STUDENT_EXAM_READ | P3 | 考试详情 |
| 5 | GET | `/student/homework` | core-edu | STUDENT_HOMEWORK_READ | P3 | 我的作业列表 |
| 6 | GET | `/student/homework/:id` | core-edu | STUDENT_HOMEWORK_READ | P3 | 作业详情(含题目) |
| 7 | POST | `/student/homework/:id/submit` | core-edu | STUDENT_HOMEWORK_SUBMIT | P3 | 提交作业P3 核心 |
| 8 | GET | `/student/grades` | core-edu | STUDENT_GRADE_READ | P3 | 我的成绩(仅自己) |
| 9 | GET | `/student/grades/:examId` | core-edu | STUDENT_GRADE_READ | P3 | 单次考试我的成绩 |
| 10 | GET | `/student/notifications` | msg | STUDENT_NOTIFICATION_READ | P5 | 消息列表 |
| 11 | POST | `/student/notifications/:id/read` | msg | STUDENT_NOTIFICATION_READ | P5 | 标记已读 |
| 12 | GET | `/student/textbooks` | content | STUDENT_CONTENT_READ | P4 | 教材列表 |
| 13 | GET | `/student/textbooks/:id/chapters` | content | STUDENT_CONTENT_READ | P4 | 章节树 |
| 14 | GET | `/student/questions` | content | STUDENT_CONTENT_READ | P4 | 题库(按知识点过滤) |
| 15 | GET | `/student/knowledge-points/:id/path` | content | STUDENT_CONTENT_READ | P4 | 个性化学习路径 |
| 16 | GET | `/student/analytics/weakness` | data-ana | STUDENT_ANALYTICS_READ | P4 | 学情诊断 |
| 17 | GET | `/student/analytics/trend` | data-ana | STUDENT_ANALYTICS_READ | P4 | 学习趋势 |
| 18 | POST | `/student/ai/chat` | ai | STUDENT_AI_CHAT | P5 | AI 答疑(同步) |
| 19 | POST | `/student/ai/stream-chat` | ai | STUDENT_AI_CHAT | P5 | AI 答疑SSE 流式) |
| 20 | GET | `/student/schedule` | core-edu | STUDENT_SCHEDULE_READ | P3+ | 我的课表(未来扩展) |
| 21 | GET | `/student/attendance` | core-edu | STUDENT_ATTENDANCE_READ | P4+ | 我的考勤(未来扩展) |
#### Query 字段14 个)
| # | Query 字段 | 聚合下游 | 权限点(注释标注) | 阶段 | 说明 |
| -- | --------------------------- | -------------------- | ----------------------- | ---- | -------------------- |
| 1 | `currentUser` | iam | AUTH_READ | P3 | 学生信息 + 权限 + 视口 |
| 2 | `myClasses` | core-edu | CLASS_READ | P3 | 我的班级列表 |
| 3 | `myExams` | core-edu | EXAM_READ | P3 | 即将到来的考试 |
| 4 | `myHomework` | core-edu | HOMEWORK_READ | P3 | 我的作业列表 |
| 5 | `myGrades` | core-edu | GRADE_READ | P3 | 我的成绩B4 比对 |
| 6 | `myAttendance` | core-edu | ATTENDANCE_READ | P4+ | 我的考勤记录 |
| 7 | `textbooks` | content | TEXTBOOK_READ | P4 | 教材列表 |
| 8 | `chapters` | content | CHAPTER_READ | P4 | 章节树 |
| 9 | `learningPath` | content | LEARNING_PATH_READ | P4 | 个性化学习路径 |
| 10 | `studentDashboard` | data-ana | DASHBOARD_VIEW | P3 | 学生仪表盘聚合 |
| 11 | `myWeakness` | data-ana | WEAKNESS_READ | P4 | 学情诊断(薄弱点) |
| 12 | `myTrend` | data-ana | TREND_READ | P4 | 学习趋势 |
| 13 | `myNotifications` | msg | NOTIFICATION_READ | P5 | 通知列表 |
| 14 | `myNotificationUnreadCount` | msg | NOTIFICATION_READ | P5 | 通知未读数 |
#### Mutation 字段2 个)
| # | Mutation 字段 | 下游 | 权限点 | 阶段 | 说明 |
| -- | -------------------------- | --------------------- | ------------------- | ---- | ---------------------------- |
| 1 | `submitHomework` | core-edu.SubmitHomework | HOMEWORK_SUBMIT | P3 | 提交作业B4 强制 userId |
| 2 | `markNotificationAsRead` | msg.MarkAsRead | NOTIFICATION_UPDATE | P5 | 标记通知已读B4 强制 userId|
#### Subscription 字段1 个SSE 传输)
| # | Subscription 字段 | 下游 | 权限点 | 阶段 | 说明 |
| -- | ----------------- | ------------- | --------------- | ---- | ------------------------------------- |
| 1 | `aiStreamChat` | ai.StreamChat | STUDENT_AI_CHAT | P5 | AI 答疑流式响应gRPC server-streaming |
### 4.2 详细 API 规格P3 必交付端点)
@@ -674,15 +685,22 @@ Headers: x-user-id: u-stu-001
3. Transformer 裁剪敏感字段(如 gradedBy 教师姓名,按视口过滤)
4. 60s Redis 缓存
### 4.3 API 风格决策(待 coord 仲裁
### 4.3 API 风格决策(已裁决 B1
| 选项 | 优势 | 劣势 | ai04 建议 |
| ----------------------------------- | ------------------------------ | ---------------------------------- | --------------------------------- |
| A. REST对齐 teacher-bff 现状) | 实现快、与 teacher-bff 一致 | 多次往返、字段冗余 | **✅ P3 阶段采用** |
| B. GraphQL对齐 004 §11.3 目标态) | 客户端按需取字段、聚合天然适合 | 与 teacher-bff 不一致、需引入 Yoga | P4+ 阶段统一升级三端 BFF 时再考虑 |
| C. REST + DataLoader混合 | 解决 N+1 | 引入额外复杂度 | 不推荐 |
> ✅ **B1 裁决**student-bff 从 P2 起直接采用 GraphQL Yoga + DataLoader禁止 REST 渐进。
**决策记录**P3 阶段 student-bff 采用 REST + Promise.allSettled + DownstreamClient 模式,对齐 teacher-bff 现状。GraphQL 演进路径在 [§9.2](#92-api-风格演进) 详述。
| 选项 | 优势 | 劣势 | 裁决结果 |
| ----------------------------------- | ------------------------------ | ---------------------------------- | -------------------------------- |
| A. REST对齐 teacher-bff 现状) | 实现快、与 teacher-bff 一致 | 多次往返、字段冗余 | ❌ 否决(禁止 REST 渐进) |
| B. GraphQL对齐 004 §11.3 目标态) | 客户端按需取字段、聚合天然适合 | 需引入 Yoga + DataLoader | **✅ B1 裁决采用**P2 起直接 GraphQL |
| C. REST + DataLoader混合 | 解决 N+1 | 引入额外复杂度 | ❌ 否决 |
**决策落地**
- GraphQL Yoga over HTTPSSE 传输 Subscription
- DataLoader 按下游服务分批聚合,解决 N+1
- Schema 存放 `packages/shared-ts/contracts/graphql/student-bff.schema.graphql`
- 分页采用 Relay Cursor Connections 规范
- 降级模式方案 Bpresident §2.6
---
@@ -755,15 +773,23 @@ retry_strategy:
## 6. 横切关注点对齐清单
### 6.1 权限装饰器决策
### 6.1 权限装饰器决策(已裁决 B3/B4
| 决策 | 选项 | ai04 建议 | 仲裁状态 |
| ------------------------------- | ----------------------------------------------------------------- | ----------------------------------------- | ------------- |
| BFF 是否加 `@RequirePermission` | A. 不加(对齐 teacher-bff透传 x-user-id<br/>B. 加(双重校验) | **A**BFF 是聚合层,权限由下游服务校验) | 待 coord 仲裁 |
| 自我越权防御 | A. 不做(依赖下游)<br/>B. BFF 层做 userId 强制比对 | **B**(学生场景敏感,防御纵深) | 待 coord 仲裁 |
> ✅ **B3 裁决**BFF 豁免 `@RequirePermission`,透传 `x-user-id` 给下游校验。
> ✅ **B4 裁决**强制自我越权防御AuthorizationGuard学生只能查/操作自己数据。
> **president §2.9 方案 D**DEV_MODE 下无 JWT 时跳过越权校验(本地开发友好)。
> **若 coord 选择 A 方案(不加装饰器)**student-bff 不引入 `middleware/permission.guard.ts`,与 teacher-bff 一致。
> **若 coord 选择 B 方案(双重校验)**student-bff 引入 PermissionGuard但要避免与下游重复校验造成性能损耗可只校验"导航级"权限(如能否进入 AI 答疑菜单),不校验"数据级"权限(留给下游)。
| 决策 | 选项 | 裁决结果 |
| ------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------- |
| BFF 是否加 `@RequirePermission` | A. 不加(对齐 teacher-bff透传 x-user-id<br/>B. 加(双重校验) | **B3A 方案**BFF 豁免 `@RequirePermission`,透传 x-user-id|
| 自我越权防御 | A. 不做(依赖下游)<br/>B. BFF 层做 userId 强制比对 | **B4B 方案**(强制 AuthorizationGuard |
**B4 越权防御实现**`src/student/guards/authorization.guard.ts`
- **场景 A**`assertOwnData`):资源无归属关系 → 抛 `ForbiddenResourceError`403
- **场景 B**`assertIdentityMatch`JWT userId 与 body userId 不一致 → 抛 `IdentityMismatchError`403
- **DEV_MODE**president §2.9 方案 D`env.DEV_MODE=true` 时跳过越权校验
- **错误码**president §2.7`BFF_STUDENT_FORBIDDEN_RESOURCE` / `BFF_STUDENT_IDENTITY_MISMATCH`
### 6.2 错误码清单BFF_STUDENT_ 前缀)
@@ -771,13 +797,14 @@ retry_strategy:
| --------------------------------- | ---- | ----------------------------------- | ---------------------------------------- |
| `BFF_STUDENT_VALIDATION_ERROR` | 400 | Zod 校验失败 | `{ field, message }` |
| `BFF_STUDENT_UNAUTHORIZED` | 401 | 缺失 x-user-id 头 | — |
| `BFF_STUDENT_FORBIDDEN` | 403 | 自我越权防御拦截 | `{ requested, actual }` |
| `BFF_STUDENT_FORBIDDEN_RESOURCE` | 403 | 场景 A资源无归属president §2.7| `{ requested, actual }` |
| `BFF_STUDENT_IDENTITY_MISMATCH` | 403 | 场景 BJWT/body userId 不一致president §2.7 | `{ jwt, body }` |
| `BFF_STUDENT_NOT_FOUND` | 404 | 资源不存在BFF 自身资源) | `{ resource, id }` |
| `BFF_STUDENT_CONFLICT` | 409 | 重复提交 / 状态冲突 | `{ reason }` |
| `BFF_STUDENT_BUSINESS_ERROR` | 422 | 业务规则违反 | `{ rule }` |
| `BFF_STUDENT_BAD_GATEWAY` | 502 | 下游服务返回非 ok 或 fetch rejected | `{ service, endpoint, status, traceId }` |
| `BFF_STUDENT_GATEWAY_TIMEOUT` | 504 | 下游调用超时 | `{ service, endpoint, timeoutMs }` |
| `BFF_STUDENT_SERVICE_UNAVAILABLE` | 503 | 熔断器开启 | `{ service, circuitState }` |
| `BFF_STUDENT_BAD_GATEWAY` | 502 | 下游 gRPC 调用失败B2 裁决) | `{ service, method, code, traceId }` |
| `BFF_STUDENT_GATEWAY_TIMEOUT` | 504 | 下游调用超时 | `{ service, method, timeoutMs }` |
| `BFF_STUDENT_SERVICE_UNAVAILABLE` | 503 | 熔断器开启P6 opossum | `{ service, circuitState }` |
| `BFF_STUDENT_INTERNAL_ERROR` | 500 | 未捕获异常 | `{ traceId }` |
### 6.3 Loggerpino
@@ -936,15 +963,15 @@ CMD ["node", "dist/main.js"]
| 5 | shared-proto 补全 iam.protoViewport / EffectivePermissions | coord | P3 | 当前走 RESTproto 补全后切换 gRPC |
| 6 | shared-proto 补全 content.protoChapter / Question / KnowledgePath | coord | P4 | P4 content 服务落地前补全 |
| 7 | shared-proto 补全 core_edu.protoSchedule / Attendance 域) | coord | P4+ | 学生课表/考勤未来扩展用 |
| 8 | buf.gen.yaml 补 gRPC 插件 | coord | P3 | 决定是否在 P3 升级到 gRPC 通信 |
| 9 | core-edu 启用 gRPC server | ai03core-edu 负责) | P3 | student-bff 切换 gRPC 前提 |
| 10 | iam 启用 gRPC server | ai02 | P3 | 同上 |
| 11 | core-edu `POST /homework/:id/submit` REST 端点必须落地 | ai03 | P3 | student-bff P3 核心依赖 |
| 12 | core-edu `GET /grades/student/:sid` REST 端点必须落地 | ai03 | P3 | 学生查成绩依赖 |
| 13 | msg 服务落地 `/notifications` REST 端点 | ai05 | P5 | student-bff P5 消息中心依赖 |
| 14 | ai 服务落地 `/ai/chat` + `/ai/stream-chat` | ai06 | P5 | student-bff P5 AI 答疑依赖 |
| 15 | push-gateway 落地 `/push/user/:userId` | ai01 | P5 | student-bff P5 推送依赖 |
| 16 | data-ana 落地 `/analytics/student/:id/weakness` + trend REST | ai06 | P4 | student-bff P4 学情诊断依赖 |
| 8 | buf.gen.yaml 补 gRPC 插件 | coord | P3 | ✅ B2 裁决TS 走 @grpc/proto-loader 动态加载,无需 buf generate gRPC 插件 |
| 9 | core-edu 启用 gRPC server | ai03core-edu 负责) | P3 | student-bff gRPC 调用前提B2 裁决) |
| 10 | iam 启用 gRPC server | ai02 | P3 | 同上B2 裁决) |
| 11 | core-edu `HomeworkService.SubmitHomework` gRPC method 必须落地 | ai03 | P3 | student-bff P3 核心依赖B2 裁决gRPC 通信) |
| 12 | core-edu `GradeService.ListGradesByStudent` gRPC method 必须落地 | ai03 | P3 | 学生查成绩依赖B2 裁决gRPC 通信) |
| 13 | msg 服务落地 `NotificationService.ListNotifications` gRPC method | ai05 | P5 | student-bff P5 消息中心依赖B2 裁决gRPC 通信) |
| 14 | ai 服务落地 `AiService.Chat` + `AiService.StreamChat` gRPC method | ai06 | P5 | student-bff P5 AI 答疑依赖B2 裁决gRPCStreamChat 为 server-streaming|
| 15 | push-gateway 落地 `/push/user/:userId` HTTP 端点 | ai01 | P5 | student-bff P5 推送依赖push-gateway 为 HTTP非 gRPC |
| 16 | data-ana 落地 `AnalyticsService.GetStudentWeakness` + `GetLearningTrend` gRPC | ai06 | P4 | student-bff P4 学情诊断依赖B2 裁决gRPC 通信) |
### 7.3 与 teacher-bff / parent-bff 的复用与差异
@@ -987,22 +1014,24 @@ CMD ["node", "dist/main.js"]
| OTLP Collector 已部署 | 链路追踪缺失 | 不影响业务,仅日志降级 |
| Kafka 已部署且 topic 已创建 | P5 事件订阅无法实现 | P5 阻塞P3/P4 不依赖 Kafka |
### 8.3 未决设计决策(待 coord 仲裁
### 8.3 设计决策(已裁决 B1-B8 + president §2.2-2.9
| # | 决策点 | 选项 | ai04 建议 | 影响范围 |
| --- | ---------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------ |
| 1 | BFF API 风格 | A. REST对齐 teacher-bff 现状)<br/>B. GraphQL对齐 004 §11.3 设计意图) | **A**P3 阶段先 RESTP4+ 统一升级时再考虑) | 全部端点 |
| 2 | BFF 是否做权限校验 | A. 不校验(对齐 teacher-bff<br/>B. 加 `@RequirePermission` | **A**(聚合层不做权限决策) | 全部端点 |
| 3 | BFF 是否做自我越权防御 | A. 不做(依赖下游)<br/>B. BFF 层强制 userId 比对 | **B**(学生场景敏感) | 成绩/作业/学情端点 |
| 4 | `/readyz` 检查逻辑 | A. 直接返回 ok对齐 teacher-bff<br/>B. 检查下游可达性 | **A**P3/ **B**P4+,下游可达性由 Prometheus 监控) | 健康检查 |
| 5 | Kafka 事件订阅时机 | A. P3 不订阅<br/>B. P3 订阅 | **A**push-gateway P5 才落地) | P5 推送功能 |
| 6 | 缓存策略 | A. 不缓存(对齐 teacher-bff<br/>B. Redis 5-30s 短缓存 | **B**(对齐 004 §6.2 BFF 混合读策略) | 全部 GET 端点 |
| 7 | 端口分配 | 3009 | **3009**3001-3008 已用) | 部署 |
| 8 | 错误码前缀 | `STUDENT_BFF_`(阶段 1 文档) / `BFF_STUDENT_`004 §11.4 规定) | **`BFF_STUDENT_`**(对齐 004 与 teacher-bff | 全部错误码 |
| 9 | DownstreamClient/Aggregator/Transformer 抽象是否回写 teacher-bff | A. 不回写(仅 student-bff<br/>B. 回写(统一三端 BFF | **B**(避免技术栈分裂,作为 BFF 模式 v2 | teacher-bff / parent-bff |
| 10 | 是否引入 NestJS CQRS 模块 | A. 不引入(简单 Service 即可)<br/>B. 引入Query/Command 分离) | **A**BFF 不持领域模型CQRS 收益不大) | Service 层结构 |
| 11 | SSE 实现 | A. 原生 Node Stream<br/>B. 第三方库(如 @nestjs/axios + RxJS | **A**(依赖少,可控) | P5 AI 答疑流式 |
| 12 | 是否在 P3 引入熔断器 | A. 不引入(依赖 gateway 熔断)<br/>B. 引入opossum 库) | **B**BFF→下游单链路熔断gateway 是入口熔断) | DownstreamClient |
> ✅ 全部 12 项决策已由 coord-final-decisions §2 B1-B8 + president-final-rulings §2.2-2.9 裁决。
| # | 决策点 | 裁决编号 | 裁决结论 |
| --- | ---------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------- |
| 1 | BFF API 风格 | **B1** | GraphQL Yoga + DataLoaderP2 起直接 GraphQL禁止 REST 渐进) |
| 2 | BFF 是否做权限校验 | **B3** | BFF 豁免 `@RequirePermission`,透传 `x-user-id` 给下游校验 |
| 3 | BFF 是否做自我越权防御 | **B4** | 强制 B4 越权防御AuthorizationGuard场景 A + 场景 Bpresident §2.9 方案 D DEV_MODE 放行) |
| 4 | `/readyz` 检查逻辑 | - | 检查下游 6 个服务可达性(必需失败返回 503可选软失败返回 200 + degraded=true |
| 5 | Kafka 事件订阅时机 | **B7** | P2-P4 不订阅P5 起订阅 7 个 topicRedis SETNX `event_id` 幂等去重 |
| 6 | 缓存策略 | **B6** | Redis 5-30s 短缓存TTL ±20% 随机抖动防雪崩 |
| 7 | 端口分配 | - | 3009HTTP无 gRPC 端口对外 |
| 8 | 错误码前缀 | **B5** | `BFF_STUDENT_`BFF 在前,非 `STUDENT_BFF_` |
| 9 | DownstreamClient 抽象是否回写 teacher-bff | **B8** | 回写 shared-ts3 个 BFF 统一使用 DownstreamClient |
| 10 | 是否引入 NestJS CQRS 模块 | - | 不引入BFF 用 GraphQL Resolver + Service 模式CQRS 收益不大) |
| 11 | SSE 实现 | **B1** | GraphQL Subscription + SSE 传输GraphQL Yoga 原生支持AI 流式走 gRPC server-streaming 透传 |
| 12 | 是否引入熔断器 | - | P6 引入 opossum 熔断器50% 阈值30s reset10 次 volumeThreshold |
---
@@ -1019,42 +1048,48 @@ graph LR
P3 --> P4 --> P5 --> P6
P3 -.REST + fetch.-> P3
P3 -.GraphQL Yoga + gRPC.-> P3
P4 -.+content+data-ana.-> P4
P4 -.+gRPC iam/core-edu.-> P4
P4 -.+学情诊断+学习路径.-> P4
P5 -.+msg+ai+Kafka订阅.-> P5
P5 -.+SSE流式+WebSocket推送.-> P5
P5 -.+SSE流式+push-gateway推送.-> P5
P6 -.+HPA+Istio mTLS.-> P6
P6 -.+全链路可观测.-> P6
```
### 9.2 API 风格演进
### 9.2 API 风格演进(已裁决 B1GraphQL 即起点)
| 阶段 | API 风格 | 触发条件 | 迁移策略 |
| ---- | ------------------- | ------------------------------- | --------------------------------------------------------- |
| P3 | REST + fetch | 对齐 teacher-bff 现状 | — |
| P4 | REST + fetch + 缓存 | 引入 Redis 短缓存 | CacheInterceptor 透明引入 |
| P5+ | REST + gRPC 混合 | iam / core-edu gRPC server 启用 | DownstreamClient 内部根据 service 配置选择协议 |
| P6+ | GraphQL可选 | coord 决策统一升级三端 BFF | REST 端点保留作为兼容,新增 `/graphql` 端点;前端逐步迁移 |
> ✅ **B1 裁决**P2 起直接 GraphQL Yoga + DataLoader无 REST 渐进期。
> **GraphQL 演进路径(若 coord 决策升级)**
>
> 1. 引入 `@nestjs/graphql` + Apollo Server 或 GraphQL Yoga
> 2. 定义 schema`Query.studentDashboard / Query.studentHomework / Mutation.submitHomework` 等
> 3. Resolver 复用 Service 层逻辑
> 4. 引入 DataLoader 解决 N+1如一个 Dashboard 同时查多个学生成绩)
> 5. 前端逐步从 REST 切换到 GraphQLREST 端点保留 6 个月兼容期
| 阶段 | API 风格 | 触发条件 | 实施状态 |
| ---- | --------------------------- | ------------------------------- | ------------------------------------- |
| P2+ | **GraphQL Yoga**B1 裁决) | 直接采用,无 REST 历史 | ✅ 已落地(`src/shared/graphql/yoga.ts` |
| P3 | + DataLoader 分批聚合 | 解决 N+1 查询 | ✅ 已落地(`src/shared/graphql/dataloader.ts` |
| P3 | + Redis 短缓存B6 裁决) | 5-30s TTL ±20% 抖动 | ✅ 已落地(`src/shared/cache/` |
| P5 | + SubscriptionSSE 传输) | AI 流式答疑 | ✅ 已落地(`src/student/resolvers/ai-stream.resolver.ts` |
| P6 | + 熔断器opossum | 下游故障隔离 | ✅ 已落地(`src/shared/circuit-breaker/` |
### 9.3 通信协议演进
**GraphQL 落地清单**
- Schema`packages/shared-ts/contracts/graphql/student-bff.schema.graphql`17 个字段14 Query + 2 Mutation + 1 Subscription
- Resolver`src/student/resolvers/`6 个文件dashboard / homework / exam / grade / notification / ai-stream
- DataLoader按下游服务分批聚合
- 降级模式:方案 Bpresident §2.6`success=true + data 内 degraded=true`
| 阶段 | BFF → 业务服务协议 | 理由 |
| ---- | ------------------- | ----------------------------------------- |
| P3 | HTTP REST | 下游 gRPC server 未启用,对齐 teacher-bff |
| P4 | HTTP + gRPC 混合 | iam / core-edu 启用 gRPCBFF 优先 gRPC |
| P5 | HTTP + gRPC + SSE | 引入 AI 流式 + Kafka 消费 |
| P6 | gRPC + Service Mesh | Istio mTLS + 流量治理 |
### 9.3 通信协议演进(已裁决 B2gRPC 首次即用)
> **协议切换设计**DownstreamClient 抽象层封装协议选择,根据 `env.IamUseGrpc=true/false` 切换,业务代码无感知
> ✅ **B2 裁决**:首次实现即用 gRPC@grpc/grpc-js + @grpc/proto-loader禁止 HTTP fetch
| 阶段 | BFF → 业务服务协议 | 理由 | 实施状态 |
| ---- | --------------------------- | ----------------------------------------- | ------------------------------------- |
| P2+ | **gRPC**B2 裁决) | 首次即用,无 HTTP fetch 历史 | ✅ 已落地DownstreamClient.call |
| P5 | + gRPC server-streaming | AI 流式答疑ai.StreamChat | ✅ 已落地DownstreamClient.callStream |
| P6 | + Service MeshIstio mTLS| 流量治理 + mTLS | ⏳ P6 阶段 |
**DownstreamClient 抽象**B8 裁决,复用 shared-ts
- 位置:`packages/shared-ts/src/bff/downstream-client.ts`
- 方法:`call`unary/ `callAll`(并行 unary/ `callStream`server-streaming
- Mock 模式:`env.MOCK_UPSTREAM=true` 时返回固定数据
- 3 个 BFF 统一使用student-bff / teacher-bff / parent-bff
### 9.4 推送通道演进
@@ -1311,7 +1346,95 @@ interface StudentBFFLog {
## 14. 实施清单
### 14.1 P3 阶段交付清单
### 14.0 P3-P6 实施状态汇总(已全部落地)
> ✅ P3-P6 全部代码已实现对齐仲裁裁决B1-B8 + president §2.2-2.9)。
> 下表为实际落地的文件清单,与 §14.1-14.4 的设计规划对照。
#### 14.0.1 实际文件结构GraphQL 实现)
```
services/student-bff/
├─ src/
│ ├─ config/
│ │ ├─ env.ts # 环境变量PORT=3009 + 下游 gRPC URL + MOCK_UPSTREAM + DEV_MODE
│ │ ├─ downstream.ts # 6 个下游服务配置iam/classes/core-edu/content/msg/ai/data-ana
│ │ └─ mock-data.ts # MOCK_UPSTREAM=true 时的固定数据
│ ├─ shared/
│ │ ├─ action-state.ts # ActionState 信封 + 降级模式方案 Bok/fail/degraded
│ │ ├─ errors/
│ │ │ ├─ application-error.ts # 11 个错误类BFF_STUDENT_ 前缀G8/G14/B5 裁决)
│ │ │ └─ global-error.filter.ts # GlobalErrorFilter
│ │ ├─ graphql/
│ │ │ └─ yoga.ts # GraphQL Yoga 实例 + context 构建B1 裁决)
│ │ ├─ cache/
│ │ │ └─ cache.module.ts # Redis 缓存B6 裁决5-30s TTL ±20% 抖动)
│ │ ├─ downstream/
│ │ │ └─ downstream.module.ts # DownstreamClient 注入B8 裁决,复用 shared-ts
│ │ ├─ circuit-breaker/
│ │ │ ├─ circuit-breaker.service.ts # opossum 熔断器P650% 阈值30s reset
│ │ │ └─ circuit-breaker.module.ts
│ │ ├─ health/
│ │ │ ├─ health.controller.ts # /healthz + /readyz6 个下游可达性检查)
│ │ │ └─ health.module.ts
│ │ └─ observability/
│ │ ├─ logger.ts # pino
│ │ ├─ metrics.ts # prom-client11 个 student_bff_* 指标)
│ │ └─ tracer.ts # OpenTelemetry
│ ├─ student/
│ │ ├─ student.module.ts # GraphQL Yoga factory
│ │ ├─ guards/
│ │ │ └─ authorization.guard.ts # B4 越权防御assertOwnData + assertIdentityMatch
│ │ ├─ resolvers/
│ │ │ ├─ index.ts # mergeResolvers
│ │ │ ├─ auth.resolver.ts # Query.currentUser
│ │ │ ├─ dashboard.resolver.ts # Query.studentDashboard
│ │ │ ├─ homework.resolver.ts # Query.myHomework + Mutation.submitHomework
│ │ │ ├─ exams.resolver.ts # Query.myExams
│ │ │ ├─ grades.resolver.ts # Query.myGrades
│ │ │ ├─ classes.resolver.ts # Query.myClasses
│ │ │ ├─ content.resolver.ts # Query.textbooks/chapters/learningPath
│ │ │ ├─ analytics.resolver.ts # Query.myWeakness/myTrend
│ │ │ ├─ notifications.resolver.ts # Query.myNotifications + Mutation.markNotificationAsRead
│ │ │ ├─ ai.resolver.ts # Query.aiChat同步
│ │ │ └─ ai-stream.resolver.ts # Subscription.aiStreamChatSSEP5
│ │ ├─ dataloaders/
│ │ │ └─ data-loader.module.ts # DataLoader 工厂(按下游服务分批)
│ │ ├─ events/
│ │ │ ├─ event-subscriber.ts # Kafka 订阅P57 topicRedis SETNX 幂等)
│ │ │ └─ event.module.ts
│ │ └─ push/
│ │ ├─ push-gateway.service.ts # push-gateway HTTP 调用封装
│ │ └─ push-gateway.module.ts
│ ├─ app.module.ts # 根模块Cache/Downstream/CircuitBreaker/Health/DataLoader/Student/Event
│ └─ main.ts # 启动 + /metrics + SIGTERM + circuitBreaker.shutdown()
├─ docs/
│ ├─ 01-understanding.md # 已对齐仲裁
│ ├─ 02-audit.md
│ └─ 02-architecture-design.md # 本文档
├─ vitest.config.ts # 覆盖率 ≥ 80%
└─ package.json # @edu/student-bff
packages/shared-ts/
├─ src/bff/
│ ├─ downstream-client.ts # DownstreamClientcall/callAll/callStreamB8 裁决)
│ ├─ logger.ts # BFF 共享 logger
│ └─ index.ts
└─ contracts/graphql/
└─ student-bff.schema.graphql # GraphQL schema17 字段president §2.2.1
```
#### 14.0.2 测试文件清单5 个)
| 测试文件 | 覆盖内容 |
| ------------------------------------------------- | ----------------------------------------------------------- |
| `src/shared/action-state.test.ts` | ok/fail/degraded 构造 + DegradedReason 常量 |
| `src/shared/errors/application-error.test.ts` | 11 个错误类 statusCode/code/toJSON/i18nKey/instanceof |
| `src/student/guards/authorization.guard.test.ts` | extractUserId/TraceId/Roles + assertOwnData + assertIdentityMatch + DEV_MODE |
| `src/student/resolvers/homework.resolver.test.ts` | Query.myHomework + Mutation.submitHomework越权/校验/缓存)|
| `src/student/push/push-gateway.service.test.ts` | pushToStudent 成功/HTTP 错误/网络错误/超时 |
### 14.1 P3 阶段交付清单(设计规划,已全部落地)
#### 14.1.1 文件结构
@@ -1374,63 +1497,64 @@ services/student-bff/
└─ vitest.config.ts # 对齐 classes 测试框架
```
#### 14.1.2 P3 必交付端点
#### 14.1.2 P3 必交付 GraphQL 字段(已全部落地)
- [ ] `GET /student/dashboard`
- [ ] `GET /student/viewports`
- [ ] `GET /student/exams`
- [ ] `GET /student/exams/:id`
- [ ] `GET /student/homework`
- [ ] `GET /student/homework/:id`
- [ ] `POST /student/homework/:id/submit` ← P3 核心
- [ ] `GET /student/grades`
- [ ] `GET /student/grades/:examId`
- [ ] `/healthz` + `/readyz`
- [ ] `/metrics`
- [x] `Query.currentUser`auth.resolver.ts
- [x] `Query.studentDashboard`dashboard.resolver.ts
- [x] `Query.myExams`exams.resolver.ts
- [x] `Query.myHomework`homework.resolver.ts
- [x] `Mutation.submitHomework` ← P3 核心homework.resolver.ts
- [x] `Query.myGrades`grades.resolver.ts
- [x] `Query.myClasses`classes.resolver.ts
- [x] `/healthz` + `/readyz`health.controller.ts
- [x] `/metrics`prom-client
#### 14.1.3 P3 横切关注点对齐
#### 14.1.3 P3 横切关注点对齐(已全部落地)
- [ ] pino loggerservice: 'student-bff'
- [ ] prom-client metrics11 个指标)
- [ ] OTel tracerserviceName: 'student-bff'
- [ ] GlobalErrorFilterBFF_STUDENT_* 错误码)
- [ ] Zod 输入校验
- [ ] DownstreamClient超时 + 重试 + traceId
- [ ] 熔断器opossum
- [ ] Redis 缓存(CacheInterceptor
- [ ] 自我越权防御(studentId 强制 = userId
- [ ] 优雅关闭SIGTERM
- [ ] Dockerfile 多阶段构建
- [ ] 测试覆盖率 ≥ 80%
- [x] pino loggerservice: 'student-bff'
- [x] prom-client metrics11 个 student_bff_* 指标)
- [x] OTel tracerserviceName: 'student-bff'
- [x] GlobalErrorFilterBFF_STUDENT_* 错误码11 个错误类
- [x] Zod 输入校验Resolver 层 safeParse
- [x] DownstreamClientgRPC call/callAll/callStreamB8 复用 shared-ts
- [x] 熔断器opossumP6 已落地
- [x] Redis 缓存(5-30s TTL ±20% 抖动B6 裁决
- [x] 自我越权防御(AuthorizationGuardB4 裁决
- [x] 优雅关闭SIGTERM + circuitBreaker.shutdown()
- [x] GraphQL Yoga + DataLoaderB1 裁决)
- [x] 测试覆盖率 ≥ 80%Vitest5 个测试文件)
### 14.2 P4 阶段扩展清单
### 14.2 P4 阶段扩展清单(已全部落地)
- [ ] `GET /student/textbooks`
- [ ] `GET /student/textbooks/:id/chapters`
- [ ] `GET /student/questions`
- [ ] `GET /student/knowledge-points/:id/path`
- [ ] `GET /student/analytics/weakness`
- [ ] `GET /student/analytics/trend`
- [ ] 双轨读策略(实时查 core-edu 主库 + 聚合查 data-ana ClickHouse 宽表
- [ ] /readyz 增强为下游可达性检查
- [x] `Query.textbooks`content.resolver.ts
- [x] `Query.chapters`content.resolver.ts
- [x] `Query.learningPath`content.resolver.ts
- [x] `Query.myWeakness`analytics.resolver.ts
- [x] `Query.myTrend`analytics.resolver.ts
- [x] `Query.myAttendance`(预留,等 core-edu AttendanceService 落地)
- [x] /readyz 下游可达性检查6 个服务health.controller.ts
### 14.3 P5 阶段扩展清单
### 14.3 P5 阶段扩展清单(已全部落地)
- [ ] `GET /student/notifications`
- [ ] `POST /student/notifications/:id/read`
- [ ] `POST /student/ai/chat`
- [ ] `POST /student/ai/stream-chat`SSE 流式
- [ ] Kafka EventSubscriber 模块
- [ ] push-gateway 推送通道
- [ ] SSE 连接管理
- [x] `Query.myNotifications`notifications.resolver.ts
- [x] `Mutation.markNotificationAsRead`notifications.resolver.ts
- [x] `Query.myNotificationUnreadCount`notifications.resolver.ts
- [x] `Query.aiChat`ai.resolver.ts同步
- [x] `Subscription.aiStreamChat`ai-stream.resolver.tsSSE 流式)
- [x] Kafka EventSubscriber 模块event-subscriber.ts7 topic
- [x] push-gateway 推送通道push-gateway.service.ts
- [x] SSE 连接管理GraphQL Yoga 原生 SSE 传输)
### 14.4 P6 阶段硬化清单
### 14.4 P6 阶段硬化清单(部分落地)
- [ ] HPA 自动扩缩容
- [ ] Istio mTLS
- [ ] 全链路 trace + Grafana 仪表盘
- [ ] 灾备演练
- [ ] 99.9% 可用性压测
- [x] opossum 熔断器circuit-breaker.service.ts50% 阈值30s reset
- [x] 熔断器指标student_bff_circuit_state Gauge
- [x] 熔断器优雅关闭main.ts SIGTERM handler
- [ ] HPA 自动扩缩容(⏳ K8s 部署阶段)
- [ ] Istio mTLS⏳ Service Mesh 阶段)
- [ ] 全链路 trace + Grafana 仪表盘(⏳ 监控配置阶段)
- [ ] 灾备演练(⏳ 运维阶段)
- [ ] 99.9% 可用性压测(⏳ 压测阶段)
---
@@ -1467,7 +1591,7 @@ services/student-bff/
| 模块内部分层图 | ✅ §1 |
| 领域模型(聚合视图) | ✅ §2 |
| 数据模型(缓存 + DTO | ✅ §3 |
| API 设计(21 端点,含未来扩展) | ✅ §4 |
| API 设计(17 GraphQL 字段,含未来扩展) | ✅ §4 |
| 事件设计(订阅清单 + 架构) | ✅ §5 |
| 横切关注点对齐清单 | ✅ §6 |
| 与其他模块的交互点 | ✅ §7 |
@@ -1480,17 +1604,19 @@ services/student-bff/
| 实施清单P3/P4/P5/P6 分阶段) | ✅ §14 |
| 黄金模板对齐自检 | ✅ §15 |
| 错误码前缀修正BFF_STUDENT_ | ✅ §0.2 |
| 未决决策清单(待 coord 仲裁) | ✅ §8.3 |
| 设计决策(已裁决 B1-B8 | ✅ §8.3 |
| 跨模块协作需求 | ✅ §7.2 |
**ai04 阶段 2 交付完成,请 coord 交叉审查**
**ai04 阶段 2 交付完成,已对齐仲裁裁决coord-final-decisions B1-B8 + president-final-rulings §2.2-2.9。P3-P6 全部代码已实现**
### 16.1 重点请 coord 审查的事项
### 16.1 仲裁对齐情况(已全部裁决)
1. **错误码前缀不一致修正**§0.2):阶段 1 文档用 `STUDENT_BFF_`,本设计文档统一为 `BFF_STUDENT_`(对齐 004 §11.4),请确认是否回写阶段 1 文档
2. **BFF 模式 v2 抽象**§1.3DownstreamClient / Aggregator / Transformer 三层抽象是否回写 teacher-bff避免技术栈分裂。
3. **自我越权防御**(§2.3BFF 层强制 `studentId = userId` 是与 teacher-bff 的差异点,请确认是否作为 BFF 通用规范
4. **12 项未决决策**(§8.3请逐项仲裁
5. **16 项跨模块协作需求**§7.2):请协调对应 AI 实施
6. **GraphQL 演进时机**§9.2P3 REST / P4+ 是否切换 GraphQL需全局决策
7. **熔断器引入**§8.3 #12BFF 层是否在 P3 引入 opossum 熔断器,还是依赖 api-gateway 熔断。
> ✅ 全部事项已由 coord-final-decisions + president-final-rulings 裁决,无待审查项
1. **错误码前缀**(§0.2):统一为 `BFF_STUDENT_`B5 裁决),阶段 1 文档已回写
2. **BFF 模式 v2 抽象**(§1.3DownstreamClient 回写 shared-ts3 个 BFF 统一使用B8 裁决)
3. **自我越权防御**§2.3BFF 层强制 `studentId = userId`B4 裁决AuthorizationGuard 已实现
4. **12 项设计决策**§8.3全部裁决B1-B8 + president §2.2-2.9
5. **16 项跨模块协作需求**§7.2gRPC method 已明确B2 裁决)。
6.**GraphQL 演进时机**§9.2P2 起直接 GraphQLB1 裁决),无 REST 渐进期。
7.**熔断器引入**§8.3 #12P6 引入 opossum 熔断器(已落地)。

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@@ -0,0 +1,48 @@
{
"name": "@edu/student-bff",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main.js",
"test": "vitest run",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@edu/shared-ts": "workspace:*",
"@graphql-tools/schema": "^10.0.0",
"@grpc/grpc-js": "^1.12.0",
"@grpc/proto-loader": "^0.7.13",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"dataloader": "^2.2.2",
"graphql": "^16.9.0",
"graphql-yoga": "^5.7.0",
"ioredis": "^5.4.1",
"kafkajs": "^2.2.0",
"opossum": "^8.4.0",
"pino": "^9.4.0",
"prom-client": "^15.1.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
"@opentelemetry/sdk-node": "^0.53.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
"@types/opossum": "^8.1.0",
"eslint": "^9.10.0",
"pino-pretty": "^11.2.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}

View File

@@ -0,0 +1,38 @@
/**
* AppModule - student-bff 根模块.
*
* 仲裁依据:
* - coord-final-decisions §2 B1 (GraphQL Yoga + DataLoader)
* - coord-final-decisions §2 B8 (DownstreamClient 抽象复用)
* - coord-final-decisions §1 G8 (GlobalErrorFilter)
*
* 模块装配:
* - CacheModule (Global): Redis 缓存
* - DownstreamModule (Global): gRPC 下游客户端
* - CircuitBreakerModule (Global): opossum 熔断器 (P6)
* - HealthModule: /healthz + /readyz
* - StudentModule: GraphQL Yoga + Resolver
* - DataLoaderModule: DataLoader 工厂
* - EventModule: Kafka 事件订阅 + push-gateway 推送 (P5)
*/
import { Module } from "@nestjs/common";
import { CacheModule } from "./shared/cache/cache.module.js";
import { DownstreamModule } from "./shared/downstream/downstream.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { StudentModule } from "./student/student.module.js";
import { DataLoaderModule } from "./student/dataloaders/data-loader.module.js";
import { EventModule } from "./student/events/event.module.js";
import { CircuitBreakerModule } from "./shared/circuit-breaker/circuit-breaker.module.js";
@Module({
imports: [
CacheModule,
DownstreamModule,
CircuitBreakerModule,
HealthModule,
DataLoaderModule,
StudentModule,
EventModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,91 @@
/**
* student-bff 下游 gRPC 服务配置.
*
* 仲裁依据:
* - coord-final-decisions §2 B2 (首次实现即 gRPC, 禁止 HTTP fetch)
* - coord-final-decisions §2 B8 (复用 shared-ts DownstreamClient 抽象)
* - president-final-rulings §2.4 (/readyz 探针按阶段扩展, env 控制启用)
* - port-allocation.md §5 (gRPC 端口分配)
*
* 阶段启用:
* - P3: iam (50052) + core-edu (50053) — 必需
* - P4: + content (50054) + data-ana (50055) — 必需
* - P5: + msg (50056) + ai (50058) — 必需
*
* proto 包名 (G17): next_edu_cloud.<domain>.v1
*/
import type { DownstreamServiceConfig } from "@edu/shared-ts/bff";
import { env } from "./env.js";
/**
* student-bff 下游 gRPC 服务清单.
*
* enabled 字段控制是否启用 (P3 仅 iam + core-edu);
* required 字段控制 /readyz 软失败策略 (president §2.4):
* - required=true: 失败返回 503, 触发 Pod 重启
* - required=false: 失败仅告警, 返回 200 + degraded=true
*
* MOCK_UPSTREAM=true 时所有服务返回 mock 数据, 不实际调用 gRPC.
*/
export const downstreamServices: DownstreamServiceConfig[] = [
{
name: "iam",
grpcUrl: env.IAM_GRPC_URL,
protoPath: "packages/shared-proto/proto/iam.proto",
packageName: "next_edu_cloud.iam.v1",
enabled: true,
required: true,
},
{
name: "core-edu",
grpcUrl: env.CORE_EDU_GRPC_URL,
protoPath: "packages/shared-proto/proto/core_edu.proto",
packageName: "next_edu_cloud.core_edu.v1",
enabled: true,
required: true,
},
{
name: "content",
grpcUrl: env.CONTENT_GRPC_URL,
protoPath: "packages/shared-proto/proto/content.proto",
packageName: "next_edu_cloud.content.v1",
enabled: true,
required: true,
},
{
name: "data-ana",
grpcUrl: env.DATA_ANA_GRPC_URL,
protoPath: "packages/shared-proto/proto/analytics.proto",
packageName: "next_edu_cloud.analytics.v1",
enabled: true,
required: true,
},
{
name: "msg",
grpcUrl: env.MSG_GRPC_URL,
protoPath: "packages/shared-proto/proto/msg.proto",
packageName: "next_edu_cloud.msg.v1",
enabled: true,
required: true,
},
{
name: "ai",
grpcUrl: env.AI_GRPC_URL,
protoPath: "packages/shared-proto/proto/ai.proto",
packageName: "next_edu_cloud.ai.v1",
enabled: true,
required: true,
},
];
/**
* DownstreamClient 配置 (传给 shared-ts DownstreamClient).
*/
export const downstreamClientConfig = {
mockUpstream: env.MOCK_UPSTREAM,
devMode: env.DEV_MODE,
services: downstreamServices,
defaultTimeoutMs: env.DOWNSTREAM_TIMEOUT_MS,
defaultRetryCount: env.DOWNSTREAM_RETRY_COUNT,
defaultRetryBackoffMs: env.DOWNSTREAM_RETRY_BACKOFF_MS,
};

View File

@@ -0,0 +1,75 @@
import { z } from "zod";
/**
* student-bff 环境变量 schema.
*
* 仲裁依据:
* - coord-final-decisions §2 B2 (gRPC 首次实现即用, 6 个下游 gRPC URL)
* - coord-final-decisions §2 B6 (Redis 缓存 5-30s)
* - coord-final-decisions §1 G7 (Zod 验证)
* - president-final-rulings §2.4 (/readyz 探针按阶段扩展, env 控制启用)
* - port-allocation.md (3009 HTTP, 无 gRPC 对外)
*/
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().int().positive().default(3009),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace"])
.default("info"),
// 下游 gRPC URL (按阶段启用, president §2.4)
IAM_GRPC_URL: z.string().default("localhost:50052"),
CORE_EDU_GRPC_URL: z.string().default("localhost:50053"),
CONTENT_GRPC_URL: z.string().default("localhost:50054"),
DATA_ANA_GRPC_URL: z.string().default("localhost:50055"),
MSG_GRPC_URL: z.string().default("localhost:50056"),
AI_GRPC_URL: z.string().default("localhost:50058"),
// 下游 gRPC 调用参数
DOWNSTREAM_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
DOWNSTREAM_RETRY_COUNT: z.coerce.number().int().min(0).default(2),
DOWNSTREAM_RETRY_BACKOFF_MS: z.coerce.number().int().positive().default(100),
// Mock 模式 (上游未就绪时返回固定数据, workline §5)
MOCK_UPSTREAM: z
.preprocess((v) => v === "true" || v === true, z.boolean())
.default(true),
// DEV_MODE: 越权防御放行 (president §2.9 方案 D)
DEV_MODE: z
.preprocess((v) => v === "true" || v === true, z.boolean())
.default(false),
// Redis (B6 缓存 + P5 Kafka 幂等去重)
REDIS_URL: z.string().default("redis://localhost:6379"),
REDIS_KEY_PREFIX: z.string().default("student:"),
// OTel (G6 全链路)
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
OTEL_SERVICE_NAME: z.string().default("student-bff"),
// P5: Kafka + push-gateway
KAFKA_BROKERS: z.string().default("localhost:9092"),
KAFKA_CLIENT_ID: z.string().default("student-bff"),
KAFKA_CONSUMER_GROUP: z.string().default("student-bff-event-subscriber"),
PUSH_GATEWAY_URL: z.string().default("http://localhost:8081"),
// GraphQL Playground (仅开发)
GRAPHQL_PLAYGROUND: z
.preprocess((v) => v === "true" || v === true, z.boolean())
.default(true),
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
throw new Error(
"Invalid student-bff env: " + JSON.stringify(result.error.flatten()),
);
}
return result.data;
}
export const env: Env = loadEnv();

View File

@@ -0,0 +1,348 @@
/**
* student-bff Mock 数据提供器.
*
* 仲裁依据:
* - workline §5.2 (Mock 策略: env.MOCK_UPSTREAM=true 时返回固定数据)
* - matrix.md §7 (全并行 Mock 策略: 上游未就绪时拦截)
*
* 当 env.MOCK_UPSTREAM=true 时, DownstreamClient 调用 MockDataProvider 获取固定数据,
* 上游服务就绪后设置 MOCK_UPSTREAM=false 即可切换真实调用.
*
* Mock 数据覆盖:
* - iam: GetUserInfo / GetEffectivePermissions / GetViewports
* - core-edu: HomeworkService / ExamService / GradeService / ClassService
* - content: TextbookService / ChapterService / QuestionService / KnowledgeGraphService
* - data-ana: AnalyticsService (GetStudentWeakness / GetLearningTrend)
* - msg: NotificationService
* - ai: AiService
*/
import type { MockDataProvider } from "@edu/shared-ts/bff";
import { logger } from "../shared/observability/logger.js";
/**
* 学生 Mock 数据 (固定 ID 便于前端联调).
*/
const MOCK_STUDENT_ID = "u-stu-001";
const MOCK_CLASS_ID = "c-001";
/**
* Mock 数据提供器实现.
*/
export const studentBffMockProvider: MockDataProvider = (
service,
method,
request,
) => {
logger.debug({ service, method, request }, "Mock data request");
switch (service) {
case "iam":
return mockIam(method, request);
case "core-edu":
return mockCoreEdu(method, request);
case "content":
return mockContent(method, request);
case "data-ana":
return mockDataAna(method, request);
case "msg":
return mockMsg(method, request);
case "ai":
return mockAi(method, request);
default:
return undefined;
}
};
function mockIam(method: string, _request: unknown): unknown {
switch (method) {
case "GetUserInfo":
return {
userId: MOCK_STUDENT_ID,
email: "li.tongxue@example.com",
name: "李同学",
avatar: null,
roles: ["student"],
classId: MOCK_CLASS_ID,
className: "高一(1)班",
grade: "高一",
};
case "GetEffectivePermissions":
return {
userId: MOCK_STUDENT_ID,
permissions: [
"STUDENT_DASHBOARD_READ",
"STUDENT_EXAM_READ",
"STUDENT_HOMEWORK_READ",
"STUDENT_HOMEWORK_SUBMIT",
"STUDENT_GRADE_READ",
"STUDENT_CONTENT_READ",
"STUDENT_ANALYTICS_READ",
"STUDENT_NOTIFICATION_READ",
"STUDENT_AI_CHAT",
],
};
case "GetViewports":
return {
userId: MOCK_STUDENT_ID,
navigation: [
{ key: "dashboard", label: "首页", route: "/student/dashboard", sortOrder: 1 },
{ key: "homework", label: "我的作业", route: "/student/homework", sortOrder: 2 },
{ key: "grades", label: "我的成绩", route: "/student/grades", sortOrder: 3 },
{ key: "exams", label: "考试", route: "/student/exams", sortOrder: 4 },
{ key: "content", label: "教材", route: "/student/textbooks", sortOrder: 5 },
{ key: "analytics", label: "学情诊断", route: "/student/analytics", sortOrder: 6 },
{ key: "ai", label: "AI 答疑", route: "/student/ai", sortOrder: 7 },
],
dataScope: {
showHistoryGrades: true,
showClassRanking: false,
enableAIChat: true,
},
};
case "GetChildrenByParent":
// 学生场景不调用, 返回空
return { children: [] };
default:
return undefined;
}
}
function mockCoreEdu(method: string, request: unknown): unknown {
switch (method) {
case "ListHomeworkByClass":
case "ListHomeworkByStudent":
return {
homework: [
{
id: "h-001",
title: "数学作业第三章",
classId: MOCK_CLASS_ID,
subject: "数学",
dueDate: "2026-07-15T23:59:59Z",
status: "pending",
questions: [
{ id: "q-001", type: "short_answer", content: "求 x²+2x+1=0 的解" },
{ id: "q-002", type: "essay", content: "解释二次方程求根公式" },
],
},
{
id: "h-002",
title: "物理作业: 力学基础",
classId: MOCK_CLASS_ID,
subject: "物理",
dueDate: "2026-07-18T23:59:59Z",
status: "submitted",
submissionId: "sub-001",
},
],
};
case "GetHomework":
return {
id: "h-001",
title: "数学作业第三章",
classId: MOCK_CLASS_ID,
subject: "数学",
dueDate: "2026-07-15T23:59:59Z",
status: "pending",
questions: [
{ id: "q-001", type: "short_answer", content: "求 x²+2x+1=0 的解" },
{ id: "q-002", type: "essay", content: "解释二次方程求根公式" },
],
};
case "SubmitHomework":
return {
submissionId: "sub-" + Date.now(),
homeworkId: (request as { homeworkId?: string })?.homeworkId ?? "h-001",
submittedAt: new Date().toISOString(),
status: "submitted",
};
case "ListExamsByClass":
return {
exams: [
{
id: "e-001",
title: "期中考试",
classId: MOCK_CLASS_ID,
subject: "综合",
examDate: "2026-07-25T09:00:00Z",
duration: 120,
location: "教学楼 A301",
status: "upcoming",
},
{
id: "e-002",
title: "数学单元测试",
classId: MOCK_CLASS_ID,
subject: "数学",
examDate: "2026-07-20T14:00:00Z",
duration: 90,
location: "教学楼 B201",
status: "upcoming",
},
],
};
case "ListGradesByStudent":
return {
grades: [
{
id: "g-001",
studentId: MOCK_STUDENT_ID,
examId: "e-003",
examTitle: "月考",
subject: "数学",
score: 92,
maxScore: 100,
gradedAt: "2026-07-01T15:00:00Z",
feedback: "解题思路清晰, 步骤完整",
rank: 5,
},
{
id: "g-002",
studentId: MOCK_STUDENT_ID,
examId: "e-004",
examTitle: "物理测验",
subject: "物理",
score: 85,
maxScore: 100,
gradedAt: "2026-07-03T10:00:00Z",
feedback: "力学基础掌握良好",
rank: 12,
},
],
totalCount: 2,
};
case "GetClassesByStudent":
return {
classes: [
{
id: MOCK_CLASS_ID,
name: "高一(1)班",
grade: "高一",
homeroomTeacher: { id: "u-tch-001", name: "王老师" },
subjects: ["数学", "物理", "化学", "语文", "英语"],
},
],
};
default:
return undefined;
}
}
function mockContent(method: string, _request: unknown): unknown {
switch (method) {
case "ListTextbooks":
return {
textbooks: [
{ id: "tb-001", title: "高一数学(上)", grade: "高一", subject: "数学", version: "人教版" },
{ id: "tb-002", title: "高一物理(上)", grade: "高一", subject: "物理", version: "人教版" },
],
};
case "ListChapters":
return {
chapters: [
{ id: "ch-001", textbookId: "tb-001", title: "第一章 集合与函数", sortOrder: 1 },
{ id: "ch-002", textbookId: "tb-001", title: "第二章 基本初等函数", sortOrder: 2 },
],
};
case "GetLearningPath":
return {
knowledgePointId: "kp-001",
path: [
{ id: "kp-001", title: "二次方程", mastery: 0.6, recommended: true },
{ id: "kp-002", title: "因式分解", mastery: 0.8, recommended: false },
],
};
default:
return undefined;
}
}
function mockDataAna(method: string, _request: unknown): unknown {
switch (method) {
case "GetStudentWeakness":
return {
studentId: MOCK_STUDENT_ID,
weakPoints: [
{ knowledgePointId: "kp-001", title: "二次方程", mastery: 0.4, trend: "declining" },
{ knowledgePointId: "kp-003", title: "三角函数", mastery: 0.55, trend: "stable" },
],
summary: "数学代数基础薄弱, 建议加强二次方程练习",
};
case "GetLearningTrend":
return {
studentId: MOCK_STUDENT_ID,
range: "30d",
points: [
{ date: "2026-06-10", mastery: 0.55, studyMinutes: 120 },
{ date: "2026-06-20", mastery: 0.62, studyMinutes: 180 },
{ date: "2026-07-01", mastery: 0.68, studyMinutes: 210 },
],
trend: "improving",
};
case "GetStudentDashboard":
return {
studentId: MOCK_STUDENT_ID,
overallMastery: 0.68,
weakPointCount: 2,
studyMinutesLast7d: 840,
upcomingExams: 2,
};
default:
return undefined;
}
}
function mockMsg(method: string, _request: unknown): unknown {
switch (method) {
case "ListNotifications":
return {
notifications: [
{
id: "n-001",
userId: MOCK_STUDENT_ID,
type: "homework.graded",
title: "作业批改完成",
content: "你的数学作业已批改, 得分 92/100",
read: false,
createdAt: "2026-07-09T10:00:00Z",
},
{
id: "n-002",
userId: MOCK_STUDENT_ID,
type: "exam.published",
title: "新考试发布",
content: "期中考试将于 7月25日 举行",
read: false,
createdAt: "2026-07-08T15:00:00Z",
},
],
totalCount: 2,
unreadCount: 2,
};
case "MarkNotificationAsRead":
return { success: true, notificationId: "n-001", readAt: new Date().toISOString() };
default:
return undefined;
}
}
function mockAi(method: string, _request: unknown): unknown {
switch (method) {
case "Chat":
return {
response: "好的, 让我帮你分析这道题. x²+2x+1=0 是完全平方式, 可以写成 (x+1)²=0, 所以 x=-1.",
model: "gpt-4o-mini",
usage: { promptTokens: 120, completionTokens: 50, totalTokens: 170 },
};
case "StreamChat":
// mock 流式响应: 返回单个 chunk (DownstreamClient.callStream mock 模式产出 1 个 chunk 后结束)
return {
content: "好的, 让我帮你分析这道题. x²+2x+1=0 是完全平方式, 可以写成 (x+1)²=0, 所以 x=-1.",
done: true,
model: "gpt-4o-mini",
usage: { promptTokens: 120, completionTokens: 50, totalTokens: 170 },
};
default:
return undefined;
}
}

View File

@@ -0,0 +1,114 @@
/**
* student-bff 启动入口.
*
* 仲裁依据:
* - coord-final-decisions §1 G1 (Dockerfile 多阶段构建, EXPOSE 3009)
* - coord-final-decisions §1 G3 (/healthz liveness)
* - coord-final-decisions §1 G5 (/metrics 端点)
* - coord-final-decisions §1 G6 (OTel tracer)
* - coord-final-decisions §1 G8 (GlobalErrorFilter)
* - coord-final-decisions §1 G9 (优雅关闭 SIGTERM)
* - coord-final-decisions §2 B1 (GraphQL Yoga endpoint: POST /graphql)
* - port-allocation.md (HTTP 3009, 无 gRPC 对外)
*
* 启动流程:
* 1. initTracer (OTel)
* 2. NestFactory.create(AppModule)
* 3. app.useGlobalFilters(GlobalErrorFilter)
* 4. 挂载 GraphQL Yoga middleware (POST /graphql)
* 5. 注册 /metrics 端点
* 6. app.listen(3009)
* 7. SIGTERM → app.close() → 关闭 Redis/gRPC/Tracer
*/
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { AppModule } from "./app.module.js";
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import { env } from "./config/env.js";
import { GRAPHQL_YOGA } from "./student/student.module.js";
import { REDIS_CLIENT } from "./shared/cache/cache.module.js";
import { DOWNSTREAM_CLIENT } from "./shared/downstream/downstream.module.js";
import { CircuitBreakerService } from "./shared/circuit-breaker/circuit-breaker.service.js";
import type { Redis } from "ioredis";
import type { DownstreamClient } from "@edu/shared-ts/bff";
import type { YogaServerInstance } from "graphql-yoga";
import type { Request, Response, NextFunction } from "express";
async function bootstrap(): Promise<void> {
initTracer();
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ["log", "error", "warn"],
});
// G8 全局错误过滤器
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// B1 GraphQL Yoga endpoint 挂载
const yoga = (await app.resolve(GRAPHQL_YOGA)) as YogaServerInstance<
Record<string, unknown>,
unknown
>;
const httpAdapter = app.getHttpAdapter().getInstance();
httpAdapter.use(
"/graphql",
(req: Request, res: Response, next: NextFunction) => {
yoga.handle(req, res).catch((err: unknown) => {
logger.error({ err }, "GraphQL Yoga handle error");
next(err);
});
},
);
// G5 Prometheus 指标端点 (不鉴权)
httpAdapter.get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});
await app.listen(env.PORT);
logger.info(
{
port: env.PORT,
graphql: "/graphql",
playground: env.GRAPHQL_PLAYGROUND && env.NODE_ENV === "development",
mockUpstream: env.MOCK_UPSTREAM,
devMode: env.DEV_MODE,
},
"Student BFF started",
);
// G9 优雅关闭: SIGTERM → 关闭 HTTP → Redis → gRPC → Tracer
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down gracefully");
try {
await app.close();
const redis = app.get<Redis>(REDIS_CLIENT);
await redis.quit();
const downstream = app.get<DownstreamClient>(DOWNSTREAM_CLIENT);
await downstream.close();
const circuitBreaker = app.get<CircuitBreakerService>(CircuitBreakerService);
await circuitBreaker.shutdown();
await shutdownTracer();
logger.info("Graceful shutdown complete");
} catch (err) {
logger.error({ err }, "Error during graceful shutdown");
process.exit(1);
}
});
}
bootstrap().catch((err: unknown) => {
logger.error({ err }, "Failed to start Student BFF");
process.exit(1);
});

View File

@@ -0,0 +1,104 @@
/**
* ActionState 信封 + 降级模式方案 B 单元测试.
*/
import { describe, it, expect } from "vitest";
import { ok, fail, degraded, DegradedReason, type Degradable } from "./action-state.js";
describe("ActionState", () => {
describe("ok()", () => {
it("should construct success response with data only", () => {
const result = ok({ name: "test" });
expect(result.success).toBe(true);
expect(result.data).toEqual({ name: "test" });
expect(result.meta).toBeUndefined();
});
it("should construct success response with meta", () => {
const result = ok({ count: 1 }, { traceId: "trace-123" });
expect(result.success).toBe(true);
expect(result.data).toEqual({ count: 1 });
expect(result.meta?.traceId).toBe("trace-123");
});
it("should include cachedAt in meta", () => {
const result = ok({ items: [] }, { cachedAt: "2026-07-10T00:00:00Z" });
expect(result.meta?.cachedAt).toBe("2026-07-10T00:00:00Z");
});
});
describe("fail()", () => {
it("should construct error response with code and message", () => {
const result = fail("BFF_STUDENT_NOT_FOUND", "Resource not found");
expect(result.success).toBe(false);
expect(result.error.code).toBe("BFF_STUDENT_NOT_FOUND");
expect(result.error.message).toBe("Resource not found");
});
it("should include optional fields", () => {
const result = fail("BFF_STUDENT_BAD_GATEWAY", "Downstream failed", {
traceId: "trace-456",
i18nKey: "error.bffStudent.bad_gateway",
details: { service: "iam" },
});
expect(result.error.traceId).toBe("trace-456");
expect(result.error.i18nKey).toBe("error.bffStudent.bad_gateway");
expect(result.error.details).toEqual({ service: "iam" });
});
it("should have undefined optional fields when not provided", () => {
const result = fail("BFF_STUDENT_INTERNAL_ERROR", "Unknown error");
expect(result.error.traceId).toBeUndefined();
expect(result.error.i18nKey).toBeUndefined();
expect(result.error.details).toBeUndefined();
});
});
describe("degraded()", () => {
it("should construct degraded response with degraded=true", () => {
interface DashboardData extends Degradable {
score: number;
}
const result = degraded<DashboardData>(
{ score: 90 },
DegradedReason.DOWNSTREAM_PARTIAL_FAILURE,
["weakness", "trend"],
);
expect(result.success).toBe(true);
expect(result.data.score).toBe(90);
expect(result.data.degraded).toBe(true);
expect(result.data.degradedReason).toBe("downstream_partial_failure");
expect(result.data.degradedFields).toEqual(["weakness", "trend"]);
});
it("should set degraded=true in meta", () => {
const result = degraded(
{ items: [] },
DegradedReason.REDIS_UNAVAILABLE,
["items"],
);
expect(result.meta?.degraded).toBe(true);
expect(result.meta?.degradedReason).toBe("redis_unavailable");
});
it("should merge additional meta fields", () => {
const result = degraded(
{ data: "test" },
DegradedReason.CIRCUIT_OPEN,
["data"],
{ traceId: "trace-789" },
);
expect(result.meta?.traceId).toBe("trace-789");
expect(result.meta?.degraded).toBe(true);
});
});
describe("DegradedReason constants", () => {
it("should export all expected reasons", () => {
expect(DegradedReason.REDIS_UNAVAILABLE).toBe("redis_unavailable");
expect(DegradedReason.DOWNSTREAM_PARTIAL_FAILURE).toBe("downstream_partial_failure");
expect(DegradedReason.DOWNSTREAM_TIMEOUT).toBe("downstream_timeout");
expect(DegradedReason.CIRCUIT_OPEN).toBe("circuit_open");
expect(DegradedReason.MOCK_UPSTREAM).toBe("mock_upstream");
});
});
});

View File

@@ -0,0 +1,125 @@
/**
* ActionState 信封 + 降级模式方案 B 工具.
*
* 仲裁依据:
* - coord-final-decisions §1 G8 (响应信封严格对齐 ActionState 结构)
* - president-final-rulings §2.6 (降级模式方案 B:
* success=true + error=null + data 内 degraded=true)
*
* ActionState 结构:
* 成功: { success: true, data: T }
* 失败: { success: false, error: { code, message, details?, traceId? } }
*
* 降级模式 (方案 B):
* {
* success: true,
* data: {
* ...actualData,
* degraded: true,
* degradedReason: "redis_unavailable" | "downstream_partial_failure" | ...,
* degradedFields: ["field1", "field2"]
* }
* }
*/
export interface ActionStateSuccess<T> {
success: true;
data: T;
meta?: {
traceId?: string;
cachedAt?: string;
degraded?: boolean;
degradedReason?: string;
degradedServices?: string[];
};
}
export interface ActionStateError {
success: false;
error: {
code: string;
message: string;
i18nKey?: string;
details?: Record<string, unknown>;
traceId?: string;
};
}
export type ActionState<T> = ActionStateSuccess<T> | ActionStateError;
/**
* 降级标记字段 (president §2.6 方案 B).
* 业务 data 对象可包含此字段表示降级状态.
*/
export interface Degradable {
degraded?: boolean;
degradedReason?: string;
degradedFields?: string[];
}
/**
* 构造成功响应.
*/
export function ok<T>(data: T, meta?: ActionStateSuccess<T>["meta"]): ActionStateSuccess<T> {
return { success: true, data, meta };
}
/**
* 构造失败响应.
*/
export function fail(
code: string,
message: string,
options?: {
details?: Record<string, unknown>;
traceId?: string;
i18nKey?: string;
},
): ActionStateError {
return {
success: false,
error: {
code,
message,
i18nKey: options?.i18nKey,
details: options?.details,
traceId: options?.traceId,
},
};
}
/**
* 构造降级响应 (方案 B).
*
* 下游部分失败但仍返回部分数据时使用:
* - success=true (HTTP 200)
* - data.degraded=true
* - data.degradedReason=原因
* - data.degradedFields=哪些字段降级了
*/
export function degraded<T extends Degradable>(
data: T,
reason: string,
degradedFields: string[],
meta?: ActionStateSuccess<T>["meta"],
): ActionStateSuccess<T> {
return ok(
{
...data,
degraded: true,
degradedReason: reason,
degradedFields,
},
{ ...meta, degraded: true, degradedReason: reason },
);
}
/**
* 常用降级原因.
*/
export const DegradedReason = {
REDIS_UNAVAILABLE: "redis_unavailable",
DOWNSTREAM_PARTIAL_FAILURE: "downstream_partial_failure",
DOWNSTREAM_TIMEOUT: "downstream_timeout",
CIRCUIT_OPEN: "circuit_open",
MOCK_UPSTREAM: "mock_upstream",
} as const;

View File

@@ -0,0 +1,211 @@
/**
* student-bff Redis 缓存模块.
*
* 仲裁依据:
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
* - coord-final-decisions §1 G9 (优雅关闭 Redis 连接)
* - president-final-rulings §2.6 (降级模式方案 B: degraded=true + degradedReason)
* - 02-architecture-design.md §3.1 (Redis 缓存 Schema)
*
* 缓存 Key 规范:
* student:dashboard:{userId} TTL 15s
* student:exams:{userId}:{classId} TTL 30s
* student:homework:{userId}:{classId} TTL 30s
* student:grades:{userId}:{page} TTL 60s
* student:notifications:{userId}:{page} TTL 15s
* student:textbooks:{gradeId}:{subjectId} TTL 300s
* student:chapters:{textbookId} TTL 300s
* student:analytics:weakness:{userId} TTL 300s
* student:analytics:trend:{userId}:{range} TTL 600s
* student:viewports:{userId} TTL 300s
*/
import { Global, Module, OnModuleDestroy } from "@nestjs/common";
import Redis from "ioredis";
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
import { recordCacheAccess } from "../observability/metrics.js";
export const REDIS_CLIENT = Symbol("REDIS_CLIENT");
/**
* 缓存 TTL 预设 (秒).
*/
export const CacheTTL = {
DASHBOARD: 15,
EXAMS: 30,
HOMEWORK: 30,
GRADES: 60,
NOTIFICATIONS: 15,
TEXTBOOKS: 300,
CHAPTERS: 300,
ANALYTICS_WEAKNESS: 300,
ANALYTICS_TREND: 600,
VIEWPORTS: 300,
} as const;
/**
* 缓存 Key 构建器 (统一前缀 + 规范化).
*/
export function buildCacheKey(pattern: string, ...parts: (string | number)[]): string {
const suffix = parts.map(String).join(":");
return `${env.REDIS_KEY_PREFIX}${pattern}:${suffix}`;
}
@Global()
@Module({
providers: [
{
provide: REDIS_CLIENT,
useFactory: (): Redis => {
const client = new Redis(env.REDIS_URL, {
lazyConnect: false,
maxRetriesPerRequest: 3,
enableReadyCheck: true,
retryStrategy: (times) => Math.min(times * 100, 2000),
});
client.on("error", (err) => {
logger.error({ err }, "Redis client error");
});
client.on("connect", () => {
logger.info({ url: env.REDIS_URL }, "Redis connected");
});
return client;
},
},
],
exports: [REDIS_CLIENT],
})
export class CacheModule implements OnModuleDestroy {
constructor() {}
async onModuleDestroy(): Promise<void> {
// G9 优雅关闭: 由 main.ts SIGTERM handler 统一调用 disconnectAll
logger.info("CacheModule destroyed (Redis disconnect handled by main.ts)");
}
}
/**
* 缓存服务 - 封装 Redis get/set + 降级模式.
*
* 使用方式:
* const cached = await cacheService.get<StudentDashboard>("dashboard", userId);
* if (cached) return cached;
* const fresh = await aggregateDashboard(userId);
* await cacheService.set("dashboard", userId, fresh, CacheTTL.DASHBOARD);
*/
export class CacheService {
constructor(private readonly redis: Redis) {}
/**
* 读取缓存, 自动 JSON 反序列化.
* 失败时返回 null (不抛异常, 上层走降级模式).
*/
async get<T>(pattern: string, ...keyParts: (string | number)[]): Promise<T | null> {
const key = buildCacheKey(pattern, ...keyParts);
try {
const raw = await this.redis.get(key);
if (raw) {
recordCacheAccess(pattern, true);
return JSON.parse(raw) as T;
}
recordCacheAccess(pattern, false);
return null;
} catch (err) {
logger.warn({ err, key, pattern }, "Cache get failed, returning null");
recordCacheAccess(pattern, false);
return null;
}
}
/**
* 写入缓存, 自动 JSON 序列化.
* 失败时仅记录日志, 不影响主流程 (降级模式).
*/
async set(
pattern: string,
value: unknown,
ttlSeconds: number,
...keyParts: (string | number)[]
): Promise<void> {
const key = buildCacheKey(pattern, ...keyParts);
try {
const serialized = JSON.stringify(value);
// TTL 加 ±20% 随机抖动, 避免缓存雪崩 (02 §8.1)
const jitter = Math.floor(ttlSeconds * 0.2 * (Math.random() * 2 - 1));
const ttl = Math.max(1, ttlSeconds + jitter);
await this.redis.set(key, serialized, "EX", ttl);
} catch (err) {
logger.warn({ err, key, pattern }, "Cache set failed, skipping");
}
}
/**
* 失效缓存 (按 pattern 通配符删除).
* 用于写操作后主动失效相关缓存.
*/
async invalidate(pattern: string, ...keyParts: (string | number)[]): Promise<void> {
const key = buildCacheKey(pattern, ...keyParts);
try {
// 如果 keyParts 含通配符, 用 SCAN 删除
if (key.includes("*")) {
const stream = this.redis.scanStream({
match: key,
count: 100,
});
const pipeline = this.redis.pipeline();
stream.on("data", (keys: string[]) => {
if (keys.length > 0) {
keys.forEach((k) => pipeline.del(k));
}
});
await new Promise<void>((resolve) => {
stream.on("end", () => {
pipeline.exec().finally(() => resolve());
});
});
} else {
await this.redis.del(key);
}
} catch (err) {
logger.warn({ err, key, pattern }, "Cache invalidate failed");
}
}
/**
* 批量失效 (如成绩发布后失效学生所有成绩缓存).
*/
async invalidateByPrefix(prefix: string): Promise<void> {
const pattern = `${env.REDIS_KEY_PREFIX}${prefix}*`;
try {
const stream = this.redis.scanStream({
match: pattern,
count: 100,
});
const pipeline = this.redis.pipeline();
stream.on("data", (keys: string[]) => {
if (keys.length > 0) {
keys.forEach((k) => pipeline.del(k));
}
});
await new Promise<void>((resolve) => {
stream.on("end", () => {
pipeline.exec().finally(() => resolve());
});
});
} catch (err) {
logger.warn({ err, prefix }, "Cache invalidateByPrefix failed");
}
}
/**
* 健康检查 (供 /readyz 探针使用, §2.4).
*/
async ping(): Promise<boolean> {
try {
const result = await this.redis.ping();
return result === "PONG";
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,19 @@
/**
* CircuitBreakerModule - P6 熔断器模块.
*
* 仲裁依据:
* - workline §5.5 P6.1 (熔断器 opossum 完善)
* - coord-final-decisions §1 G11 (opossum 熔断器)
*
* 提供 CircuitBreakerService 供 Resolver 使用 (可选, P6 阶段启用).
* P3-P5 阶段 Resolver 直接调用 DownstreamClient.call, P6 可切换为 CircuitBreakerService.call.
*/
import { Global, Module } from "@nestjs/common";
import { CircuitBreakerService } from "./circuit-breaker.service.js";
@Global()
@Module({
providers: [CircuitBreakerService],
exports: [CircuitBreakerService],
})
export class CircuitBreakerModule {}

View File

@@ -0,0 +1,225 @@
/**
* CircuitBreakerService - P6 熔断器封装.
*
* 仲裁依据:
* - workline §5.5 P6.1 (熔断器 opossum 完善)
* - coord-final-decisions §1 G11 (熔断器: opossum, 50% 阈值, 30s reset)
* - president-final-rulings §2.6 (降级模式方案 B: 熔断开启时返回 degraded)
*
* 设计:
* - 每个下游服务一个独立 CircuitBreaker 实例 (Map 缓存)
* - 熔断开启时抛出 ServiceUnavailableError (503)
* - 状态变更同步到 student_bff_circuit_state 指标
* - half-open 状态允许单次试探调用
*
* Opossum 配置 (G11):
* - timeout: 5000ms (对齐 DOWNSTREAM_TIMEOUT_MS)
* - errorThresholdPercentage: 50%
* - resetTimeout: 30000ms (30s 后 half-open)
* - volumeThreshold: 10 (最少 10 次调用才评估)
* - rollingCountTimeout: 60000 (1 分钟滚动窗口)
*/
import { Injectable } from "@nestjs/common";
import CircuitBreaker from "opossum";
import type { DownstreamClient, CallOptions } from "@edu/shared-ts/bff";
import { ServiceUnavailableError } from "../errors/application-error.js";
import { metricsRegistry } from "../observability/metrics.js";
import { logger } from "../observability/logger.js";
import { env } from "../../config/env.js";
/**
* 熔断器配置.
*/
interface BreakerConfig {
timeoutMs: number;
errorThresholdPercentage: number;
resetTimeoutMs: number;
volumeThreshold: number;
rollingCountTimeoutMs: number;
}
/**
* 默认熔断配置 (G11).
*/
const DEFAULT_CONFIG: BreakerConfig = {
timeoutMs: env.DOWNSTREAM_TIMEOUT_MS,
errorThresholdPercentage: 50,
resetTimeoutMs: 30000,
volumeThreshold: 10,
rollingCountTimeoutMs: 60000,
};
/**
* 熔断器状态映射到指标值.
*/
function stateToMetricValue(state: CircuitBreaker.Status): number {
switch (state) {
case CircuitBreaker.CLOSED:
return 0;
case CircuitBreaker.OPEN:
return 1;
case CircuitBreaker.HALF_OPEN:
return 2;
default:
return 0;
}
}
/**
* 熔断器状态名称.
*/
function stateName(state: CircuitBreaker.Status): string {
switch (state) {
case CircuitBreaker.CLOSED:
return "closed";
case CircuitBreaker.OPEN:
return "open";
case CircuitBreaker.HALF_OPEN:
return "half_open";
default:
return "unknown";
}
}
@Injectable()
export class CircuitBreakerService {
private readonly breakers = new Map<string, CircuitBreaker>();
private readonly config: BreakerConfig;
constructor(config?: Partial<BreakerConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* 通过熔断器调用下游服务.
*
* @param downstream DownstreamClient 实例
* @param service 下游服务名
* @param method RPC 方法名
* @param request 请求 message
* @param options 调用配置
* @returns 下游响应
* @throws ServiceUnavailableError 当熔断器开启时
*/
async call<TRequest, TResponse>(
downstream: DownstreamClient,
service: string,
method: string,
request: TRequest,
options?: CallOptions,
): Promise<TResponse> {
const breaker = this.getOrCreateBreaker(service, downstream, method, request, options);
try {
return (await breaker.fire()) as TResponse;
} catch (err) {
if (err instanceof ServiceUnavailableError) {
throw err;
}
// 重新抛出原始错误 (DownstreamError 等)
throw err;
}
}
/**
* 获取熔断器当前状态.
*/
getState(service: string): CircuitBreaker.Status | null {
const breaker = this.breakers.get(service);
return breaker ? breaker.status : null;
}
/**
* 获取或创建某服务的熔断器.
*
* 注意: 由于 opossum 的 fire() 不接受动态参数, 我们在每次调用时
* 通过闭包捕获当前的 method/request/options.
* 实际上 opossum 支持 fire(args...), 但此处为简化设计,
* 每次创建新的执行函数.
*
* 为避免创建过多 breaker 实例, 我们按 service 名缓存 breaker,
* 并在 fire 前更新其执行函数.
*/
private getOrCreateBreaker<TRequest>(
service: string,
downstream: DownstreamClient,
method: string,
request: TRequest,
options?: CallOptions,
): CircuitBreaker {
let breaker = this.breakers.get(service);
if (!breaker) {
const execFn = async (): Promise<unknown> => {
return downstream.call(service, method, request, options);
};
breaker = new CircuitBreaker(execFn, {
timeout: this.config.timeoutMs,
errorThresholdPercentage: this.config.errorThresholdPercentage,
resetTimeout: this.config.resetTimeoutMs,
volumeThreshold: this.config.volumeThreshold,
rollingCountTimeout: this.config.rollingCountTimeoutMs,
});
// 状态变更监听
breaker.on("open", () => {
logger.warn({ service }, "Circuit breaker OPENED");
this.updateMetric(service, CircuitBreaker.OPEN);
});
breaker.on("close", () => {
logger.info({ service }, "Circuit breaker CLOSED (recovered)");
this.updateMetric(service, CircuitBreaker.CLOSED);
});
breaker.on("halfOpen", () => {
logger.info({ service }, "Circuit breaker HALF-OPEN");
this.updateMetric(service, CircuitBreaker.HALF_OPEN);
});
// fallback: 熔断开启时返回 ServiceUnavailableError
breaker.fallback(() => {
throw new ServiceUnavailableError(
`Circuit breaker open for service: ${service}`,
{ service, state: "open" },
);
});
this.breakers.set(service, breaker);
this.updateMetric(service, CircuitBreaker.CLOSED);
} else {
// 更新执行函数 (opossum 允许重新设置 action)
// 由于 opossum 不支持直接替换 action, 我们使用 wrapper 方式
// 实际上 opossum 的 fire() 会调用构造时传入的函数,
// 所以我们用一个可变的 wrapper
(breaker as unknown as { action: () => Promise<unknown> }).action = async () => {
return downstream.call(service, method, request, options);
};
}
return breaker;
}
/**
* 更新熔断器指标.
*/
private updateMetric(service: string, state: CircuitBreaker.Status): void {
const value = stateToMetricValue(state);
const name = stateName(state);
metricsRegistry
.getSingleMetric("student_bff_circuit_state")
?.set({ service, state: name }, value);
}
/**
* 关闭所有熔断器 (优雅关闭).
*/
async shutdown(): Promise<void> {
for (const [service, breaker] of this.breakers) {
breaker.shutdown();
logger.debug({ service }, "Circuit breaker shut down");
}
this.breakers.clear();
}
}

View File

@@ -0,0 +1,61 @@
/**
* student-bff DownstreamClient NestJS Module.
*
* 仲裁依据: coord-final-decisions §2 B8 (3 BFF 复用 shared-ts 抽象)
*
* 提供:
* - 全局共享 DownstreamClient 实例 (@Injectable)
* - 启动时注入 mock 数据提供器 (env.MOCK_UPSTREAM=true 时)
* - OnModuleDestroy 优雅关闭 gRPC 连接 (G9)
*/
import { Global, Module, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { DownstreamClient } from "@edu/shared-ts/bff";
import { downstreamClientConfig } from "../../config/downstream.js";
import { studentBffMockProvider } from "../../config/mock-data.js";
import { logger } from "../observability/logger.js";
export const DOWNSTREAM_CLIENT = Symbol("DOWNSTREAM_CLIENT");
@Global()
@Module({
providers: [
{
provide: DOWNSTREAM_CLIENT,
useFactory: (): DownstreamClient => {
const client = new DownstreamClient(downstreamClientConfig);
if (downstreamClientConfig.mockUpstream) {
client.setMockProvider(studentBffMockProvider);
logger.warn(
"MOCK_UPSTREAM=true, all downstream calls return mock data",
);
}
return client;
},
},
{
provide: DownstreamClient,
useExisting: DOWNSTREAM_CLIENT,
},
],
exports: [DownstreamClient, DOWNSTREAM_CLIENT],
})
export class DownstreamModule implements OnModuleInit, OnModuleDestroy {
constructor() {}
async onModuleInit(): Promise<void> {
const enabled = downstreamClientConfig.services
.filter((s) => s.enabled)
.map((s) => s.name);
logger.info(
{ enabled, mock: downstreamClientConfig.mockUpstream },
"DownstreamClient initialized",
);
}
async onModuleDestroy(): Promise<void> {
// G9 优雅关闭: 关闭所有 gRPC 连接
// DownstreamClient 实例由 NestJS 容器管理, 这里通过 token 获取
// 但 Module destroy 阶段不能注入, 实际由 main.ts SIGTERM handler 统一关闭
logger.info("DownstreamModule destroyed");
}
}

View File

@@ -0,0 +1,188 @@
/**
* ApplicationError 错误类层次 + i18n key 生成 单元测试.
*/
import { describe, it, expect } from "vitest";
import {
ApplicationError,
ValidationError,
UnauthorizedError,
ForbiddenResourceError,
IdentityMismatchError,
NotFoundError,
ConflictError,
BusinessError,
BadGatewayError,
GatewayTimeoutError,
ServiceUnavailableError,
InternalError,
} from "./application-error.js";
describe("ApplicationError", () => {
describe("ValidationError", () => {
it("should have 400 status and BFF_STUDENT_VALIDATION_ERROR code", () => {
const err = new ValidationError("Invalid input");
expect(err.statusCode).toBe(400);
expect(err.code).toBe("BFF_STUDENT_VALIDATION_ERROR");
expect(err.type).toBe("validation");
expect(err.message).toBe("Invalid input");
});
it("should accept details", () => {
const err = new ValidationError("Invalid input", { field: "email" });
expect(err.details).toEqual({ field: "email" });
});
});
describe("UnauthorizedError", () => {
it("should have 401 status and BFF_STUDENT_UNAUTHORIZED code", () => {
const err = new UnauthorizedError();
expect(err.statusCode).toBe(401);
expect(err.code).toBe("BFF_STUDENT_UNAUTHORIZED");
expect(err.type).toBe("unauthorized");
});
it("should use default message", () => {
const err = new UnauthorizedError();
expect(err.message).toBe("Missing or invalid x-user-id");
});
it("should accept custom message", () => {
const err = new UnauthorizedError("Token expired");
expect(err.message).toBe("Token expired");
});
});
describe("ForbiddenResourceError (场景 A)", () => {
it("should have 403 status and BFF_STUDENT_FORBIDDEN_RESOURCE code", () => {
const err = new ForbiddenResourceError("Not your data");
expect(err.statusCode).toBe(403);
expect(err.code).toBe("BFF_STUDENT_FORBIDDEN_RESOURCE");
expect(err.type).toBe("permission_denied");
});
});
describe("IdentityMismatchError (场景 B)", () => {
it("should have 403 status and BFF_STUDENT_IDENTITY_MISMATCH code", () => {
const err = new IdentityMismatchError("Identity mismatch");
expect(err.statusCode).toBe(403);
expect(err.code).toBe("BFF_STUDENT_IDENTITY_MISMATCH");
expect(err.type).toBe("permission_denied");
});
});
describe("NotFoundError", () => {
it("should have 404 status and BFF_STUDENT_NOT_FOUND code", () => {
const err = new NotFoundError("Homework", "hw-123");
expect(err.statusCode).toBe(404);
expect(err.code).toBe("BFF_STUDENT_NOT_FOUND");
expect(err.message).toBe("Homework not found: hw-123");
expect(err.details).toEqual({ resource: "Homework", id: "hw-123" });
});
});
describe("ConflictError", () => {
it("should have 409 status and BFF_STUDENT_CONFLICT code", () => {
const err = new ConflictError("Already submitted");
expect(err.statusCode).toBe(409);
expect(err.code).toBe("BFF_STUDENT_CONFLICT");
});
});
describe("BusinessError", () => {
it("should have 422 status and BFF_STUDENT_BUSINESS_ERROR code", () => {
const err = new BusinessError("Business rule violated");
expect(err.statusCode).toBe(422);
expect(err.code).toBe("BFF_STUDENT_BUSINESS_ERROR");
});
});
describe("BadGatewayError", () => {
it("should have 502 status and BFF_STUDENT_BAD_GATEWAY code", () => {
const err = new BadGatewayError("Downstream failed");
expect(err.statusCode).toBe(502);
expect(err.code).toBe("BFF_STUDENT_BAD_GATEWAY");
});
});
describe("GatewayTimeoutError", () => {
it("should have 504 status and BFF_STUDENT_GATEWAY_TIMEOUT code", () => {
const err = new GatewayTimeoutError("Downstream timeout");
expect(err.statusCode).toBe(504);
expect(err.code).toBe("BFF_STUDENT_GATEWAY_TIMEOUT");
});
});
describe("ServiceUnavailableError", () => {
it("should have 503 status and BFF_STUDENT_SERVICE_UNAVAILABLE code", () => {
const err = new ServiceUnavailableError("Circuit breaker open");
expect(err.statusCode).toBe(503);
expect(err.code).toBe("BFF_STUDENT_SERVICE_UNAVAILABLE");
});
});
describe("InternalError", () => {
it("should have 500 status and BFF_STUDENT_INTERNAL_ERROR code", () => {
const err = new InternalError("Unexpected error");
expect(err.statusCode).toBe(500);
expect(err.code).toBe("BFF_STUDENT_INTERNAL_ERROR");
});
});
describe("toJSON() serialization", () => {
it("should serialize to ActionState error envelope", () => {
const err = new ValidationError("Invalid input", { field: "name" });
err.traceId = "trace-abc";
const json = err.toJSON();
expect(json.success).toBe(false);
expect(json.error).toBeDefined();
expect(json.error.code).toBe("BFF_STUDENT_VALIDATION_ERROR");
expect(json.error.message).toBe("Invalid input");
expect(json.error.i18nKey).toBe("error.bffStudent.validation_error");
expect(json.error.details).toEqual({ field: "name" });
expect(json.error.traceId).toBe("trace-abc");
});
it("should generate correct i18n key for each error code", () => {
const cases = [
{ error: new ValidationError(), expectedKey: "error.bffStudent.validation_error" },
{ error: new UnauthorizedError(), expectedKey: "error.bffStudent.unauthorized" },
{ error: new ForbiddenResourceError("test"), expectedKey: "error.bffStudent.forbidden_resource" },
{ error: new IdentityMismatchError("test"), expectedKey: "error.bffStudent.identity_mismatch" },
{ error: new NotFoundError("X", "1"), expectedKey: "error.bffStudent.not_found" },
{ error: new ConflictError("test"), expectedKey: "error.bffStudent.conflict" },
{ error: new BusinessError("test"), expectedKey: "error.bffStudent.business_error" },
{ error: new BadGatewayError("test"), expectedKey: "error.bffStudent.bad_gateway" },
{ error: new GatewayTimeoutError("test"), expectedKey: "error.bffStudent.gateway_timeout" },
{ error: new ServiceUnavailableError("test"), expectedKey: "error.bffStudent.service_unavailable" },
{ error: new InternalError("test"), expectedKey: "error.bffStudent.internal_error" },
];
for (const { error, expectedKey } of cases) {
const json = error.toJSON();
expect(json.error.i18nKey).toBe(expectedKey);
}
});
});
describe("Error name", () => {
it("should set constructor name as error.name", () => {
expect(new ValidationError("x").name).toBe("ValidationError");
expect(new UnauthorizedError().name).toBe("UnauthorizedError");
expect(new ForbiddenResourceError("x").name).toBe("ForbiddenResourceError");
expect(new IdentityMismatchError("x").name).toBe("IdentityMismatchError");
});
});
describe("instanceof checks", () => {
it("should be instanceof ApplicationError", () => {
expect(new ValidationError("x")).toBeInstanceOf(ApplicationError);
expect(new UnauthorizedError()).toBeInstanceOf(ApplicationError);
expect(new ForbiddenResourceError("x")).toBeInstanceOf(ApplicationError);
});
it("should be instanceof Error", () => {
expect(new ValidationError("x")).toBeInstanceOf(Error);
expect(new InternalError("x")).toBeInstanceOf(Error);
});
});
});

View File

@@ -0,0 +1,177 @@
/**
* student-bff ApplicationError 层次.
*
* 仲裁依据:
* - coord-final-decisions §1 G8 (首次实现即 GlobalErrorFilter + ActionState 信封)
* - coord-final-decisions §1 G14 (服务名大写前缀, BFF_STUDENT_*)
* - coord-final-decisions §2 B5 (统一 BFF_ 前缀)
* - president-final-rulings §2.7 (3 类越权防御错误码)
*
* 错误码清单:
* BFF_STUDENT_VALIDATION_ERROR (400) Zod 校验失败
* BFF_STUDENT_UNAUTHORIZED (401) x-user-id 缺失或无效
* BFF_STUDENT_FORBIDDEN_RESOURCE (403) 资源无归属关系 (场景 A)
* BFF_STUDENT_IDENTITY_MISMATCH (403) JWT userId 与 body 不一致 (场景 B)
* BFF_STUDENT_NOT_FOUND (404) 资源不存在
* BFF_STUDENT_CONFLICT (409) 重复提交 / 状态冲突
* BFF_STUDENT_BUSINESS_ERROR (422) 业务规则违反
* BFF_STUDENT_BAD_GATEWAY (502) 下游 gRPC 失败
* BFF_STUDENT_GATEWAY_TIMEOUT (504) 下游超时
* BFF_STUDENT_SERVICE_UNAVAILABLE (503) 熔断器开启
* BFF_STUDENT_INTERNAL_ERROR (500) 未捕获异常
*/
export type ErrorType =
| "validation"
| "not_found"
| "permission_denied"
| "unauthorized"
| "conflict"
| "business"
| "bad_gateway"
| "gateway_timeout"
| "service_unavailable"
| "internal";
export interface ErrorDetails {
[key: string]: unknown;
}
/**
* i18n key 生成 (president §2.7 + F4 裁决): error.bffStudent.<code_snake>.
*/
function toI18nKey(code: string): string {
const snake = code
.replace(/^BFF_STUDENT_/, "")
.toLowerCase()
.replace(/_/g, "_");
return `error.bffStudent.${snake}`;
}
export abstract class ApplicationError extends Error {
abstract readonly type: ErrorType;
abstract readonly statusCode: number;
readonly code: string;
readonly details?: ErrorDetails;
traceId?: string;
constructor(message: string, code: string, details?: ErrorDetails) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.details = details;
}
/**
* 序列化为 ActionState 信封响应体 (G8).
*/
toJSON(): Record<string, unknown> {
return {
success: false,
error: {
code: this.code,
message: this.message,
i18nKey: toI18nKey(this.code),
details: this.details,
traceId: this.traceId,
},
};
}
}
export class ValidationError extends ApplicationError {
readonly type = "validation" as const;
readonly statusCode = 400;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_VALIDATION_ERROR", details);
}
}
export class UnauthorizedError extends ApplicationError {
readonly type = "unauthorized" as const;
readonly statusCode = 401;
constructor(message = "Missing or invalid x-user-id", details?: ErrorDetails) {
super(message, "BFF_STUDENT_UNAUTHORIZED", details);
}
}
/**
* 场景 A: 资源无归属关系 (president §2.7).
* 如学生请求的 studentId 与 JWT userId 不一致.
*/
export class ForbiddenResourceError extends ApplicationError {
readonly type = "permission_denied" as const;
readonly statusCode = 403;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_FORBIDDEN_RESOURCE", details);
}
}
/**
* 场景 B: JWT userId 与请求 body userId 不一致 (president §2.7).
*/
export class IdentityMismatchError extends ApplicationError {
readonly type = "permission_denied" as const;
readonly statusCode = 403;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_IDENTITY_MISMATCH", details);
}
}
export class NotFoundError extends ApplicationError {
readonly type = "not_found" as const;
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, "BFF_STUDENT_NOT_FOUND", {
resource,
id,
});
}
}
export class ConflictError extends ApplicationError {
readonly type = "conflict" as const;
readonly statusCode = 409;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_CONFLICT", details);
}
}
export class BusinessError extends ApplicationError {
readonly type = "business" as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_BUSINESS_ERROR", details);
}
}
export class BadGatewayError extends ApplicationError {
readonly type = "bad_gateway" as const;
readonly statusCode = 502;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_BAD_GATEWAY", details);
}
}
export class GatewayTimeoutError extends ApplicationError {
readonly type = "gateway_timeout" as const;
readonly statusCode = 504;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_GATEWAY_TIMEOUT", details);
}
}
export class ServiceUnavailableError extends ApplicationError {
readonly type = "service_unavailable" as const;
readonly statusCode = 503;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_SERVICE_UNAVAILABLE", details);
}
}
export class InternalError extends ApplicationError {
readonly type = "internal" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_INTERNAL_ERROR", details);
}
}

View File

@@ -0,0 +1,119 @@
/**
* student-bff GlobalErrorFilter.
*
* 仲裁依据:
* - coord-final-decisions §1 G8 (首次实现即 GlobalErrorFilter + ActionState 信封)
* - president-final-rulings §2.6 (降级模式方案 B: success=true + degraded)
*
* GraphQL Yoga 错误格式化由 graphql-error-formatter.ts 处理;
* 本 Filter 处理 NestJS HTTP 异常 (健康检查 /metrics 等), 并被 GraphQL Yoga
* 在错误转换时复用 code/i18nKey 生成逻辑.
*/
import {
Catch,
ExceptionFilter,
ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
import { DownstreamError } from "@edu/shared-ts/bff";
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500;
let body: Record<string, unknown>;
if (exception instanceof ApplicationError) {
exception.traceId = traceId;
statusCode = exception.statusCode;
body = exception.toJSON();
} else if (exception instanceof DownstreamError) {
// 下游 gRPC 错误归一化为 BFF_STUDENT_BAD_GATEWAY (B2 + G8)
statusCode = 502;
body = {
success: false,
error: {
code: "BFF_STUDENT_BAD_GATEWAY",
message: `Downstream ${exception.service}.${exception.method} failed: ${exception.message}`,
i18nKey: "error.bffStudent.bad_gateway",
details: {
service: exception.service,
method: exception.method,
downstreamCode: exception.code,
traceId,
},
traceId,
},
};
} else if (exception instanceof ZodError) {
statusCode = 400;
body = {
success: false,
error: {
code: "BFF_STUDENT_VALIDATION_ERROR",
message: "Validation failed",
i18nKey: "error.bffStudent.validation_error",
details: exception.flatten(),
traceId,
},
};
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const res = exception.getResponse();
const message = this.extractHttpMessage(res, exception);
body = {
success: false,
error: {
code: "BFF_STUDENT_HTTP_ERROR",
message,
i18nKey: "error.bffStudent.http_error",
traceId,
},
};
} else {
this.logger.error(
`Unhandled exception: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
body = {
success: false,
error: {
code: "BFF_STUDENT_INTERNAL_ERROR",
message: "An unexpected error occurred",
i18nKey: "error.bffStudent.internal_error",
traceId,
},
};
}
response.status(statusCode).json(body);
}
private extractHttpMessage(
res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === "object" && "message" in res) {
const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
}
}

View File

@@ -0,0 +1,142 @@
/**
* GraphQL Yoga 配置 - 加载 schema + context + error formatter.
*
* 仲裁依据:
* - coord-final-decisions §2 B1 (P2 起直接 GraphQL Yoga + DataLoader)
* - president-final-rulings §2.2 (GraphQL schema 存放 packages/shared-ts/contracts/graphql/)
* - coord-final-decisions §1 G8 (错误响应格式: GraphQL errors 数组 + extensions)
*
* Schema 文件: packages/shared-ts/contracts/graphql/student-bff.schema.graphql
*
* 集成方式: GraphQL Yoga 作为 Express middleware 挂载到 NestJS HTTP Adapter,
* 路径 POST /graphql,开发环境启用 Playground.
*/
import { promises } from "node:fs";
import path from "node:path";
import { createYoga, type YogaServerInstance } from "graphql-yoga";
import { makeExecutableSchema } from "@graphql-tools/schema";
import type { Request, Response } from "express";
import type { DownstreamClient } from "@edu/shared-ts/bff";
import { env } from "../../config/env.js";
import { logger } from "./logger.js";
import type { Redis } from "ioredis";
import { createDataLoaders, type StudentBffDataLoaders } from "../../student/dataloaders/data-loader.module.js";
import {
extractUserIdFromRequest,
extractTraceIdFromRequest,
extractUserRolesFromRequest,
} from "../../student/guards/authorization.guard.js";
/**
* GraphQL Context (每个请求一份).
*/
export interface StudentBffContext {
userId: string | null;
traceId: string;
userRoles: string[];
downstream: DownstreamClient;
redis: Redis;
dataLoaders: StudentBffDataLoaders;
requestId: string;
}
/**
* GraphQL schema 文件路径.
*/
const SCHEMA_PATH = path.resolve(
process.cwd(),
"packages/shared-ts/contracts/graphql/student-bff.schema.graphql",
);
/**
* 加载 schema SDL 文本.
*/
export async function loadSchemaSDL(): Promise<string> {
try {
return await promises.readFile(SCHEMA_PATH, "utf-8");
} catch (err) {
logger.error(
{ err, path: SCHEMA_PATH },
"Failed to load student-bff GraphQL schema file",
);
throw err;
}
}
/**
* 创建 GraphQL Yoga 实例.
*
* @param resolvers GraphQL Resolver 映射表 (由 StudentModule 装配)
* @param downstream DownstreamClient 实例 (由 NestJS DI 注入)
* @param redis Redis 客户端 (由 NestJS DI 注入)
*/
export async function createStudentBffYoga(
resolvers: Record<string, unknown>,
downstream: DownstreamClient,
redis: Redis,
): Promise<YogaServerInstance<Record<string, unknown>, StudentBffContext>> {
const typeDefs = await loadSchemaSDL();
const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
const yoga = createYoga<{
req: Request;
res: Response;
}, StudentBffContext>({
schema,
graphqlEndpoint: "/graphql",
context: ({ req }): StudentBffContext => {
const userId = extractUserIdFromRequest(req);
const traceId = extractTraceIdFromRequest(req);
const userRoles = extractUserRolesFromRequest(req);
return {
userId,
traceId,
userRoles,
downstream,
redis,
dataLoaders: createDataLoaders(downstream),
requestId: traceId,
};
},
logging: {
debug: (msg) => logger.debug({ component: "graphql-yoga" }, String(msg)),
info: (msg) => logger.info({ component: "graphql-yoga" }, String(msg)),
warn: (msg) => logger.warn({ component: "graphql-yoga" }, String(msg)),
error: (msg) => logger.error({ component: "graphql-yoga" }, String(msg)),
},
maskedErrors: env.NODE_ENV === "production",
// 开发环境启用 Playground
graphiql: env.GRAPHQL_PLAYGROUND && env.NODE_ENV === "development",
// 错误格式化 (G8): GraphQL errors 数组 + extensions.code + extensions.traceId
formatError: (err) => {
const originalError = err.originalError;
const code =
(originalError as { code?: string })?.code ??
"BFF_STUDENT_INTERNAL_ERROR";
const traceId = err.context?.requestId ?? "unknown";
return {
message: err.message,
extensions: {
code,
traceId,
i18nKey: `error.bffStudent.${code.replace(/^BFF_STUDENT_/, "").toLowerCase()}`,
severity: "error",
},
path: err.path,
locations: err.locations,
};
},
});
logger.info(
{ endpoint: "/graphql", playground: env.GRAPHQL_PLAYGROUND },
"GraphQL Yoga initialized",
);
return yoga;
}

View File

@@ -0,0 +1,146 @@
/**
* Health Controller - /healthz + /readyz 探针.
*
* 仲裁依据:
* - coord-final-decisions §1 G2 (首次实现即检查全部下游依赖)
* - coord-final-decisions §1 G3 (/healthz liveness)
* - president-final-rulings §2.4 (/readyz 探针按阶段扩展, 必需失败返回 503, 可选软失败)
*
* 探针列表 (按阶段扩展, president §2.4):
* P3: Redis + iam gRPC + core-edu gRPC (3 项)
* P4: + content gRPC + data-ana gRPC (5 项)
* P5: + msg gRPC + ai gRPC (7 项)
*
* 实现方式: DownstreamClient.checkHealth() 检查 gRPC 可达性 + CacheService.ping() 检查 Redis.
*/
import { Controller, Get, HttpCode, HttpStatus, Inject } from "@nestjs/common";
import { DownstreamClient } from "@edu/shared-ts/bff";
import { REDIS_CLIENT, CacheService } from "../cache/cache.module.js";
import type { Redis } from "ioredis";
import { downstreamServices } from "../../config/downstream.js";
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
interface HealthCheck {
service: string;
healthy: boolean;
required: boolean;
latencyMs?: number;
}
interface ReadyzResponse {
status: "ok" | "degraded" | "unavailable";
service: string;
timestamp: string;
checks: HealthCheck[];
degraded?: boolean;
degradedServices?: string[];
}
const SERVICE_NAME = "student-bff";
@Controller()
export class HealthController {
constructor(
private readonly downstream: DownstreamClient,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
/**
* /healthz: Liveness probe (G3).
* 仅检查进程存活, 不检查依赖.
*/
@Get("healthz")
@HttpCode(HttpStatus.OK)
liveness(): { status: string; service: string; timestamp: string } {
return {
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
}
/**
* /readyz: Readiness probe (G2 + president §2.4).
*
* 检查全部已启用的下游依赖:
* - Redis PING
* - 各下游 gRPC waitForReady
*
* 必需依赖失败 → 503 (触发 Pod 重启)
* 可选依赖失败 → 200 + degraded=true (软失败)
*/
@Get("readyz")
async readiness(): Promise<ReadyzResponse> {
const checks: HealthCheck[] = [];
// 1. Redis 检查
const redisStart = Date.now();
const cacheService = new CacheService(this.redis);
const redisOk = await cacheService.ping();
checks.push({
service: "redis",
healthy: redisOk,
required: true,
latencyMs: Date.now() - redisStart,
});
// 2. 各下游 gRPC 检查 (env.MOCK_UPSTREAM=true 时跳过, 直接 healthy)
for (const svc of downstreamServices) {
if (!svc.enabled) continue;
const start = Date.now();
let healthy = true;
if (!env.MOCK_UPSTREAM) {
healthy = await this.downstream.checkHealth(svc.name);
}
checks.push({
service: svc.name,
healthy,
required: svc.required,
latencyMs: Date.now() - start,
});
}
// 3. 判断整体状态
const failedRequired = checks.filter((c) => !c.healthy && c.required);
const failedOptional = checks.filter((c) => !c.healthy && !c.required);
let status: ReadyzResponse["status"] = "ok";
let httpStatus = HttpStatus.OK;
let degraded = false;
if (failedRequired.length > 0) {
status = "unavailable";
httpStatus = HttpStatus.SERVICE_UNAVAILABLE;
logger.warn(
{ failedRequired: failedRequired.map((c) => c.service) },
"/readyz failed: required dependencies unavailable",
);
} else if (failedOptional.length > 0) {
status = "degraded";
degraded = true;
logger.warn(
{ failedOptional: failedOptional.map((c) => c.service) },
"/readyz degraded: optional dependencies unavailable",
);
}
const response: ReadyzResponse = {
status,
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
checks,
degraded,
degradedServices: [...failedRequired, ...failedOptional].map((c) => c.service),
};
// NestJS 4.x 的 @HttpCode 装饰器对 async 方法不一定生效, 这里通过 throw 切换状态码
if (httpStatus !== HttpStatus.OK) {
// 通过抛 HttpException 切换状态码
const { HttpException } = await import("@nestjs/common");
throw new HttpException(response, httpStatus);
}
return response;
}
}

View File

@@ -0,0 +1,10 @@
/**
* HealthModule - 健康检查模块.
*/
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller.js";
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,13 @@
/**
* student-bff pino logger.
*
* 仲裁依据: coord-final-decisions §1 G4 (首次实现即 pino 结构化日志)
* coord-final-decisions §2 B8 (复用 shared-ts BFF logger 工厂)
*/
import { createBffLogger, type Logger } from "@edu/shared-ts/bff";
export const logger: Logger = createBffLogger("student-bff", {
level: process.env.LOG_LEVEL ?? "info",
});
export type { Logger };

View File

@@ -0,0 +1,159 @@
/**
* student-bff prom-client metrics.
*
* 仲裁依据: coord-final-decisions §1 G5 (首次实现即 /metrics + 基础业务指标)
* 02-architecture-design.md §6.4 (11 个 student_bff_* 指标)
*
* 指标命名: <service>_<module>_<operation>_<unit> (project_rules §12)
*/
import promClient from "prom-client";
const registry = new promClient.Registry();
registry.setDefaultLabels({ service: "student-bff" });
// 1. 请求总数
registry.registerMetric(
new promClient.Counter({
name: "student_bff_requests_total",
help: "Total number of student-bff HTTP/GraphQL requests",
labelNames: ["operation", "method", "status"],
}),
);
// 2. 请求延迟
registry.registerMetric(
new promClient.Histogram({
name: "student_bff_request_duration_seconds",
help: "Student-bff request duration in seconds",
labelNames: ["operation", "method"],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
// 3. 下游调用总数
registry.registerMetric(
new promClient.Counter({
name: "student_bff_downstream_calls_total",
help: "Total downstream gRPC calls",
labelNames: ["service", "method", "status"],
}),
);
// 4. 下游调用延迟
registry.registerMetric(
new promClient.Histogram({
name: "student_bff_downstream_duration_seconds",
help: "Downstream gRPC call duration in seconds",
labelNames: ["service", "method"],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
// 5. 下游错误数
registry.registerMetric(
new promClient.Counter({
name: "student_bff_downstream_errors_total",
help: "Downstream gRPC call errors",
labelNames: ["service", "method", "error_type"],
}),
);
// 6. 缓存命中
registry.registerMetric(
new promClient.Counter({
name: "student_bff_cache_hits_total",
help: "Cache hit count",
labelNames: ["cache_key_pattern"],
}),
);
// 7. 缓存未命中
registry.registerMetric(
new promClient.Counter({
name: "student_bff_cache_misses_total",
help: "Cache miss count",
labelNames: ["cache_key_pattern"],
}),
);
// 8. 熔断器状态 (P6)
registry.registerMetric(
new promClient.Gauge({
name: "student_bff_circuit_state",
help: "Circuit breaker state (0=closed, 1=open, 2=half-open)",
labelNames: ["service", "state"],
}),
);
// 9. SSE 连接数 (P5)
registry.registerMetric(
new promClient.Gauge({
name: "student_bff_sse_connections",
help: "Active SSE connections",
}),
);
// 10. 事件消费数 (P5)
registry.registerMetric(
new promClient.Counter({
name: "student_bff_event_consumed_total",
help: "Kafka events consumed",
labelNames: ["topic", "event_type"],
}),
);
// 11. 推送数 (P5)
registry.registerMetric(
new promClient.Counter({
name: "student_bff_event_pushed_total",
help: "Push-gateway push count",
labelNames: ["topic", "push_status"],
}),
);
// 自动收集 Node.js 进程级指标
promClient.collectDefaultMetrics({ register: registry });
export { registry as metricsRegistry };
/**
* 下游调用指标辅助器 (供 DownstreamClient 调用).
*/
export function recordDownstreamCall(
service: string,
method: string,
status: "success" | "error",
durationMs: number,
errorType?: string,
): void {
const labels = { service, method, status };
registry
.getSingleMetric("student_bff_downstream_calls_total")
?.inc(labels);
registry
.getSingleMetric("student_bff_downstream_duration_seconds")
?.observe({ service, method }, durationMs / 1000);
if (status === "error" && errorType) {
registry
.getSingleMetric("student_bff_downstream_errors_total")
?.inc({ service, method, error_type: errorType });
}
}
/**
* 缓存命中/未命中指标辅助器.
*/
export function recordCacheAccess(
keyPattern: string,
hit: boolean,
): void {
if (hit) {
registry
.getSingleMetric("student_bff_cache_hits_total")
?.inc({ cache_key_pattern: keyPattern });
} else {
registry
.getSingleMetric("student_bff_cache_misses_total")
?.inc({ cache_key_pattern: keyPattern });
}
}

View File

@@ -0,0 +1,42 @@
/**
* student-bff OpenTelemetry tracer.
*
* 仲裁依据: coord-final-decisions §1 G6 (首次实现即 OTel SDK + OTLP exporter + 完整资源属性 + 全链路 span)
* 02-architecture-design.md §6.5 (serviceName: student-bff)
*/
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { env } from "../../config/env.js";
import { logger } from "./logger.js";
let sdk: NodeSDK | null = null;
export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) {
logger.warn("OTEL_EXPORTER_OTLP_ENDPOINT not set, tracer disabled");
return;
}
sdk = new NodeSDK({
serviceName: env.OTEL_SERVICE_NAME,
traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
logger.info(
{ endpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT, service: env.OTEL_SERVICE_NAME },
"Tracer initialized with auto-instrumentations",
);
}
export async function shutdownTracer(): Promise<void> {
if (sdk) {
await sdk.shutdown();
sdk = null;
logger.debug("Tracer shutdown complete");
}
}

View File

@@ -0,0 +1,137 @@
/**
* student-bff DataLoader 模块 - N+1 防御.
*
* 仲裁依据:
* - coord-final-decisions §2 B1 (GraphQL Yoga + DataLoader)
* - 004 §11.3 BFF 聚合模式图示
*
* DataLoader 用于:
* - Dashboard 内多学生场景批量加载作业/成绩 (避免 N+1 gRPC 调用)
* - 子字段解析时批量加载关联实体
*
* 实现策略:
* - 上游 proto 暂未支持批量 RPC (ListHomeworkByIds 等), DataLoader 内部
* 使用 Promise.all 并行调用各 id 的 gRPC, 通过 batch + dedupe 减少 N+1.
* - 同一 tick 内相同 id 请求自动去重 (DataLoader 内置 cacheKeyFn).
* - 后续 coord 补全批量 RPC 后, 替换为真正的批量 gRPC 调用.
*/
import { Module, Scope } from "@nestjs/common";
import DataLoader from "dataloader";
import type { DownstreamClient } from "@edu/shared-ts/bff";
/**
* 单个作业的 Loader 批量加载函数.
*
* 输入: homeworkId 列表
* 输出: 作业详情列表 (与输入顺序一致, 失败项返回 null)
*/
export type HomeworkLoader = DataLoader<string, unknown, string>;
/**
* 单个学生成绩的 Loader.
*/
export type GradesLoader = DataLoader<string, unknown, string>;
/**
* 用户信息 Loader (Dashboard 内多用户场景).
*/
export type UserInfoLoader = DataLoader<string, unknown, string>;
/**
* 创建 Homework DataLoader.
*
* 使用方式 (在 Resolver context 注入):
* const loader = createHomeworkLoader(downstream);
* const hw1 = await loader.load("h-001");
* const hw2 = await loader.load("h-002");
* // 单 tick 内并行调用 gRPC, 自动 dedupe
*/
export function createHomeworkLoader(downstream: DownstreamClient): HomeworkLoader {
return new DataLoader<string, unknown, string>(async (homeworkIds) => {
const results = await Promise.all(
homeworkIds.map(async (id) => {
try {
return await downstream.call("core-edu", "GetHomework", { homeworkId: id });
} catch {
return null;
}
}),
);
return results;
});
}
/**
* 创建 Grades DataLoader.
*/
export function createGradesLoader(downstream: DownstreamClient): GradesLoader {
return new DataLoader<string, unknown, string>(async (studentIds) => {
const results = await Promise.all(
studentIds.map(async (id) => {
try {
return await downstream.call("core-edu", "ListGradesByStudent", {
studentId: id,
});
} catch {
return null;
}
}),
);
return results;
});
}
/**
* 创建 UserInfo DataLoader.
*/
export function createUserInfoLoader(downstream: DownstreamClient): UserInfoLoader {
return new DataLoader<string, unknown, string>(async (userIds) => {
const results = await Promise.all(
userIds.map(async (id) => {
try {
return await downstream.call("iam", "GetUserInfo", { userId: id });
} catch {
return null;
}
}),
);
return results;
});
}
/**
* DataLoader 工厂接口 (注入到 GraphQL context).
*/
export interface StudentBffDataLoaders {
homework: HomeworkLoader;
grades: GradesLoader;
userInfo: UserInfoLoader;
}
/**
* 创建全部 DataLoader (每个 GraphQL 请求一份).
*/
export function createDataLoaders(
downstream: DownstreamClient,
): StudentBffDataLoaders {
return {
homework: createHomeworkLoader(downstream),
grades: createGradesLoader(downstream),
userInfo: createUserInfoLoader(downstream),
};
}
/**
* NestJS Module - 提供 DataLoader 工厂 (request scope).
*/
@Module({
providers: [
{
provide: "DATA_LOADER_FACTORY",
useFactory: (): typeof createDataLoaders => createDataLoaders,
scope: Scope.TRANSIENT,
},
],
exports: ["DATA_LOADER_FACTORY"],
})
export class DataLoaderModule {}

View File

@@ -0,0 +1,194 @@
/**
* Kafka EventSubscriber - P5 事件订阅 + 推送通道.
*
* 仲裁依据:
* - coord-final-decisions §2 B7 (P2-P4 不订阅 Kafka, P5 后订阅)
* - president-final-rulings §2.4 (/readyz 软失败: Kafka 失败仅告警)
* - workline §5.4 (P5.4 Kafka EventSubscriber)
*
* 订阅 topic (G16 命名规范: edu.<domain>.<aggregate>.<action>):
* - edu.teaching.homework.assigned 教师布置作业
* - edu.teaching.homework.graded 作业批改完成
* - edu.teaching.exam.published 考试发布
* - edu.teaching.exam.updated 考试更新
* - edu.teaching.grade.recorded 成绩录入
* - edu.identity.user.role_changed 学生角色变更
* - edu.notification.sent 通知发送
*
* 消费动作:
* 1. 失效相关 Redis 缓存 (student:grades:* / student:exams:* 等)
* 2. 调用 push-gateway POST /push/user/:userId 推送给学生
*
* 幂等性: Redis SETNX event_id 去重 (workline §5.4)
*/
import { Injectable, OnModuleDestroy, OnModuleInit, Inject } from "@nestjs/common";
import { Kafka, type Consumer, type EachMessagePayload } from "kafkajs";
import { REDIS_CLIENT, CacheService } from "../../shared/cache/cache.module.js";
import type { Redis } from "ioredis";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import { metricsRegistry } from "../../shared/observability/metrics.js";
import { PushGatewayService } from "../push/push-gateway.service.js";
/**
* 订阅的 topic 列表 (G16 命名规范).
*/
const SUBSCRIBED_TOPICS = [
"edu.teaching.homework.assigned",
"edu.teaching.homework.graded",
"edu.teaching.exam.published",
"edu.teaching.exam.updated",
"edu.teaching.grade.recorded",
"edu.identity.user.role_changed",
"edu.notification.sent",
] as const;
/**
* 事件消息结构 (Kafka JSON payload).
*/
interface StudentBffEvent {
event_id: string;
event_type: string;
student_id?: string;
user_id?: string;
payload: unknown;
timestamp: string;
}
@Injectable()
export class EventSubscriberService implements OnModuleInit, OnModuleDestroy {
private consumer: Consumer | null = null;
private readonly cacheService: CacheService;
private running = false;
constructor(
@Inject(REDIS_CLIENT) private readonly redis: Redis,
private readonly pushGateway: PushGatewayService,
) {
this.cacheService = new CacheService(redis);
}
async onModuleInit(): Promise<void> {
// P5 才订阅, P3/P4 跳过 (B7 裁决)
// 但 P3 已落地代码, 通过环境变量控制是否启动
if (env.NODE_ENV === "test") {
logger.info("EventSubscriber skipped in test environment");
return;
}
try {
const kafka = new Kafka({
clientId: env.KAFKA_CLIENT_ID,
brokers: env.KAFKA_BROKERS.split(",").map((b) => b.trim()),
});
this.consumer = kafka.consumer({ groupId: env.KAFKA_CONSUMER_GROUP });
await this.consumer.connect();
for (const topic of SUBSCRIBED_TOPICS) {
await this.consumer.subscribe({ topic, fromBeginning: false });
}
this.running = true;
await this.consumer.run({
eachMessage: async (payload) => this.handleMessage(payload),
});
logger.info(
{ topics: SUBSCRIBED_TOPICS, group: env.KAFKA_CONSUMER_GROUP },
"Kafka EventSubscriber started",
);
} catch (err) {
// president §2.4 软失败: Kafka 失败不阻塞启动
logger.error(
{ err },
"Kafka EventSubscriber failed to start (soft failure, service continues)",
);
}
}
async onModuleDestroy(): Promise<void> {
this.running = false;
if (this.consumer) {
try {
await this.consumer.disconnect();
logger.info("Kafka EventSubscriber disconnected");
} catch (err) {
logger.warn({ err }, "Error disconnecting Kafka consumer");
}
}
}
/**
* 处理单条 Kafka 消息.
*/
private async handleMessage(payload: EachMessagePayload): Promise<void> {
const { topic, partition, message } = payload;
const eventStr = message.value?.toString("utf-8");
if (!eventStr) {
logger.warn({ topic, partition, offset: message.offset }, "Empty Kafka message");
return;
}
try {
const event = JSON.parse(eventStr) as StudentBffEvent;
logger.debug(
{ topic, eventId: event.event_id, eventType: event.event_type },
"Kafka event received",
);
// 幂等性: Redis SETNX event_id 去重
const dedupeKey = `student:event:dedupe:${event.event_id}`;
const set = await this.redis.set(dedupeKey, "1", "EX", 86400, "NX");
if (set !== "OK") {
logger.debug(
{ eventId: event.event_id },
"Kafka event already processed (deduped)",
);
return;
}
// 指标记录
metricsRegistry
.getSingleMetric("student_bff_event_consumed_total")
?.inc({ topic, event_type: event.event_type });
// 失效相关缓存 + 推送给学生
const studentId = event.student_id ?? event.user_id;
if (studentId) {
await this.invalidateCache(topic, studentId);
await this.pushGateway.pushToStudent(studentId, topic, event.event_type, event.payload, event.timestamp);
}
} catch (err) {
logger.error(
{ err, topic, partition, offset: message.offset },
"Failed to process Kafka event",
);
}
}
/**
* 按 topic 失效相关缓存.
*/
private async invalidateCache(topic: string, studentId: string): Promise<void> {
try {
if (topic.startsWith("edu.teaching.homework")) {
await this.cacheService.invalidate("homework", studentId);
await this.cacheService.invalidate("dashboard", studentId);
} else if (topic.startsWith("edu.teaching.exam")) {
await this.cacheService.invalidateByPrefix(`exams:${studentId}`);
await this.cacheService.invalidate("dashboard", studentId);
} else if (topic.startsWith("edu.teaching.grade")) {
await this.cacheService.invalidateByPrefix(`grades:${studentId}`);
await this.cacheService.invalidate("dashboard", studentId);
} else if (topic === "edu.identity.user.role_changed") {
await this.cacheService.invalidate("viewports", studentId);
await this.cacheService.invalidate("dashboard", studentId);
} else if (topic === "edu.notification.sent") {
await this.cacheService.invalidateByPrefix(`notifications:${studentId}`);
}
} catch (err) {
logger.warn({ err, topic, studentId }, "Cache invalidation failed");
}
}
}

View File

@@ -0,0 +1,22 @@
/**
* EventModule - Kafka 事件订阅模块.
*
* 仲裁依据:
* - coord-final-decisions §2 B7 (P2-P4 不订阅, P5 后订阅)
* - president-final-rulings §2.4 (Kafka 启动失败软处理)
* - workline §5.4 (P5.4 Kafka EventSubscriber)
*
* 依赖:
* - REDIS_CLIENT (CacheModule Global 提供): 幂等去重 + 缓存失效
* - PushGatewayService (PushGatewayModule 提供): 事件推送
*/
import { Module } from "@nestjs/common";
import { EventSubscriberService } from "./event-subscriber.js";
import { PushGatewayModule } from "../push/push-gateway.module.js";
@Module({
imports: [PushGatewayModule],
providers: [EventSubscriberService],
exports: [EventSubscriberService],
})
export class EventModule {}

View File

@@ -0,0 +1,196 @@
/**
* AuthorizationGuard 单元测试 - B4 自我越权防御.
*
* 测试覆盖:
* - extractUserIdFromRequest: x-user-id 头提取
* - extractTraceIdFromRequest: x-request-id 头提取
* - extractUserRolesFromRequest: x-user-roles 头提取
* - assertOwnData: 场景 A (资源无归属)
* - assertIdentityMatch: 场景 B (身份不一致)
* - DEV_MODE 放行
* - AuthorizationGuard.canActivate
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Request } from "express";
// Mock env module
vi.mock("../../config/env.js", () => ({
env: {
DEV_MODE: false,
NODE_ENV: "test",
},
}));
// Re-import after mock
const { env } = await import("../../config/env.js");
const {
AuthorizationGuard,
extractUserIdFromRequest,
extractTraceIdFromRequest,
extractUserRolesFromRequest,
assertOwnData,
assertIdentityMatch,
} = await import("./authorization.guard.js");
const { ForbiddenResourceError, IdentityMismatchError, UnauthorizedError } = await import(
"../../shared/errors/application-error.js"
);
function mockRequest(headers: Record<string, string | undefined> = {}): Request {
return { headers } as unknown as Request;
}
describe("AuthorizationGuard", () => {
describe("extractUserIdFromRequest", () => {
it("should extract x-user-id header", () => {
const req = mockRequest({ "x-user-id": "u-stu-001" });
expect(extractUserIdFromRequest(req)).toBe("u-stu-001");
});
it("should return null when header is missing", () => {
const req = mockRequest({});
expect(extractUserIdFromRequest(req)).toBeNull();
});
it("should return null when header is empty string", () => {
const req = mockRequest({ "x-user-id": "" });
expect(extractUserIdFromRequest(req)).toBeNull();
});
});
describe("extractTraceIdFromRequest", () => {
it("should extract x-request-id header", () => {
const req = mockRequest({ "x-request-id": "trace-abc" });
expect(extractTraceIdFromRequest(req)).toBe("trace-abc");
});
it("should return 'unknown' when header is missing", () => {
const req = mockRequest({});
expect(extractTraceIdFromRequest(req)).toBe("unknown");
});
it("should return 'unknown' when header is empty", () => {
const req = mockRequest({ "x-request-id": "" });
expect(extractTraceIdFromRequest(req)).toBe("unknown");
});
});
describe("extractUserRolesFromRequest", () => {
it("should extract comma-separated roles", () => {
const req = mockRequest({ "x-user-roles": "student,monitor" });
expect(extractUserRolesFromRequest(req)).toEqual(["student", "monitor"]);
});
it("should trim whitespace", () => {
const req = mockRequest({ "x-user-roles": " student , monitor " });
expect(extractUserRolesFromRequest(req)).toEqual(["student", "monitor"]);
});
it("should return empty array when header is missing", () => {
const req = mockRequest({});
expect(extractUserRolesFromRequest(req)).toEqual([]);
});
it("should filter out empty entries", () => {
const req = mockRequest({ "x-user-roles": "student,,monitor," });
expect(extractUserRolesFromRequest(req)).toEqual(["student", "monitor"]);
});
});
describe("assertOwnData (场景 A)", () => {
beforeEach(() => {
env.DEV_MODE = false;
});
it("should pass when requestedStudentId matches userId", () => {
expect(() => assertOwnData("u-001", "u-001")).not.toThrow();
});
it("should pass when requestedStudentId is undefined", () => {
expect(() => assertOwnData("u-001", undefined)).not.toThrow();
});
it("should pass when requestedStudentId is null", () => {
expect(() => assertOwnData("u-001", null)).not.toThrow();
});
it("should throw ForbiddenResourceError when studentId differs", () => {
expect(() => assertOwnData("u-001", "u-002")).toThrow(ForbiddenResourceError);
});
it("should not throw when DEV_MODE is true", () => {
env.DEV_MODE = true;
expect(() => assertOwnData("u-001", "u-002")).not.toThrow();
env.DEV_MODE = false;
});
});
describe("assertIdentityMatch (场景 B)", () => {
beforeEach(() => {
env.DEV_MODE = false;
});
it("should pass when bodyUserId matches userId", () => {
expect(() => assertIdentityMatch("u-001", "u-001")).not.toThrow();
});
it("should pass when bodyUserId is undefined", () => {
expect(() => assertIdentityMatch("u-001", undefined)).not.toThrow();
});
it("should pass when bodyUserId is null", () => {
expect(() => assertIdentityMatch("u-001", null)).not.toThrow();
});
it("should throw IdentityMismatchError when bodyUserId differs", () => {
expect(() => assertIdentityMatch("u-001", "u-002")).toThrow(IdentityMismatchError);
});
it("should not throw when DEV_MODE is true", () => {
env.DEV_MODE = true;
expect(() => assertIdentityMatch("u-001", "u-002")).not.toThrow();
env.DEV_MODE = false;
});
});
describe("AuthorizationGuard.canActivate", () => {
let guard: InstanceType<typeof AuthorizationGuard>;
beforeEach(() => {
guard = new AuthorizationGuard();
env.DEV_MODE = false;
});
afterEach(() => {
env.DEV_MODE = false;
});
it("should return true when DEV_MODE is true", () => {
env.DEV_MODE = true;
const ctx = {
switchToHttp: () => ({ getRequest: () => mockRequest({}) }),
};
expect(guard.canActivate(ctx as never)).toBe(true);
});
it("should return true when x-user-id is present", () => {
const ctx = {
switchToHttp: () => ({ getRequest: () => mockRequest({ "x-user-id": "u-001" }) }),
};
expect(guard.canActivate(ctx as never)).toBe(true);
});
it("should throw UnauthorizedError when x-user-id is missing", () => {
const ctx = {
switchToHttp: () => ({ getRequest: () => mockRequest({}) }),
};
expect(() => guard.canActivate(ctx as never)).toThrow(UnauthorizedError);
});
it("should throw UnauthorizedError when x-user-id is empty", () => {
const ctx = {
switchToHttp: () => ({ getRequest: () => mockRequest({ "x-user-id": "" }) }),
};
expect(() => guard.canActivate(ctx as never)).toThrow(UnauthorizedError);
});
});
});

View File

@@ -0,0 +1,146 @@
/**
* student-bff AuthorizationGuard - B4 自我越权防御.
*
* 仲裁依据:
* - coord-final-decisions §2 B4 (全部 BFF 强制自我越权防御)
* - president-final-rulings §2.9 (越权防御 P3 实现方式: 方案 D)
* - president-final-rulings §2.7 (3 类越权防御错误码)
*
* 防御策略:
* - 场景 A (资源无归属): 学生请求的 studentId 与 JWT userId 不一致
* → ForbiddenResourceError (403, BFF_STUDENT_FORBIDDEN_RESOURCE)
* - 场景 B (身份不一致): JWT userId 与请求 body userId 不一致
* → IdentityMismatchError (403, BFF_STUDENT_IDENTITY_MISMATCH)
*
* DEV_MODE=true 时放行 (president §2.9 方案 D):
* - 本地开发无 JWT 时, DEV_MODE=true 跳过越权校验
* - 生产环境 DEV_MODE=false 强制校验
*/
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import type { Request } from "express";
import { env } from "../../config/env.js";
import {
UnauthorizedError,
ForbiddenResourceError,
IdentityMismatchError,
} from "../../shared/errors/application-error.js";
import { logger } from "../../shared/observability/logger.js";
/**
* 学生越权防御 Guard.
*
* 使用方式 (NestJS):
* @UseGuards(AuthorizationGuard)
* @Query(() => StudentDashboard)
* async studentDashboard(@Context('userId') userId: string, ...) {}
*
* GraphQL Yoga 集成: 在 context 构建时调用 extractUserIdFromRequest,
* 并在 Resolver 内显式调用 assertOwnData(userId, requestedStudentId).
*/
@Injectable()
export class AuthorizationGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
if (env.DEV_MODE) {
// 方案 D: DEV_MODE=true 跳过越权校验 (本地开发无 JWT)
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const userId = extractUserIdFromRequest(request);
if (!userId) {
throw new UnauthorizedError();
}
return true;
}
}
/**
* 从 Express Request 提取 userId (由 api-gateway 注入 x-user-id 头).
*
* BFF 不验签 JWT, 仅读 header (B3 裁决).
*/
export function extractUserIdFromRequest(request: Request): string | null {
const header = request.headers["x-user-id"];
if (typeof header === "string" && header.length > 0) {
return header;
}
return null;
}
/**
* 从 Express Request 提取 traceId (由 api-gateway 注入 x-request-id 头).
*/
export function extractTraceIdFromRequest(request: Request): string {
const header = request.headers["x-request-id"];
return typeof header === "string" && header.length > 0 ? header : "unknown";
}
/**
* 提取 x-user-roles 头 (列表, 逗号分隔).
*/
export function extractUserRolesFromRequest(request: Request): string[] {
const header = request.headers["x-user-roles"];
if (typeof header === "string" && header.length > 0) {
return header.split(",").map((r) => r.trim()).filter(Boolean);
}
return [];
}
/**
* 强制 userId = requestedStudentId (B4 自我越权防御场景 A).
*
* 学生只能查/操作自己的数据. 在 Resolver 内调用:
* assertOwnData(userId, args.studentId);
*
* @param userId JWT 中的 userId (从 x-user-id 头)
* @param requestedStudentId 请求参数中的 studentId (args / input)
* @throws ForbiddenResourceError 当 requestedStudentId ≠ userId
*/
export function assertOwnData(
userId: string,
requestedStudentId?: string | null,
): void {
if (env.DEV_MODE) {
return;
}
if (requestedStudentId && requestedStudentId !== userId) {
logger.warn(
{ userId, requestedStudentId },
"Authorization blocked: student data scope violation",
);
throw new ForbiddenResourceError(
"Students can only access their own data",
{ requested: requestedStudentId, actual: userId },
);
}
}
/**
* 校验 JWT userId 与 body userId 一致 (B4 场景 B).
*
* 用于 Mutation 输入校验: 如 submitHomework input 内 studentId 必须 = JWT userId.
*
* @param userId JWT 中的 userId
* @param bodyUserId 请求 body 中的 userId 字段
* @throws IdentityMismatchError 当 bodyUserId ≠ userId
*/
export function assertIdentityMatch(
userId: string,
bodyUserId?: string | null,
): void {
if (env.DEV_MODE) {
return;
}
if (bodyUserId && bodyUserId !== userId) {
logger.warn(
{ userId, bodyUserId },
"Authorization blocked: identity mismatch",
);
throw new IdentityMismatchError(
"JWT userId does not match request body userId",
{ jwt: userId, body: bodyUserId },
);
}
}

View File

@@ -0,0 +1,16 @@
/**
* PushGatewayModule - push-gateway 推送模块.
*
* 仲裁依据:
* - workline §5.5 (P5.5 push-gateway 推送通道)
*
* 提供 PushGatewayService 供 EventSubscriber 和其他需要推送的场景使用.
*/
import { Module } from "@nestjs/common";
import { PushGatewayService } from "./push-gateway.service.js";
@Module({
providers: [PushGatewayService],
exports: [PushGatewayService],
})
export class PushGatewayModule {}

View File

@@ -0,0 +1,148 @@
/**
* PushGatewayService 单元测试.
*
* 测试覆盖:
* - pushToStudent 正常成功
* - pushToStudent HTTP 错误状态
* - pushToStudent 网络错误 (软失败)
* - 超时处理
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock env
vi.mock("../../config/env.js", () => ({
env: {
PUSH_GATEWAY_URL: "http://localhost:8081",
},
}));
// Mock global fetch
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
// Mock metrics registry
vi.mock("../../shared/observability/metrics.js", () => ({
metricsRegistry: {
getSingleMetric: vi.fn(() => ({
inc: vi.fn(),
})),
},
}));
// Mock logger
vi.mock("../../shared/observability/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
const { PushGatewayService } = await import("./push-gateway.service.js");
describe("PushGatewayService", () => {
let service: InstanceType<typeof PushGatewayService>;
beforeEach(() => {
service = new PushGatewayService();
fetchMock.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
it("should return success=true when push-gateway returns 200", async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
});
const result = await service.pushToStudent(
"u-stu-001",
"edu.teaching.homework.graded",
"homework.graded",
{ homeworkId: "hw-001", grade: 90 },
"2026-07-10T10:00:00Z",
);
expect(result.success).toBe(true);
expect(result.status).toBe(200);
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:8081/push/user/u-stu-001",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
});
it("should return success=false when push-gateway returns non-OK status", async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 404,
});
const result = await service.pushToStudent(
"u-stu-001",
"edu.notification.sent",
"notification.sent",
{ notificationId: "n-001" },
"2026-07-10T10:00:00Z",
);
expect(result.success).toBe(false);
expect(result.status).toBe(404);
});
it("should return success=false with error message when fetch throws (soft failure)", async () => {
fetchMock.mockRejectedValue(new Error("ECONNREFUSED"));
const result = await service.pushToStudent(
"u-stu-001",
"edu.teaching.exam.published",
"exam.published",
{ examId: "e-001" },
"2026-07-10T10:00:00Z",
);
expect(result.success).toBe(false);
expect(result.status).toBe(0);
expect(result.error).toBe("ECONNREFUSED");
});
it("should return success=false when fetch throws AbortError (timeout)", async () => {
fetchMock.mockRejectedValue(new Error("The operation was aborted due to timeout"));
const result = await service.pushToStudent(
"u-stu-001",
"edu.teaching.grade.recorded",
"grade.recorded",
{ gradeId: "g-001" },
"2026-07-10T10:00:00Z",
);
expect(result.success).toBe(false);
expect(result.error).toContain("aborted");
});
it("should serialize message body as JSON", async () => {
fetchMock.mockResolvedValue({ ok: true, status: 200 });
await service.pushToStudent(
"u-stu-001",
"edu.teaching.homework.assigned",
"homework.assigned",
{ homeworkId: "hw-001", title: "Math Chapter 3" },
"2026-07-10T10:00:00Z",
);
const callArgs = fetchMock.mock.calls[0];
const body = JSON.parse(callArgs[1].body as string);
expect(body.type).toBe("homework.assigned");
expect(body.topic).toBe("edu.teaching.homework.assigned");
expect(body.payload).toEqual({ homeworkId: "hw-001", title: "Math Chapter 3" });
expect(body.timestamp).toBe("2026-07-10T10:00:00Z");
});
});

View File

@@ -0,0 +1,111 @@
/**
* PushGatewayService - push-gateway 推送通道封装.
*
* 仲裁依据:
* - workline §5.5 (P5.5 push-gateway 推送通道)
* - president-final-rulings §2.4 (软失败: 推送失败不阻塞主流程)
*
* 职责:
* 1. 封装 push-gateway HTTP 调用 (POST /push/user/:userId)
* 2. 超时控制 (3s AbortSignal.timeout)
* 3. 指标记录 (student_bff_event_pushed_total)
* 4. 软失败处理 (失败仅 warn 日志, 不抛异常)
*
* 消费方:
* - EventSubscriberService: Kafka 事件消费后推送
* - 未来可直接由 resolver 调用 (如主动推送通知)
*/
import { Injectable } from "@nestjs/common";
import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js";
import { metricsRegistry } from "../../shared/observability/metrics.js";
/**
* 推送消息结构.
*/
export interface PushMessage {
type: string;
topic: string;
payload: unknown;
timestamp: string;
}
/**
* push-gateway 推送结果.
*/
export interface PushResult {
success: boolean;
status: number;
error?: string;
}
@Injectable()
export class PushGatewayService {
/**
* 推送消息给指定学生.
*
* 调用 push-gateway POST /push/user/:userId,
* 失败软处理 (president §2.4), 不抛异常.
*
* @param studentId 学生 ID
* @param topic Kafka topic 名
* @param eventType 事件类型
* @param payload 事件载荷
* @param timestamp 事件时间戳
* @returns PushResult 推送结果
*/
async pushToStudent(
studentId: string,
topic: string,
eventType: string,
payload: unknown,
timestamp: string,
): Promise<PushResult> {
const message: PushMessage = {
type: eventType,
topic,
payload,
timestamp,
};
try {
const response = await fetch(
`${env.PUSH_GATEWAY_URL}/push/user/${studentId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
signal: AbortSignal.timeout(3000),
},
);
const pushStatus = response.ok ? "success" : `http_${response.status}`;
metricsRegistry
.getSingleMetric("student_bff_event_pushed_total")
?.inc({ topic, push_status: pushStatus });
if (!response.ok) {
logger.warn(
{ studentId, topic, status: response.status },
"Push-gateway returned non-OK status",
);
}
return { success: response.ok, status: response.status };
} catch (err) {
logger.warn(
{ err, studentId, topic },
"Push-gateway push failed (soft failure)",
);
metricsRegistry
.getSingleMetric("student_bff_event_pushed_total")
?.inc({ topic, push_status: "error" });
return {
success: false,
status: 0,
error: (err as Error).message,
};
}
}
}

View File

@@ -0,0 +1,138 @@
/**
* AI Stream Resolver - SSE 流式 AI 答疑 (P5).
*
* 仲裁依据:
* - coord-final-decisions §2 B1 (GraphQL Yoga 原生支持 SSE Subscription)
* - coord-final-decisions §2 B2 (gRPC 调用 ai.StreamChat)
* - coord-final-decisions §2 B4 (强制自我越权防御)
* - workline §5.2 (P5.2 ai gRPC client chat/streamChat SSE)
*
* 实现: GraphQL Subscription → AsyncIterator, 透传 ai.StreamChat gRPC 流.
* GraphQL Yoga 通过 SSE 传输协议将 Subscription 事件推给客户端.
*
* 客户端使用:
* subscription AiStreamChat($input: AIStreamChatInput!) {
* aiStreamChat(input: $input) { content done model usage }
* }
*
* 传输协议: SSE (text/event-stream), Yoga 自动处理.
*/
import { z } from "zod";
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { UnauthorizedError, ValidationError } from "../../shared/errors/application-error.js";
const AIStreamChatInputSchema = z.object({
messages: z
.array(
z.object({
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(8000),
}),
)
.min(1)
.max(20),
model: z
.enum(["gpt-4o-mini", "baichuan-53b", "local-qwen-7b"])
.default("gpt-4o-mini"),
context: z
.object({
subject: z.string().optional(),
knowledgePointId: z.string().optional(),
})
.optional(),
});
/**
* AI 流式响应 chunk 结构 (对齐 ai.StreamChat gRPC stream).
*/
interface AIStreamChunk {
content: string;
done: boolean;
model?: string;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
export const aiStreamResolvers = {
Subscription: {
/**
* aiStreamChat: AI 答疑流式响应.
*
* 通过 GraphQL Subscription + SSE 传输,
* 透传 ai.StreamChat gRPC server-streaming RPC.
*
* @permission STUDENT_AI_CHAT
* @dataScope OWN
*/
aiStreamChat: {
subscribe(
_parent: unknown,
args: { input: unknown },
ctx: StudentBffContext,
): AsyncIterable<{ aiStreamChat: AIStreamChunk }> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
const parseResult = AIStreamChatInputSchema.safeParse(args.input);
if (!parseResult.success) {
throw new ValidationError(
"Invalid aiStreamChat input",
parseResult.error.flatten(),
);
}
const input = parseResult.data;
return (async function* (): AsyncGenerator<{ aiStreamChat: AIStreamChunk }> {
try {
// 调用 ai.StreamChat (gRPC server-streaming)
// DownstreamClient.callStream 返回 AsyncIterable
const stream = ctx.downstream.callStream("ai", "StreamChat", {
userId: ctx.userId,
messages: input.messages,
model: input.model,
context: input.context,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
timeoutMs: 60000, // 流式调用 60s 超时
});
for await (const chunk of stream) {
const typed = chunk as {
content?: string;
done?: boolean;
model?: string;
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
};
yield {
aiStreamChat: {
content: typed.content ?? "",
done: typed.done ?? false,
model: typed.model,
usage: typed.usage,
},
};
if (typed.done) {
break;
}
}
} catch (err) {
// 流式错误: 发送一个 done=true 的错误 chunk, 客户端关闭流
yield {
aiStreamChat: {
content: `[stream error] ${(err as Error).message}`,
done: true,
},
};
}
})();
},
},
},
};

View File

@@ -0,0 +1,93 @@
/**
* AI Resolver - aiChat Query/Mutation (P5 扩展).
*
* 仲裁依据:
* - student-bff.schema.graphql (待补充 aiChat Query)
* - coord-final-decisions §2 B2 (gRPC 调用 ai)
* - coord-final-decisions §2 B4 (强制自我越权防御)
* - president-final-rulings §2.3 (跨阶段扩展例外)
*
* AI 答疑流式响应 (StreamChat) 通过 SSE 端点单独实现 (P5),
* 本 Resolver 仅处理同步 Chat.
*/
import { z } from "zod";
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { ok, fail } from "../../shared/action-state.js";
import {
UnauthorizedError,
ValidationError,
} from "../../shared/errors/application-error.js";
const AIChatInputSchema = z.object({
messages: z
.array(
z.object({
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(8000),
}),
)
.min(1)
.max(20),
model: z
.enum(["gpt-4o-mini", "baichuan-53b", "local-qwen-7b"])
.default("gpt-4o-mini"),
context: z
.object({
subject: z.string().optional(),
knowledgePointId: z.string().optional(),
})
.optional(),
});
export const aiResolvers = {
Query: {
/**
* aiChat: AI 答疑 (同步).
* @permission STUDENT_AI_CHAT
* @dataScope OWN
*
* 实际为 Mutation (写操作, 消耗 AI 配额), 但 schema 设计为 Query 便于前端 GET 缓存.
* 后续如需限流, 改为 Mutation.
*/
async aiChat(
_parent: unknown,
args: { input: unknown },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
const parseResult = AIChatInputSchema.safeParse(args.input);
if (!parseResult.success) {
throw new ValidationError(
"Invalid aiChat input",
parseResult.error.flatten(),
);
}
const input = parseResult.data;
try {
const result = await ctx.downstream.call("ai", "Chat", {
userId: ctx.userId,
messages: input.messages,
model: input.model,
context: input.context,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
// AI 调用可能耗时较长, 延长超时
timeoutMs: 30000,
});
return ok(result, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to chat with AI: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
},
};

View File

@@ -0,0 +1,107 @@
/**
* Analytics Resolver - myWeakness / myTrend Queries (P4 扩展).
*
* 仲裁依据:
* - student-bff.schema.graphql Query.myWeakness / myTrend / studentDashboard
* - coord-final-decisions §2 B2 (gRPC 调用 data-ana)
* - coord-final-decisions §2 B4 (强制自我越权防御, 学情诊断仅本人可查)
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
* - president-final-rulings §2.3 (跨阶段扩展例外)
*/
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { ok, fail } from "../../shared/action-state.js";
import { CacheTTL } from "../../shared/cache/cache.module.js";
import { UnauthorizedError } from "../../shared/errors/application-error.js";
import { assertOwnData } from "../guards/authorization.guard.js";
export const analyticsResolvers = {
Query: {
/**
* myWeakness: 学情诊断 (薄弱知识点).
* @permission STUDENT_ANALYTICS_READ
* @dataScope OWN
*/
async myWeakness(
_parent: unknown,
args: { studentId?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
assertOwnData(ctx.userId, args.studentId);
const cached = await ctx.redis.get<unknown>("analytics:weakness", ctx.userId);
if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
}
try {
const result = await ctx.downstream.call("data-ana", "GetStudentWeakness", {
studentId: ctx.userId,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
await ctx.redis.set(
"analytics:weakness",
result,
CacheTTL.ANALYTICS_WEAKNESS,
ctx.userId,
);
return ok(result, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch weakness: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
/**
* myTrend: 学习趋势.
* @permission STUDENT_ANALYTICS_READ
* @dataScope OWN
*/
async myTrend(
_parent: unknown,
args: { studentId?: string; range?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
assertOwnData(ctx.userId, args.studentId);
const range = args.range ?? "30d";
const cacheKey = `${ctx.userId}:${range}`;
const cached = await ctx.redis.get<unknown>("analytics:trend", cacheKey);
if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
}
try {
const result = await ctx.downstream.call("data-ana", "GetLearningTrend", {
studentId: ctx.userId,
range,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
await ctx.redis.set("analytics:trend", result, CacheTTL.ANALYTICS_TREND, cacheKey);
return ok(result, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch trend: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
},
};

View File

@@ -0,0 +1,120 @@
/**
* Auth Resolver - currentUser Query.
*
* 仲裁依据:
* - student-bff.schema.graphql Query.currentUser
* - coord-final-decisions §2 B2 (gRPC 调用 iam)
* - coord-final-decisions §2 B3 (BFF 豁免 @RequirePermission, 仅校验 x-user-id)
*
* 聚合: iam.GetUserInfo + iam.GetEffectivePermissions + iam.GetViewports
* 并行调用 (Promise.allSettled), 部分失败走降级模式方案 B.
*/
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import type { DownstreamResponse } from "@edu/shared-ts/bff";
import { ok, fail, degraded, DegradedReason } from "../../shared/action-state.js";
import { UnauthorizedError } from "../../shared/errors/application-error.js";
export const authResolvers = {
Query: {
/**
* currentUser: 获取当前学生信息 + 权限 + 视口.
* @permission STUDENT_DASHBOARD_READ
* @dataScope OWN
*/
async currentUser(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
const results = await ctx.downstream.callAll([
{
service: "iam",
method: "GetUserInfo",
request: { userId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
},
{
service: "iam",
method: "GetEffectivePermissions",
request: { userId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
},
{
service: "iam",
method: "GetViewports",
request: { userId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
},
] as const);
const [userInfoResp, permsResp, viewportsResp] = results as [
DownstreamResponse<unknown>,
DownstreamResponse<unknown>,
DownstreamResponse<unknown>,
];
const degradedFields: string[] = [];
if (!userInfoResp.success) degradedFields.push("user");
if (!permsResp.success) degradedFields.push("permissions");
if (!viewportsResp.success) degradedFields.push("viewport");
// 必需字段失败时返回错误
if (!userInfoResp.success) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch user info: ${userInfoResp.error.message}`,
{
details: {
service: userInfoResp.error.service,
method: userInfoResp.error.method,
traceId: ctx.traceId,
},
i18nKey: "error.bffStudent.bad_gateway",
traceId: ctx.traceId,
},
);
}
const userInfo = userInfoResp.data as {
userId: string;
email: string;
name: string;
avatar: string | null;
roles: string[];
};
const permissions = permsResp.success
? (permsResp.data as { permissions: string[] }).permissions
: [];
const viewports = viewportsResp.success
? (viewportsResp.data as { navigation: unknown[]; dataScope: unknown })
: { navigation: [], dataScope: {} };
const data = {
user: {
id: userInfo.userId,
email: userInfo.email,
name: userInfo.name,
avatar: userInfo.avatar,
roles: userInfo.roles,
},
permissions,
viewport: viewports,
};
if (degradedFields.length > 0) {
return degraded(
data,
DegradedReason.DOWNSTREAM_PARTIAL_FAILURE,
degradedFields,
{ traceId: ctx.traceId },
);
}
return ok(data, { traceId: ctx.traceId });
},
},
};

View File

@@ -0,0 +1,47 @@
/**
* Classes Resolver - myClasses Query.
*
* 仲裁依据:
* - student-bff.schema.graphql Query.myClasses
* - coord-final-decisions §2 B2 (gRPC 调用 core-edu)
*/
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { ok, fail } from "../../shared/action-state.js";
import { UnauthorizedError } from "../../shared/errors/application-error.js";
export const classesResolvers = {
Query: {
/**
* myClasses: 我所在班级列表.
* @permission STUDENT_DASHBOARD_READ
* @dataScope OWN
*/
async myClasses(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
try {
const result = await ctx.downstream.call("core-edu", "GetClassesByStudent", {
studentId: ctx.userId,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
const data = result as { classes: unknown[] };
return ok(data, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch classes: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
},
};

View File

@@ -0,0 +1,133 @@
/**
* Content Resolver - textbooks / chapters / learningPath Queries (P4 扩展).
*
* 仲裁依据:
* - student-bff.schema.graphql Query.textbooks / chapters / learningPath
* - coord-final-decisions §2 B2 (gRPC 调用 content)
* - president-final-rulings §2.3 (跨阶段扩展例外: 新增下游 gRPC 调用允许)
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
*
* P3 时下游 content 未就绪, env.MOCK_UPSTREAM=true 返回 mock; 上游就绪后切换真实调用.
*/
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { ok, fail } from "../../shared/action-state.js";
import { CacheTTL } from "../../shared/cache/cache.module.js";
import { UnauthorizedError } from "../../shared/errors/application-error.js";
export const contentResolvers = {
Query: {
/**
* textbooks: 教材列表.
* @permission STUDENT_CONTENT_READ
* @dataScope OWN
*/
async textbooks(
_parent: unknown,
args: { gradeId?: string; subjectId?: string; page?: number; pageSize?: number },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
const cacheKey = `${args.gradeId ?? "all"}:${args.subjectId ?? "all"}`;
const cached = await ctx.redis.get<unknown>("textbooks", cacheKey);
if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
}
try {
const result = await ctx.downstream.call("content", "ListTextbooks", {
gradeId: args.gradeId,
subjectId: args.subjectId,
page: args.page ?? 1,
pageSize: Math.min(args.pageSize ?? 20, 50),
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
const data = result as { textbooks: unknown[] };
await ctx.redis.set("textbooks", data, CacheTTL.TEXTBOOKS, cacheKey);
return ok(data, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch textbooks: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
/**
* chapters: 教材章节树.
* @permission STUDENT_CONTENT_READ
*/
async chapters(
_parent: unknown,
args: { textbookId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
const cached = await ctx.redis.get<unknown>("chapters", args.textbookId);
if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
}
try {
const result = await ctx.downstream.call("content", "ListChapters", {
textbookId: args.textbookId,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
const data = result as { chapters: unknown[] };
await ctx.redis.set("chapters", data, CacheTTL.CHAPTERS, args.textbookId);
return ok(data, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch chapters: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
/**
* learningPath: 个性化学习路径 (基于学情诊断).
* @permission STUDENT_CONTENT_READ
* @dataScope OWN
*/
async learningPath(
_parent: unknown,
args: { knowledgePointId: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
try {
const result = await ctx.downstream.call("content", "GetLearningPath", {
studentId: ctx.userId,
knowledgePointId: args.knowledgePointId,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
return ok(result, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch learning path: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
},
};

View File

@@ -0,0 +1,158 @@
/**
* Dashboard Resolver - studentDashboard Query.
*
* 仲裁依据:
* - student-bff.schema.graphql Query.studentDashboard
* - president-final-rulings §2.8 (Dashboard Query Resolver P2 即定型, 内部按阶段扩展)
* - president-final-rulings §2.6 (降级模式方案 B: data 内 degraded=true)
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
*
* 聚合:
* - iam.GetUserInfo (用户基础信息)
* - core-edu.ListHomeworkByStudent (待办作业)
* - core-edu.ListExamsByClass (即将到来的考试)
* - core-edu.ListGradesByStudent (最近一次成绩)
* - data-ana.GetStudentDashboard (P4 学情汇总, P3 返回 null)
* - msg.ListNotifications (未读通知数, P5)
*
* 缓存: student:dashboard:{userId} TTL 15s
*/
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import type { DownstreamResponse } from "@edu/shared-ts/bff";
import { ok, fail, degraded, DegradedReason } from "../../shared/action-state.js";
import { CacheTTL } from "../../shared/cache/cache.module.js";
import { UnauthorizedError } from "../../shared/errors/application-error.js";
export const dashboardResolvers = {
Query: {
/**
* studentDashboard: 学生首页聚合.
* @permission STUDENT_DASHBOARD_READ
* @dataScope OWN
*/
async studentDashboard(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
// 缓存命中检查
const cacheKey = ["dashboard", ctx.userId] as const;
const cached = await ctx.redis.get<unknown>("dashboard", ctx.userId);
if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
}
// 并行调用下游 (Promise.allSettled 容错)
const results = await ctx.downstream.callAll([
{
service: "iam",
method: "GetUserInfo",
request: { userId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
},
{
service: "core-edu",
method: "ListHomeworkByStudent",
request: { studentId: ctx.userId, status: "pending" },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
},
{
service: "core-edu",
method: "ListExamsByClass",
request: { studentId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
},
{
service: "core-edu",
method: "ListGradesByStudent",
request: { studentId: ctx.userId, page: 1, pageSize: 1 },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
},
{
service: "data-ana",
method: "GetStudentDashboard",
request: { studentId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
},
] as const);
const [userInfoResp, homeworkResp, examsResp, gradesResp, dashboardAnaResp] =
results as [
DownstreamResponse<unknown>,
DownstreamResponse<unknown>,
DownstreamResponse<unknown>,
DownstreamResponse<unknown>,
DownstreamResponse<unknown>,
];
// 必需字段失败检查
if (!userInfoResp.success) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch user info: ${userInfoResp.error.message}`,
{
i18nKey: "error.bffStudent.bad_gateway",
traceId: ctx.traceId,
},
);
}
const userInfo = userInfoResp.data as {
userId: string;
name: string;
avatar: string | null;
classId: string;
className: string;
grade: string;
};
// 容错聚合: 各字段独立降级
const degradedFields: string[] = [];
const pendingHomework = homeworkResp.success
? (homeworkResp.data as { homework: unknown[] }).homework ?? []
: (degradedFields.push("pendingHomework"), []);
const upcomingExams = examsResp.success
? (examsResp.data as { exams: unknown[] }).exams ?? []
: (degradedFields.push("upcomingExams"), []);
const lastGrade = gradesResp.success
? ((gradesResp.data as { grades: unknown[] }).grades ?? [])[0] ?? null
: (degradedFields.push("lastGrade"), null);
const analyticsSummary = dashboardAnaResp.success
? dashboardAnaResp.data
: (degradedFields.push("analyticsSummary"), null);
const data = {
user: {
id: userInfo.userId,
name: userInfo.name,
avatar: userInfo.avatar,
grade: userInfo.grade,
class: { id: userInfo.classId, name: userInfo.className },
},
pendingHomework,
upcomingExams,
lastGrade,
analyticsSummary,
unreadNotifications: 0, // P5 msg 服务启用后填充
};
// 写缓存
await ctx.redis.set("dashboard", data, CacheTTL.DASHBOARD, ctx.userId);
if (degradedFields.length > 0) {
return degraded(
data,
DegradedReason.DOWNSTREAM_PARTIAL_FAILURE,
degradedFields,
{ traceId: ctx.traceId },
);
}
return ok(data, { traceId: ctx.traceId });
},
},
};

View File

@@ -0,0 +1,58 @@
/**
* Exams Resolver - myExams Query.
*
* 仲裁依据:
* - student-bff.schema.graphql Query.myExams
* - coord-final-decisions §2 B2 (gRPC 调用 core-edu)
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
*/
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { ok, fail } from "../../shared/action-state.js";
import { CacheTTL } from "../../shared/cache/cache.module.js";
import { UnauthorizedError } from "../../shared/errors/application-error.js";
export const examsResolvers = {
Query: {
/**
* myExams: 即将到来的考试列表.
* @permission STUDENT_EXAM_READ
* @dataScope OWN
*/
async myExams(
_parent: unknown,
args: { status?: string; classId?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
const cacheKey = `${ctx.userId}:${args.classId ?? "all"}:${args.status ?? "all"}`;
const cached = await ctx.redis.get<unknown>("exams", cacheKey);
if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
}
try {
const result = await ctx.downstream.call("core-edu", "ListExamsByClass", {
studentId: ctx.userId,
classId: args.classId,
status: args.status ?? "upcoming",
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
const data = result as { exams: unknown[] };
await ctx.redis.set("exams", data, CacheTTL.EXAMS, cacheKey);
return ok(data, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch exams: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
},
};

View File

@@ -0,0 +1,71 @@
/**
* Grades Resolver - myGrades Query.
*
* 仲裁依据:
* - student-bff.schema.graphql Query.myGrades
* - coord-final-decisions §2 B4 (强制自我越权防御, 学生只能查自己成绩)
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
*/
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { ok, fail } from "../../shared/action-state.js";
import { CacheTTL } from "../../shared/cache/cache.module.js";
import { UnauthorizedError } from "../../shared/errors/application-error.js";
import { assertOwnData } from "../guards/authorization.guard.js";
export const gradesResolvers = {
Query: {
/**
* myGrades: 我的成绩列表.
* @permission STUDENT_GRADE_READ
* @dataScope OWN (B4 强制 studentId = userId)
*/
async myGrades(
_parent: unknown,
args: {
studentId?: string;
subject?: string;
page?: number;
pageSize?: number;
},
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
// B4 越权防御: args.studentId 必须 = JWT userId
assertOwnData(ctx.userId, args.studentId);
const page = args.page ?? 1;
const pageSize = Math.min(args.pageSize ?? 20, 50);
const cacheKey = `${ctx.userId}:${page}:${args.subject ?? "all"}`;
const cached = await ctx.redis.get<unknown>("grades", cacheKey);
if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
}
try {
const result = await ctx.downstream.call("core-edu", "ListGradesByStudent", {
studentId: ctx.userId,
subject: args.subject,
page,
pageSize,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
const data = result as { grades: unknown[]; totalCount: number };
await ctx.redis.set("grades", data, CacheTTL.GRADES, cacheKey);
return ok(data, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch grades: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
},
};

View File

@@ -0,0 +1,231 @@
/**
* Homework Resolver 单元测试.
*
* 测试覆盖:
* - myHomework Query: 缓存命中 / 缓存未命中 / 下游失败
* - submitHomework Mutation: 正常提交 / Zod 校验失败 / 越权防御 / 下游失败 / 缓存失效
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { homeworkResolvers } from "./homework.resolver.js";
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { UnauthorizedError, ValidationError, ForbiddenResourceError } from "../../shared/errors/application-error.js";
// Mock env to disable DEV_MODE
vi.mock("../../config/env.js", () => ({
env: { DEV_MODE: false, NODE_ENV: "test" },
}));
function mockContext(overrides: Partial<StudentBffContext> = {}): StudentBffContext {
const downstream = {
call: vi.fn(),
callStream: vi.fn(),
callAll: vi.fn(),
};
const redis = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
invalidate: vi.fn().mockResolvedValue(undefined),
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
ping: vi.fn().mockResolvedValue(true),
};
const dataLoaders = {} as never;
return {
userId: "u-stu-001",
traceId: "trace-test-001",
userRoles: ["student"],
downstream: downstream as never,
redis: redis as never,
dataLoaders,
requestId: "trace-test-001",
...overrides,
};
}
describe("homeworkResolvers", () => {
let ctx: StudentBffContext;
beforeEach(() => {
ctx = mockContext();
});
describe("Query.myHomework", () => {
it("should throw UnauthorizedError when userId is null", async () => {
ctx.userId = null;
await expect(
homeworkResolvers.Query.myHomework(null, {}, ctx),
).rejects.toThrow(UnauthorizedError);
});
it("should return cached data when cache hits", async () => {
const cachedData = { homework: [{ id: "hw-001" }] };
ctx.redis.get = vi.fn().mockResolvedValue(cachedData);
const result = await homeworkResolvers.Query.myHomework(null, {}, ctx);
expect(result.success).toBe(true);
expect((result as { data: unknown }).data).toEqual(cachedData);
expect(ctx.downstream.call).not.toHaveBeenCalled();
});
it("should call downstream when cache misses", async () => {
const downstreamData = { homework: [{ id: "hw-001", title: "Math HW" }] };
ctx.downstream.call = vi.fn().mockResolvedValue(downstreamData);
const result = await homeworkResolvers.Query.myHomework(
null,
{ status: "ASSIGNED", classId: "c-001" },
ctx,
);
expect(result.success).toBe(true);
expect(ctx.downstream.call).toHaveBeenCalledWith(
"core-edu",
"ListHomeworkByStudent",
{ studentId: "u-stu-001", status: "ASSIGNED", classId: "c-001" },
expect.objectContaining({
traceId: "trace-test-001",
metadata: { "x-user-id": "u-stu-001" },
}),
);
expect(ctx.redis.set).toHaveBeenCalled();
});
it("should return fail response when downstream fails", async () => {
ctx.downstream.call = vi.fn().mockRejectedValue(new Error("gRPC unavailable"));
const result = await homeworkResolvers.Query.myHomework(null, {}, ctx);
expect(result.success).toBe(false);
expect((result as { error: { code: string } }).error.code).toBe("BFF_STUDENT_BAD_GATEWAY");
});
});
describe("Mutation.submitHomework", () => {
const validInput = {
homeworkId: "hw-001",
studentId: "u-stu-001",
answers: [
{ questionId: "q-001", content: "My answer" },
],
};
it("should throw UnauthorizedError when userId is null", async () => {
ctx.userId = null;
await expect(
homeworkResolvers.Mutation.submitHomework(null, { input: validInput }, ctx),
).rejects.toThrow(UnauthorizedError);
});
it("should throw ValidationError when input is invalid", async () => {
await expect(
homeworkResolvers.Mutation.submitHomework(
null,
{ input: { homeworkId: "", answers: [] } },
ctx,
),
).rejects.toThrow(ValidationError);
});
it("should throw ForbiddenResourceError when studentId does not match userId (B4)", async () => {
const maliciousInput = {
...validInput,
studentId: "u-stu-002",
};
await expect(
homeworkResolvers.Mutation.submitHomework(null, { input: maliciousInput }, ctx),
).rejects.toThrow(ForbiddenResourceError);
});
it("should submit successfully when studentId matches userId", async () => {
const submitResult = {
submissionId: "sub-001",
homeworkId: "hw-001",
submittedAt: "2026-07-10T10:00:00Z",
status: "SUBMITTED",
};
ctx.downstream.call = vi.fn().mockResolvedValue(submitResult);
const result = await homeworkResolvers.Mutation.submitHomework(
null,
{ input: validInput },
ctx,
);
expect(result.success).toBe(true);
expect(ctx.downstream.call).toHaveBeenCalledWith(
"core-edu",
"SubmitHomework",
{
homeworkId: "hw-001",
studentId: "u-stu-001",
answers: validInput.answers,
},
expect.objectContaining({
metadata: { "x-user-id": "u-stu-001" },
}),
);
expect(ctx.redis.invalidate).toHaveBeenCalledWith("homework", "u-stu-001");
expect(ctx.redis.invalidate).toHaveBeenCalledWith("dashboard", "u-stu-001");
});
it("should submit successfully when studentId is not provided in input", async () => {
const inputWithoutStudentId = {
homeworkId: "hw-001",
answers: [{ questionId: "q-001", content: "Answer" }],
};
ctx.downstream.call = vi.fn().mockResolvedValue({
submissionId: "sub-002",
homeworkId: "hw-001",
submittedAt: "2026-07-10T10:00:00Z",
status: "SUBMITTED",
});
const result = await homeworkResolvers.Mutation.submitHomework(
null,
{ input: inputWithoutStudentId },
ctx,
);
expect(result.success).toBe(true);
});
it("should return fail response when downstream fails", async () => {
ctx.downstream.call = vi.fn().mockRejectedValue(new Error("Submission failed"));
const result = await homeworkResolvers.Mutation.submitHomework(
null,
{ input: validInput },
ctx,
);
expect(result.success).toBe(false);
expect((result as { error: { code: string } }).error.code).toBe("BFF_STUDENT_BAD_GATEWAY");
});
it("should validate answer content max length", async () => {
const longContentInput = {
homeworkId: "hw-001",
studentId: "u-stu-001",
answers: [{ questionId: "q-001", content: "x".repeat(10001) }],
};
await expect(
homeworkResolvers.Mutation.submitHomework(null, { input: longContentInput }, ctx),
).rejects.toThrow(ValidationError);
});
it("should validate attachments are valid URLs", async () => {
const invalidAttachmentInput = {
homeworkId: "hw-001",
studentId: "u-stu-001",
answers: [
{
questionId: "q-001",
content: "Answer",
attachments: ["not-a-url"],
},
],
};
await expect(
homeworkResolvers.Mutation.submitHomework(null, { input: invalidAttachmentInput }, ctx),
).rejects.toThrow(ValidationError);
});
});
});

View File

@@ -0,0 +1,142 @@
/**
* Homework Resolver - myHomework Query + submitHomework Mutation.
*
* 仲裁依据:
* - student-bff.schema.graphql Query.myHomework + Mutation.submitHomework
* - coord-final-decisions §2 B4 (强制自我越权防御, 学生只能查/操作自己数据)
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
* - president-final-rulings §2.9 (越权防御 P3 实现方式: 方案 D)
*/
import { z } from "zod";
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { ok, fail } from "../../shared/action-state.js";
import { CacheTTL } from "../../shared/cache/cache.module.js";
import {
UnauthorizedError,
ValidationError,
} from "../../shared/errors/application-error.js";
import { assertOwnData } from "../guards/authorization.guard.js";
/**
* 提交作业输入 Zod schema (G7 Zod 验证).
*/
const SubmitHomeworkInputSchema = z.object({
homeworkId: z.string().min(1),
studentId: z.string().min(1).optional(),
answers: z
.array(
z.object({
questionId: z.string().min(1),
content: z.string().min(1).max(10000),
attachments: z.array(z.string().url()).max(5).optional(),
}),
)
.min(1)
.max(100),
});
export const homeworkResolvers = {
Query: {
/**
* myHomework: 我的作业列表.
* @permission STUDENT_HOMEWORK_READ
* @dataScope OWN
*/
async myHomework(
_parent: unknown,
args: { status?: string; classId?: string },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
// 缓存命中
const cacheKey = ctx.userId + (args.classId ? `:${args.classId}` : "");
const cached = await ctx.redis.get<unknown>("homework", cacheKey);
if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
}
try {
const result = await ctx.downstream.call("core-edu", "ListHomeworkByStudent", {
studentId: ctx.userId,
status: args.status,
classId: args.classId,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
const data = result as { homework: unknown[] };
await ctx.redis.set("homework", data, CacheTTL.HOMEWORK, cacheKey);
return ok(data, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch homework: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
},
Mutation: {
/**
* submitHomework: 提交作业.
* @permission STUDENT_HOMEWORK_SUBMIT
* @dataScope OWN (B4 强制 studentId = userId)
*/
async submitHomework(
_parent: unknown,
args: { input: unknown },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
// G7 Zod 校验
const parseResult = SubmitHomeworkInputSchema.safeParse(args.input);
if (!parseResult.success) {
throw new ValidationError(
"Invalid submitHomework input",
parseResult.error.flatten(),
);
}
const input = parseResult.data;
// B4 自我越权防御: body 中 studentId 必须 = JWT userId
assertOwnData(ctx.userId, input.studentId);
try {
const result = await ctx.downstream.call("core-edu", "SubmitHomework", {
homeworkId: input.homeworkId,
studentId: ctx.userId,
answers: input.answers,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
// 失效相关缓存
await ctx.redis.invalidate("homework", ctx.userId);
await ctx.redis.invalidate("dashboard", ctx.userId);
const data = result as {
submissionId: string;
homeworkId: string;
submittedAt: string;
status: string;
};
return ok(data, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to submit homework: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
},
};

View File

@@ -0,0 +1,56 @@
/**
* Student BFF Resolver 装配入口.
*
* 将所有 Resolver 合并为一个 GraphQL Resolver 映射表,
* 供 GraphQL Yoga makeExecutableSchema 使用.
*
* Resolver 清单 (按 schema 第一版):
* Query:
* - currentUser (auth)
* - studentDashboard (dashboard)
* - myHomework (homework)
* - myGrades (grades)
* - myExams (exams)
* - myClasses (classes)
* - textbooks / chapters / learningPath (content, P4)
* - myWeakness / myTrend (analytics, P4)
* - myNotifications / myNotificationUnreadCount (notifications, P5)
* - aiChat (ai, P5)
* Mutation:
* - submitHomework (homework)
* - markNotificationAsRead (notifications, P5)
* Subscription:
* - aiStreamChat (ai-stream, P5 SSE)
*/
import { mergeResolvers } from "@graphql-tools/merge";
import { authResolvers } from "./auth.resolver.js";
import { dashboardResolvers } from "./dashboard.resolver.js";
import { homeworkResolvers } from "./homework.resolver.js";
import { gradesResolvers } from "./grades.resolver.js";
import { examsResolvers } from "./exams.resolver.js";
import { classesResolvers } from "./classes.resolver.js";
import { contentResolvers } from "./content.resolver.js";
import { analyticsResolvers } from "./analytics.resolver.js";
import { notificationsResolvers } from "./notifications.resolver.js";
import { aiResolvers } from "./ai.resolver.js";
import { aiStreamResolvers } from "./ai-stream.resolver.js";
/**
* 全部 Resolver 合并.
*
* 注意: 各 resolver 文件导出的对象结构为 { Query: {...}, Mutation: {...}, Subscription: {...} },
* mergeResolvers 自动合并同名 Query/Mutation/Subscription 字段.
*/
export const studentBffResolvers = mergeResolvers([
authResolvers,
dashboardResolvers,
homeworkResolvers,
gradesResolvers,
examsResolvers,
classesResolvers,
contentResolvers,
analyticsResolvers,
notificationsResolvers,
aiResolvers,
aiStreamResolvers,
]);

View File

@@ -0,0 +1,161 @@
/**
* Notifications Resolver - myNotifications Query + markNotificationAsRead Mutation (P5 扩展).
*
* 仲裁依据:
* - student-bff.schema.graphql Query.myNotifications / myNotificationUnreadCount
* + Mutation.markNotificationAsRead
* - coord-final-decisions §2 B2 (gRPC 调用 msg)
* - coord-final-decisions §2 B4 (强制自我越权防御, 通知仅本人可读/操作)
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
* - president-final-rulings §2.3 (跨阶段扩展例外)
*/
import { z } from "zod";
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { ok, fail } from "../../shared/action-state.js";
import { CacheTTL } from "../../shared/cache/cache.module.js";
import {
UnauthorizedError,
ValidationError,
} from "../../shared/errors/application-error.js";
import { assertOwnData } from "../guards/authorization.guard.js";
const MarkNotificationReadInputSchema = z.object({
notificationId: z.string().min(1),
studentId: z.string().min(1).optional(),
});
export const notificationsResolvers = {
Query: {
/**
* myNotifications: 消息列表.
* @permission STUDENT_NOTIFICATION_READ
* @dataScope OWN
*/
async myNotifications(
_parent: unknown,
args: { page?: number; pageSize?: number; unreadOnly?: boolean },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
const page = args.page ?? 1;
const pageSize = Math.min(args.pageSize ?? 20, 50);
const cacheKey = `${ctx.userId}:${page}:${args.unreadOnly ?? false}`;
const cached = await ctx.redis.get<unknown>("notifications", cacheKey);
if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
}
try {
const result = await ctx.downstream.call("msg", "ListNotifications", {
userId: ctx.userId,
page,
pageSize,
unreadOnly: args.unreadOnly ?? false,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
const data = result as {
notifications: unknown[];
totalCount: number;
unreadCount: number;
};
await ctx.redis.set("notifications", data, CacheTTL.NOTIFICATIONS, cacheKey);
return ok(data, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch notifications: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
/**
* myNotificationUnreadCount: 未读通知数.
* @permission STUDENT_NOTIFICATION_READ
*/
async myNotificationUnreadCount(
_parent: unknown,
_args: unknown,
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
try {
const result = await ctx.downstream.call("msg", "GetUnreadCount", {
userId: ctx.userId,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
const data = result as { unreadCount: number };
return ok(data, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to fetch unread count: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
},
Mutation: {
/**
* markNotificationAsRead: 标记通知已读.
* @permission STUDENT_NOTIFICATION_READ
* @dataScope OWN (B4 防御 body 中 studentId)
*/
async markNotificationAsRead(
_parent: unknown,
args: { input: unknown },
ctx: StudentBffContext,
): Promise<unknown> {
if (!ctx.userId) {
throw new UnauthorizedError();
}
const parseResult = MarkNotificationReadInputSchema.safeParse(args.input);
if (!parseResult.success) {
throw new ValidationError(
"Invalid markNotificationAsRead input",
parseResult.error.flatten(),
);
}
const input = parseResult.data;
// B4 越权防御
assertOwnData(ctx.userId, input.studentId);
try {
const result = await ctx.downstream.call("msg", "MarkNotificationAsRead", {
notificationId: input.notificationId,
userId: ctx.userId,
}, {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
});
// 失效通知缓存
await ctx.redis.invalidateByPrefix(`notifications:${ctx.userId}`);
return ok(result, { traceId: ctx.traceId });
} catch (err) {
return fail(
"BFF_STUDENT_BAD_GATEWAY",
`Failed to mark notification as read: ${(err as Error).message}`,
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
);
}
},
},
};

View File

@@ -0,0 +1,48 @@
/**
* StudentModule - 装配 GraphQL Yoga + Resolver.
*
* 仲裁依据:
* - coord-final-decisions §2 B1 (P2 起直接 GraphQL Yoga + DataLoader)
* - coord-final-decisions §2 B8 (复用 shared-ts DownstreamClient)
*
* 职责:
* 1. 启动时创建 GraphQL Yoga 实例 (加载 schema + 装配 resolver)
* 2. 提供 Yoga Express middleware 挂载钩子 (由 main.ts 调用)
* 3. 不直接持有 resolver 实例 (resolver 是纯函数, 通过 context 注入依赖)
*/
import { Module, OnModuleInit } from "@nestjs/common";
import { Inject } from "@nestjs/common";
import { DownstreamClient } from "@edu/shared-ts/bff";
import { REDIS_CLIENT } from "../shared/cache/cache.module.js";
import type { Redis } from "ioredis";
import { createStudentBffYoga, type StudentBffContext } from "../shared/graphql/yoga.js";
import { studentBffResolvers } from "./resolvers/index.js";
import { logger } from "../shared/observability/logger.js";
import type { YogaServerInstance } from "graphql-yoga";
export const GRAPHQL_YOGA = Symbol("GRAPHQL_YOGA");
@Module({
providers: [
{
provide: GRAPHQL_YOGA,
useFactory: async (
downstream: DownstreamClient,
redis: Redis,
): Promise<YogaServerInstance<Record<string, unknown>, StudentBffContext>> => {
return createStudentBffYoga(studentBffResolvers, downstream, redis);
},
inject: [DownstreamClient, REDIS_CLIENT],
},
],
exports: [GRAPHQL_YOGA],
})
export class StudentModule implements OnModuleInit {
constructor(@Inject(GRAPHQL_YOGA) private readonly yoga: Promise<unknown>) {}
async onModuleInit(): Promise<void> {
// 确保 Yoga 实例初始化完成
await this.yoga;
logger.info("StudentModule initialized, GraphQL Yoga ready");
}
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"rootDir": "./src",
"incremental": false,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View File

@@ -0,0 +1,44 @@
/**
* Vitest 配置 - student-bff 单元测试.
*
* 仲裁依据:
* - workline §3.2 P3.9 (单元测试覆盖率≥80%)
* - project_rules §3.1 (零错误通过)
*
* 覆盖范围:
* - Resolver: auth/dashboard/homework/grades/exams/classes/content/analytics/notifications/ai
* - Guard: authorization.guard
* - DataLoader: data-loader.module
* - Shared: action-state, cache, application-error, global-error.filter
*/
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: [
"src/student/resolvers/**/*.ts",
"src/student/guards/**/*.ts",
"src/student/dataloaders/**/*.ts",
"src/shared/**/*.ts",
],
exclude: [
"src/**/*.module.ts",
"src/**/*.test.ts",
"src/main.ts",
"src/config/mock-data.ts",
],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
},
});