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

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

View File

@@ -0,0 +1,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 {}

View File

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

View File

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

View File

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

View 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);
});

View 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");
});
});
});

View 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;

View 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;
}
}
}

View File

@@ -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 {}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View 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);
});
});
});

View 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);
}
}

View 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;
}
}

View 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;
}

View 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;
}
}

View File

@@ -0,0 +1,10 @@
/**
* HealthModule - 健康检查模块.
*/
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller.js";
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View 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 };

View 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 });
}
}

View 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");
}
}

View File

@@ -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 {}

View 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");
}
}
}

View 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 {}

View File

@@ -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);
});
});
});

View 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 },
);
}
}

View 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 {}

View File

@@ -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");
});
});

View 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,
};
}
}
}

View 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,
},
};
}
})();
},
},
},
};

View 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 },
);
}
},
},
};

View 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 },
);
}
},
},
};

View 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 });
},
},
};

View File

@@ -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 },
);
}
},
},
};

View 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 },
);
}
},
},
};

View 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 });
},
},
};

View 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 },
);
}
},
},
};

View File

@@ -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 },
);
}
},
},
};

View File

@@ -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);
});
});
});

View 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 },
);
}
},
},
};

View 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,
]);

View File

@@ -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 },
);
}
},
},
};

View 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");
}
}