feat(student-bff): 完整实现 student-bff 聚合层

包含 src 全部实现、Dockerfile、shared-ts/bff 包等
This commit is contained in:
SpecialX
2026-07-10 19:10:51 +08:00
parent e5ca4c6c7b
commit f585080e70
55 changed files with 7141 additions and 252 deletions

View File

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

View File

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

View File

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

View File

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

View File

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