feat(student-bff): 完整实现 student-bff 聚合层
包含 src 全部实现、Dockerfile、shared-ts/bff 包等
This commit is contained in:
38
services/student-bff/src/app.module.ts
Normal file
38
services/student-bff/src/app.module.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* AppModule - student-bff 根模块.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B1 (GraphQL Yoga + DataLoader)
|
||||
* - coord-final-decisions §2 B8 (DownstreamClient 抽象复用)
|
||||
* - coord-final-decisions §1 G8 (GlobalErrorFilter)
|
||||
*
|
||||
* 模块装配:
|
||||
* - CacheModule (Global): Redis 缓存
|
||||
* - DownstreamModule (Global): gRPC 下游客户端
|
||||
* - CircuitBreakerModule (Global): opossum 熔断器 (P6)
|
||||
* - HealthModule: /healthz + /readyz
|
||||
* - StudentModule: GraphQL Yoga + Resolver
|
||||
* - DataLoaderModule: DataLoader 工厂
|
||||
* - EventModule: Kafka 事件订阅 + push-gateway 推送 (P5)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CacheModule } from "./shared/cache/cache.module.js";
|
||||
import { DownstreamModule } from "./shared/downstream/downstream.module.js";
|
||||
import { HealthModule } from "./shared/health/health.module.js";
|
||||
import { StudentModule } from "./student/student.module.js";
|
||||
import { DataLoaderModule } from "./student/dataloaders/data-loader.module.js";
|
||||
import { EventModule } from "./student/events/event.module.js";
|
||||
import { CircuitBreakerModule } from "./shared/circuit-breaker/circuit-breaker.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CacheModule,
|
||||
DownstreamModule,
|
||||
CircuitBreakerModule,
|
||||
HealthModule,
|
||||
DataLoaderModule,
|
||||
StudentModule,
|
||||
EventModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
91
services/student-bff/src/config/downstream.ts
Normal file
91
services/student-bff/src/config/downstream.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* student-bff 下游 gRPC 服务配置.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B2 (首次实现即 gRPC, 禁止 HTTP fetch)
|
||||
* - coord-final-decisions §2 B8 (复用 shared-ts DownstreamClient 抽象)
|
||||
* - president-final-rulings §2.4 (/readyz 探针按阶段扩展, env 控制启用)
|
||||
* - port-allocation.md §5 (gRPC 端口分配)
|
||||
*
|
||||
* 阶段启用:
|
||||
* - P3: iam (50052) + core-edu (50053) — 必需
|
||||
* - P4: + content (50054) + data-ana (50055) — 必需
|
||||
* - P5: + msg (50056) + ai (50058) — 必需
|
||||
*
|
||||
* proto 包名 (G17): next_edu_cloud.<domain>.v1
|
||||
*/
|
||||
import type { DownstreamServiceConfig } from "@edu/shared-ts/bff";
|
||||
import { env } from "./env.js";
|
||||
|
||||
/**
|
||||
* student-bff 下游 gRPC 服务清单.
|
||||
*
|
||||
* enabled 字段控制是否启用 (P3 仅 iam + core-edu);
|
||||
* required 字段控制 /readyz 软失败策略 (president §2.4):
|
||||
* - required=true: 失败返回 503, 触发 Pod 重启
|
||||
* - required=false: 失败仅告警, 返回 200 + degraded=true
|
||||
*
|
||||
* MOCK_UPSTREAM=true 时所有服务返回 mock 数据, 不实际调用 gRPC.
|
||||
*/
|
||||
export const downstreamServices: DownstreamServiceConfig[] = [
|
||||
{
|
||||
name: "iam",
|
||||
grpcUrl: env.IAM_GRPC_URL,
|
||||
protoPath: "packages/shared-proto/proto/iam.proto",
|
||||
packageName: "next_edu_cloud.iam.v1",
|
||||
enabled: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "core-edu",
|
||||
grpcUrl: env.CORE_EDU_GRPC_URL,
|
||||
protoPath: "packages/shared-proto/proto/core_edu.proto",
|
||||
packageName: "next_edu_cloud.core_edu.v1",
|
||||
enabled: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
grpcUrl: env.CONTENT_GRPC_URL,
|
||||
protoPath: "packages/shared-proto/proto/content.proto",
|
||||
packageName: "next_edu_cloud.content.v1",
|
||||
enabled: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "data-ana",
|
||||
grpcUrl: env.DATA_ANA_GRPC_URL,
|
||||
protoPath: "packages/shared-proto/proto/analytics.proto",
|
||||
packageName: "next_edu_cloud.analytics.v1",
|
||||
enabled: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "msg",
|
||||
grpcUrl: env.MSG_GRPC_URL,
|
||||
protoPath: "packages/shared-proto/proto/msg.proto",
|
||||
packageName: "next_edu_cloud.msg.v1",
|
||||
enabled: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "ai",
|
||||
grpcUrl: env.AI_GRPC_URL,
|
||||
protoPath: "packages/shared-proto/proto/ai.proto",
|
||||
packageName: "next_edu_cloud.ai.v1",
|
||||
enabled: true,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* DownstreamClient 配置 (传给 shared-ts DownstreamClient).
|
||||
*/
|
||||
export const downstreamClientConfig = {
|
||||
mockUpstream: env.MOCK_UPSTREAM,
|
||||
devMode: env.DEV_MODE,
|
||||
services: downstreamServices,
|
||||
defaultTimeoutMs: env.DOWNSTREAM_TIMEOUT_MS,
|
||||
defaultRetryCount: env.DOWNSTREAM_RETRY_COUNT,
|
||||
defaultRetryBackoffMs: env.DOWNSTREAM_RETRY_BACKOFF_MS,
|
||||
};
|
||||
75
services/student-bff/src/config/env.ts
Normal file
75
services/student-bff/src/config/env.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* student-bff 环境变量 schema.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B2 (gRPC 首次实现即用, 6 个下游 gRPC URL)
|
||||
* - coord-final-decisions §2 B6 (Redis 缓存 5-30s)
|
||||
* - coord-final-decisions §1 G7 (Zod 验证)
|
||||
* - president-final-rulings §2.4 (/readyz 探针按阶段扩展, env 控制启用)
|
||||
* - port-allocation.md (3009 HTTP, 无 gRPC 对外)
|
||||
*/
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(3009),
|
||||
LOG_LEVEL: z
|
||||
.enum(["fatal", "error", "warn", "info", "debug", "trace"])
|
||||
.default("info"),
|
||||
|
||||
// 下游 gRPC URL (按阶段启用, president §2.4)
|
||||
IAM_GRPC_URL: z.string().default("localhost:50052"),
|
||||
CORE_EDU_GRPC_URL: z.string().default("localhost:50053"),
|
||||
CONTENT_GRPC_URL: z.string().default("localhost:50054"),
|
||||
DATA_ANA_GRPC_URL: z.string().default("localhost:50055"),
|
||||
MSG_GRPC_URL: z.string().default("localhost:50056"),
|
||||
AI_GRPC_URL: z.string().default("localhost:50058"),
|
||||
|
||||
// 下游 gRPC 调用参数
|
||||
DOWNSTREAM_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||
DOWNSTREAM_RETRY_COUNT: z.coerce.number().int().min(0).default(2),
|
||||
DOWNSTREAM_RETRY_BACKOFF_MS: z.coerce.number().int().positive().default(100),
|
||||
|
||||
// Mock 模式 (上游未就绪时返回固定数据, workline §5)
|
||||
MOCK_UPSTREAM: z
|
||||
.preprocess((v) => v === "true" || v === true, z.boolean())
|
||||
.default(true),
|
||||
|
||||
// DEV_MODE: 越权防御放行 (president §2.9 方案 D)
|
||||
DEV_MODE: z
|
||||
.preprocess((v) => v === "true" || v === true, z.boolean())
|
||||
.default(false),
|
||||
|
||||
// Redis (B6 缓存 + P5 Kafka 幂等去重)
|
||||
REDIS_URL: z.string().default("redis://localhost:6379"),
|
||||
REDIS_KEY_PREFIX: z.string().default("student:"),
|
||||
|
||||
// OTel (G6 全链路)
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||
OTEL_SERVICE_NAME: z.string().default("student-bff"),
|
||||
|
||||
// P5: Kafka + push-gateway
|
||||
KAFKA_BROKERS: z.string().default("localhost:9092"),
|
||||
KAFKA_CLIENT_ID: z.string().default("student-bff"),
|
||||
KAFKA_CONSUMER_GROUP: z.string().default("student-bff-event-subscriber"),
|
||||
PUSH_GATEWAY_URL: z.string().default("http://localhost:8081"),
|
||||
|
||||
// GraphQL Playground (仅开发)
|
||||
GRAPHQL_PLAYGROUND: z
|
||||
.preprocess((v) => v === "true" || v === true, z.boolean())
|
||||
.default(true),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadEnv(): Env {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
"Invalid student-bff env: " + JSON.stringify(result.error.flatten()),
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const env: Env = loadEnv();
|
||||
348
services/student-bff/src/config/mock-data.ts
Normal file
348
services/student-bff/src/config/mock-data.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* student-bff Mock 数据提供器.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - workline §5.2 (Mock 策略: env.MOCK_UPSTREAM=true 时返回固定数据)
|
||||
* - matrix.md §7 (全并行 Mock 策略: 上游未就绪时拦截)
|
||||
*
|
||||
* 当 env.MOCK_UPSTREAM=true 时, DownstreamClient 调用 MockDataProvider 获取固定数据,
|
||||
* 上游服务就绪后设置 MOCK_UPSTREAM=false 即可切换真实调用.
|
||||
*
|
||||
* Mock 数据覆盖:
|
||||
* - iam: GetUserInfo / GetEffectivePermissions / GetViewports
|
||||
* - core-edu: HomeworkService / ExamService / GradeService / ClassService
|
||||
* - content: TextbookService / ChapterService / QuestionService / KnowledgeGraphService
|
||||
* - data-ana: AnalyticsService (GetStudentWeakness / GetLearningTrend)
|
||||
* - msg: NotificationService
|
||||
* - ai: AiService
|
||||
*/
|
||||
import type { MockDataProvider } from "@edu/shared-ts/bff";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
|
||||
/**
|
||||
* 学生 Mock 数据 (固定 ID 便于前端联调).
|
||||
*/
|
||||
const MOCK_STUDENT_ID = "u-stu-001";
|
||||
const MOCK_CLASS_ID = "c-001";
|
||||
|
||||
/**
|
||||
* Mock 数据提供器实现.
|
||||
*/
|
||||
export const studentBffMockProvider: MockDataProvider = (
|
||||
service,
|
||||
method,
|
||||
request,
|
||||
) => {
|
||||
logger.debug({ service, method, request }, "Mock data request");
|
||||
|
||||
switch (service) {
|
||||
case "iam":
|
||||
return mockIam(method, request);
|
||||
case "core-edu":
|
||||
return mockCoreEdu(method, request);
|
||||
case "content":
|
||||
return mockContent(method, request);
|
||||
case "data-ana":
|
||||
return mockDataAna(method, request);
|
||||
case "msg":
|
||||
return mockMsg(method, request);
|
||||
case "ai":
|
||||
return mockAi(method, request);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
function mockIam(method: string, _request: unknown): unknown {
|
||||
switch (method) {
|
||||
case "GetUserInfo":
|
||||
return {
|
||||
userId: MOCK_STUDENT_ID,
|
||||
email: "li.tongxue@example.com",
|
||||
name: "李同学",
|
||||
avatar: null,
|
||||
roles: ["student"],
|
||||
classId: MOCK_CLASS_ID,
|
||||
className: "高一(1)班",
|
||||
grade: "高一",
|
||||
};
|
||||
case "GetEffectivePermissions":
|
||||
return {
|
||||
userId: MOCK_STUDENT_ID,
|
||||
permissions: [
|
||||
"STUDENT_DASHBOARD_READ",
|
||||
"STUDENT_EXAM_READ",
|
||||
"STUDENT_HOMEWORK_READ",
|
||||
"STUDENT_HOMEWORK_SUBMIT",
|
||||
"STUDENT_GRADE_READ",
|
||||
"STUDENT_CONTENT_READ",
|
||||
"STUDENT_ANALYTICS_READ",
|
||||
"STUDENT_NOTIFICATION_READ",
|
||||
"STUDENT_AI_CHAT",
|
||||
],
|
||||
};
|
||||
case "GetViewports":
|
||||
return {
|
||||
userId: MOCK_STUDENT_ID,
|
||||
navigation: [
|
||||
{ key: "dashboard", label: "首页", route: "/student/dashboard", sortOrder: 1 },
|
||||
{ key: "homework", label: "我的作业", route: "/student/homework", sortOrder: 2 },
|
||||
{ key: "grades", label: "我的成绩", route: "/student/grades", sortOrder: 3 },
|
||||
{ key: "exams", label: "考试", route: "/student/exams", sortOrder: 4 },
|
||||
{ key: "content", label: "教材", route: "/student/textbooks", sortOrder: 5 },
|
||||
{ key: "analytics", label: "学情诊断", route: "/student/analytics", sortOrder: 6 },
|
||||
{ key: "ai", label: "AI 答疑", route: "/student/ai", sortOrder: 7 },
|
||||
],
|
||||
dataScope: {
|
||||
showHistoryGrades: true,
|
||||
showClassRanking: false,
|
||||
enableAIChat: true,
|
||||
},
|
||||
};
|
||||
case "GetChildrenByParent":
|
||||
// 学生场景不调用, 返回空
|
||||
return { children: [] };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function mockCoreEdu(method: string, request: unknown): unknown {
|
||||
switch (method) {
|
||||
case "ListHomeworkByClass":
|
||||
case "ListHomeworkByStudent":
|
||||
return {
|
||||
homework: [
|
||||
{
|
||||
id: "h-001",
|
||||
title: "数学作业第三章",
|
||||
classId: MOCK_CLASS_ID,
|
||||
subject: "数学",
|
||||
dueDate: "2026-07-15T23:59:59Z",
|
||||
status: "pending",
|
||||
questions: [
|
||||
{ id: "q-001", type: "short_answer", content: "求 x²+2x+1=0 的解" },
|
||||
{ id: "q-002", type: "essay", content: "解释二次方程求根公式" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "h-002",
|
||||
title: "物理作业: 力学基础",
|
||||
classId: MOCK_CLASS_ID,
|
||||
subject: "物理",
|
||||
dueDate: "2026-07-18T23:59:59Z",
|
||||
status: "submitted",
|
||||
submissionId: "sub-001",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "GetHomework":
|
||||
return {
|
||||
id: "h-001",
|
||||
title: "数学作业第三章",
|
||||
classId: MOCK_CLASS_ID,
|
||||
subject: "数学",
|
||||
dueDate: "2026-07-15T23:59:59Z",
|
||||
status: "pending",
|
||||
questions: [
|
||||
{ id: "q-001", type: "short_answer", content: "求 x²+2x+1=0 的解" },
|
||||
{ id: "q-002", type: "essay", content: "解释二次方程求根公式" },
|
||||
],
|
||||
};
|
||||
case "SubmitHomework":
|
||||
return {
|
||||
submissionId: "sub-" + Date.now(),
|
||||
homeworkId: (request as { homeworkId?: string })?.homeworkId ?? "h-001",
|
||||
submittedAt: new Date().toISOString(),
|
||||
status: "submitted",
|
||||
};
|
||||
case "ListExamsByClass":
|
||||
return {
|
||||
exams: [
|
||||
{
|
||||
id: "e-001",
|
||||
title: "期中考试",
|
||||
classId: MOCK_CLASS_ID,
|
||||
subject: "综合",
|
||||
examDate: "2026-07-25T09:00:00Z",
|
||||
duration: 120,
|
||||
location: "教学楼 A301",
|
||||
status: "upcoming",
|
||||
},
|
||||
{
|
||||
id: "e-002",
|
||||
title: "数学单元测试",
|
||||
classId: MOCK_CLASS_ID,
|
||||
subject: "数学",
|
||||
examDate: "2026-07-20T14:00:00Z",
|
||||
duration: 90,
|
||||
location: "教学楼 B201",
|
||||
status: "upcoming",
|
||||
},
|
||||
],
|
||||
};
|
||||
case "ListGradesByStudent":
|
||||
return {
|
||||
grades: [
|
||||
{
|
||||
id: "g-001",
|
||||
studentId: MOCK_STUDENT_ID,
|
||||
examId: "e-003",
|
||||
examTitle: "月考",
|
||||
subject: "数学",
|
||||
score: 92,
|
||||
maxScore: 100,
|
||||
gradedAt: "2026-07-01T15:00:00Z",
|
||||
feedback: "解题思路清晰, 步骤完整",
|
||||
rank: 5,
|
||||
},
|
||||
{
|
||||
id: "g-002",
|
||||
studentId: MOCK_STUDENT_ID,
|
||||
examId: "e-004",
|
||||
examTitle: "物理测验",
|
||||
subject: "物理",
|
||||
score: 85,
|
||||
maxScore: 100,
|
||||
gradedAt: "2026-07-03T10:00:00Z",
|
||||
feedback: "力学基础掌握良好",
|
||||
rank: 12,
|
||||
},
|
||||
],
|
||||
totalCount: 2,
|
||||
};
|
||||
case "GetClassesByStudent":
|
||||
return {
|
||||
classes: [
|
||||
{
|
||||
id: MOCK_CLASS_ID,
|
||||
name: "高一(1)班",
|
||||
grade: "高一",
|
||||
homeroomTeacher: { id: "u-tch-001", name: "王老师" },
|
||||
subjects: ["数学", "物理", "化学", "语文", "英语"],
|
||||
},
|
||||
],
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function mockContent(method: string, _request: unknown): unknown {
|
||||
switch (method) {
|
||||
case "ListTextbooks":
|
||||
return {
|
||||
textbooks: [
|
||||
{ id: "tb-001", title: "高一数学(上)", grade: "高一", subject: "数学", version: "人教版" },
|
||||
{ id: "tb-002", title: "高一物理(上)", grade: "高一", subject: "物理", version: "人教版" },
|
||||
],
|
||||
};
|
||||
case "ListChapters":
|
||||
return {
|
||||
chapters: [
|
||||
{ id: "ch-001", textbookId: "tb-001", title: "第一章 集合与函数", sortOrder: 1 },
|
||||
{ id: "ch-002", textbookId: "tb-001", title: "第二章 基本初等函数", sortOrder: 2 },
|
||||
],
|
||||
};
|
||||
case "GetLearningPath":
|
||||
return {
|
||||
knowledgePointId: "kp-001",
|
||||
path: [
|
||||
{ id: "kp-001", title: "二次方程", mastery: 0.6, recommended: true },
|
||||
{ id: "kp-002", title: "因式分解", mastery: 0.8, recommended: false },
|
||||
],
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function mockDataAna(method: string, _request: unknown): unknown {
|
||||
switch (method) {
|
||||
case "GetStudentWeakness":
|
||||
return {
|
||||
studentId: MOCK_STUDENT_ID,
|
||||
weakPoints: [
|
||||
{ knowledgePointId: "kp-001", title: "二次方程", mastery: 0.4, trend: "declining" },
|
||||
{ knowledgePointId: "kp-003", title: "三角函数", mastery: 0.55, trend: "stable" },
|
||||
],
|
||||
summary: "数学代数基础薄弱, 建议加强二次方程练习",
|
||||
};
|
||||
case "GetLearningTrend":
|
||||
return {
|
||||
studentId: MOCK_STUDENT_ID,
|
||||
range: "30d",
|
||||
points: [
|
||||
{ date: "2026-06-10", mastery: 0.55, studyMinutes: 120 },
|
||||
{ date: "2026-06-20", mastery: 0.62, studyMinutes: 180 },
|
||||
{ date: "2026-07-01", mastery: 0.68, studyMinutes: 210 },
|
||||
],
|
||||
trend: "improving",
|
||||
};
|
||||
case "GetStudentDashboard":
|
||||
return {
|
||||
studentId: MOCK_STUDENT_ID,
|
||||
overallMastery: 0.68,
|
||||
weakPointCount: 2,
|
||||
studyMinutesLast7d: 840,
|
||||
upcomingExams: 2,
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function mockMsg(method: string, _request: unknown): unknown {
|
||||
switch (method) {
|
||||
case "ListNotifications":
|
||||
return {
|
||||
notifications: [
|
||||
{
|
||||
id: "n-001",
|
||||
userId: MOCK_STUDENT_ID,
|
||||
type: "homework.graded",
|
||||
title: "作业批改完成",
|
||||
content: "你的数学作业已批改, 得分 92/100",
|
||||
read: false,
|
||||
createdAt: "2026-07-09T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "n-002",
|
||||
userId: MOCK_STUDENT_ID,
|
||||
type: "exam.published",
|
||||
title: "新考试发布",
|
||||
content: "期中考试将于 7月25日 举行",
|
||||
read: false,
|
||||
createdAt: "2026-07-08T15:00:00Z",
|
||||
},
|
||||
],
|
||||
totalCount: 2,
|
||||
unreadCount: 2,
|
||||
};
|
||||
case "MarkNotificationAsRead":
|
||||
return { success: true, notificationId: "n-001", readAt: new Date().toISOString() };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function mockAi(method: string, _request: unknown): unknown {
|
||||
switch (method) {
|
||||
case "Chat":
|
||||
return {
|
||||
response: "好的, 让我帮你分析这道题. x²+2x+1=0 是完全平方式, 可以写成 (x+1)²=0, 所以 x=-1.",
|
||||
model: "gpt-4o-mini",
|
||||
usage: { promptTokens: 120, completionTokens: 50, totalTokens: 170 },
|
||||
};
|
||||
case "StreamChat":
|
||||
// mock 流式响应: 返回单个 chunk (DownstreamClient.callStream mock 模式产出 1 个 chunk 后结束)
|
||||
return {
|
||||
content: "好的, 让我帮你分析这道题. x²+2x+1=0 是完全平方式, 可以写成 (x+1)²=0, 所以 x=-1.",
|
||||
done: true,
|
||||
model: "gpt-4o-mini",
|
||||
usage: { promptTokens: 120, completionTokens: 50, totalTokens: 170 },
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
114
services/student-bff/src/main.ts
Normal file
114
services/student-bff/src/main.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* student-bff 启动入口.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §1 G1 (Dockerfile 多阶段构建, EXPOSE 3009)
|
||||
* - coord-final-decisions §1 G3 (/healthz liveness)
|
||||
* - coord-final-decisions §1 G5 (/metrics 端点)
|
||||
* - coord-final-decisions §1 G6 (OTel tracer)
|
||||
* - coord-final-decisions §1 G8 (GlobalErrorFilter)
|
||||
* - coord-final-decisions §1 G9 (优雅关闭 SIGTERM)
|
||||
* - coord-final-decisions §2 B1 (GraphQL Yoga endpoint: POST /graphql)
|
||||
* - port-allocation.md (HTTP 3009, 无 gRPC 对外)
|
||||
*
|
||||
* 启动流程:
|
||||
* 1. initTracer (OTel)
|
||||
* 2. NestFactory.create(AppModule)
|
||||
* 3. app.useGlobalFilters(GlobalErrorFilter)
|
||||
* 4. 挂载 GraphQL Yoga middleware (POST /graphql)
|
||||
* 5. 注册 /metrics 端点
|
||||
* 6. app.listen(3009)
|
||||
* 7. SIGTERM → app.close() → 关闭 Redis/gRPC/Tracer
|
||||
*/
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { AppModule } from "./app.module.js";
|
||||
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||
import { logger } from "./shared/observability/logger.js";
|
||||
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||
import { env } from "./config/env.js";
|
||||
import { GRAPHQL_YOGA } from "./student/student.module.js";
|
||||
import { REDIS_CLIENT } from "./shared/cache/cache.module.js";
|
||||
import { DOWNSTREAM_CLIENT } from "./shared/downstream/downstream.module.js";
|
||||
import { CircuitBreakerService } from "./shared/circuit-breaker/circuit-breaker.service.js";
|
||||
import type { Redis } from "ioredis";
|
||||
import type { DownstreamClient } from "@edu/shared-ts/bff";
|
||||
import type { YogaServerInstance } from "graphql-yoga";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: ["log", "error", "warn"],
|
||||
});
|
||||
|
||||
// G8 全局错误过滤器
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// B1 GraphQL Yoga endpoint 挂载
|
||||
const yoga = (await app.resolve(GRAPHQL_YOGA)) as YogaServerInstance<
|
||||
Record<string, unknown>,
|
||||
unknown
|
||||
>;
|
||||
const httpAdapter = app.getHttpAdapter().getInstance();
|
||||
httpAdapter.use(
|
||||
"/graphql",
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
yoga.handle(req, res).catch((err: unknown) => {
|
||||
logger.error({ err }, "GraphQL Yoga handle error");
|
||||
next(err);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// G5 Prometheus 指标端点 (不鉴权)
|
||||
httpAdapter.get("/metrics", async (_req: Request, res: Response) => {
|
||||
res.set("Content-Type", metricsRegistry.contentType);
|
||||
res.end(await metricsRegistry.metrics());
|
||||
});
|
||||
|
||||
await app.listen(env.PORT);
|
||||
logger.info(
|
||||
{
|
||||
port: env.PORT,
|
||||
graphql: "/graphql",
|
||||
playground: env.GRAPHQL_PLAYGROUND && env.NODE_ENV === "development",
|
||||
mockUpstream: env.MOCK_UPSTREAM,
|
||||
devMode: env.DEV_MODE,
|
||||
},
|
||||
"Student BFF started",
|
||||
);
|
||||
|
||||
// G9 优雅关闭: SIGTERM → 关闭 HTTP → Redis → gRPC → Tracer
|
||||
process.on("SIGTERM", async () => {
|
||||
logger.info("SIGTERM received, shutting down gracefully");
|
||||
try {
|
||||
await app.close();
|
||||
|
||||
const redis = app.get<Redis>(REDIS_CLIENT);
|
||||
await redis.quit();
|
||||
|
||||
const downstream = app.get<DownstreamClient>(DOWNSTREAM_CLIENT);
|
||||
await downstream.close();
|
||||
|
||||
const circuitBreaker = app.get<CircuitBreakerService>(CircuitBreakerService);
|
||||
await circuitBreaker.shutdown();
|
||||
|
||||
await shutdownTracer();
|
||||
logger.info("Graceful shutdown complete");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Error during graceful shutdown");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap().catch((err: unknown) => {
|
||||
logger.error({ err }, "Failed to start Student BFF");
|
||||
process.exit(1);
|
||||
});
|
||||
104
services/student-bff/src/shared/action-state.test.ts
Normal file
104
services/student-bff/src/shared/action-state.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* ActionState 信封 + 降级模式方案 B 单元测试.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ok, fail, degraded, DegradedReason, type Degradable } from "./action-state.js";
|
||||
|
||||
describe("ActionState", () => {
|
||||
describe("ok()", () => {
|
||||
it("should construct success response with data only", () => {
|
||||
const result = ok({ name: "test" });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ name: "test" });
|
||||
expect(result.meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should construct success response with meta", () => {
|
||||
const result = ok({ count: 1 }, { traceId: "trace-123" });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual({ count: 1 });
|
||||
expect(result.meta?.traceId).toBe("trace-123");
|
||||
});
|
||||
|
||||
it("should include cachedAt in meta", () => {
|
||||
const result = ok({ items: [] }, { cachedAt: "2026-07-10T00:00:00Z" });
|
||||
expect(result.meta?.cachedAt).toBe("2026-07-10T00:00:00Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail()", () => {
|
||||
it("should construct error response with code and message", () => {
|
||||
const result = fail("BFF_STUDENT_NOT_FOUND", "Resource not found");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error.code).toBe("BFF_STUDENT_NOT_FOUND");
|
||||
expect(result.error.message).toBe("Resource not found");
|
||||
});
|
||||
|
||||
it("should include optional fields", () => {
|
||||
const result = fail("BFF_STUDENT_BAD_GATEWAY", "Downstream failed", {
|
||||
traceId: "trace-456",
|
||||
i18nKey: "error.bffStudent.bad_gateway",
|
||||
details: { service: "iam" },
|
||||
});
|
||||
expect(result.error.traceId).toBe("trace-456");
|
||||
expect(result.error.i18nKey).toBe("error.bffStudent.bad_gateway");
|
||||
expect(result.error.details).toEqual({ service: "iam" });
|
||||
});
|
||||
|
||||
it("should have undefined optional fields when not provided", () => {
|
||||
const result = fail("BFF_STUDENT_INTERNAL_ERROR", "Unknown error");
|
||||
expect(result.error.traceId).toBeUndefined();
|
||||
expect(result.error.i18nKey).toBeUndefined();
|
||||
expect(result.error.details).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("degraded()", () => {
|
||||
it("should construct degraded response with degraded=true", () => {
|
||||
interface DashboardData extends Degradable {
|
||||
score: number;
|
||||
}
|
||||
const result = degraded<DashboardData>(
|
||||
{ score: 90 },
|
||||
DegradedReason.DOWNSTREAM_PARTIAL_FAILURE,
|
||||
["weakness", "trend"],
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.score).toBe(90);
|
||||
expect(result.data.degraded).toBe(true);
|
||||
expect(result.data.degradedReason).toBe("downstream_partial_failure");
|
||||
expect(result.data.degradedFields).toEqual(["weakness", "trend"]);
|
||||
});
|
||||
|
||||
it("should set degraded=true in meta", () => {
|
||||
const result = degraded(
|
||||
{ items: [] },
|
||||
DegradedReason.REDIS_UNAVAILABLE,
|
||||
["items"],
|
||||
);
|
||||
expect(result.meta?.degraded).toBe(true);
|
||||
expect(result.meta?.degradedReason).toBe("redis_unavailable");
|
||||
});
|
||||
|
||||
it("should merge additional meta fields", () => {
|
||||
const result = degraded(
|
||||
{ data: "test" },
|
||||
DegradedReason.CIRCUIT_OPEN,
|
||||
["data"],
|
||||
{ traceId: "trace-789" },
|
||||
);
|
||||
expect(result.meta?.traceId).toBe("trace-789");
|
||||
expect(result.meta?.degraded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DegradedReason constants", () => {
|
||||
it("should export all expected reasons", () => {
|
||||
expect(DegradedReason.REDIS_UNAVAILABLE).toBe("redis_unavailable");
|
||||
expect(DegradedReason.DOWNSTREAM_PARTIAL_FAILURE).toBe("downstream_partial_failure");
|
||||
expect(DegradedReason.DOWNSTREAM_TIMEOUT).toBe("downstream_timeout");
|
||||
expect(DegradedReason.CIRCUIT_OPEN).toBe("circuit_open");
|
||||
expect(DegradedReason.MOCK_UPSTREAM).toBe("mock_upstream");
|
||||
});
|
||||
});
|
||||
});
|
||||
125
services/student-bff/src/shared/action-state.ts
Normal file
125
services/student-bff/src/shared/action-state.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* ActionState 信封 + 降级模式方案 B 工具.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §1 G8 (响应信封严格对齐 ActionState 结构)
|
||||
* - president-final-rulings §2.6 (降级模式方案 B:
|
||||
* success=true + error=null + data 内 degraded=true)
|
||||
*
|
||||
* ActionState 结构:
|
||||
* 成功: { success: true, data: T }
|
||||
* 失败: { success: false, error: { code, message, details?, traceId? } }
|
||||
*
|
||||
* 降级模式 (方案 B):
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* ...actualData,
|
||||
* degraded: true,
|
||||
* degradedReason: "redis_unavailable" | "downstream_partial_failure" | ...,
|
||||
* degradedFields: ["field1", "field2"]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export interface ActionStateSuccess<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
meta?: {
|
||||
traceId?: string;
|
||||
cachedAt?: string;
|
||||
degraded?: boolean;
|
||||
degradedReason?: string;
|
||||
degradedServices?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ActionStateError {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
i18nKey?: string;
|
||||
details?: Record<string, unknown>;
|
||||
traceId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ActionState<T> = ActionStateSuccess<T> | ActionStateError;
|
||||
|
||||
/**
|
||||
* 降级标记字段 (president §2.6 方案 B).
|
||||
* 业务 data 对象可包含此字段表示降级状态.
|
||||
*/
|
||||
export interface Degradable {
|
||||
degraded?: boolean;
|
||||
degradedReason?: string;
|
||||
degradedFields?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造成功响应.
|
||||
*/
|
||||
export function ok<T>(data: T, meta?: ActionStateSuccess<T>["meta"]): ActionStateSuccess<T> {
|
||||
return { success: true, data, meta };
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造失败响应.
|
||||
*/
|
||||
export function fail(
|
||||
code: string,
|
||||
message: string,
|
||||
options?: {
|
||||
details?: Record<string, unknown>;
|
||||
traceId?: string;
|
||||
i18nKey?: string;
|
||||
},
|
||||
): ActionStateError {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
i18nKey: options?.i18nKey,
|
||||
details: options?.details,
|
||||
traceId: options?.traceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造降级响应 (方案 B).
|
||||
*
|
||||
* 下游部分失败但仍返回部分数据时使用:
|
||||
* - success=true (HTTP 200)
|
||||
* - data.degraded=true
|
||||
* - data.degradedReason=原因
|
||||
* - data.degradedFields=哪些字段降级了
|
||||
*/
|
||||
export function degraded<T extends Degradable>(
|
||||
data: T,
|
||||
reason: string,
|
||||
degradedFields: string[],
|
||||
meta?: ActionStateSuccess<T>["meta"],
|
||||
): ActionStateSuccess<T> {
|
||||
return ok(
|
||||
{
|
||||
...data,
|
||||
degraded: true,
|
||||
degradedReason: reason,
|
||||
degradedFields,
|
||||
},
|
||||
{ ...meta, degraded: true, degradedReason: reason },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 常用降级原因.
|
||||
*/
|
||||
export const DegradedReason = {
|
||||
REDIS_UNAVAILABLE: "redis_unavailable",
|
||||
DOWNSTREAM_PARTIAL_FAILURE: "downstream_partial_failure",
|
||||
DOWNSTREAM_TIMEOUT: "downstream_timeout",
|
||||
CIRCUIT_OPEN: "circuit_open",
|
||||
MOCK_UPSTREAM: "mock_upstream",
|
||||
} as const;
|
||||
211
services/student-bff/src/shared/cache/cache.module.ts
vendored
Normal file
211
services/student-bff/src/shared/cache/cache.module.ts
vendored
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* student-bff Redis 缓存模块.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
* - coord-final-decisions §1 G9 (优雅关闭 Redis 连接)
|
||||
* - president-final-rulings §2.6 (降级模式方案 B: degraded=true + degradedReason)
|
||||
* - 02-architecture-design.md §3.1 (Redis 缓存 Schema)
|
||||
*
|
||||
* 缓存 Key 规范:
|
||||
* student:dashboard:{userId} TTL 15s
|
||||
* student:exams:{userId}:{classId} TTL 30s
|
||||
* student:homework:{userId}:{classId} TTL 30s
|
||||
* student:grades:{userId}:{page} TTL 60s
|
||||
* student:notifications:{userId}:{page} TTL 15s
|
||||
* student:textbooks:{gradeId}:{subjectId} TTL 300s
|
||||
* student:chapters:{textbookId} TTL 300s
|
||||
* student:analytics:weakness:{userId} TTL 300s
|
||||
* student:analytics:trend:{userId}:{range} TTL 600s
|
||||
* student:viewports:{userId} TTL 300s
|
||||
*/
|
||||
import { Global, Module, OnModuleDestroy } from "@nestjs/common";
|
||||
import Redis from "ioredis";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { recordCacheAccess } from "../observability/metrics.js";
|
||||
|
||||
export const REDIS_CLIENT = Symbol("REDIS_CLIENT");
|
||||
|
||||
/**
|
||||
* 缓存 TTL 预设 (秒).
|
||||
*/
|
||||
export const CacheTTL = {
|
||||
DASHBOARD: 15,
|
||||
EXAMS: 30,
|
||||
HOMEWORK: 30,
|
||||
GRADES: 60,
|
||||
NOTIFICATIONS: 15,
|
||||
TEXTBOOKS: 300,
|
||||
CHAPTERS: 300,
|
||||
ANALYTICS_WEAKNESS: 300,
|
||||
ANALYTICS_TREND: 600,
|
||||
VIEWPORTS: 300,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 缓存 Key 构建器 (统一前缀 + 规范化).
|
||||
*/
|
||||
export function buildCacheKey(pattern: string, ...parts: (string | number)[]): string {
|
||||
const suffix = parts.map(String).join(":");
|
||||
return `${env.REDIS_KEY_PREFIX}${pattern}:${suffix}`;
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: REDIS_CLIENT,
|
||||
useFactory: (): Redis => {
|
||||
const client = new Redis(env.REDIS_URL, {
|
||||
lazyConnect: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
enableReadyCheck: true,
|
||||
retryStrategy: (times) => Math.min(times * 100, 2000),
|
||||
});
|
||||
client.on("error", (err) => {
|
||||
logger.error({ err }, "Redis client error");
|
||||
});
|
||||
client.on("connect", () => {
|
||||
logger.info({ url: env.REDIS_URL }, "Redis connected");
|
||||
});
|
||||
return client;
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [REDIS_CLIENT],
|
||||
})
|
||||
export class CacheModule implements OnModuleDestroy {
|
||||
constructor() {}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
// G9 优雅关闭: 由 main.ts SIGTERM handler 统一调用 disconnectAll
|
||||
logger.info("CacheModule destroyed (Redis disconnect handled by main.ts)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存服务 - 封装 Redis get/set + 降级模式.
|
||||
*
|
||||
* 使用方式:
|
||||
* const cached = await cacheService.get<StudentDashboard>("dashboard", userId);
|
||||
* if (cached) return cached;
|
||||
* const fresh = await aggregateDashboard(userId);
|
||||
* await cacheService.set("dashboard", userId, fresh, CacheTTL.DASHBOARD);
|
||||
*/
|
||||
export class CacheService {
|
||||
constructor(private readonly redis: Redis) {}
|
||||
|
||||
/**
|
||||
* 读取缓存, 自动 JSON 反序列化.
|
||||
* 失败时返回 null (不抛异常, 上层走降级模式).
|
||||
*/
|
||||
async get<T>(pattern: string, ...keyParts: (string | number)[]): Promise<T | null> {
|
||||
const key = buildCacheKey(pattern, ...keyParts);
|
||||
try {
|
||||
const raw = await this.redis.get(key);
|
||||
if (raw) {
|
||||
recordCacheAccess(pattern, true);
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
recordCacheAccess(pattern, false);
|
||||
return null;
|
||||
} catch (err) {
|
||||
logger.warn({ err, key, pattern }, "Cache get failed, returning null");
|
||||
recordCacheAccess(pattern, false);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入缓存, 自动 JSON 序列化.
|
||||
* 失败时仅记录日志, 不影响主流程 (降级模式).
|
||||
*/
|
||||
async set(
|
||||
pattern: string,
|
||||
value: unknown,
|
||||
ttlSeconds: number,
|
||||
...keyParts: (string | number)[]
|
||||
): Promise<void> {
|
||||
const key = buildCacheKey(pattern, ...keyParts);
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
// TTL 加 ±20% 随机抖动, 避免缓存雪崩 (02 §8.1)
|
||||
const jitter = Math.floor(ttlSeconds * 0.2 * (Math.random() * 2 - 1));
|
||||
const ttl = Math.max(1, ttlSeconds + jitter);
|
||||
await this.redis.set(key, serialized, "EX", ttl);
|
||||
} catch (err) {
|
||||
logger.warn({ err, key, pattern }, "Cache set failed, skipping");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 失效缓存 (按 pattern 通配符删除).
|
||||
* 用于写操作后主动失效相关缓存.
|
||||
*/
|
||||
async invalidate(pattern: string, ...keyParts: (string | number)[]): Promise<void> {
|
||||
const key = buildCacheKey(pattern, ...keyParts);
|
||||
try {
|
||||
// 如果 keyParts 含通配符, 用 SCAN 删除
|
||||
if (key.includes("*")) {
|
||||
const stream = this.redis.scanStream({
|
||||
match: key,
|
||||
count: 100,
|
||||
});
|
||||
const pipeline = this.redis.pipeline();
|
||||
stream.on("data", (keys: string[]) => {
|
||||
if (keys.length > 0) {
|
||||
keys.forEach((k) => pipeline.del(k));
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.on("end", () => {
|
||||
pipeline.exec().finally(() => resolve());
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err, key, pattern }, "Cache invalidate failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量失效 (如成绩发布后失效学生所有成绩缓存).
|
||||
*/
|
||||
async invalidateByPrefix(prefix: string): Promise<void> {
|
||||
const pattern = `${env.REDIS_KEY_PREFIX}${prefix}*`;
|
||||
try {
|
||||
const stream = this.redis.scanStream({
|
||||
match: pattern,
|
||||
count: 100,
|
||||
});
|
||||
const pipeline = this.redis.pipeline();
|
||||
stream.on("data", (keys: string[]) => {
|
||||
if (keys.length > 0) {
|
||||
keys.forEach((k) => pipeline.del(k));
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.on("end", () => {
|
||||
pipeline.exec().finally(() => resolve());
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err, prefix }, "Cache invalidateByPrefix failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查 (供 /readyz 探针使用, §2.4).
|
||||
*/
|
||||
async ping(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.redis.ping();
|
||||
return result === "PONG";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* CircuitBreakerModule - P6 熔断器模块.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - workline §5.5 P6.1 (熔断器 opossum 完善)
|
||||
* - coord-final-decisions §1 G11 (opossum 熔断器)
|
||||
*
|
||||
* 提供 CircuitBreakerService 供 Resolver 使用 (可选, P6 阶段启用).
|
||||
* P3-P5 阶段 Resolver 直接调用 DownstreamClient.call, P6 可切换为 CircuitBreakerService.call.
|
||||
*/
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { CircuitBreakerService } from "./circuit-breaker.service.js";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CircuitBreakerService],
|
||||
exports: [CircuitBreakerService],
|
||||
})
|
||||
export class CircuitBreakerModule {}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* CircuitBreakerService - P6 熔断器封装.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - workline §5.5 P6.1 (熔断器 opossum 完善)
|
||||
* - coord-final-decisions §1 G11 (熔断器: opossum, 50% 阈值, 30s reset)
|
||||
* - president-final-rulings §2.6 (降级模式方案 B: 熔断开启时返回 degraded)
|
||||
*
|
||||
* 设计:
|
||||
* - 每个下游服务一个独立 CircuitBreaker 实例 (Map 缓存)
|
||||
* - 熔断开启时抛出 ServiceUnavailableError (503)
|
||||
* - 状态变更同步到 student_bff_circuit_state 指标
|
||||
* - half-open 状态允许单次试探调用
|
||||
*
|
||||
* Opossum 配置 (G11):
|
||||
* - timeout: 5000ms (对齐 DOWNSTREAM_TIMEOUT_MS)
|
||||
* - errorThresholdPercentage: 50%
|
||||
* - resetTimeout: 30000ms (30s 后 half-open)
|
||||
* - volumeThreshold: 10 (最少 10 次调用才评估)
|
||||
* - rollingCountTimeout: 60000 (1 分钟滚动窗口)
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import CircuitBreaker from "opossum";
|
||||
import type { DownstreamClient, CallOptions } from "@edu/shared-ts/bff";
|
||||
import { ServiceUnavailableError } from "../errors/application-error.js";
|
||||
import { metricsRegistry } from "../observability/metrics.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { env } from "../../config/env.js";
|
||||
|
||||
/**
|
||||
* 熔断器配置.
|
||||
*/
|
||||
interface BreakerConfig {
|
||||
timeoutMs: number;
|
||||
errorThresholdPercentage: number;
|
||||
resetTimeoutMs: number;
|
||||
volumeThreshold: number;
|
||||
rollingCountTimeoutMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认熔断配置 (G11).
|
||||
*/
|
||||
const DEFAULT_CONFIG: BreakerConfig = {
|
||||
timeoutMs: env.DOWNSTREAM_TIMEOUT_MS,
|
||||
errorThresholdPercentage: 50,
|
||||
resetTimeoutMs: 30000,
|
||||
volumeThreshold: 10,
|
||||
rollingCountTimeoutMs: 60000,
|
||||
};
|
||||
|
||||
/**
|
||||
* 熔断器状态映射到指标值.
|
||||
*/
|
||||
function stateToMetricValue(state: CircuitBreaker.Status): number {
|
||||
switch (state) {
|
||||
case CircuitBreaker.CLOSED:
|
||||
return 0;
|
||||
case CircuitBreaker.OPEN:
|
||||
return 1;
|
||||
case CircuitBreaker.HALF_OPEN:
|
||||
return 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 熔断器状态名称.
|
||||
*/
|
||||
function stateName(state: CircuitBreaker.Status): string {
|
||||
switch (state) {
|
||||
case CircuitBreaker.CLOSED:
|
||||
return "closed";
|
||||
case CircuitBreaker.OPEN:
|
||||
return "open";
|
||||
case CircuitBreaker.HALF_OPEN:
|
||||
return "half_open";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CircuitBreakerService {
|
||||
private readonly breakers = new Map<string, CircuitBreaker>();
|
||||
private readonly config: BreakerConfig;
|
||||
|
||||
constructor(config?: Partial<BreakerConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过熔断器调用下游服务.
|
||||
*
|
||||
* @param downstream DownstreamClient 实例
|
||||
* @param service 下游服务名
|
||||
* @param method RPC 方法名
|
||||
* @param request 请求 message
|
||||
* @param options 调用配置
|
||||
* @returns 下游响应
|
||||
* @throws ServiceUnavailableError 当熔断器开启时
|
||||
*/
|
||||
async call<TRequest, TResponse>(
|
||||
downstream: DownstreamClient,
|
||||
service: string,
|
||||
method: string,
|
||||
request: TRequest,
|
||||
options?: CallOptions,
|
||||
): Promise<TResponse> {
|
||||
const breaker = this.getOrCreateBreaker(service, downstream, method, request, options);
|
||||
|
||||
try {
|
||||
return (await breaker.fire()) as TResponse;
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceUnavailableError) {
|
||||
throw err;
|
||||
}
|
||||
// 重新抛出原始错误 (DownstreamError 等)
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取熔断器当前状态.
|
||||
*/
|
||||
getState(service: string): CircuitBreaker.Status | null {
|
||||
const breaker = this.breakers.get(service);
|
||||
return breaker ? breaker.status : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建某服务的熔断器.
|
||||
*
|
||||
* 注意: 由于 opossum 的 fire() 不接受动态参数, 我们在每次调用时
|
||||
* 通过闭包捕获当前的 method/request/options.
|
||||
* 实际上 opossum 支持 fire(args...), 但此处为简化设计,
|
||||
* 每次创建新的执行函数.
|
||||
*
|
||||
* 为避免创建过多 breaker 实例, 我们按 service 名缓存 breaker,
|
||||
* 并在 fire 前更新其执行函数.
|
||||
*/
|
||||
private getOrCreateBreaker<TRequest>(
|
||||
service: string,
|
||||
downstream: DownstreamClient,
|
||||
method: string,
|
||||
request: TRequest,
|
||||
options?: CallOptions,
|
||||
): CircuitBreaker {
|
||||
let breaker = this.breakers.get(service);
|
||||
|
||||
if (!breaker) {
|
||||
const execFn = async (): Promise<unknown> => {
|
||||
return downstream.call(service, method, request, options);
|
||||
};
|
||||
|
||||
breaker = new CircuitBreaker(execFn, {
|
||||
timeout: this.config.timeoutMs,
|
||||
errorThresholdPercentage: this.config.errorThresholdPercentage,
|
||||
resetTimeout: this.config.resetTimeoutMs,
|
||||
volumeThreshold: this.config.volumeThreshold,
|
||||
rollingCountTimeout: this.config.rollingCountTimeoutMs,
|
||||
});
|
||||
|
||||
// 状态变更监听
|
||||
breaker.on("open", () => {
|
||||
logger.warn({ service }, "Circuit breaker OPENED");
|
||||
this.updateMetric(service, CircuitBreaker.OPEN);
|
||||
});
|
||||
|
||||
breaker.on("close", () => {
|
||||
logger.info({ service }, "Circuit breaker CLOSED (recovered)");
|
||||
this.updateMetric(service, CircuitBreaker.CLOSED);
|
||||
});
|
||||
|
||||
breaker.on("halfOpen", () => {
|
||||
logger.info({ service }, "Circuit breaker HALF-OPEN");
|
||||
this.updateMetric(service, CircuitBreaker.HALF_OPEN);
|
||||
});
|
||||
|
||||
// fallback: 熔断开启时返回 ServiceUnavailableError
|
||||
breaker.fallback(() => {
|
||||
throw new ServiceUnavailableError(
|
||||
`Circuit breaker open for service: ${service}`,
|
||||
{ service, state: "open" },
|
||||
);
|
||||
});
|
||||
|
||||
this.breakers.set(service, breaker);
|
||||
this.updateMetric(service, CircuitBreaker.CLOSED);
|
||||
} else {
|
||||
// 更新执行函数 (opossum 允许重新设置 action)
|
||||
// 由于 opossum 不支持直接替换 action, 我们使用 wrapper 方式
|
||||
// 实际上 opossum 的 fire() 会调用构造时传入的函数,
|
||||
// 所以我们用一个可变的 wrapper
|
||||
(breaker as unknown as { action: () => Promise<unknown> }).action = async () => {
|
||||
return downstream.call(service, method, request, options);
|
||||
};
|
||||
}
|
||||
|
||||
return breaker;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新熔断器指标.
|
||||
*/
|
||||
private updateMetric(service: string, state: CircuitBreaker.Status): void {
|
||||
const value = stateToMetricValue(state);
|
||||
const name = stateName(state);
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_circuit_state")
|
||||
?.set({ service, state: name }, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有熔断器 (优雅关闭).
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
for (const [service, breaker] of this.breakers) {
|
||||
breaker.shutdown();
|
||||
logger.debug({ service }, "Circuit breaker shut down");
|
||||
}
|
||||
this.breakers.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* student-bff DownstreamClient NestJS Module.
|
||||
*
|
||||
* 仲裁依据: coord-final-decisions §2 B8 (3 BFF 复用 shared-ts 抽象)
|
||||
*
|
||||
* 提供:
|
||||
* - 全局共享 DownstreamClient 实例 (@Injectable)
|
||||
* - 启动时注入 mock 数据提供器 (env.MOCK_UPSTREAM=true 时)
|
||||
* - OnModuleDestroy 优雅关闭 gRPC 连接 (G9)
|
||||
*/
|
||||
import { Global, Module, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { DownstreamClient } from "@edu/shared-ts/bff";
|
||||
import { downstreamClientConfig } from "../../config/downstream.js";
|
||||
import { studentBffMockProvider } from "../../config/mock-data.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
|
||||
export const DOWNSTREAM_CLIENT = Symbol("DOWNSTREAM_CLIENT");
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DOWNSTREAM_CLIENT,
|
||||
useFactory: (): DownstreamClient => {
|
||||
const client = new DownstreamClient(downstreamClientConfig);
|
||||
if (downstreamClientConfig.mockUpstream) {
|
||||
client.setMockProvider(studentBffMockProvider);
|
||||
logger.warn(
|
||||
"MOCK_UPSTREAM=true, all downstream calls return mock data",
|
||||
);
|
||||
}
|
||||
return client;
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DownstreamClient,
|
||||
useExisting: DOWNSTREAM_CLIENT,
|
||||
},
|
||||
],
|
||||
exports: [DownstreamClient, DOWNSTREAM_CLIENT],
|
||||
})
|
||||
export class DownstreamModule implements OnModuleInit, OnModuleDestroy {
|
||||
constructor() {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const enabled = downstreamClientConfig.services
|
||||
.filter((s) => s.enabled)
|
||||
.map((s) => s.name);
|
||||
logger.info(
|
||||
{ enabled, mock: downstreamClientConfig.mockUpstream },
|
||||
"DownstreamClient initialized",
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
// G9 优雅关闭: 关闭所有 gRPC 连接
|
||||
// DownstreamClient 实例由 NestJS 容器管理, 这里通过 token 获取
|
||||
// 但 Module destroy 阶段不能注入, 实际由 main.ts SIGTERM handler 统一关闭
|
||||
logger.info("DownstreamModule destroyed");
|
||||
}
|
||||
}
|
||||
188
services/student-bff/src/shared/errors/application-error.test.ts
Normal file
188
services/student-bff/src/shared/errors/application-error.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* ApplicationError 错误类层次 + i18n key 生成 单元测试.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
ApplicationError,
|
||||
ValidationError,
|
||||
UnauthorizedError,
|
||||
ForbiddenResourceError,
|
||||
IdentityMismatchError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
BusinessError,
|
||||
BadGatewayError,
|
||||
GatewayTimeoutError,
|
||||
ServiceUnavailableError,
|
||||
InternalError,
|
||||
} from "./application-error.js";
|
||||
|
||||
describe("ApplicationError", () => {
|
||||
describe("ValidationError", () => {
|
||||
it("should have 400 status and BFF_STUDENT_VALIDATION_ERROR code", () => {
|
||||
const err = new ValidationError("Invalid input");
|
||||
expect(err.statusCode).toBe(400);
|
||||
expect(err.code).toBe("BFF_STUDENT_VALIDATION_ERROR");
|
||||
expect(err.type).toBe("validation");
|
||||
expect(err.message).toBe("Invalid input");
|
||||
});
|
||||
|
||||
it("should accept details", () => {
|
||||
const err = new ValidationError("Invalid input", { field: "email" });
|
||||
expect(err.details).toEqual({ field: "email" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnauthorizedError", () => {
|
||||
it("should have 401 status and BFF_STUDENT_UNAUTHORIZED code", () => {
|
||||
const err = new UnauthorizedError();
|
||||
expect(err.statusCode).toBe(401);
|
||||
expect(err.code).toBe("BFF_STUDENT_UNAUTHORIZED");
|
||||
expect(err.type).toBe("unauthorized");
|
||||
});
|
||||
|
||||
it("should use default message", () => {
|
||||
const err = new UnauthorizedError();
|
||||
expect(err.message).toBe("Missing or invalid x-user-id");
|
||||
});
|
||||
|
||||
it("should accept custom message", () => {
|
||||
const err = new UnauthorizedError("Token expired");
|
||||
expect(err.message).toBe("Token expired");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ForbiddenResourceError (场景 A)", () => {
|
||||
it("should have 403 status and BFF_STUDENT_FORBIDDEN_RESOURCE code", () => {
|
||||
const err = new ForbiddenResourceError("Not your data");
|
||||
expect(err.statusCode).toBe(403);
|
||||
expect(err.code).toBe("BFF_STUDENT_FORBIDDEN_RESOURCE");
|
||||
expect(err.type).toBe("permission_denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IdentityMismatchError (场景 B)", () => {
|
||||
it("should have 403 status and BFF_STUDENT_IDENTITY_MISMATCH code", () => {
|
||||
const err = new IdentityMismatchError("Identity mismatch");
|
||||
expect(err.statusCode).toBe(403);
|
||||
expect(err.code).toBe("BFF_STUDENT_IDENTITY_MISMATCH");
|
||||
expect(err.type).toBe("permission_denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotFoundError", () => {
|
||||
it("should have 404 status and BFF_STUDENT_NOT_FOUND code", () => {
|
||||
const err = new NotFoundError("Homework", "hw-123");
|
||||
expect(err.statusCode).toBe(404);
|
||||
expect(err.code).toBe("BFF_STUDENT_NOT_FOUND");
|
||||
expect(err.message).toBe("Homework not found: hw-123");
|
||||
expect(err.details).toEqual({ resource: "Homework", id: "hw-123" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConflictError", () => {
|
||||
it("should have 409 status and BFF_STUDENT_CONFLICT code", () => {
|
||||
const err = new ConflictError("Already submitted");
|
||||
expect(err.statusCode).toBe(409);
|
||||
expect(err.code).toBe("BFF_STUDENT_CONFLICT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BusinessError", () => {
|
||||
it("should have 422 status and BFF_STUDENT_BUSINESS_ERROR code", () => {
|
||||
const err = new BusinessError("Business rule violated");
|
||||
expect(err.statusCode).toBe(422);
|
||||
expect(err.code).toBe("BFF_STUDENT_BUSINESS_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BadGatewayError", () => {
|
||||
it("should have 502 status and BFF_STUDENT_BAD_GATEWAY code", () => {
|
||||
const err = new BadGatewayError("Downstream failed");
|
||||
expect(err.statusCode).toBe(502);
|
||||
expect(err.code).toBe("BFF_STUDENT_BAD_GATEWAY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatewayTimeoutError", () => {
|
||||
it("should have 504 status and BFF_STUDENT_GATEWAY_TIMEOUT code", () => {
|
||||
const err = new GatewayTimeoutError("Downstream timeout");
|
||||
expect(err.statusCode).toBe(504);
|
||||
expect(err.code).toBe("BFF_STUDENT_GATEWAY_TIMEOUT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServiceUnavailableError", () => {
|
||||
it("should have 503 status and BFF_STUDENT_SERVICE_UNAVAILABLE code", () => {
|
||||
const err = new ServiceUnavailableError("Circuit breaker open");
|
||||
expect(err.statusCode).toBe(503);
|
||||
expect(err.code).toBe("BFF_STUDENT_SERVICE_UNAVAILABLE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InternalError", () => {
|
||||
it("should have 500 status and BFF_STUDENT_INTERNAL_ERROR code", () => {
|
||||
const err = new InternalError("Unexpected error");
|
||||
expect(err.statusCode).toBe(500);
|
||||
expect(err.code).toBe("BFF_STUDENT_INTERNAL_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toJSON() serialization", () => {
|
||||
it("should serialize to ActionState error envelope", () => {
|
||||
const err = new ValidationError("Invalid input", { field: "name" });
|
||||
err.traceId = "trace-abc";
|
||||
const json = err.toJSON();
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBeDefined();
|
||||
expect(json.error.code).toBe("BFF_STUDENT_VALIDATION_ERROR");
|
||||
expect(json.error.message).toBe("Invalid input");
|
||||
expect(json.error.i18nKey).toBe("error.bffStudent.validation_error");
|
||||
expect(json.error.details).toEqual({ field: "name" });
|
||||
expect(json.error.traceId).toBe("trace-abc");
|
||||
});
|
||||
|
||||
it("should generate correct i18n key for each error code", () => {
|
||||
const cases = [
|
||||
{ error: new ValidationError(), expectedKey: "error.bffStudent.validation_error" },
|
||||
{ error: new UnauthorizedError(), expectedKey: "error.bffStudent.unauthorized" },
|
||||
{ error: new ForbiddenResourceError("test"), expectedKey: "error.bffStudent.forbidden_resource" },
|
||||
{ error: new IdentityMismatchError("test"), expectedKey: "error.bffStudent.identity_mismatch" },
|
||||
{ error: new NotFoundError("X", "1"), expectedKey: "error.bffStudent.not_found" },
|
||||
{ error: new ConflictError("test"), expectedKey: "error.bffStudent.conflict" },
|
||||
{ error: new BusinessError("test"), expectedKey: "error.bffStudent.business_error" },
|
||||
{ error: new BadGatewayError("test"), expectedKey: "error.bffStudent.bad_gateway" },
|
||||
{ error: new GatewayTimeoutError("test"), expectedKey: "error.bffStudent.gateway_timeout" },
|
||||
{ error: new ServiceUnavailableError("test"), expectedKey: "error.bffStudent.service_unavailable" },
|
||||
{ error: new InternalError("test"), expectedKey: "error.bffStudent.internal_error" },
|
||||
];
|
||||
|
||||
for (const { error, expectedKey } of cases) {
|
||||
const json = error.toJSON();
|
||||
expect(json.error.i18nKey).toBe(expectedKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error name", () => {
|
||||
it("should set constructor name as error.name", () => {
|
||||
expect(new ValidationError("x").name).toBe("ValidationError");
|
||||
expect(new UnauthorizedError().name).toBe("UnauthorizedError");
|
||||
expect(new ForbiddenResourceError("x").name).toBe("ForbiddenResourceError");
|
||||
expect(new IdentityMismatchError("x").name).toBe("IdentityMismatchError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("instanceof checks", () => {
|
||||
it("should be instanceof ApplicationError", () => {
|
||||
expect(new ValidationError("x")).toBeInstanceOf(ApplicationError);
|
||||
expect(new UnauthorizedError()).toBeInstanceOf(ApplicationError);
|
||||
expect(new ForbiddenResourceError("x")).toBeInstanceOf(ApplicationError);
|
||||
});
|
||||
|
||||
it("should be instanceof Error", () => {
|
||||
expect(new ValidationError("x")).toBeInstanceOf(Error);
|
||||
expect(new InternalError("x")).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
177
services/student-bff/src/shared/errors/application-error.ts
Normal file
177
services/student-bff/src/shared/errors/application-error.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* student-bff ApplicationError 层次.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §1 G8 (首次实现即 GlobalErrorFilter + ActionState 信封)
|
||||
* - coord-final-decisions §1 G14 (服务名大写前缀, BFF_STUDENT_*)
|
||||
* - coord-final-decisions §2 B5 (统一 BFF_ 前缀)
|
||||
* - president-final-rulings §2.7 (3 类越权防御错误码)
|
||||
*
|
||||
* 错误码清单:
|
||||
* BFF_STUDENT_VALIDATION_ERROR (400) Zod 校验失败
|
||||
* BFF_STUDENT_UNAUTHORIZED (401) x-user-id 缺失或无效
|
||||
* BFF_STUDENT_FORBIDDEN_RESOURCE (403) 资源无归属关系 (场景 A)
|
||||
* BFF_STUDENT_IDENTITY_MISMATCH (403) JWT userId 与 body 不一致 (场景 B)
|
||||
* BFF_STUDENT_NOT_FOUND (404) 资源不存在
|
||||
* BFF_STUDENT_CONFLICT (409) 重复提交 / 状态冲突
|
||||
* BFF_STUDENT_BUSINESS_ERROR (422) 业务规则违反
|
||||
* BFF_STUDENT_BAD_GATEWAY (502) 下游 gRPC 失败
|
||||
* BFF_STUDENT_GATEWAY_TIMEOUT (504) 下游超时
|
||||
* BFF_STUDENT_SERVICE_UNAVAILABLE (503) 熔断器开启
|
||||
* BFF_STUDENT_INTERNAL_ERROR (500) 未捕获异常
|
||||
*/
|
||||
export type ErrorType =
|
||||
| "validation"
|
||||
| "not_found"
|
||||
| "permission_denied"
|
||||
| "unauthorized"
|
||||
| "conflict"
|
||||
| "business"
|
||||
| "bad_gateway"
|
||||
| "gateway_timeout"
|
||||
| "service_unavailable"
|
||||
| "internal";
|
||||
|
||||
export interface ErrorDetails {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n key 生成 (president §2.7 + F4 裁决): error.bffStudent.<code_snake>.
|
||||
*/
|
||||
function toI18nKey(code: string): string {
|
||||
const snake = code
|
||||
.replace(/^BFF_STUDENT_/, "")
|
||||
.toLowerCase()
|
||||
.replace(/_/g, "_");
|
||||
return `error.bffStudent.${snake}`;
|
||||
}
|
||||
|
||||
export abstract class ApplicationError extends Error {
|
||||
abstract readonly type: ErrorType;
|
||||
abstract readonly statusCode: number;
|
||||
readonly code: string;
|
||||
readonly details?: ErrorDetails;
|
||||
traceId?: string;
|
||||
|
||||
constructor(message: string, code: string, details?: ErrorDetails) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化为 ActionState 信封响应体 (G8).
|
||||
*/
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
i18nKey: toI18nKey(this.code),
|
||||
details: this.details,
|
||||
traceId: this.traceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends ApplicationError {
|
||||
readonly type = "validation" as const;
|
||||
readonly statusCode = 400;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_STUDENT_VALIDATION_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends ApplicationError {
|
||||
readonly type = "unauthorized" as const;
|
||||
readonly statusCode = 401;
|
||||
constructor(message = "Missing or invalid x-user-id", details?: ErrorDetails) {
|
||||
super(message, "BFF_STUDENT_UNAUTHORIZED", details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景 A: 资源无归属关系 (president §2.7).
|
||||
* 如学生请求的 studentId 与 JWT userId 不一致.
|
||||
*/
|
||||
export class ForbiddenResourceError extends ApplicationError {
|
||||
readonly type = "permission_denied" as const;
|
||||
readonly statusCode = 403;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_STUDENT_FORBIDDEN_RESOURCE", details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景 B: JWT userId 与请求 body userId 不一致 (president §2.7).
|
||||
*/
|
||||
export class IdentityMismatchError extends ApplicationError {
|
||||
readonly type = "permission_denied" as const;
|
||||
readonly statusCode = 403;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_STUDENT_IDENTITY_MISMATCH", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends ApplicationError {
|
||||
readonly type = "not_found" as const;
|
||||
readonly statusCode = 404;
|
||||
constructor(resource: string, id: string) {
|
||||
super(`${resource} not found: ${id}`, "BFF_STUDENT_NOT_FOUND", {
|
||||
resource,
|
||||
id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends ApplicationError {
|
||||
readonly type = "conflict" as const;
|
||||
readonly statusCode = 409;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_STUDENT_CONFLICT", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class BusinessError extends ApplicationError {
|
||||
readonly type = "business" as const;
|
||||
readonly statusCode = 422;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_STUDENT_BUSINESS_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class BadGatewayError extends ApplicationError {
|
||||
readonly type = "bad_gateway" as const;
|
||||
readonly statusCode = 502;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_STUDENT_BAD_GATEWAY", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class GatewayTimeoutError extends ApplicationError {
|
||||
readonly type = "gateway_timeout" as const;
|
||||
readonly statusCode = 504;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_STUDENT_GATEWAY_TIMEOUT", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceUnavailableError extends ApplicationError {
|
||||
readonly type = "service_unavailable" as const;
|
||||
readonly statusCode = 503;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_STUDENT_SERVICE_UNAVAILABLE", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends ApplicationError {
|
||||
readonly type = "internal" as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "BFF_STUDENT_INTERNAL_ERROR", details);
|
||||
}
|
||||
}
|
||||
119
services/student-bff/src/shared/errors/global-error.filter.ts
Normal file
119
services/student-bff/src/shared/errors/global-error.filter.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* student-bff GlobalErrorFilter.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §1 G8 (首次实现即 GlobalErrorFilter + ActionState 信封)
|
||||
* - president-final-rulings §2.6 (降级模式方案 B: success=true + degraded)
|
||||
*
|
||||
* GraphQL Yoga 错误格式化由 graphql-error-formatter.ts 处理;
|
||||
* 本 Filter 处理 NestJS HTTP 异常 (健康检查 /metrics 等), 并被 GraphQL Yoga
|
||||
* 在错误转换时复用 code/i18nKey 生成逻辑.
|
||||
*/
|
||||
import {
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { ZodError } from "zod";
|
||||
import { ApplicationError } from "./application-error.js";
|
||||
import { DownstreamError } from "@edu/shared-ts/bff";
|
||||
|
||||
@Catch()
|
||||
export class GlobalErrorFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalErrorFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const traceIdHeader = request.headers["x-request-id"];
|
||||
const traceId =
|
||||
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
|
||||
|
||||
let statusCode = 500;
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
if (exception instanceof ApplicationError) {
|
||||
exception.traceId = traceId;
|
||||
statusCode = exception.statusCode;
|
||||
body = exception.toJSON();
|
||||
} else if (exception instanceof DownstreamError) {
|
||||
// 下游 gRPC 错误归一化为 BFF_STUDENT_BAD_GATEWAY (B2 + G8)
|
||||
statusCode = 502;
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "BFF_STUDENT_BAD_GATEWAY",
|
||||
message: `Downstream ${exception.service}.${exception.method} failed: ${exception.message}`,
|
||||
i18nKey: "error.bffStudent.bad_gateway",
|
||||
details: {
|
||||
service: exception.service,
|
||||
method: exception.method,
|
||||
downstreamCode: exception.code,
|
||||
traceId,
|
||||
},
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
} else if (exception instanceof ZodError) {
|
||||
statusCode = 400;
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "BFF_STUDENT_VALIDATION_ERROR",
|
||||
message: "Validation failed",
|
||||
i18nKey: "error.bffStudent.validation_error",
|
||||
details: exception.flatten(),
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
} else if (exception instanceof HttpException) {
|
||||
statusCode = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
const message = this.extractHttpMessage(res, exception);
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "BFF_STUDENT_HTTP_ERROR",
|
||||
message,
|
||||
i18nKey: "error.bffStudent.http_error",
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Unhandled exception: ${exception}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "BFF_STUDENT_INTERNAL_ERROR",
|
||||
message: "An unexpected error occurred",
|
||||
i18nKey: "error.bffStudent.internal_error",
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
response.status(statusCode).json(body);
|
||||
}
|
||||
|
||||
private extractHttpMessage(
|
||||
res: string | object,
|
||||
exception: HttpException,
|
||||
): string {
|
||||
if (typeof res === "string") {
|
||||
return res;
|
||||
}
|
||||
if (res && typeof res === "object" && "message" in res) {
|
||||
const msg = (res as { message: unknown }).message;
|
||||
return typeof msg === "string" ? msg : exception.message;
|
||||
}
|
||||
return exception.message;
|
||||
}
|
||||
}
|
||||
142
services/student-bff/src/shared/graphql/yoga.ts
Normal file
142
services/student-bff/src/shared/graphql/yoga.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* GraphQL Yoga 配置 - 加载 schema + context + error formatter.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B1 (P2 起直接 GraphQL Yoga + DataLoader)
|
||||
* - president-final-rulings §2.2 (GraphQL schema 存放 packages/shared-ts/contracts/graphql/)
|
||||
* - coord-final-decisions §1 G8 (错误响应格式: GraphQL errors 数组 + extensions)
|
||||
*
|
||||
* Schema 文件: packages/shared-ts/contracts/graphql/student-bff.schema.graphql
|
||||
*
|
||||
* 集成方式: GraphQL Yoga 作为 Express middleware 挂载到 NestJS HTTP Adapter,
|
||||
* 路径 POST /graphql,开发环境启用 Playground.
|
||||
*/
|
||||
import { promises } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createYoga, type YogaServerInstance } from "graphql-yoga";
|
||||
import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||
import type { Request, Response } from "express";
|
||||
import type { DownstreamClient } from "@edu/shared-ts/bff";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "./logger.js";
|
||||
import type { Redis } from "ioredis";
|
||||
import { createDataLoaders, type StudentBffDataLoaders } from "../../student/dataloaders/data-loader.module.js";
|
||||
import {
|
||||
extractUserIdFromRequest,
|
||||
extractTraceIdFromRequest,
|
||||
extractUserRolesFromRequest,
|
||||
} from "../../student/guards/authorization.guard.js";
|
||||
|
||||
/**
|
||||
* GraphQL Context (每个请求一份).
|
||||
*/
|
||||
export interface StudentBffContext {
|
||||
userId: string | null;
|
||||
traceId: string;
|
||||
userRoles: string[];
|
||||
downstream: DownstreamClient;
|
||||
redis: Redis;
|
||||
dataLoaders: StudentBffDataLoaders;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL schema 文件路径.
|
||||
*/
|
||||
const SCHEMA_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
"packages/shared-ts/contracts/graphql/student-bff.schema.graphql",
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载 schema SDL 文本.
|
||||
*/
|
||||
export async function loadSchemaSDL(): Promise<string> {
|
||||
try {
|
||||
return await promises.readFile(SCHEMA_PATH, "utf-8");
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, path: SCHEMA_PATH },
|
||||
"Failed to load student-bff GraphQL schema file",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 GraphQL Yoga 实例.
|
||||
*
|
||||
* @param resolvers GraphQL Resolver 映射表 (由 StudentModule 装配)
|
||||
* @param downstream DownstreamClient 实例 (由 NestJS DI 注入)
|
||||
* @param redis Redis 客户端 (由 NestJS DI 注入)
|
||||
*/
|
||||
export async function createStudentBffYoga(
|
||||
resolvers: Record<string, unknown>,
|
||||
downstream: DownstreamClient,
|
||||
redis: Redis,
|
||||
): Promise<YogaServerInstance<Record<string, unknown>, StudentBffContext>> {
|
||||
const typeDefs = await loadSchemaSDL();
|
||||
|
||||
const schema = makeExecutableSchema({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
});
|
||||
|
||||
const yoga = createYoga<{
|
||||
req: Request;
|
||||
res: Response;
|
||||
}, StudentBffContext>({
|
||||
schema,
|
||||
graphqlEndpoint: "/graphql",
|
||||
context: ({ req }): StudentBffContext => {
|
||||
const userId = extractUserIdFromRequest(req);
|
||||
const traceId = extractTraceIdFromRequest(req);
|
||||
const userRoles = extractUserRolesFromRequest(req);
|
||||
return {
|
||||
userId,
|
||||
traceId,
|
||||
userRoles,
|
||||
downstream,
|
||||
redis,
|
||||
dataLoaders: createDataLoaders(downstream),
|
||||
requestId: traceId,
|
||||
};
|
||||
},
|
||||
logging: {
|
||||
debug: (msg) => logger.debug({ component: "graphql-yoga" }, String(msg)),
|
||||
info: (msg) => logger.info({ component: "graphql-yoga" }, String(msg)),
|
||||
warn: (msg) => logger.warn({ component: "graphql-yoga" }, String(msg)),
|
||||
error: (msg) => logger.error({ component: "graphql-yoga" }, String(msg)),
|
||||
},
|
||||
maskedErrors: env.NODE_ENV === "production",
|
||||
// 开发环境启用 Playground
|
||||
graphiql: env.GRAPHQL_PLAYGROUND && env.NODE_ENV === "development",
|
||||
// 错误格式化 (G8): GraphQL errors 数组 + extensions.code + extensions.traceId
|
||||
formatError: (err) => {
|
||||
const originalError = err.originalError;
|
||||
const code =
|
||||
(originalError as { code?: string })?.code ??
|
||||
"BFF_STUDENT_INTERNAL_ERROR";
|
||||
const traceId = err.context?.requestId ?? "unknown";
|
||||
|
||||
return {
|
||||
message: err.message,
|
||||
extensions: {
|
||||
code,
|
||||
traceId,
|
||||
i18nKey: `error.bffStudent.${code.replace(/^BFF_STUDENT_/, "").toLowerCase()}`,
|
||||
severity: "error",
|
||||
},
|
||||
path: err.path,
|
||||
locations: err.locations,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ endpoint: "/graphql", playground: env.GRAPHQL_PLAYGROUND },
|
||||
"GraphQL Yoga initialized",
|
||||
);
|
||||
|
||||
return yoga;
|
||||
}
|
||||
146
services/student-bff/src/shared/health/health.controller.ts
Normal file
146
services/student-bff/src/shared/health/health.controller.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Health Controller - /healthz + /readyz 探针.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §1 G2 (首次实现即检查全部下游依赖)
|
||||
* - coord-final-decisions §1 G3 (/healthz liveness)
|
||||
* - president-final-rulings §2.4 (/readyz 探针按阶段扩展, 必需失败返回 503, 可选软失败)
|
||||
*
|
||||
* 探针列表 (按阶段扩展, president §2.4):
|
||||
* P3: Redis + iam gRPC + core-edu gRPC (3 项)
|
||||
* P4: + content gRPC + data-ana gRPC (5 项)
|
||||
* P5: + msg gRPC + ai gRPC (7 项)
|
||||
*
|
||||
* 实现方式: DownstreamClient.checkHealth() 检查 gRPC 可达性 + CacheService.ping() 检查 Redis.
|
||||
*/
|
||||
import { Controller, Get, HttpCode, HttpStatus, Inject } from "@nestjs/common";
|
||||
import { DownstreamClient } from "@edu/shared-ts/bff";
|
||||
import { REDIS_CLIENT, CacheService } from "../cache/cache.module.js";
|
||||
import type { Redis } from "ioredis";
|
||||
import { downstreamServices } from "../../config/downstream.js";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
|
||||
interface HealthCheck {
|
||||
service: string;
|
||||
healthy: boolean;
|
||||
required: boolean;
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
interface ReadyzResponse {
|
||||
status: "ok" | "degraded" | "unavailable";
|
||||
service: string;
|
||||
timestamp: string;
|
||||
checks: HealthCheck[];
|
||||
degraded?: boolean;
|
||||
degradedServices?: string[];
|
||||
}
|
||||
|
||||
const SERVICE_NAME = "student-bff";
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly downstream: DownstreamClient,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* /healthz: Liveness probe (G3).
|
||||
* 仅检查进程存活, 不检查依赖.
|
||||
*/
|
||||
@Get("healthz")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
liveness(): { status: string; service: string; timestamp: string } {
|
||||
return {
|
||||
status: "ok",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* /readyz: Readiness probe (G2 + president §2.4).
|
||||
*
|
||||
* 检查全部已启用的下游依赖:
|
||||
* - Redis PING
|
||||
* - 各下游 gRPC waitForReady
|
||||
*
|
||||
* 必需依赖失败 → 503 (触发 Pod 重启)
|
||||
* 可选依赖失败 → 200 + degraded=true (软失败)
|
||||
*/
|
||||
@Get("readyz")
|
||||
async readiness(): Promise<ReadyzResponse> {
|
||||
const checks: HealthCheck[] = [];
|
||||
|
||||
// 1. Redis 检查
|
||||
const redisStart = Date.now();
|
||||
const cacheService = new CacheService(this.redis);
|
||||
const redisOk = await cacheService.ping();
|
||||
checks.push({
|
||||
service: "redis",
|
||||
healthy: redisOk,
|
||||
required: true,
|
||||
latencyMs: Date.now() - redisStart,
|
||||
});
|
||||
|
||||
// 2. 各下游 gRPC 检查 (env.MOCK_UPSTREAM=true 时跳过, 直接 healthy)
|
||||
for (const svc of downstreamServices) {
|
||||
if (!svc.enabled) continue;
|
||||
const start = Date.now();
|
||||
let healthy = true;
|
||||
if (!env.MOCK_UPSTREAM) {
|
||||
healthy = await this.downstream.checkHealth(svc.name);
|
||||
}
|
||||
checks.push({
|
||||
service: svc.name,
|
||||
healthy,
|
||||
required: svc.required,
|
||||
latencyMs: Date.now() - start,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 判断整体状态
|
||||
const failedRequired = checks.filter((c) => !c.healthy && c.required);
|
||||
const failedOptional = checks.filter((c) => !c.healthy && !c.required);
|
||||
|
||||
let status: ReadyzResponse["status"] = "ok";
|
||||
let httpStatus = HttpStatus.OK;
|
||||
let degraded = false;
|
||||
|
||||
if (failedRequired.length > 0) {
|
||||
status = "unavailable";
|
||||
httpStatus = HttpStatus.SERVICE_UNAVAILABLE;
|
||||
logger.warn(
|
||||
{ failedRequired: failedRequired.map((c) => c.service) },
|
||||
"/readyz failed: required dependencies unavailable",
|
||||
);
|
||||
} else if (failedOptional.length > 0) {
|
||||
status = "degraded";
|
||||
degraded = true;
|
||||
logger.warn(
|
||||
{ failedOptional: failedOptional.map((c) => c.service) },
|
||||
"/readyz degraded: optional dependencies unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
const response: ReadyzResponse = {
|
||||
status,
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
degraded,
|
||||
degradedServices: [...failedRequired, ...failedOptional].map((c) => c.service),
|
||||
};
|
||||
|
||||
// NestJS 4.x 的 @HttpCode 装饰器对 async 方法不一定生效, 这里通过 throw 切换状态码
|
||||
if (httpStatus !== HttpStatus.OK) {
|
||||
// 通过抛 HttpException 切换状态码
|
||||
const { HttpException } = await import("@nestjs/common");
|
||||
throw new HttpException(response, httpStatus);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
10
services/student-bff/src/shared/health/health.module.ts
Normal file
10
services/student-bff/src/shared/health/health.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* HealthModule - 健康检查模块.
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
13
services/student-bff/src/shared/observability/logger.ts
Normal file
13
services/student-bff/src/shared/observability/logger.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* student-bff pino logger.
|
||||
*
|
||||
* 仲裁依据: coord-final-decisions §1 G4 (首次实现即 pino 结构化日志)
|
||||
* coord-final-decisions §2 B8 (复用 shared-ts BFF logger 工厂)
|
||||
*/
|
||||
import { createBffLogger, type Logger } from "@edu/shared-ts/bff";
|
||||
|
||||
export const logger: Logger = createBffLogger("student-bff", {
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
});
|
||||
|
||||
export type { Logger };
|
||||
159
services/student-bff/src/shared/observability/metrics.ts
Normal file
159
services/student-bff/src/shared/observability/metrics.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* student-bff prom-client metrics.
|
||||
*
|
||||
* 仲裁依据: coord-final-decisions §1 G5 (首次实现即 /metrics + 基础业务指标)
|
||||
* 02-architecture-design.md §6.4 (11 个 student_bff_* 指标)
|
||||
*
|
||||
* 指标命名: <service>_<module>_<operation>_<unit> (project_rules §12)
|
||||
*/
|
||||
import promClient from "prom-client";
|
||||
|
||||
const registry = new promClient.Registry();
|
||||
registry.setDefaultLabels({ service: "student-bff" });
|
||||
|
||||
// 1. 请求总数
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "student_bff_requests_total",
|
||||
help: "Total number of student-bff HTTP/GraphQL requests",
|
||||
labelNames: ["operation", "method", "status"],
|
||||
}),
|
||||
);
|
||||
|
||||
// 2. 请求延迟
|
||||
registry.registerMetric(
|
||||
new promClient.Histogram({
|
||||
name: "student_bff_request_duration_seconds",
|
||||
help: "Student-bff request duration in seconds",
|
||||
labelNames: ["operation", "method"],
|
||||
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
|
||||
}),
|
||||
);
|
||||
|
||||
// 3. 下游调用总数
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "student_bff_downstream_calls_total",
|
||||
help: "Total downstream gRPC calls",
|
||||
labelNames: ["service", "method", "status"],
|
||||
}),
|
||||
);
|
||||
|
||||
// 4. 下游调用延迟
|
||||
registry.registerMetric(
|
||||
new promClient.Histogram({
|
||||
name: "student_bff_downstream_duration_seconds",
|
||||
help: "Downstream gRPC call duration in seconds",
|
||||
labelNames: ["service", "method"],
|
||||
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
|
||||
}),
|
||||
);
|
||||
|
||||
// 5. 下游错误数
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "student_bff_downstream_errors_total",
|
||||
help: "Downstream gRPC call errors",
|
||||
labelNames: ["service", "method", "error_type"],
|
||||
}),
|
||||
);
|
||||
|
||||
// 6. 缓存命中
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "student_bff_cache_hits_total",
|
||||
help: "Cache hit count",
|
||||
labelNames: ["cache_key_pattern"],
|
||||
}),
|
||||
);
|
||||
|
||||
// 7. 缓存未命中
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "student_bff_cache_misses_total",
|
||||
help: "Cache miss count",
|
||||
labelNames: ["cache_key_pattern"],
|
||||
}),
|
||||
);
|
||||
|
||||
// 8. 熔断器状态 (P6)
|
||||
registry.registerMetric(
|
||||
new promClient.Gauge({
|
||||
name: "student_bff_circuit_state",
|
||||
help: "Circuit breaker state (0=closed, 1=open, 2=half-open)",
|
||||
labelNames: ["service", "state"],
|
||||
}),
|
||||
);
|
||||
|
||||
// 9. SSE 连接数 (P5)
|
||||
registry.registerMetric(
|
||||
new promClient.Gauge({
|
||||
name: "student_bff_sse_connections",
|
||||
help: "Active SSE connections",
|
||||
}),
|
||||
);
|
||||
|
||||
// 10. 事件消费数 (P5)
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "student_bff_event_consumed_total",
|
||||
help: "Kafka events consumed",
|
||||
labelNames: ["topic", "event_type"],
|
||||
}),
|
||||
);
|
||||
|
||||
// 11. 推送数 (P5)
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "student_bff_event_pushed_total",
|
||||
help: "Push-gateway push count",
|
||||
labelNames: ["topic", "push_status"],
|
||||
}),
|
||||
);
|
||||
|
||||
// 自动收集 Node.js 进程级指标
|
||||
promClient.collectDefaultMetrics({ register: registry });
|
||||
|
||||
export { registry as metricsRegistry };
|
||||
|
||||
/**
|
||||
* 下游调用指标辅助器 (供 DownstreamClient 调用).
|
||||
*/
|
||||
export function recordDownstreamCall(
|
||||
service: string,
|
||||
method: string,
|
||||
status: "success" | "error",
|
||||
durationMs: number,
|
||||
errorType?: string,
|
||||
): void {
|
||||
const labels = { service, method, status };
|
||||
registry
|
||||
.getSingleMetric("student_bff_downstream_calls_total")
|
||||
?.inc(labels);
|
||||
registry
|
||||
.getSingleMetric("student_bff_downstream_duration_seconds")
|
||||
?.observe({ service, method }, durationMs / 1000);
|
||||
if (status === "error" && errorType) {
|
||||
registry
|
||||
.getSingleMetric("student_bff_downstream_errors_total")
|
||||
?.inc({ service, method, error_type: errorType });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存命中/未命中指标辅助器.
|
||||
*/
|
||||
export function recordCacheAccess(
|
||||
keyPattern: string,
|
||||
hit: boolean,
|
||||
): void {
|
||||
if (hit) {
|
||||
registry
|
||||
.getSingleMetric("student_bff_cache_hits_total")
|
||||
?.inc({ cache_key_pattern: keyPattern });
|
||||
} else {
|
||||
registry
|
||||
.getSingleMetric("student_bff_cache_misses_total")
|
||||
?.inc({ cache_key_pattern: keyPattern });
|
||||
}
|
||||
}
|
||||
42
services/student-bff/src/shared/observability/tracer.ts
Normal file
42
services/student-bff/src/shared/observability/tracer.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* student-bff OpenTelemetry tracer.
|
||||
*
|
||||
* 仲裁依据: coord-final-decisions §1 G6 (首次实现即 OTel SDK + OTLP exporter + 完整资源属性 + 全链路 span)
|
||||
* 02-architecture-design.md §6.5 (serviceName: student-bff)
|
||||
*/
|
||||
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
let sdk: NodeSDK | null = null;
|
||||
|
||||
export function initTracer(): void {
|
||||
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
||||
logger.warn("OTEL_EXPORTER_OTLP_ENDPOINT not set, tracer disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
sdk = new NodeSDK({
|
||||
serviceName: env.OTEL_SERVICE_NAME,
|
||||
traceExporter: new OTLPTraceExporter({
|
||||
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
|
||||
}),
|
||||
instrumentations: [getNodeAutoInstrumentations()],
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
logger.info(
|
||||
{ endpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT, service: env.OTEL_SERVICE_NAME },
|
||||
"Tracer initialized with auto-instrumentations",
|
||||
);
|
||||
}
|
||||
|
||||
export async function shutdownTracer(): Promise<void> {
|
||||
if (sdk) {
|
||||
await sdk.shutdown();
|
||||
sdk = null;
|
||||
logger.debug("Tracer shutdown complete");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* student-bff DataLoader 模块 - N+1 防御.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B1 (GraphQL Yoga + DataLoader)
|
||||
* - 004 §11.3 BFF 聚合模式图示
|
||||
*
|
||||
* DataLoader 用于:
|
||||
* - Dashboard 内多学生场景批量加载作业/成绩 (避免 N+1 gRPC 调用)
|
||||
* - 子字段解析时批量加载关联实体
|
||||
*
|
||||
* 实现策略:
|
||||
* - 上游 proto 暂未支持批量 RPC (ListHomeworkByIds 等), DataLoader 内部
|
||||
* 使用 Promise.all 并行调用各 id 的 gRPC, 通过 batch + dedupe 减少 N+1.
|
||||
* - 同一 tick 内相同 id 请求自动去重 (DataLoader 内置 cacheKeyFn).
|
||||
* - 后续 coord 补全批量 RPC 后, 替换为真正的批量 gRPC 调用.
|
||||
*/
|
||||
import { Module, Scope } from "@nestjs/common";
|
||||
import DataLoader from "dataloader";
|
||||
import type { DownstreamClient } from "@edu/shared-ts/bff";
|
||||
|
||||
/**
|
||||
* 单个作业的 Loader 批量加载函数.
|
||||
*
|
||||
* 输入: homeworkId 列表
|
||||
* 输出: 作业详情列表 (与输入顺序一致, 失败项返回 null)
|
||||
*/
|
||||
export type HomeworkLoader = DataLoader<string, unknown, string>;
|
||||
|
||||
/**
|
||||
* 单个学生成绩的 Loader.
|
||||
*/
|
||||
export type GradesLoader = DataLoader<string, unknown, string>;
|
||||
|
||||
/**
|
||||
* 用户信息 Loader (Dashboard 内多用户场景).
|
||||
*/
|
||||
export type UserInfoLoader = DataLoader<string, unknown, string>;
|
||||
|
||||
/**
|
||||
* 创建 Homework DataLoader.
|
||||
*
|
||||
* 使用方式 (在 Resolver context 注入):
|
||||
* const loader = createHomeworkLoader(downstream);
|
||||
* const hw1 = await loader.load("h-001");
|
||||
* const hw2 = await loader.load("h-002");
|
||||
* // 单 tick 内并行调用 gRPC, 自动 dedupe
|
||||
*/
|
||||
export function createHomeworkLoader(downstream: DownstreamClient): HomeworkLoader {
|
||||
return new DataLoader<string, unknown, string>(async (homeworkIds) => {
|
||||
const results = await Promise.all(
|
||||
homeworkIds.map(async (id) => {
|
||||
try {
|
||||
return await downstream.call("core-edu", "GetHomework", { homeworkId: id });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Grades DataLoader.
|
||||
*/
|
||||
export function createGradesLoader(downstream: DownstreamClient): GradesLoader {
|
||||
return new DataLoader<string, unknown, string>(async (studentIds) => {
|
||||
const results = await Promise.all(
|
||||
studentIds.map(async (id) => {
|
||||
try {
|
||||
return await downstream.call("core-edu", "ListGradesByStudent", {
|
||||
studentId: id,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 UserInfo DataLoader.
|
||||
*/
|
||||
export function createUserInfoLoader(downstream: DownstreamClient): UserInfoLoader {
|
||||
return new DataLoader<string, unknown, string>(async (userIds) => {
|
||||
const results = await Promise.all(
|
||||
userIds.map(async (id) => {
|
||||
try {
|
||||
return await downstream.call("iam", "GetUserInfo", { userId: id });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DataLoader 工厂接口 (注入到 GraphQL context).
|
||||
*/
|
||||
export interface StudentBffDataLoaders {
|
||||
homework: HomeworkLoader;
|
||||
grades: GradesLoader;
|
||||
userInfo: UserInfoLoader;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建全部 DataLoader (每个 GraphQL 请求一份).
|
||||
*/
|
||||
export function createDataLoaders(
|
||||
downstream: DownstreamClient,
|
||||
): StudentBffDataLoaders {
|
||||
return {
|
||||
homework: createHomeworkLoader(downstream),
|
||||
grades: createGradesLoader(downstream),
|
||||
userInfo: createUserInfoLoader(downstream),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* NestJS Module - 提供 DataLoader 工厂 (request scope).
|
||||
*/
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: "DATA_LOADER_FACTORY",
|
||||
useFactory: (): typeof createDataLoaders => createDataLoaders,
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
],
|
||||
exports: ["DATA_LOADER_FACTORY"],
|
||||
})
|
||||
export class DataLoaderModule {}
|
||||
194
services/student-bff/src/student/events/event-subscriber.ts
Normal file
194
services/student-bff/src/student/events/event-subscriber.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Kafka EventSubscriber - P5 事件订阅 + 推送通道.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B7 (P2-P4 不订阅 Kafka, P5 后订阅)
|
||||
* - president-final-rulings §2.4 (/readyz 软失败: Kafka 失败仅告警)
|
||||
* - workline §5.4 (P5.4 Kafka EventSubscriber)
|
||||
*
|
||||
* 订阅 topic (G16 命名规范: edu.<domain>.<aggregate>.<action>):
|
||||
* - edu.teaching.homework.assigned 教师布置作业
|
||||
* - edu.teaching.homework.graded 作业批改完成
|
||||
* - edu.teaching.exam.published 考试发布
|
||||
* - edu.teaching.exam.updated 考试更新
|
||||
* - edu.teaching.grade.recorded 成绩录入
|
||||
* - edu.identity.user.role_changed 学生角色变更
|
||||
* - edu.notification.sent 通知发送
|
||||
*
|
||||
* 消费动作:
|
||||
* 1. 失效相关 Redis 缓存 (student:grades:* / student:exams:* 等)
|
||||
* 2. 调用 push-gateway POST /push/user/:userId 推送给学生
|
||||
*
|
||||
* 幂等性: Redis SETNX event_id 去重 (workline §5.4)
|
||||
*/
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit, Inject } from "@nestjs/common";
|
||||
import { Kafka, type Consumer, type EachMessagePayload } from "kafkajs";
|
||||
import { REDIS_CLIENT, CacheService } from "../../shared/cache/cache.module.js";
|
||||
import type { Redis } from "ioredis";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { metricsRegistry } from "../../shared/observability/metrics.js";
|
||||
import { PushGatewayService } from "../push/push-gateway.service.js";
|
||||
|
||||
/**
|
||||
* 订阅的 topic 列表 (G16 命名规范).
|
||||
*/
|
||||
const SUBSCRIBED_TOPICS = [
|
||||
"edu.teaching.homework.assigned",
|
||||
"edu.teaching.homework.graded",
|
||||
"edu.teaching.exam.published",
|
||||
"edu.teaching.exam.updated",
|
||||
"edu.teaching.grade.recorded",
|
||||
"edu.identity.user.role_changed",
|
||||
"edu.notification.sent",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 事件消息结构 (Kafka JSON payload).
|
||||
*/
|
||||
interface StudentBffEvent {
|
||||
event_id: string;
|
||||
event_type: string;
|
||||
student_id?: string;
|
||||
user_id?: string;
|
||||
payload: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EventSubscriberService implements OnModuleInit, OnModuleDestroy {
|
||||
private consumer: Consumer | null = null;
|
||||
private readonly cacheService: CacheService;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
private readonly pushGateway: PushGatewayService,
|
||||
) {
|
||||
this.cacheService = new CacheService(redis);
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// P5 才订阅, P3/P4 跳过 (B7 裁决)
|
||||
// 但 P3 已落地代码, 通过环境变量控制是否启动
|
||||
if (env.NODE_ENV === "test") {
|
||||
logger.info("EventSubscriber skipped in test environment");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const kafka = new Kafka({
|
||||
clientId: env.KAFKA_CLIENT_ID,
|
||||
brokers: env.KAFKA_BROKERS.split(",").map((b) => b.trim()),
|
||||
});
|
||||
|
||||
this.consumer = kafka.consumer({ groupId: env.KAFKA_CONSUMER_GROUP });
|
||||
await this.consumer.connect();
|
||||
|
||||
for (const topic of SUBSCRIBED_TOPICS) {
|
||||
await this.consumer.subscribe({ topic, fromBeginning: false });
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
await this.consumer.run({
|
||||
eachMessage: async (payload) => this.handleMessage(payload),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ topics: SUBSCRIBED_TOPICS, group: env.KAFKA_CONSUMER_GROUP },
|
||||
"Kafka EventSubscriber started",
|
||||
);
|
||||
} catch (err) {
|
||||
// president §2.4 软失败: Kafka 失败不阻塞启动
|
||||
logger.error(
|
||||
{ err },
|
||||
"Kafka EventSubscriber failed to start (soft failure, service continues)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.running = false;
|
||||
if (this.consumer) {
|
||||
try {
|
||||
await this.consumer.disconnect();
|
||||
logger.info("Kafka EventSubscriber disconnected");
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Error disconnecting Kafka consumer");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单条 Kafka 消息.
|
||||
*/
|
||||
private async handleMessage(payload: EachMessagePayload): Promise<void> {
|
||||
const { topic, partition, message } = payload;
|
||||
const eventStr = message.value?.toString("utf-8");
|
||||
if (!eventStr) {
|
||||
logger.warn({ topic, partition, offset: message.offset }, "Empty Kafka message");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(eventStr) as StudentBffEvent;
|
||||
logger.debug(
|
||||
{ topic, eventId: event.event_id, eventType: event.event_type },
|
||||
"Kafka event received",
|
||||
);
|
||||
|
||||
// 幂等性: Redis SETNX event_id 去重
|
||||
const dedupeKey = `student:event:dedupe:${event.event_id}`;
|
||||
const set = await this.redis.set(dedupeKey, "1", "EX", 86400, "NX");
|
||||
if (set !== "OK") {
|
||||
logger.debug(
|
||||
{ eventId: event.event_id },
|
||||
"Kafka event already processed (deduped)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 指标记录
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_event_consumed_total")
|
||||
?.inc({ topic, event_type: event.event_type });
|
||||
|
||||
// 失效相关缓存 + 推送给学生
|
||||
const studentId = event.student_id ?? event.user_id;
|
||||
if (studentId) {
|
||||
await this.invalidateCache(topic, studentId);
|
||||
await this.pushGateway.pushToStudent(studentId, topic, event.event_type, event.payload, event.timestamp);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, topic, partition, offset: message.offset },
|
||||
"Failed to process Kafka event",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 topic 失效相关缓存.
|
||||
*/
|
||||
private async invalidateCache(topic: string, studentId: string): Promise<void> {
|
||||
try {
|
||||
if (topic.startsWith("edu.teaching.homework")) {
|
||||
await this.cacheService.invalidate("homework", studentId);
|
||||
await this.cacheService.invalidate("dashboard", studentId);
|
||||
} else if (topic.startsWith("edu.teaching.exam")) {
|
||||
await this.cacheService.invalidateByPrefix(`exams:${studentId}`);
|
||||
await this.cacheService.invalidate("dashboard", studentId);
|
||||
} else if (topic.startsWith("edu.teaching.grade")) {
|
||||
await this.cacheService.invalidateByPrefix(`grades:${studentId}`);
|
||||
await this.cacheService.invalidate("dashboard", studentId);
|
||||
} else if (topic === "edu.identity.user.role_changed") {
|
||||
await this.cacheService.invalidate("viewports", studentId);
|
||||
await this.cacheService.invalidate("dashboard", studentId);
|
||||
} else if (topic === "edu.notification.sent") {
|
||||
await this.cacheService.invalidateByPrefix(`notifications:${studentId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err, topic, studentId }, "Cache invalidation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
22
services/student-bff/src/student/events/event.module.ts
Normal file
22
services/student-bff/src/student/events/event.module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* EventModule - Kafka 事件订阅模块.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B7 (P2-P4 不订阅, P5 后订阅)
|
||||
* - president-final-rulings §2.4 (Kafka 启动失败软处理)
|
||||
* - workline §5.4 (P5.4 Kafka EventSubscriber)
|
||||
*
|
||||
* 依赖:
|
||||
* - REDIS_CLIENT (CacheModule Global 提供): 幂等去重 + 缓存失效
|
||||
* - PushGatewayService (PushGatewayModule 提供): 事件推送
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EventSubscriberService } from "./event-subscriber.js";
|
||||
import { PushGatewayModule } from "../push/push-gateway.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [PushGatewayModule],
|
||||
providers: [EventSubscriberService],
|
||||
exports: [EventSubscriberService],
|
||||
})
|
||||
export class EventModule {}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* AuthorizationGuard 单元测试 - B4 自我越权防御.
|
||||
*
|
||||
* 测试覆盖:
|
||||
* - extractUserIdFromRequest: x-user-id 头提取
|
||||
* - extractTraceIdFromRequest: x-request-id 头提取
|
||||
* - extractUserRolesFromRequest: x-user-roles 头提取
|
||||
* - assertOwnData: 场景 A (资源无归属)
|
||||
* - assertIdentityMatch: 场景 B (身份不一致)
|
||||
* - DEV_MODE 放行
|
||||
* - AuthorizationGuard.canActivate
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { Request } from "express";
|
||||
|
||||
// Mock env module
|
||||
vi.mock("../../config/env.js", () => ({
|
||||
env: {
|
||||
DEV_MODE: false,
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
}));
|
||||
|
||||
// Re-import after mock
|
||||
const { env } = await import("../../config/env.js");
|
||||
const {
|
||||
AuthorizationGuard,
|
||||
extractUserIdFromRequest,
|
||||
extractTraceIdFromRequest,
|
||||
extractUserRolesFromRequest,
|
||||
assertOwnData,
|
||||
assertIdentityMatch,
|
||||
} = await import("./authorization.guard.js");
|
||||
const { ForbiddenResourceError, IdentityMismatchError, UnauthorizedError } = await import(
|
||||
"../../shared/errors/application-error.js"
|
||||
);
|
||||
|
||||
function mockRequest(headers: Record<string, string | undefined> = {}): Request {
|
||||
return { headers } as unknown as Request;
|
||||
}
|
||||
|
||||
describe("AuthorizationGuard", () => {
|
||||
describe("extractUserIdFromRequest", () => {
|
||||
it("should extract x-user-id header", () => {
|
||||
const req = mockRequest({ "x-user-id": "u-stu-001" });
|
||||
expect(extractUserIdFromRequest(req)).toBe("u-stu-001");
|
||||
});
|
||||
|
||||
it("should return null when header is missing", () => {
|
||||
const req = mockRequest({});
|
||||
expect(extractUserIdFromRequest(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when header is empty string", () => {
|
||||
const req = mockRequest({ "x-user-id": "" });
|
||||
expect(extractUserIdFromRequest(req)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTraceIdFromRequest", () => {
|
||||
it("should extract x-request-id header", () => {
|
||||
const req = mockRequest({ "x-request-id": "trace-abc" });
|
||||
expect(extractTraceIdFromRequest(req)).toBe("trace-abc");
|
||||
});
|
||||
|
||||
it("should return 'unknown' when header is missing", () => {
|
||||
const req = mockRequest({});
|
||||
expect(extractTraceIdFromRequest(req)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("should return 'unknown' when header is empty", () => {
|
||||
const req = mockRequest({ "x-request-id": "" });
|
||||
expect(extractTraceIdFromRequest(req)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractUserRolesFromRequest", () => {
|
||||
it("should extract comma-separated roles", () => {
|
||||
const req = mockRequest({ "x-user-roles": "student,monitor" });
|
||||
expect(extractUserRolesFromRequest(req)).toEqual(["student", "monitor"]);
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
const req = mockRequest({ "x-user-roles": " student , monitor " });
|
||||
expect(extractUserRolesFromRequest(req)).toEqual(["student", "monitor"]);
|
||||
});
|
||||
|
||||
it("should return empty array when header is missing", () => {
|
||||
const req = mockRequest({});
|
||||
expect(extractUserRolesFromRequest(req)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should filter out empty entries", () => {
|
||||
const req = mockRequest({ "x-user-roles": "student,,monitor," });
|
||||
expect(extractUserRolesFromRequest(req)).toEqual(["student", "monitor"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertOwnData (场景 A)", () => {
|
||||
beforeEach(() => {
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
|
||||
it("should pass when requestedStudentId matches userId", () => {
|
||||
expect(() => assertOwnData("u-001", "u-001")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should pass when requestedStudentId is undefined", () => {
|
||||
expect(() => assertOwnData("u-001", undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should pass when requestedStudentId is null", () => {
|
||||
expect(() => assertOwnData("u-001", null)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw ForbiddenResourceError when studentId differs", () => {
|
||||
expect(() => assertOwnData("u-001", "u-002")).toThrow(ForbiddenResourceError);
|
||||
});
|
||||
|
||||
it("should not throw when DEV_MODE is true", () => {
|
||||
env.DEV_MODE = true;
|
||||
expect(() => assertOwnData("u-001", "u-002")).not.toThrow();
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertIdentityMatch (场景 B)", () => {
|
||||
beforeEach(() => {
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
|
||||
it("should pass when bodyUserId matches userId", () => {
|
||||
expect(() => assertIdentityMatch("u-001", "u-001")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should pass when bodyUserId is undefined", () => {
|
||||
expect(() => assertIdentityMatch("u-001", undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should pass when bodyUserId is null", () => {
|
||||
expect(() => assertIdentityMatch("u-001", null)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw IdentityMismatchError when bodyUserId differs", () => {
|
||||
expect(() => assertIdentityMatch("u-001", "u-002")).toThrow(IdentityMismatchError);
|
||||
});
|
||||
|
||||
it("should not throw when DEV_MODE is true", () => {
|
||||
env.DEV_MODE = true;
|
||||
expect(() => assertIdentityMatch("u-001", "u-002")).not.toThrow();
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthorizationGuard.canActivate", () => {
|
||||
let guard: InstanceType<typeof AuthorizationGuard>;
|
||||
|
||||
beforeEach(() => {
|
||||
guard = new AuthorizationGuard();
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
|
||||
it("should return true when DEV_MODE is true", () => {
|
||||
env.DEV_MODE = true;
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: () => mockRequest({}) }),
|
||||
};
|
||||
expect(guard.canActivate(ctx as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when x-user-id is present", () => {
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: () => mockRequest({ "x-user-id": "u-001" }) }),
|
||||
};
|
||||
expect(guard.canActivate(ctx as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedError when x-user-id is missing", () => {
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: () => mockRequest({}) }),
|
||||
};
|
||||
expect(() => guard.canActivate(ctx as never)).toThrow(UnauthorizedError);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedError when x-user-id is empty", () => {
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: () => mockRequest({ "x-user-id": "" }) }),
|
||||
};
|
||||
expect(() => guard.canActivate(ctx as never)).toThrow(UnauthorizedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
146
services/student-bff/src/student/guards/authorization.guard.ts
Normal file
146
services/student-bff/src/student/guards/authorization.guard.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* student-bff AuthorizationGuard - B4 自我越权防御.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B4 (全部 BFF 强制自我越权防御)
|
||||
* - president-final-rulings §2.9 (越权防御 P3 实现方式: 方案 D)
|
||||
* - president-final-rulings §2.7 (3 类越权防御错误码)
|
||||
*
|
||||
* 防御策略:
|
||||
* - 场景 A (资源无归属): 学生请求的 studentId 与 JWT userId 不一致
|
||||
* → ForbiddenResourceError (403, BFF_STUDENT_FORBIDDEN_RESOURCE)
|
||||
* - 场景 B (身份不一致): JWT userId 与请求 body userId 不一致
|
||||
* → IdentityMismatchError (403, BFF_STUDENT_IDENTITY_MISMATCH)
|
||||
*
|
||||
* DEV_MODE=true 时放行 (president §2.9 方案 D):
|
||||
* - 本地开发无 JWT 时, DEV_MODE=true 跳过越权校验
|
||||
* - 生产环境 DEV_MODE=false 强制校验
|
||||
*/
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { env } from "../../config/env.js";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ForbiddenResourceError,
|
||||
IdentityMismatchError,
|
||||
} from "../../shared/errors/application-error.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
|
||||
/**
|
||||
* 学生越权防御 Guard.
|
||||
*
|
||||
* 使用方式 (NestJS):
|
||||
* @UseGuards(AuthorizationGuard)
|
||||
* @Query(() => StudentDashboard)
|
||||
* async studentDashboard(@Context('userId') userId: string, ...) {}
|
||||
*
|
||||
* GraphQL Yoga 集成: 在 context 构建时调用 extractUserIdFromRequest,
|
||||
* 并在 Resolver 内显式调用 assertOwnData(userId, requestedStudentId).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthorizationGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (env.DEV_MODE) {
|
||||
// 方案 D: DEV_MODE=true 跳过越权校验 (本地开发无 JWT)
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const userId = extractUserIdFromRequest(request);
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Express Request 提取 userId (由 api-gateway 注入 x-user-id 头).
|
||||
*
|
||||
* BFF 不验签 JWT, 仅读 header (B3 裁决).
|
||||
*/
|
||||
export function extractUserIdFromRequest(request: Request): string | null {
|
||||
const header = request.headers["x-user-id"];
|
||||
if (typeof header === "string" && header.length > 0) {
|
||||
return header;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Express Request 提取 traceId (由 api-gateway 注入 x-request-id 头).
|
||||
*/
|
||||
export function extractTraceIdFromRequest(request: Request): string {
|
||||
const header = request.headers["x-request-id"];
|
||||
return typeof header === "string" && header.length > 0 ? header : "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 x-user-roles 头 (列表, 逗号分隔).
|
||||
*/
|
||||
export function extractUserRolesFromRequest(request: Request): string[] {
|
||||
const header = request.headers["x-user-roles"];
|
||||
if (typeof header === "string" && header.length > 0) {
|
||||
return header.split(",").map((r) => r.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制 userId = requestedStudentId (B4 自我越权防御场景 A).
|
||||
*
|
||||
* 学生只能查/操作自己的数据. 在 Resolver 内调用:
|
||||
* assertOwnData(userId, args.studentId);
|
||||
*
|
||||
* @param userId JWT 中的 userId (从 x-user-id 头)
|
||||
* @param requestedStudentId 请求参数中的 studentId (args / input)
|
||||
* @throws ForbiddenResourceError 当 requestedStudentId ≠ userId
|
||||
*/
|
||||
export function assertOwnData(
|
||||
userId: string,
|
||||
requestedStudentId?: string | null,
|
||||
): void {
|
||||
if (env.DEV_MODE) {
|
||||
return;
|
||||
}
|
||||
if (requestedStudentId && requestedStudentId !== userId) {
|
||||
logger.warn(
|
||||
{ userId, requestedStudentId },
|
||||
"Authorization blocked: student data scope violation",
|
||||
);
|
||||
throw new ForbiddenResourceError(
|
||||
"Students can only access their own data",
|
||||
{ requested: requestedStudentId, actual: userId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 JWT userId 与 body userId 一致 (B4 场景 B).
|
||||
*
|
||||
* 用于 Mutation 输入校验: 如 submitHomework input 内 studentId 必须 = JWT userId.
|
||||
*
|
||||
* @param userId JWT 中的 userId
|
||||
* @param bodyUserId 请求 body 中的 userId 字段
|
||||
* @throws IdentityMismatchError 当 bodyUserId ≠ userId
|
||||
*/
|
||||
export function assertIdentityMatch(
|
||||
userId: string,
|
||||
bodyUserId?: string | null,
|
||||
): void {
|
||||
if (env.DEV_MODE) {
|
||||
return;
|
||||
}
|
||||
if (bodyUserId && bodyUserId !== userId) {
|
||||
logger.warn(
|
||||
{ userId, bodyUserId },
|
||||
"Authorization blocked: identity mismatch",
|
||||
);
|
||||
throw new IdentityMismatchError(
|
||||
"JWT userId does not match request body userId",
|
||||
{ jwt: userId, body: bodyUserId },
|
||||
);
|
||||
}
|
||||
}
|
||||
16
services/student-bff/src/student/push/push-gateway.module.ts
Normal file
16
services/student-bff/src/student/push/push-gateway.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* PushGatewayModule - push-gateway 推送模块.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - workline §5.5 (P5.5 push-gateway 推送通道)
|
||||
*
|
||||
* 提供 PushGatewayService 供 EventSubscriber 和其他需要推送的场景使用.
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PushGatewayService } from "./push-gateway.service.js";
|
||||
|
||||
@Module({
|
||||
providers: [PushGatewayService],
|
||||
exports: [PushGatewayService],
|
||||
})
|
||||
export class PushGatewayModule {}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* PushGatewayService 单元测试.
|
||||
*
|
||||
* 测试覆盖:
|
||||
* - pushToStudent 正常成功
|
||||
* - pushToStudent HTTP 错误状态
|
||||
* - pushToStudent 网络错误 (软失败)
|
||||
* - 超时处理
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock env
|
||||
vi.mock("../../config/env.js", () => ({
|
||||
env: {
|
||||
PUSH_GATEWAY_URL: "http://localhost:8081",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// Mock metrics registry
|
||||
vi.mock("../../shared/observability/metrics.js", () => ({
|
||||
metricsRegistry: {
|
||||
getSingleMetric: vi.fn(() => ({
|
||||
inc: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock("../../shared/observability/logger.js", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { PushGatewayService } = await import("./push-gateway.service.js");
|
||||
|
||||
describe("PushGatewayService", () => {
|
||||
let service: InstanceType<typeof PushGatewayService>;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PushGatewayService();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return success=true when push-gateway returns 200", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const result = await service.pushToStudent(
|
||||
"u-stu-001",
|
||||
"edu.teaching.homework.graded",
|
||||
"homework.graded",
|
||||
{ homeworkId: "hw-001", grade: 90 },
|
||||
"2026-07-10T10:00:00Z",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:8081/push/user/u-stu-001",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return success=false when push-gateway returns non-OK status", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const result = await service.pushToStudent(
|
||||
"u-stu-001",
|
||||
"edu.notification.sent",
|
||||
"notification.sent",
|
||||
{ notificationId: "n-001" },
|
||||
"2026-07-10T10:00:00Z",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return success=false with error message when fetch throws (soft failure)", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
|
||||
const result = await service.pushToStudent(
|
||||
"u-stu-001",
|
||||
"edu.teaching.exam.published",
|
||||
"exam.published",
|
||||
{ examId: "e-001" },
|
||||
"2026-07-10T10:00:00Z",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.error).toBe("ECONNREFUSED");
|
||||
});
|
||||
|
||||
it("should return success=false when fetch throws AbortError (timeout)", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("The operation was aborted due to timeout"));
|
||||
|
||||
const result = await service.pushToStudent(
|
||||
"u-stu-001",
|
||||
"edu.teaching.grade.recorded",
|
||||
"grade.recorded",
|
||||
{ gradeId: "g-001" },
|
||||
"2026-07-10T10:00:00Z",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("aborted");
|
||||
});
|
||||
|
||||
it("should serialize message body as JSON", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await service.pushToStudent(
|
||||
"u-stu-001",
|
||||
"edu.teaching.homework.assigned",
|
||||
"homework.assigned",
|
||||
{ homeworkId: "hw-001", title: "Math Chapter 3" },
|
||||
"2026-07-10T10:00:00Z",
|
||||
);
|
||||
|
||||
const callArgs = fetchMock.mock.calls[0];
|
||||
const body = JSON.parse(callArgs[1].body as string);
|
||||
expect(body.type).toBe("homework.assigned");
|
||||
expect(body.topic).toBe("edu.teaching.homework.assigned");
|
||||
expect(body.payload).toEqual({ homeworkId: "hw-001", title: "Math Chapter 3" });
|
||||
expect(body.timestamp).toBe("2026-07-10T10:00:00Z");
|
||||
});
|
||||
});
|
||||
111
services/student-bff/src/student/push/push-gateway.service.ts
Normal file
111
services/student-bff/src/student/push/push-gateway.service.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* PushGatewayService - push-gateway 推送通道封装.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - workline §5.5 (P5.5 push-gateway 推送通道)
|
||||
* - president-final-rulings §2.4 (软失败: 推送失败不阻塞主流程)
|
||||
*
|
||||
* 职责:
|
||||
* 1. 封装 push-gateway HTTP 调用 (POST /push/user/:userId)
|
||||
* 2. 超时控制 (3s AbortSignal.timeout)
|
||||
* 3. 指标记录 (student_bff_event_pushed_total)
|
||||
* 4. 软失败处理 (失败仅 warn 日志, 不抛异常)
|
||||
*
|
||||
* 消费方:
|
||||
* - EventSubscriberService: Kafka 事件消费后推送
|
||||
* - 未来可直接由 resolver 调用 (如主动推送通知)
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { metricsRegistry } from "../../shared/observability/metrics.js";
|
||||
|
||||
/**
|
||||
* 推送消息结构.
|
||||
*/
|
||||
export interface PushMessage {
|
||||
type: string;
|
||||
topic: string;
|
||||
payload: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* push-gateway 推送结果.
|
||||
*/
|
||||
export interface PushResult {
|
||||
success: boolean;
|
||||
status: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PushGatewayService {
|
||||
/**
|
||||
* 推送消息给指定学生.
|
||||
*
|
||||
* 调用 push-gateway POST /push/user/:userId,
|
||||
* 失败软处理 (president §2.4), 不抛异常.
|
||||
*
|
||||
* @param studentId 学生 ID
|
||||
* @param topic Kafka topic 名
|
||||
* @param eventType 事件类型
|
||||
* @param payload 事件载荷
|
||||
* @param timestamp 事件时间戳
|
||||
* @returns PushResult 推送结果
|
||||
*/
|
||||
async pushToStudent(
|
||||
studentId: string,
|
||||
topic: string,
|
||||
eventType: string,
|
||||
payload: unknown,
|
||||
timestamp: string,
|
||||
): Promise<PushResult> {
|
||||
const message: PushMessage = {
|
||||
type: eventType,
|
||||
topic,
|
||||
payload,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${env.PUSH_GATEWAY_URL}/push/user/${studentId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(message),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
},
|
||||
);
|
||||
|
||||
const pushStatus = response.ok ? "success" : `http_${response.status}`;
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_event_pushed_total")
|
||||
?.inc({ topic, push_status: pushStatus });
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
{ studentId, topic, status: response.status },
|
||||
"Push-gateway returned non-OK status",
|
||||
);
|
||||
}
|
||||
|
||||
return { success: response.ok, status: response.status };
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, studentId, topic },
|
||||
"Push-gateway push failed (soft failure)",
|
||||
);
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_event_pushed_total")
|
||||
?.inc({ topic, push_status: "error" });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: 0,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
138
services/student-bff/src/student/resolvers/ai-stream.resolver.ts
Normal file
138
services/student-bff/src/student/resolvers/ai-stream.resolver.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* AI Stream Resolver - SSE 流式 AI 答疑 (P5).
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B1 (GraphQL Yoga 原生支持 SSE Subscription)
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 ai.StreamChat)
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御)
|
||||
* - workline §5.2 (P5.2 ai gRPC client chat/streamChat SSE)
|
||||
*
|
||||
* 实现: GraphQL Subscription → AsyncIterator, 透传 ai.StreamChat gRPC 流.
|
||||
* GraphQL Yoga 通过 SSE 传输协议将 Subscription 事件推给客户端.
|
||||
*
|
||||
* 客户端使用:
|
||||
* subscription AiStreamChat($input: AIStreamChatInput!) {
|
||||
* aiStreamChat(input: $input) { content done model usage }
|
||||
* }
|
||||
*
|
||||
* 传输协议: SSE (text/event-stream), Yoga 自动处理.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { UnauthorizedError, ValidationError } from "../../shared/errors/application-error.js";
|
||||
|
||||
const AIStreamChatInputSchema = z.object({
|
||||
messages: z
|
||||
.array(
|
||||
z.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string().min(1).max(8000),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(20),
|
||||
model: z
|
||||
.enum(["gpt-4o-mini", "baichuan-53b", "local-qwen-7b"])
|
||||
.default("gpt-4o-mini"),
|
||||
context: z
|
||||
.object({
|
||||
subject: z.string().optional(),
|
||||
knowledgePointId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* AI 流式响应 chunk 结构 (对齐 ai.StreamChat gRPC stream).
|
||||
*/
|
||||
interface AIStreamChunk {
|
||||
content: string;
|
||||
done: boolean;
|
||||
model?: string;
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const aiStreamResolvers = {
|
||||
Subscription: {
|
||||
/**
|
||||
* aiStreamChat: AI 答疑流式响应.
|
||||
*
|
||||
* 通过 GraphQL Subscription + SSE 传输,
|
||||
* 透传 ai.StreamChat gRPC server-streaming RPC.
|
||||
*
|
||||
* @permission STUDENT_AI_CHAT
|
||||
* @dataScope OWN
|
||||
*/
|
||||
aiStreamChat: {
|
||||
subscribe(
|
||||
_parent: unknown,
|
||||
args: { input: unknown },
|
||||
ctx: StudentBffContext,
|
||||
): AsyncIterable<{ aiStreamChat: AIStreamChunk }> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const parseResult = AIStreamChatInputSchema.safeParse(args.input);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError(
|
||||
"Invalid aiStreamChat input",
|
||||
parseResult.error.flatten(),
|
||||
);
|
||||
}
|
||||
const input = parseResult.data;
|
||||
|
||||
return (async function* (): AsyncGenerator<{ aiStreamChat: AIStreamChunk }> {
|
||||
try {
|
||||
// 调用 ai.StreamChat (gRPC server-streaming)
|
||||
// DownstreamClient.callStream 返回 AsyncIterable
|
||||
const stream = ctx.downstream.callStream("ai", "StreamChat", {
|
||||
userId: ctx.userId,
|
||||
messages: input.messages,
|
||||
model: input.model,
|
||||
context: input.context,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
timeoutMs: 60000, // 流式调用 60s 超时
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const typed = chunk as {
|
||||
content?: string;
|
||||
done?: boolean;
|
||||
model?: string;
|
||||
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||
};
|
||||
|
||||
yield {
|
||||
aiStreamChat: {
|
||||
content: typed.content ?? "",
|
||||
done: typed.done ?? false,
|
||||
model: typed.model,
|
||||
usage: typed.usage,
|
||||
},
|
||||
};
|
||||
|
||||
if (typed.done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 流式错误: 发送一个 done=true 的错误 chunk, 客户端关闭流
|
||||
yield {
|
||||
aiStreamChat: {
|
||||
content: `[stream error] ${(err as Error).message}`,
|
||||
done: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
93
services/student-bff/src/student/resolvers/ai.resolver.ts
Normal file
93
services/student-bff/src/student/resolvers/ai.resolver.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* AI Resolver - aiChat Query/Mutation (P5 扩展).
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql (待补充 aiChat Query)
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 ai)
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御)
|
||||
* - president-final-rulings §2.3 (跨阶段扩展例外)
|
||||
*
|
||||
* AI 答疑流式响应 (StreamChat) 通过 SSE 端点单独实现 (P5),
|
||||
* 本 Resolver 仅处理同步 Chat.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "../../shared/errors/application-error.js";
|
||||
|
||||
const AIChatInputSchema = z.object({
|
||||
messages: z
|
||||
.array(
|
||||
z.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string().min(1).max(8000),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(20),
|
||||
model: z
|
||||
.enum(["gpt-4o-mini", "baichuan-53b", "local-qwen-7b"])
|
||||
.default("gpt-4o-mini"),
|
||||
context: z
|
||||
.object({
|
||||
subject: z.string().optional(),
|
||||
knowledgePointId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const aiResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* aiChat: AI 答疑 (同步).
|
||||
* @permission STUDENT_AI_CHAT
|
||||
* @dataScope OWN
|
||||
*
|
||||
* 实际为 Mutation (写操作, 消耗 AI 配额), 但 schema 设计为 Query 便于前端 GET 缓存.
|
||||
* 后续如需限流, 改为 Mutation.
|
||||
*/
|
||||
async aiChat(
|
||||
_parent: unknown,
|
||||
args: { input: unknown },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const parseResult = AIChatInputSchema.safeParse(args.input);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError(
|
||||
"Invalid aiChat input",
|
||||
parseResult.error.flatten(),
|
||||
);
|
||||
}
|
||||
const input = parseResult.data;
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("ai", "Chat", {
|
||||
userId: ctx.userId,
|
||||
messages: input.messages,
|
||||
model: input.model,
|
||||
context: input.context,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
// AI 调用可能耗时较长, 延长超时
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to chat with AI: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
107
services/student-bff/src/student/resolvers/analytics.resolver.ts
Normal file
107
services/student-bff/src/student/resolvers/analytics.resolver.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Analytics Resolver - myWeakness / myTrend Queries (P4 扩展).
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myWeakness / myTrend / studentDashboard
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 data-ana)
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御, 学情诊断仅本人可查)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
* - president-final-rulings §2.3 (跨阶段扩展例外)
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
import { assertOwnData } from "../guards/authorization.guard.js";
|
||||
|
||||
export const analyticsResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myWeakness: 学情诊断 (薄弱知识点).
|
||||
* @permission STUDENT_ANALYTICS_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myWeakness(
|
||||
_parent: unknown,
|
||||
args: { studentId?: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
assertOwnData(ctx.userId, args.studentId);
|
||||
|
||||
const cached = await ctx.redis.get<unknown>("analytics:weakness", ctx.userId);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("data-ana", "GetStudentWeakness", {
|
||||
studentId: ctx.userId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
await ctx.redis.set(
|
||||
"analytics:weakness",
|
||||
result,
|
||||
CacheTTL.ANALYTICS_WEAKNESS,
|
||||
ctx.userId,
|
||||
);
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch weakness: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* myTrend: 学习趋势.
|
||||
* @permission STUDENT_ANALYTICS_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myTrend(
|
||||
_parent: unknown,
|
||||
args: { studentId?: string; range?: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
assertOwnData(ctx.userId, args.studentId);
|
||||
|
||||
const range = args.range ?? "30d";
|
||||
const cacheKey = `${ctx.userId}:${range}`;
|
||||
const cached = await ctx.redis.get<unknown>("analytics:trend", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("data-ana", "GetLearningTrend", {
|
||||
studentId: ctx.userId,
|
||||
range,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
await ctx.redis.set("analytics:trend", result, CacheTTL.ANALYTICS_TREND, cacheKey);
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch trend: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
120
services/student-bff/src/student/resolvers/auth.resolver.ts
Normal file
120
services/student-bff/src/student/resolvers/auth.resolver.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Auth Resolver - currentUser Query.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.currentUser
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 iam)
|
||||
* - coord-final-decisions §2 B3 (BFF 豁免 @RequirePermission, 仅校验 x-user-id)
|
||||
*
|
||||
* 聚合: iam.GetUserInfo + iam.GetEffectivePermissions + iam.GetViewports
|
||||
* 并行调用 (Promise.allSettled), 部分失败走降级模式方案 B.
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import type { DownstreamResponse } from "@edu/shared-ts/bff";
|
||||
import { ok, fail, degraded, DegradedReason } from "../../shared/action-state.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
|
||||
export const authResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* currentUser: 获取当前学生信息 + 权限 + 视口.
|
||||
* @permission STUDENT_DASHBOARD_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async currentUser(
|
||||
_parent: unknown,
|
||||
_args: unknown,
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const results = await ctx.downstream.callAll([
|
||||
{
|
||||
service: "iam",
|
||||
method: "GetUserInfo",
|
||||
request: { userId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "iam",
|
||||
method: "GetEffectivePermissions",
|
||||
request: { userId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "iam",
|
||||
method: "GetViewports",
|
||||
request: { userId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
] as const);
|
||||
|
||||
const [userInfoResp, permsResp, viewportsResp] = results as [
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
];
|
||||
|
||||
const degradedFields: string[] = [];
|
||||
if (!userInfoResp.success) degradedFields.push("user");
|
||||
if (!permsResp.success) degradedFields.push("permissions");
|
||||
if (!viewportsResp.success) degradedFields.push("viewport");
|
||||
|
||||
// 必需字段失败时返回错误
|
||||
if (!userInfoResp.success) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch user info: ${userInfoResp.error.message}`,
|
||||
{
|
||||
details: {
|
||||
service: userInfoResp.error.service,
|
||||
method: userInfoResp.error.method,
|
||||
traceId: ctx.traceId,
|
||||
},
|
||||
i18nKey: "error.bffStudent.bad_gateway",
|
||||
traceId: ctx.traceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const userInfo = userInfoResp.data as {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
roles: string[];
|
||||
};
|
||||
const permissions = permsResp.success
|
||||
? (permsResp.data as { permissions: string[] }).permissions
|
||||
: [];
|
||||
const viewports = viewportsResp.success
|
||||
? (viewportsResp.data as { navigation: unknown[]; dataScope: unknown })
|
||||
: { navigation: [], dataScope: {} };
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: userInfo.userId,
|
||||
email: userInfo.email,
|
||||
name: userInfo.name,
|
||||
avatar: userInfo.avatar,
|
||||
roles: userInfo.roles,
|
||||
},
|
||||
permissions,
|
||||
viewport: viewports,
|
||||
};
|
||||
|
||||
if (degradedFields.length > 0) {
|
||||
return degraded(
|
||||
data,
|
||||
DegradedReason.DOWNSTREAM_PARTIAL_FAILURE,
|
||||
degradedFields,
|
||||
{ traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Classes Resolver - myClasses Query.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myClasses
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 core-edu)
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
|
||||
export const classesResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myClasses: 我所在班级列表.
|
||||
* @permission STUDENT_DASHBOARD_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myClasses(
|
||||
_parent: unknown,
|
||||
_args: unknown,
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "GetClassesByStudent", {
|
||||
studentId: ctx.userId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { classes: unknown[] };
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch classes: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
133
services/student-bff/src/student/resolvers/content.resolver.ts
Normal file
133
services/student-bff/src/student/resolvers/content.resolver.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Content Resolver - textbooks / chapters / learningPath Queries (P4 扩展).
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.textbooks / chapters / learningPath
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 content)
|
||||
* - president-final-rulings §2.3 (跨阶段扩展例外: 新增下游 gRPC 调用允许)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
*
|
||||
* P3 时下游 content 未就绪, env.MOCK_UPSTREAM=true 返回 mock; 上游就绪后切换真实调用.
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
|
||||
export const contentResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* textbooks: 教材列表.
|
||||
* @permission STUDENT_CONTENT_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async textbooks(
|
||||
_parent: unknown,
|
||||
args: { gradeId?: string; subjectId?: string; page?: number; pageSize?: number },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const cacheKey = `${args.gradeId ?? "all"}:${args.subjectId ?? "all"}`;
|
||||
const cached = await ctx.redis.get<unknown>("textbooks", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("content", "ListTextbooks", {
|
||||
gradeId: args.gradeId,
|
||||
subjectId: args.subjectId,
|
||||
page: args.page ?? 1,
|
||||
pageSize: Math.min(args.pageSize ?? 20, 50),
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { textbooks: unknown[] };
|
||||
await ctx.redis.set("textbooks", data, CacheTTL.TEXTBOOKS, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch textbooks: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* chapters: 教材章节树.
|
||||
* @permission STUDENT_CONTENT_READ
|
||||
*/
|
||||
async chapters(
|
||||
_parent: unknown,
|
||||
args: { textbookId: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const cached = await ctx.redis.get<unknown>("chapters", args.textbookId);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("content", "ListChapters", {
|
||||
textbookId: args.textbookId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { chapters: unknown[] };
|
||||
await ctx.redis.set("chapters", data, CacheTTL.CHAPTERS, args.textbookId);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch chapters: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* learningPath: 个性化学习路径 (基于学情诊断).
|
||||
* @permission STUDENT_CONTENT_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async learningPath(
|
||||
_parent: unknown,
|
||||
args: { knowledgePointId: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("content", "GetLearningPath", {
|
||||
studentId: ctx.userId,
|
||||
knowledgePointId: args.knowledgePointId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch learning path: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
158
services/student-bff/src/student/resolvers/dashboard.resolver.ts
Normal file
158
services/student-bff/src/student/resolvers/dashboard.resolver.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Dashboard Resolver - studentDashboard Query.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.studentDashboard
|
||||
* - president-final-rulings §2.8 (Dashboard Query Resolver P2 即定型, 内部按阶段扩展)
|
||||
* - president-final-rulings §2.6 (降级模式方案 B: data 内 degraded=true)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
*
|
||||
* 聚合:
|
||||
* - iam.GetUserInfo (用户基础信息)
|
||||
* - core-edu.ListHomeworkByStudent (待办作业)
|
||||
* - core-edu.ListExamsByClass (即将到来的考试)
|
||||
* - core-edu.ListGradesByStudent (最近一次成绩)
|
||||
* - data-ana.GetStudentDashboard (P4 学情汇总, P3 返回 null)
|
||||
* - msg.ListNotifications (未读通知数, P5)
|
||||
*
|
||||
* 缓存: student:dashboard:{userId} TTL 15s
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import type { DownstreamResponse } from "@edu/shared-ts/bff";
|
||||
import { ok, fail, degraded, DegradedReason } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
|
||||
export const dashboardResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* studentDashboard: 学生首页聚合.
|
||||
* @permission STUDENT_DASHBOARD_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async studentDashboard(
|
||||
_parent: unknown,
|
||||
_args: unknown,
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
// 缓存命中检查
|
||||
const cacheKey = ["dashboard", ctx.userId] as const;
|
||||
const cached = await ctx.redis.get<unknown>("dashboard", ctx.userId);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
// 并行调用下游 (Promise.allSettled 容错)
|
||||
const results = await ctx.downstream.callAll([
|
||||
{
|
||||
service: "iam",
|
||||
method: "GetUserInfo",
|
||||
request: { userId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "core-edu",
|
||||
method: "ListHomeworkByStudent",
|
||||
request: { studentId: ctx.userId, status: "pending" },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "core-edu",
|
||||
method: "ListExamsByClass",
|
||||
request: { studentId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "core-edu",
|
||||
method: "ListGradesByStudent",
|
||||
request: { studentId: ctx.userId, page: 1, pageSize: 1 },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "data-ana",
|
||||
method: "GetStudentDashboard",
|
||||
request: { studentId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
] as const);
|
||||
|
||||
const [userInfoResp, homeworkResp, examsResp, gradesResp, dashboardAnaResp] =
|
||||
results as [
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
];
|
||||
|
||||
// 必需字段失败检查
|
||||
if (!userInfoResp.success) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch user info: ${userInfoResp.error.message}`,
|
||||
{
|
||||
i18nKey: "error.bffStudent.bad_gateway",
|
||||
traceId: ctx.traceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const userInfo = userInfoResp.data as {
|
||||
userId: string;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
classId: string;
|
||||
className: string;
|
||||
grade: string;
|
||||
};
|
||||
|
||||
// 容错聚合: 各字段独立降级
|
||||
const degradedFields: string[] = [];
|
||||
const pendingHomework = homeworkResp.success
|
||||
? (homeworkResp.data as { homework: unknown[] }).homework ?? []
|
||||
: (degradedFields.push("pendingHomework"), []);
|
||||
const upcomingExams = examsResp.success
|
||||
? (examsResp.data as { exams: unknown[] }).exams ?? []
|
||||
: (degradedFields.push("upcomingExams"), []);
|
||||
const lastGrade = gradesResp.success
|
||||
? ((gradesResp.data as { grades: unknown[] }).grades ?? [])[0] ?? null
|
||||
: (degradedFields.push("lastGrade"), null);
|
||||
const analyticsSummary = dashboardAnaResp.success
|
||||
? dashboardAnaResp.data
|
||||
: (degradedFields.push("analyticsSummary"), null);
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: userInfo.userId,
|
||||
name: userInfo.name,
|
||||
avatar: userInfo.avatar,
|
||||
grade: userInfo.grade,
|
||||
class: { id: userInfo.classId, name: userInfo.className },
|
||||
},
|
||||
pendingHomework,
|
||||
upcomingExams,
|
||||
lastGrade,
|
||||
analyticsSummary,
|
||||
unreadNotifications: 0, // P5 msg 服务启用后填充
|
||||
};
|
||||
|
||||
// 写缓存
|
||||
await ctx.redis.set("dashboard", data, CacheTTL.DASHBOARD, ctx.userId);
|
||||
|
||||
if (degradedFields.length > 0) {
|
||||
return degraded(
|
||||
data,
|
||||
DegradedReason.DOWNSTREAM_PARTIAL_FAILURE,
|
||||
degradedFields,
|
||||
{ traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
},
|
||||
},
|
||||
};
|
||||
58
services/student-bff/src/student/resolvers/exams.resolver.ts
Normal file
58
services/student-bff/src/student/resolvers/exams.resolver.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Exams Resolver - myExams Query.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myExams
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 core-edu)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
|
||||
export const examsResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myExams: 即将到来的考试列表.
|
||||
* @permission STUDENT_EXAM_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myExams(
|
||||
_parent: unknown,
|
||||
args: { status?: string; classId?: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const cacheKey = `${ctx.userId}:${args.classId ?? "all"}:${args.status ?? "all"}`;
|
||||
const cached = await ctx.redis.get<unknown>("exams", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "ListExamsByClass", {
|
||||
studentId: ctx.userId,
|
||||
classId: args.classId,
|
||||
status: args.status ?? "upcoming",
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { exams: unknown[] };
|
||||
await ctx.redis.set("exams", data, CacheTTL.EXAMS, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch exams: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Grades Resolver - myGrades Query.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myGrades
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御, 学生只能查自己成绩)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
import { assertOwnData } from "../guards/authorization.guard.js";
|
||||
|
||||
export const gradesResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myGrades: 我的成绩列表.
|
||||
* @permission STUDENT_GRADE_READ
|
||||
* @dataScope OWN (B4 强制 studentId = userId)
|
||||
*/
|
||||
async myGrades(
|
||||
_parent: unknown,
|
||||
args: {
|
||||
studentId?: string;
|
||||
subject?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
// B4 越权防御: args.studentId 必须 = JWT userId
|
||||
assertOwnData(ctx.userId, args.studentId);
|
||||
|
||||
const page = args.page ?? 1;
|
||||
const pageSize = Math.min(args.pageSize ?? 20, 50);
|
||||
const cacheKey = `${ctx.userId}:${page}:${args.subject ?? "all"}`;
|
||||
|
||||
const cached = await ctx.redis.get<unknown>("grades", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "ListGradesByStudent", {
|
||||
studentId: ctx.userId,
|
||||
subject: args.subject,
|
||||
page,
|
||||
pageSize,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { grades: unknown[]; totalCount: number };
|
||||
await ctx.redis.set("grades", data, CacheTTL.GRADES, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch grades: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Homework Resolver 单元测试.
|
||||
*
|
||||
* 测试覆盖:
|
||||
* - myHomework Query: 缓存命中 / 缓存未命中 / 下游失败
|
||||
* - submitHomework Mutation: 正常提交 / Zod 校验失败 / 越权防御 / 下游失败 / 缓存失效
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { homeworkResolvers } from "./homework.resolver.js";
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { UnauthorizedError, ValidationError, ForbiddenResourceError } from "../../shared/errors/application-error.js";
|
||||
|
||||
// Mock env to disable DEV_MODE
|
||||
vi.mock("../../config/env.js", () => ({
|
||||
env: { DEV_MODE: false, NODE_ENV: "test" },
|
||||
}));
|
||||
|
||||
function mockContext(overrides: Partial<StudentBffContext> = {}): StudentBffContext {
|
||||
const downstream = {
|
||||
call: vi.fn(),
|
||||
callStream: vi.fn(),
|
||||
callAll: vi.fn(),
|
||||
};
|
||||
const redis = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
invalidate: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
|
||||
ping: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const dataLoaders = {} as never;
|
||||
return {
|
||||
userId: "u-stu-001",
|
||||
traceId: "trace-test-001",
|
||||
userRoles: ["student"],
|
||||
downstream: downstream as never,
|
||||
redis: redis as never,
|
||||
dataLoaders,
|
||||
requestId: "trace-test-001",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("homeworkResolvers", () => {
|
||||
let ctx: StudentBffContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = mockContext();
|
||||
});
|
||||
|
||||
describe("Query.myHomework", () => {
|
||||
it("should throw UnauthorizedError when userId is null", async () => {
|
||||
ctx.userId = null;
|
||||
await expect(
|
||||
homeworkResolvers.Query.myHomework(null, {}, ctx),
|
||||
).rejects.toThrow(UnauthorizedError);
|
||||
});
|
||||
|
||||
it("should return cached data when cache hits", async () => {
|
||||
const cachedData = { homework: [{ id: "hw-001" }] };
|
||||
ctx.redis.get = vi.fn().mockResolvedValue(cachedData);
|
||||
|
||||
const result = await homeworkResolvers.Query.myHomework(null, {}, ctx);
|
||||
expect(result.success).toBe(true);
|
||||
expect((result as { data: unknown }).data).toEqual(cachedData);
|
||||
expect(ctx.downstream.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call downstream when cache misses", async () => {
|
||||
const downstreamData = { homework: [{ id: "hw-001", title: "Math HW" }] };
|
||||
ctx.downstream.call = vi.fn().mockResolvedValue(downstreamData);
|
||||
|
||||
const result = await homeworkResolvers.Query.myHomework(
|
||||
null,
|
||||
{ status: "ASSIGNED", classId: "c-001" },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(ctx.downstream.call).toHaveBeenCalledWith(
|
||||
"core-edu",
|
||||
"ListHomeworkByStudent",
|
||||
{ studentId: "u-stu-001", status: "ASSIGNED", classId: "c-001" },
|
||||
expect.objectContaining({
|
||||
traceId: "trace-test-001",
|
||||
metadata: { "x-user-id": "u-stu-001" },
|
||||
}),
|
||||
);
|
||||
expect(ctx.redis.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return fail response when downstream fails", async () => {
|
||||
ctx.downstream.call = vi.fn().mockRejectedValue(new Error("gRPC unavailable"));
|
||||
|
||||
const result = await homeworkResolvers.Query.myHomework(null, {}, ctx);
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as { error: { code: string } }).error.code).toBe("BFF_STUDENT_BAD_GATEWAY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mutation.submitHomework", () => {
|
||||
const validInput = {
|
||||
homeworkId: "hw-001",
|
||||
studentId: "u-stu-001",
|
||||
answers: [
|
||||
{ questionId: "q-001", content: "My answer" },
|
||||
],
|
||||
};
|
||||
|
||||
it("should throw UnauthorizedError when userId is null", async () => {
|
||||
ctx.userId = null;
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(null, { input: validInput }, ctx),
|
||||
).rejects.toThrow(UnauthorizedError);
|
||||
});
|
||||
|
||||
it("should throw ValidationError when input is invalid", async () => {
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: { homeworkId: "", answers: [] } },
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenResourceError when studentId does not match userId (B4)", async () => {
|
||||
const maliciousInput = {
|
||||
...validInput,
|
||||
studentId: "u-stu-002",
|
||||
};
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(null, { input: maliciousInput }, ctx),
|
||||
).rejects.toThrow(ForbiddenResourceError);
|
||||
});
|
||||
|
||||
it("should submit successfully when studentId matches userId", async () => {
|
||||
const submitResult = {
|
||||
submissionId: "sub-001",
|
||||
homeworkId: "hw-001",
|
||||
submittedAt: "2026-07-10T10:00:00Z",
|
||||
status: "SUBMITTED",
|
||||
};
|
||||
ctx.downstream.call = vi.fn().mockResolvedValue(submitResult);
|
||||
|
||||
const result = await homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: validInput },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(ctx.downstream.call).toHaveBeenCalledWith(
|
||||
"core-edu",
|
||||
"SubmitHomework",
|
||||
{
|
||||
homeworkId: "hw-001",
|
||||
studentId: "u-stu-001",
|
||||
answers: validInput.answers,
|
||||
},
|
||||
expect.objectContaining({
|
||||
metadata: { "x-user-id": "u-stu-001" },
|
||||
}),
|
||||
);
|
||||
expect(ctx.redis.invalidate).toHaveBeenCalledWith("homework", "u-stu-001");
|
||||
expect(ctx.redis.invalidate).toHaveBeenCalledWith("dashboard", "u-stu-001");
|
||||
});
|
||||
|
||||
it("should submit successfully when studentId is not provided in input", async () => {
|
||||
const inputWithoutStudentId = {
|
||||
homeworkId: "hw-001",
|
||||
answers: [{ questionId: "q-001", content: "Answer" }],
|
||||
};
|
||||
ctx.downstream.call = vi.fn().mockResolvedValue({
|
||||
submissionId: "sub-002",
|
||||
homeworkId: "hw-001",
|
||||
submittedAt: "2026-07-10T10:00:00Z",
|
||||
status: "SUBMITTED",
|
||||
});
|
||||
|
||||
const result = await homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: inputWithoutStudentId },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should return fail response when downstream fails", async () => {
|
||||
ctx.downstream.call = vi.fn().mockRejectedValue(new Error("Submission failed"));
|
||||
|
||||
const result = await homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: validInput },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as { error: { code: string } }).error.code).toBe("BFF_STUDENT_BAD_GATEWAY");
|
||||
});
|
||||
|
||||
it("should validate answer content max length", async () => {
|
||||
const longContentInput = {
|
||||
homeworkId: "hw-001",
|
||||
studentId: "u-stu-001",
|
||||
answers: [{ questionId: "q-001", content: "x".repeat(10001) }],
|
||||
};
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(null, { input: longContentInput }, ctx),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("should validate attachments are valid URLs", async () => {
|
||||
const invalidAttachmentInput = {
|
||||
homeworkId: "hw-001",
|
||||
studentId: "u-stu-001",
|
||||
answers: [
|
||||
{
|
||||
questionId: "q-001",
|
||||
content: "Answer",
|
||||
attachments: ["not-a-url"],
|
||||
},
|
||||
],
|
||||
};
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(null, { input: invalidAttachmentInput }, ctx),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
services/student-bff/src/student/resolvers/homework.resolver.ts
Normal file
142
services/student-bff/src/student/resolvers/homework.resolver.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Homework Resolver - myHomework Query + submitHomework Mutation.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myHomework + Mutation.submitHomework
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御, 学生只能查/操作自己数据)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
* - president-final-rulings §2.9 (越权防御 P3 实现方式: 方案 D)
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "../../shared/errors/application-error.js";
|
||||
import { assertOwnData } from "../guards/authorization.guard.js";
|
||||
|
||||
/**
|
||||
* 提交作业输入 Zod schema (G7 Zod 验证).
|
||||
*/
|
||||
const SubmitHomeworkInputSchema = z.object({
|
||||
homeworkId: z.string().min(1),
|
||||
studentId: z.string().min(1).optional(),
|
||||
answers: z
|
||||
.array(
|
||||
z.object({
|
||||
questionId: z.string().min(1),
|
||||
content: z.string().min(1).max(10000),
|
||||
attachments: z.array(z.string().url()).max(5).optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
});
|
||||
|
||||
export const homeworkResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myHomework: 我的作业列表.
|
||||
* @permission STUDENT_HOMEWORK_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myHomework(
|
||||
_parent: unknown,
|
||||
args: { status?: string; classId?: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
// 缓存命中
|
||||
const cacheKey = ctx.userId + (args.classId ? `:${args.classId}` : "");
|
||||
const cached = await ctx.redis.get<unknown>("homework", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "ListHomeworkByStudent", {
|
||||
studentId: ctx.userId,
|
||||
status: args.status,
|
||||
classId: args.classId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { homework: unknown[] };
|
||||
await ctx.redis.set("homework", data, CacheTTL.HOMEWORK, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch homework: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
/**
|
||||
* submitHomework: 提交作业.
|
||||
* @permission STUDENT_HOMEWORK_SUBMIT
|
||||
* @dataScope OWN (B4 强制 studentId = userId)
|
||||
*/
|
||||
async submitHomework(
|
||||
_parent: unknown,
|
||||
args: { input: unknown },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
// G7 Zod 校验
|
||||
const parseResult = SubmitHomeworkInputSchema.safeParse(args.input);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError(
|
||||
"Invalid submitHomework input",
|
||||
parseResult.error.flatten(),
|
||||
);
|
||||
}
|
||||
const input = parseResult.data;
|
||||
|
||||
// B4 自我越权防御: body 中 studentId 必须 = JWT userId
|
||||
assertOwnData(ctx.userId, input.studentId);
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "SubmitHomework", {
|
||||
homeworkId: input.homeworkId,
|
||||
studentId: ctx.userId,
|
||||
answers: input.answers,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
// 失效相关缓存
|
||||
await ctx.redis.invalidate("homework", ctx.userId);
|
||||
await ctx.redis.invalidate("dashboard", ctx.userId);
|
||||
|
||||
const data = result as {
|
||||
submissionId: string;
|
||||
homeworkId: string;
|
||||
submittedAt: string;
|
||||
status: string;
|
||||
};
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to submit homework: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
56
services/student-bff/src/student/resolvers/index.ts
Normal file
56
services/student-bff/src/student/resolvers/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Student BFF Resolver 装配入口.
|
||||
*
|
||||
* 将所有 Resolver 合并为一个 GraphQL Resolver 映射表,
|
||||
* 供 GraphQL Yoga makeExecutableSchema 使用.
|
||||
*
|
||||
* Resolver 清单 (按 schema 第一版):
|
||||
* Query:
|
||||
* - currentUser (auth)
|
||||
* - studentDashboard (dashboard)
|
||||
* - myHomework (homework)
|
||||
* - myGrades (grades)
|
||||
* - myExams (exams)
|
||||
* - myClasses (classes)
|
||||
* - textbooks / chapters / learningPath (content, P4)
|
||||
* - myWeakness / myTrend (analytics, P4)
|
||||
* - myNotifications / myNotificationUnreadCount (notifications, P5)
|
||||
* - aiChat (ai, P5)
|
||||
* Mutation:
|
||||
* - submitHomework (homework)
|
||||
* - markNotificationAsRead (notifications, P5)
|
||||
* Subscription:
|
||||
* - aiStreamChat (ai-stream, P5 SSE)
|
||||
*/
|
||||
import { mergeResolvers } from "@graphql-tools/merge";
|
||||
import { authResolvers } from "./auth.resolver.js";
|
||||
import { dashboardResolvers } from "./dashboard.resolver.js";
|
||||
import { homeworkResolvers } from "./homework.resolver.js";
|
||||
import { gradesResolvers } from "./grades.resolver.js";
|
||||
import { examsResolvers } from "./exams.resolver.js";
|
||||
import { classesResolvers } from "./classes.resolver.js";
|
||||
import { contentResolvers } from "./content.resolver.js";
|
||||
import { analyticsResolvers } from "./analytics.resolver.js";
|
||||
import { notificationsResolvers } from "./notifications.resolver.js";
|
||||
import { aiResolvers } from "./ai.resolver.js";
|
||||
import { aiStreamResolvers } from "./ai-stream.resolver.js";
|
||||
|
||||
/**
|
||||
* 全部 Resolver 合并.
|
||||
*
|
||||
* 注意: 各 resolver 文件导出的对象结构为 { Query: {...}, Mutation: {...}, Subscription: {...} },
|
||||
* mergeResolvers 自动合并同名 Query/Mutation/Subscription 字段.
|
||||
*/
|
||||
export const studentBffResolvers = mergeResolvers([
|
||||
authResolvers,
|
||||
dashboardResolvers,
|
||||
homeworkResolvers,
|
||||
gradesResolvers,
|
||||
examsResolvers,
|
||||
classesResolvers,
|
||||
contentResolvers,
|
||||
analyticsResolvers,
|
||||
notificationsResolvers,
|
||||
aiResolvers,
|
||||
aiStreamResolvers,
|
||||
]);
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Notifications Resolver - myNotifications Query + markNotificationAsRead Mutation (P5 扩展).
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myNotifications / myNotificationUnreadCount
|
||||
* + Mutation.markNotificationAsRead
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 msg)
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御, 通知仅本人可读/操作)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
* - president-final-rulings §2.3 (跨阶段扩展例外)
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "../../shared/errors/application-error.js";
|
||||
import { assertOwnData } from "../guards/authorization.guard.js";
|
||||
|
||||
const MarkNotificationReadInputSchema = z.object({
|
||||
notificationId: z.string().min(1),
|
||||
studentId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const notificationsResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myNotifications: 消息列表.
|
||||
* @permission STUDENT_NOTIFICATION_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myNotifications(
|
||||
_parent: unknown,
|
||||
args: { page?: number; pageSize?: number; unreadOnly?: boolean },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const page = args.page ?? 1;
|
||||
const pageSize = Math.min(args.pageSize ?? 20, 50);
|
||||
const cacheKey = `${ctx.userId}:${page}:${args.unreadOnly ?? false}`;
|
||||
|
||||
const cached = await ctx.redis.get<unknown>("notifications", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("msg", "ListNotifications", {
|
||||
userId: ctx.userId,
|
||||
page,
|
||||
pageSize,
|
||||
unreadOnly: args.unreadOnly ?? false,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as {
|
||||
notifications: unknown[];
|
||||
totalCount: number;
|
||||
unreadCount: number;
|
||||
};
|
||||
await ctx.redis.set("notifications", data, CacheTTL.NOTIFICATIONS, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch notifications: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* myNotificationUnreadCount: 未读通知数.
|
||||
* @permission STUDENT_NOTIFICATION_READ
|
||||
*/
|
||||
async myNotificationUnreadCount(
|
||||
_parent: unknown,
|
||||
_args: unknown,
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("msg", "GetUnreadCount", {
|
||||
userId: ctx.userId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { unreadCount: number };
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch unread count: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
/**
|
||||
* markNotificationAsRead: 标记通知已读.
|
||||
* @permission STUDENT_NOTIFICATION_READ
|
||||
* @dataScope OWN (B4 防御 body 中 studentId)
|
||||
*/
|
||||
async markNotificationAsRead(
|
||||
_parent: unknown,
|
||||
args: { input: unknown },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const parseResult = MarkNotificationReadInputSchema.safeParse(args.input);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError(
|
||||
"Invalid markNotificationAsRead input",
|
||||
parseResult.error.flatten(),
|
||||
);
|
||||
}
|
||||
const input = parseResult.data;
|
||||
|
||||
// B4 越权防御
|
||||
assertOwnData(ctx.userId, input.studentId);
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("msg", "MarkNotificationAsRead", {
|
||||
notificationId: input.notificationId,
|
||||
userId: ctx.userId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
// 失效通知缓存
|
||||
await ctx.redis.invalidateByPrefix(`notifications:${ctx.userId}`);
|
||||
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to mark notification as read: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
48
services/student-bff/src/student/student.module.ts
Normal file
48
services/student-bff/src/student/student.module.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* StudentModule - 装配 GraphQL Yoga + Resolver.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B1 (P2 起直接 GraphQL Yoga + DataLoader)
|
||||
* - coord-final-decisions §2 B8 (复用 shared-ts DownstreamClient)
|
||||
*
|
||||
* 职责:
|
||||
* 1. 启动时创建 GraphQL Yoga 实例 (加载 schema + 装配 resolver)
|
||||
* 2. 提供 Yoga Express middleware 挂载钩子 (由 main.ts 调用)
|
||||
* 3. 不直接持有 resolver 实例 (resolver 是纯函数, 通过 context 注入依赖)
|
||||
*/
|
||||
import { Module, OnModuleInit } from "@nestjs/common";
|
||||
import { Inject } from "@nestjs/common";
|
||||
import { DownstreamClient } from "@edu/shared-ts/bff";
|
||||
import { REDIS_CLIENT } from "../shared/cache/cache.module.js";
|
||||
import type { Redis } from "ioredis";
|
||||
import { createStudentBffYoga, type StudentBffContext } from "../shared/graphql/yoga.js";
|
||||
import { studentBffResolvers } from "./resolvers/index.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
import type { YogaServerInstance } from "graphql-yoga";
|
||||
|
||||
export const GRAPHQL_YOGA = Symbol("GRAPHQL_YOGA");
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: GRAPHQL_YOGA,
|
||||
useFactory: async (
|
||||
downstream: DownstreamClient,
|
||||
redis: Redis,
|
||||
): Promise<YogaServerInstance<Record<string, unknown>, StudentBffContext>> => {
|
||||
return createStudentBffYoga(studentBffResolvers, downstream, redis);
|
||||
},
|
||||
inject: [DownstreamClient, REDIS_CLIENT],
|
||||
},
|
||||
],
|
||||
exports: [GRAPHQL_YOGA],
|
||||
})
|
||||
export class StudentModule implements OnModuleInit {
|
||||
constructor(@Inject(GRAPHQL_YOGA) private readonly yoga: Promise<unknown>) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 确保 Yoga 实例初始化完成
|
||||
await this.yoga;
|
||||
logger.info("StudentModule initialized, GraphQL Yoga ready");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user