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