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

View File

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

View File

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