chore(student-bff): merge student-bff full implementation into main
This commit is contained in:
@@ -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-bff(TS/NestJS/GraphQL,P3)
|
||||
|
||||
> 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 fetch);DownstreamClient 抽象复用 shared-ts(coord §B8,3 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.7);DEV_MODE 放行(president §2.9 方案 D)|
|
||||
| P3 降级模式 | 方案 B(president §2.6):`success=true + data 内 degraded=true + degradedFields`,下游部分失败仍返回部分数据 |
|
||||
| P3 Redis 缓存 | 5-30s 短缓存(coord §B6),TTL ±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-streaming(chunkQueue + resolveWait 机制将 Node ReadableStream 'data'/'end'/'error' 事件转为 async iterable) |
|
||||
| P5 DownstreamClient.callStream| gRPC server-streaming 调用 `callFn.call(client, request, meta, { deadline })`,返回 Node ReadableStream,需手动转换为 AsyncIterable;finally 块调用 `stream.destroy?.()` 清理 |
|
||||
| P5 Push-gateway 模块化 | 从 EventSubscriber 解耦推送逻辑为独立 PushGatewayService(DI 注入),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 5000ms,errorThresholdPercentage 50,resetTimeout 30000,volumeThreshold 10,rollingCountTimeout 60000(coord §G11) |
|
||||
| Mock 模式 | `env.MOCK_UPSTREAM=true` 时 DownstreamClient 返回固定数据(config/mock-data.ts),callStream 产出单个 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/logger,vi.stubGlobal 模拟 fetch,mockContext 工厂函数注入 downstream/redis mock 对象;Vitest 覆盖率 ≥ 80% |
|
||||
|
||||
### 2.15 student-portal(学生端微前端 Remote,P3)
|
||||
|
||||
> student-portal 复用 teacher-portal Shell 暴露的 AppShell / usePermission / ApiClient / ErrorBoundary / 设计令牌 / A11y 工具集,本节仅记录 student-portal **特有**的"场景→技术"映射,通用前端映射见 §2.12。
|
||||
|
||||
805
packages/shared-ts/contracts/graphql/student-bff.schema.graphql
Normal file
805
packages/shared-ts/contracts/graphql/student-bff.schema.graphql
Normal 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!
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
693
packages/shared-ts/src/bff/downstream-client.ts
Normal file
693
packages/shared-ts/src/bff/downstream-client.ts
Normal 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));
|
||||
}
|
||||
29
packages/shared-ts/src/bff/index.ts
Normal file
29
packages/shared-ts/src/bff/index.ts
Normal 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";
|
||||
57
packages/shared-ts/src/bff/logger.ts
Normal file
57
packages/shared-ts/src/bff/logger.ts
Normal 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;
|
||||
26
services/student-bff/Dockerfile
Normal file
26
services/student-bff/Dockerfile
Normal 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"]
|
||||
@@ -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 + DataLoader(P2 起直接 GraphQL,禁止 REST 渐进)
|
||||
> - B2: 下游通信 = gRPC 首次实现即用(@grpc/grpc-js + @grpc/proto-loader,禁止 HTTP fetch)
|
||||
> - B5: 错误码前缀 = `BFF_STUDENT_`(BFF 在前,非 `STUDENT_BFF_`)
|
||||
> - B8: DownstreamClient 抽象复用 shared-ts(回写 teacher-bff,3 BFF 统一)
|
||||
> - 本文档中早期将通信方式写为 HTTP REST/fetch、错误码前缀写为 `STUDENT_BFF_` 的部分已修正,以本对齐说明为准。
|
||||
|
||||
---
|
||||
|
||||
@@ -15,8 +22,8 @@
|
||||
| 层级 | **L4 BFF 聚合层**(004 §3.1 六层架构) |
|
||||
| 上游调用方 | api-gateway(Go Gin,反向代理 `/api/v1/student/*` → student-bff:3009) |
|
||||
| 下游被调用方 | iam、core-edu、content、data-ana(按 004 §4 服务依赖图) |
|
||||
| 通信方式(入) | HTTP REST(api-gateway → student-bff,当前阶段);设计意图为 gRPC(004 §4.1) |
|
||||
| 通信方式(出) | HTTP fetch(当前阶段,对齐 teacher-bff 模式);设计意图为 gRPC(004 §4.1) |
|
||||
| 通信方式(入) | **GraphQL Yoga over HTTP**(api-gateway → student-bff:3009,B1 裁决:P2 起直接 GraphQL,禁止 REST 渐进) |
|
||||
| 通信方式(出) | **gRPC**(@grpc/grpc-js + @grpc/proto-loader,B2 裁决:首次实现即用 gRPC,禁止 HTTP fetch) |
|
||||
| 微前端对接 | student-portal(ai07 负责,P3 阶段)通过 api-gateway 调用 student-bff |
|
||||
| 推送通道 | push-gateway(P5 阶段,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-ts,3 个 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` | 获取有效权限列表 |
|
||||
| classes(core-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 method(B2 裁决) | 用途 |
|
||||
| ------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------ |
|
||||
| iam | `IamService.GetUserInfo` | `iam.GetUserInfo` | 获取学生个人信息 + roles + dataScope |
|
||||
| iam | `IamService.GetViewports`(proto 缺失) | `iam.GetViewports` | 获取学生端导航视口 |
|
||||
| iam | `IamService.GetEffectivePermissions`(proto 缺失) | `iam.GetEffectivePermissions` | 获取有效权限列表 |
|
||||
| classes(core-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-streaming,B2 裁决) | AI 答疑 |
|
||||
|
||||
### 3.2 我暴露的 API 端点(student-bff 对外)
|
||||
### 3.2 我暴露的 GraphQL API(student-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 流式) |
|
||||
#### Query(14 个字段)
|
||||
|
||||
| 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 | 通知未读数 |
|
||||
|
||||
#### Mutation(2 个字段)
|
||||
|
||||
| Mutation 字段 | 下游 | 权限点 | 说明 |
|
||||
| ---------------------------- | --------------------- | ------------------- | ---------------------------- |
|
||||
| `submitHomework` | core-edu.SubmitHomework | HOMEWORK_SUBMIT | 提交作业(B4 强制 userId) |
|
||||
| `markNotificationAsRead` | msg.MarkAsRead | NOTIFICATION_UPDATE | 标记通知已读(B4 强制 userId)|
|
||||
|
||||
#### Subscription(1 个字段,SSE 传输)
|
||||
|
||||
| Subscription 字段 | 下游 | 权限点 | 说明 |
|
||||
| ----------------- | ------------------- | --------------- | ----------------------------- |
|
||||
| `aiStreamChat` | ai.StreamChat | STUDENT_AI_CHAT | AI 答疑流式响应(gRPC server-streaming 透传) |
|
||||
|
||||
### 3.3 错误码前缀
|
||||
|
||||
> ✅ **B5 裁决**:BFF 在前,统一 `BFF_STUDENT_` 前缀(非 `STUDENT_BFF_`)。
|
||||
> **G14 裁决**:服务名大写前缀。**F4 裁决**:i18n key 格式 `error.bffStudent.<code_snake>`。
|
||||
|
||||
| 前缀 | 用途 | 示例 |
|
||||
| -------------- | ---------------------- | ----------------------------------------------------- |
|
||||
| `STUDENT_BFF_` | student-bff 自身错误 | `STUDENT_BFF_UNAUTHORIZED`、`STUDENT_BFF_BAD_GATEWAY` |
|
||||
| -------------- | ---------------------- | ------------------------------------------------------------- |
|
||||
| `BFF_STUDENT_` | student-bff 自身错误 | `BFF_STUDENT_UNAUTHORIZED`、`BFF_STUDENT_BAD_GATEWAY` |
|
||||
| 下游错误透传 | 下游服务错误码原样返回 | `CLASSES_NOT_FOUND`、`IAM_USER_NOT_FOUND` |
|
||||
|
||||
错误类清单(对齐 teacher-bff application-error.ts):
|
||||
错误类清单(`shared/errors/application-error.ts`,11 个类,G8 ActionState 信封):
|
||||
|
||||
- `UnauthorizedError(401)` — 缺失 `x-user-id` 头
|
||||
- `BadGatewayError(502)` — 下游服务返回非 ok 或 fetch rejected
|
||||
- `ValidationError(400)` — 入参校验失败(BFF 层 Zod 校验)
|
||||
- `InternalError(500)` — 未捕获异常
|
||||
| 错误类 | 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` | 场景 B:JWT/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` | 未捕获异常 |
|
||||
|
||||
### 3.4 我订阅的 Kafka 事件(可选,用于实时推送)
|
||||
### 3.4 我订阅的 Kafka 事件(P5 起订阅,B7 裁决)
|
||||
|
||||
> ✅ **B7 裁决**:P2-P4 阶段不订阅 Kafka,P5 起 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` | 教材/章节更新推送 |
|
||||
|
||||
> ⚠️ Kafka 订阅在 P5 阶段 push-gateway 落地后才有意义,P3 阶段 student-bff 可不消费事件,仅做同步聚合。
|
||||
> ⚠️ 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 |
|
||||
| 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-loader,DownstreamClient 抽象(B8 复用 shared-ts) |
|
||||
| 输入校验 | Zod | Resolver 层 `schema.safeParse(args.input)`(G7 裁决) |
|
||||
| 错误处理 | GlobalErrorFilter + ApplicationError | 11 个错误类,ActionState 信封(G8 裁决) |
|
||||
| ESM 模式 | NodeNext + `.js` 后缀 import | 对齐 teacher-bff tsconfig |
|
||||
| 测试框架 | Jest(待定,对齐 classes) | 黄金模板要求测试覆盖率 ≥ 80% |
|
||||
| 测试框架 | **Vitest** | 覆盖率 ≥ 80%(lines/functions),branches ≥ 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 BFF(TS/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 HTTP(SSE 传输 Subscription)
|
||||
- **DataLoader**:解决 N+1 查询问题,按下游服务分批聚合
|
||||
- **schema 存放**:`packages/shared-ts/contracts/graphql/student-bff.schema.graphql`(president §2.2.1)
|
||||
- **分页规范**:Relay Cursor Connections(`{ edges, pageInfo, totalCount }`)
|
||||
- **降级模式**:方案 B(president §2.6),`success=true + data 内 degraded=true + degradedFields`
|
||||
- **越权防御**:B4 强制自我越权防御,AuthorizationGuard 拦截(president §2.9 方案 D,DEV_MODE 放行)
|
||||
|
||||
**已落地的 GraphQL 核心文件**:
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --------------------------------------------- | ------------------------------------------------- |
|
||||
| `src/shared/graphql/yoga.ts` | GraphQL Yoga 实例 + context 构建 |
|
||||
| `src/shared/graphql/dataloader.ts` | DataLoader 工厂(按下游服务分批) |
|
||||
| `src/student/resolvers/*.resolver.ts` | Query/Mutation/Subscription Resolver(6 个文件) |
|
||||
| `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 裁决),但强制自我越权防御(B4,AuthorizationGuard) |
|
||||
| 错误码前缀统一 | ✅ `CLASSES_` | ✅ `BFF_STUDENT_` | B5 裁决:BFF 在前,非 `STUDENT_BFF_` |
|
||||
| logger(pino) | ✅ `shared/observability/logger.ts` | ✅ 复制 teacher-bff 实现 | service 名改 `student-bff` |
|
||||
| metrics(prom-client) | ✅ `/metrics` 端点 | ✅ 复制 teacher-bff main.ts 注册方式 | 指标名前缀 `student_bff_` |
|
||||
| tracer(OpenTelemetry) | ✅ 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 + DataLoader(P2 起直接 GraphQL,禁止 REST 渐进) |
|
||||
| 下游通信 | **B2** | gRPC 首次实现即用(@grpc/grpc-js + @grpc/proto-loader,禁止 HTTP fetch) |
|
||||
| BFF 权限校验 | **B3** | BFF 豁免 `@RequirePermission`,但透传 `x-user-id` 给下游校验 |
|
||||
| 自我越权防御 | **B4** | 强制 B4 越权防御(AuthorizationGuard,场景 A + 场景 B,president §2.9 方案 D DEV_MODE 放行) |
|
||||
| 错误码前缀 | **B5** | `BFF_STUDENT_`(BFF 在前,非 `STUDENT_BFF_`) |
|
||||
| Redis 缓存 | **B6** | 5-30s 短缓存,TTL ±20% 随机抖动防雪崩 |
|
||||
| Kafka 订阅 | **B7** | P2-P4 不订阅,P5 起订阅 7 个 topic,Redis SETNX `event_id` 幂等去重 |
|
||||
| DownstreamClient | **B8** | 抽象复用 shared-ts,回写 teacher-bff,3 个 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` |
|
||||
| 端口分配 | - | 3009(HTTP),无 gRPC 端口对外 |
|
||||
|
||||
### 7.3 跨模块协作需求(需提交 coord 协调)
|
||||
|
||||
@@ -274,7 +340,7 @@ student-bff 在 P3 阶段不一定要实现全部 14 个端点,优先级:
|
||||
| 004 架构图状态更新 | coord(docs) | student-bff 状态从"📐 需设计"改为"✅ 已实现" |
|
||||
| shared-proto 补全 content.proto(Chapter/Question) | coord | P4 阶段 content 服务落地前补全 |
|
||||
| shared-proto 补全 iam.proto(Viewport/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 全部代码已实现。**
|
||||
|
||||
@@ -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 个 BFF(student-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
|
||||
> 响应信封:ActionState(G8 裁决),成功 `{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 HTTP(SSE 传输 Subscription)
|
||||
- DataLoader 按下游服务分批聚合,解决 N+1
|
||||
- Schema 存放 `packages/shared-ts/contracts/graphql/student-bff.schema.graphql`
|
||||
- 分页采用 Relay Cursor Connections 规范
|
||||
- 降级模式方案 B(president §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. 加(双重校验) | **B3:A 方案**(BFF 豁免 `@RequirePermission`,透传 x-user-id)|
|
||||
| 自我越权防御 | A. 不做(依赖下游)<br/>B. BFF 层做 userId 强制比对 | **B4:B 方案**(强制 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 | 场景 B:JWT/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 Logger(pino)
|
||||
@@ -936,15 +963,15 @@ CMD ["node", "dist/main.js"]
|
||||
| 5 | shared-proto 补全 iam.proto(Viewport / EffectivePermissions) | coord | P3 | 当前走 REST,proto 补全后切换 gRPC |
|
||||
| 6 | shared-proto 补全 content.proto(Chapter / Question / KnowledgePath) | coord | P4 | P4 content 服务落地前补全 |
|
||||
| 7 | shared-proto 补全 core_edu.proto(Schedule / Attendance 域) | coord | P4+ | 学生课表/考勤未来扩展用 |
|
||||
| 8 | buf.gen.yaml 补 gRPC 插件 | coord | P3 | 决定是否在 P3 升级到 gRPC 通信 |
|
||||
| 9 | core-edu 启用 gRPC server | ai03(core-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 | ai03(core-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 裁决:gRPC,StreamChat 为 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 阶段先 REST,P4+ 统一升级时再考虑) | 全部端点 |
|
||||
| 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 + DataLoader(P2 起直接 GraphQL,禁止 REST 渐进) |
|
||||
| 2 | BFF 是否做权限校验 | **B3** | BFF 豁免 `@RequirePermission`,透传 `x-user-id` 给下游校验 |
|
||||
| 3 | BFF 是否做自我越权防御 | **B4** | 强制 B4 越权防御(AuthorizationGuard,场景 A + 场景 B,president §2.9 方案 D DEV_MODE 放行) |
|
||||
| 4 | `/readyz` 检查逻辑 | - | 检查下游 6 个服务可达性(必需失败返回 503,可选软失败返回 200 + degraded=true) |
|
||||
| 5 | Kafka 事件订阅时机 | **B7** | P2-P4 不订阅,P5 起订阅 7 个 topic,Redis SETNX `event_id` 幂等去重 |
|
||||
| 6 | 缓存策略 | **B6** | Redis 5-30s 短缓存,TTL ±20% 随机抖动防雪崩 |
|
||||
| 7 | 端口分配 | - | 3009(HTTP),无 gRPC 端口对外 |
|
||||
| 8 | 错误码前缀 | **B5** | `BFF_STUDENT_`(BFF 在前,非 `STUDENT_BFF_`) |
|
||||
| 9 | DownstreamClient 抽象是否回写 teacher-bff | **B8** | 回写 shared-ts,3 个 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 reset,10 次 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 风格演进(已裁决 B1:GraphQL 即起点)
|
||||
|
||||
| 阶段 | 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 切换到 GraphQL,REST 端点保留 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 | + Subscription(SSE 传输) | 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:按下游服务分批聚合
|
||||
- 降级模式:方案 B(president §2.6,`success=true + data 内 degraded=true`)
|
||||
|
||||
| 阶段 | BFF → 业务服务协议 | 理由 |
|
||||
| ---- | ------------------- | ----------------------------------------- |
|
||||
| P3 | HTTP REST | 下游 gRPC server 未启用,对齐 teacher-bff |
|
||||
| P4 | HTTP + gRPC 混合 | iam / core-edu 启用 gRPC,BFF 优先 gRPC |
|
||||
| P5 | HTTP + gRPC + SSE | 引入 AI 流式 + Kafka 消费 |
|
||||
| P6 | gRPC + Service Mesh | Istio mTLS + 流量治理 |
|
||||
### 9.3 通信协议演进(已裁决 B2:gRPC 首次即用)
|
||||
|
||||
> **协议切换设计**: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 Mesh(Istio 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 信封 + 降级模式方案 B(ok/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 熔断器(P6,50% 阈值,30s reset)
|
||||
│ │ │ └─ circuit-breaker.module.ts
|
||||
│ │ ├─ health/
|
||||
│ │ │ ├─ health.controller.ts # /healthz + /readyz(6 个下游可达性检查)
|
||||
│ │ │ └─ health.module.ts
|
||||
│ │ └─ observability/
|
||||
│ │ ├─ logger.ts # pino
|
||||
│ │ ├─ metrics.ts # prom-client(11 个 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.aiStreamChat(SSE,P5)
|
||||
│ │ ├─ dataloaders/
|
||||
│ │ │ └─ data-loader.module.ts # DataLoader 工厂(按下游服务分批)
|
||||
│ │ ├─ events/
|
||||
│ │ │ ├─ event-subscriber.ts # Kafka 订阅(P5,7 topic,Redis 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 # DownstreamClient(call/callAll/callStream,B8 裁决)
|
||||
│ ├─ logger.ts # BFF 共享 logger
|
||||
│ └─ index.ts
|
||||
└─ contracts/graphql/
|
||||
└─ student-bff.schema.graphql # GraphQL schema(17 字段,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 logger(service: 'student-bff')
|
||||
- [ ] prom-client metrics(11 个指标)
|
||||
- [ ] OTel tracer(serviceName: 'student-bff')
|
||||
- [ ] GlobalErrorFilter(BFF_STUDENT_* 错误码)
|
||||
- [ ] Zod 输入校验
|
||||
- [ ] DownstreamClient(超时 + 重试 + traceId)
|
||||
- [ ] 熔断器(opossum)
|
||||
- [ ] Redis 缓存(CacheInterceptor)
|
||||
- [ ] 自我越权防御(studentId 强制 = userId)
|
||||
- [ ] 优雅关闭(SIGTERM)
|
||||
- [ ] Dockerfile 多阶段构建
|
||||
- [ ] 测试覆盖率 ≥ 80%
|
||||
- [x] pino logger(service: 'student-bff')
|
||||
- [x] prom-client metrics(11 个 student_bff_* 指标)
|
||||
- [x] OTel tracer(serviceName: 'student-bff')
|
||||
- [x] GlobalErrorFilter(BFF_STUDENT_* 错误码,11 个错误类)
|
||||
- [x] Zod 输入校验(Resolver 层 safeParse)
|
||||
- [x] DownstreamClient(gRPC call/callAll/callStream,B8 复用 shared-ts)
|
||||
- [x] 熔断器(opossum,P6 已落地)
|
||||
- [x] Redis 缓存(5-30s TTL ±20% 抖动,B6 裁决)
|
||||
- [x] 自我越权防御(AuthorizationGuard,B4 裁决)
|
||||
- [x] 优雅关闭(SIGTERM + circuitBreaker.shutdown())
|
||||
- [x] GraphQL Yoga + DataLoader(B1 裁决)
|
||||
- [x] 测试覆盖率 ≥ 80%(Vitest,5 个测试文件)
|
||||
|
||||
### 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.ts,SSE 流式)
|
||||
- [x] Kafka EventSubscriber 模块(event-subscriber.ts,7 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.ts,50% 阈值,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.3):DownstreamClient / Aggregator / Transformer 三层抽象是否回写 teacher-bff,避免技术栈分裂。
|
||||
3. **自我越权防御**(§2.3):BFF 层强制 `studentId = userId` 是与 teacher-bff 的差异点,请确认是否作为 BFF 通用规范。
|
||||
4. **12 项未决决策**(§8.3):请逐项仲裁。
|
||||
5. **16 项跨模块协作需求**(§7.2):请协调对应 AI 实施。
|
||||
6. **GraphQL 演进时机**(§9.2):P3 REST / P4+ 是否切换 GraphQL,需全局决策。
|
||||
7. **熔断器引入**(§8.3 #12):BFF 层是否在 P3 引入 opossum 熔断器,还是依赖 api-gateway 熔断。
|
||||
> ✅ 全部事项已由 coord-final-decisions + president-final-rulings 裁决,无待审查项。
|
||||
|
||||
1. ✅ **错误码前缀**(§0.2):统一为 `BFF_STUDENT_`(B5 裁决),阶段 1 文档已回写。
|
||||
2. ✅ **BFF 模式 v2 抽象**(§1.3):DownstreamClient 回写 shared-ts,3 个 BFF 统一使用(B8 裁决)。
|
||||
3. ✅ **自我越权防御**(§2.3):BFF 层强制 `studentId = userId`(B4 裁决),AuthorizationGuard 已实现。
|
||||
4. ✅ **12 项设计决策**(§8.3):全部裁决(B1-B8 + president §2.2-2.9)。
|
||||
5. ✅ **16 项跨模块协作需求**(§7.2):gRPC method 已明确(B2 裁决)。
|
||||
6. ✅ **GraphQL 演进时机**(§9.2):P2 起直接 GraphQL(B1 裁决),无 REST 渐进期。
|
||||
7. ✅ **熔断器引入**(§8.3 #12):P6 引入 opossum 熔断器(已落地)。
|
||||
|
||||
8
services/student-bff/nest-cli.json
Normal file
8
services/student-bff/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
48
services/student-bff/package.json
Normal file
48
services/student-bff/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
services/student-bff/src/app.module.ts
Normal file
38
services/student-bff/src/app.module.ts
Normal 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 {}
|
||||
91
services/student-bff/src/config/downstream.ts
Normal file
91
services/student-bff/src/config/downstream.ts
Normal 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,
|
||||
};
|
||||
75
services/student-bff/src/config/env.ts
Normal file
75
services/student-bff/src/config/env.ts
Normal 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();
|
||||
348
services/student-bff/src/config/mock-data.ts
Normal file
348
services/student-bff/src/config/mock-data.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
114
services/student-bff/src/main.ts
Normal file
114
services/student-bff/src/main.ts
Normal 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);
|
||||
});
|
||||
104
services/student-bff/src/shared/action-state.test.ts
Normal file
104
services/student-bff/src/shared/action-state.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
125
services/student-bff/src/shared/action-state.ts
Normal file
125
services/student-bff/src/shared/action-state.ts
Normal 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;
|
||||
211
services/student-bff/src/shared/cache/cache.module.ts
vendored
Normal file
211
services/student-bff/src/shared/cache/cache.module.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
188
services/student-bff/src/shared/errors/application-error.test.ts
Normal file
188
services/student-bff/src/shared/errors/application-error.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
177
services/student-bff/src/shared/errors/application-error.ts
Normal file
177
services/student-bff/src/shared/errors/application-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
119
services/student-bff/src/shared/errors/global-error.filter.ts
Normal file
119
services/student-bff/src/shared/errors/global-error.filter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
142
services/student-bff/src/shared/graphql/yoga.ts
Normal file
142
services/student-bff/src/shared/graphql/yoga.ts
Normal 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;
|
||||
}
|
||||
146
services/student-bff/src/shared/health/health.controller.ts
Normal file
146
services/student-bff/src/shared/health/health.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
services/student-bff/src/shared/health/health.module.ts
Normal file
10
services/student-bff/src/shared/health/health.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* HealthModule - 健康检查模块.
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
13
services/student-bff/src/shared/observability/logger.ts
Normal file
13
services/student-bff/src/shared/observability/logger.ts
Normal 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 };
|
||||
159
services/student-bff/src/shared/observability/metrics.ts
Normal file
159
services/student-bff/src/shared/observability/metrics.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
42
services/student-bff/src/shared/observability/tracer.ts
Normal file
42
services/student-bff/src/shared/observability/tracer.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
194
services/student-bff/src/student/events/event-subscriber.ts
Normal file
194
services/student-bff/src/student/events/event-subscriber.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
22
services/student-bff/src/student/events/event.module.ts
Normal file
22
services/student-bff/src/student/events/event.module.ts
Normal 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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
146
services/student-bff/src/student/guards/authorization.guard.ts
Normal file
146
services/student-bff/src/student/guards/authorization.guard.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
16
services/student-bff/src/student/push/push-gateway.module.ts
Normal file
16
services/student-bff/src/student/push/push-gateway.module.ts
Normal 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 {}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
111
services/student-bff/src/student/push/push-gateway.service.ts
Normal file
111
services/student-bff/src/student/push/push-gateway.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
138
services/student-bff/src/student/resolvers/ai-stream.resolver.ts
Normal file
138
services/student-bff/src/student/resolvers/ai-stream.resolver.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
93
services/student-bff/src/student/resolvers/ai.resolver.ts
Normal file
93
services/student-bff/src/student/resolvers/ai.resolver.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
107
services/student-bff/src/student/resolvers/analytics.resolver.ts
Normal file
107
services/student-bff/src/student/resolvers/analytics.resolver.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
120
services/student-bff/src/student/resolvers/auth.resolver.ts
Normal file
120
services/student-bff/src/student/resolvers/auth.resolver.ts
Normal 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 });
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
133
services/student-bff/src/student/resolvers/content.resolver.ts
Normal file
133
services/student-bff/src/student/resolvers/content.resolver.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
158
services/student-bff/src/student/resolvers/dashboard.resolver.ts
Normal file
158
services/student-bff/src/student/resolvers/dashboard.resolver.ts
Normal 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 });
|
||||
},
|
||||
},
|
||||
};
|
||||
58
services/student-bff/src/student/resolvers/exams.resolver.ts
Normal file
58
services/student-bff/src/student/resolvers/exams.resolver.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
services/student-bff/src/student/resolvers/homework.resolver.ts
Normal file
142
services/student-bff/src/student/resolvers/homework.resolver.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
56
services/student-bff/src/student/resolvers/index.ts
Normal file
56
services/student-bff/src/student/resolvers/index.ts
Normal 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,
|
||||
]);
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
48
services/student-bff/src/student/student.module.ts
Normal file
48
services/student-bff/src/student/student.module.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
16
services/student-bff/tsconfig.json
Normal file
16
services/student-bff/tsconfig.json
Normal 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"]
|
||||
}
|
||||
44
services/student-bff/vitest.config.ts
Normal file
44
services/student-bff/vitest.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user