feat(student-bff): 完整实现 student-bff 聚合层
包含 src 全部实现、Dockerfile、shared-ts/bff 包等
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user