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