feat(student-bff): 完整实现 student-bff 聚合层
包含 src 全部实现、Dockerfile、shared-ts/bff 包等
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* student-bff DataLoader 模块 - N+1 防御.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B1 (GraphQL Yoga + DataLoader)
|
||||
* - 004 §11.3 BFF 聚合模式图示
|
||||
*
|
||||
* DataLoader 用于:
|
||||
* - Dashboard 内多学生场景批量加载作业/成绩 (避免 N+1 gRPC 调用)
|
||||
* - 子字段解析时批量加载关联实体
|
||||
*
|
||||
* 实现策略:
|
||||
* - 上游 proto 暂未支持批量 RPC (ListHomeworkByIds 等), DataLoader 内部
|
||||
* 使用 Promise.all 并行调用各 id 的 gRPC, 通过 batch + dedupe 减少 N+1.
|
||||
* - 同一 tick 内相同 id 请求自动去重 (DataLoader 内置 cacheKeyFn).
|
||||
* - 后续 coord 补全批量 RPC 后, 替换为真正的批量 gRPC 调用.
|
||||
*/
|
||||
import { Module, Scope } from "@nestjs/common";
|
||||
import DataLoader from "dataloader";
|
||||
import type { DownstreamClient } from "@edu/shared-ts/bff";
|
||||
|
||||
/**
|
||||
* 单个作业的 Loader 批量加载函数.
|
||||
*
|
||||
* 输入: homeworkId 列表
|
||||
* 输出: 作业详情列表 (与输入顺序一致, 失败项返回 null)
|
||||
*/
|
||||
export type HomeworkLoader = DataLoader<string, unknown, string>;
|
||||
|
||||
/**
|
||||
* 单个学生成绩的 Loader.
|
||||
*/
|
||||
export type GradesLoader = DataLoader<string, unknown, string>;
|
||||
|
||||
/**
|
||||
* 用户信息 Loader (Dashboard 内多用户场景).
|
||||
*/
|
||||
export type UserInfoLoader = DataLoader<string, unknown, string>;
|
||||
|
||||
/**
|
||||
* 创建 Homework DataLoader.
|
||||
*
|
||||
* 使用方式 (在 Resolver context 注入):
|
||||
* const loader = createHomeworkLoader(downstream);
|
||||
* const hw1 = await loader.load("h-001");
|
||||
* const hw2 = await loader.load("h-002");
|
||||
* // 单 tick 内并行调用 gRPC, 自动 dedupe
|
||||
*/
|
||||
export function createHomeworkLoader(downstream: DownstreamClient): HomeworkLoader {
|
||||
return new DataLoader<string, unknown, string>(async (homeworkIds) => {
|
||||
const results = await Promise.all(
|
||||
homeworkIds.map(async (id) => {
|
||||
try {
|
||||
return await downstream.call("core-edu", "GetHomework", { homeworkId: id });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Grades DataLoader.
|
||||
*/
|
||||
export function createGradesLoader(downstream: DownstreamClient): GradesLoader {
|
||||
return new DataLoader<string, unknown, string>(async (studentIds) => {
|
||||
const results = await Promise.all(
|
||||
studentIds.map(async (id) => {
|
||||
try {
|
||||
return await downstream.call("core-edu", "ListGradesByStudent", {
|
||||
studentId: id,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 UserInfo DataLoader.
|
||||
*/
|
||||
export function createUserInfoLoader(downstream: DownstreamClient): UserInfoLoader {
|
||||
return new DataLoader<string, unknown, string>(async (userIds) => {
|
||||
const results = await Promise.all(
|
||||
userIds.map(async (id) => {
|
||||
try {
|
||||
return await downstream.call("iam", "GetUserInfo", { userId: id });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DataLoader 工厂接口 (注入到 GraphQL context).
|
||||
*/
|
||||
export interface StudentBffDataLoaders {
|
||||
homework: HomeworkLoader;
|
||||
grades: GradesLoader;
|
||||
userInfo: UserInfoLoader;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建全部 DataLoader (每个 GraphQL 请求一份).
|
||||
*/
|
||||
export function createDataLoaders(
|
||||
downstream: DownstreamClient,
|
||||
): StudentBffDataLoaders {
|
||||
return {
|
||||
homework: createHomeworkLoader(downstream),
|
||||
grades: createGradesLoader(downstream),
|
||||
userInfo: createUserInfoLoader(downstream),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* NestJS Module - 提供 DataLoader 工厂 (request scope).
|
||||
*/
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: "DATA_LOADER_FACTORY",
|
||||
useFactory: (): typeof createDataLoaders => createDataLoaders,
|
||||
scope: Scope.TRANSIENT,
|
||||
},
|
||||
],
|
||||
exports: ["DATA_LOADER_FACTORY"],
|
||||
})
|
||||
export class DataLoaderModule {}
|
||||
194
services/student-bff/src/student/events/event-subscriber.ts
Normal file
194
services/student-bff/src/student/events/event-subscriber.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Kafka EventSubscriber - P5 事件订阅 + 推送通道.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B7 (P2-P4 不订阅 Kafka, P5 后订阅)
|
||||
* - president-final-rulings §2.4 (/readyz 软失败: Kafka 失败仅告警)
|
||||
* - workline §5.4 (P5.4 Kafka EventSubscriber)
|
||||
*
|
||||
* 订阅 topic (G16 命名规范: edu.<domain>.<aggregate>.<action>):
|
||||
* - edu.teaching.homework.assigned 教师布置作业
|
||||
* - edu.teaching.homework.graded 作业批改完成
|
||||
* - edu.teaching.exam.published 考试发布
|
||||
* - edu.teaching.exam.updated 考试更新
|
||||
* - edu.teaching.grade.recorded 成绩录入
|
||||
* - edu.identity.user.role_changed 学生角色变更
|
||||
* - edu.notification.sent 通知发送
|
||||
*
|
||||
* 消费动作:
|
||||
* 1. 失效相关 Redis 缓存 (student:grades:* / student:exams:* 等)
|
||||
* 2. 调用 push-gateway POST /push/user/:userId 推送给学生
|
||||
*
|
||||
* 幂等性: Redis SETNX event_id 去重 (workline §5.4)
|
||||
*/
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit, Inject } from "@nestjs/common";
|
||||
import { Kafka, type Consumer, type EachMessagePayload } from "kafkajs";
|
||||
import { REDIS_CLIENT, CacheService } from "../../shared/cache/cache.module.js";
|
||||
import type { Redis } from "ioredis";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { metricsRegistry } from "../../shared/observability/metrics.js";
|
||||
import { PushGatewayService } from "../push/push-gateway.service.js";
|
||||
|
||||
/**
|
||||
* 订阅的 topic 列表 (G16 命名规范).
|
||||
*/
|
||||
const SUBSCRIBED_TOPICS = [
|
||||
"edu.teaching.homework.assigned",
|
||||
"edu.teaching.homework.graded",
|
||||
"edu.teaching.exam.published",
|
||||
"edu.teaching.exam.updated",
|
||||
"edu.teaching.grade.recorded",
|
||||
"edu.identity.user.role_changed",
|
||||
"edu.notification.sent",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 事件消息结构 (Kafka JSON payload).
|
||||
*/
|
||||
interface StudentBffEvent {
|
||||
event_id: string;
|
||||
event_type: string;
|
||||
student_id?: string;
|
||||
user_id?: string;
|
||||
payload: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EventSubscriberService implements OnModuleInit, OnModuleDestroy {
|
||||
private consumer: Consumer | null = null;
|
||||
private readonly cacheService: CacheService;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
private readonly pushGateway: PushGatewayService,
|
||||
) {
|
||||
this.cacheService = new CacheService(redis);
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// P5 才订阅, P3/P4 跳过 (B7 裁决)
|
||||
// 但 P3 已落地代码, 通过环境变量控制是否启动
|
||||
if (env.NODE_ENV === "test") {
|
||||
logger.info("EventSubscriber skipped in test environment");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const kafka = new Kafka({
|
||||
clientId: env.KAFKA_CLIENT_ID,
|
||||
brokers: env.KAFKA_BROKERS.split(",").map((b) => b.trim()),
|
||||
});
|
||||
|
||||
this.consumer = kafka.consumer({ groupId: env.KAFKA_CONSUMER_GROUP });
|
||||
await this.consumer.connect();
|
||||
|
||||
for (const topic of SUBSCRIBED_TOPICS) {
|
||||
await this.consumer.subscribe({ topic, fromBeginning: false });
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
await this.consumer.run({
|
||||
eachMessage: async (payload) => this.handleMessage(payload),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ topics: SUBSCRIBED_TOPICS, group: env.KAFKA_CONSUMER_GROUP },
|
||||
"Kafka EventSubscriber started",
|
||||
);
|
||||
} catch (err) {
|
||||
// president §2.4 软失败: Kafka 失败不阻塞启动
|
||||
logger.error(
|
||||
{ err },
|
||||
"Kafka EventSubscriber failed to start (soft failure, service continues)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.running = false;
|
||||
if (this.consumer) {
|
||||
try {
|
||||
await this.consumer.disconnect();
|
||||
logger.info("Kafka EventSubscriber disconnected");
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Error disconnecting Kafka consumer");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单条 Kafka 消息.
|
||||
*/
|
||||
private async handleMessage(payload: EachMessagePayload): Promise<void> {
|
||||
const { topic, partition, message } = payload;
|
||||
const eventStr = message.value?.toString("utf-8");
|
||||
if (!eventStr) {
|
||||
logger.warn({ topic, partition, offset: message.offset }, "Empty Kafka message");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(eventStr) as StudentBffEvent;
|
||||
logger.debug(
|
||||
{ topic, eventId: event.event_id, eventType: event.event_type },
|
||||
"Kafka event received",
|
||||
);
|
||||
|
||||
// 幂等性: Redis SETNX event_id 去重
|
||||
const dedupeKey = `student:event:dedupe:${event.event_id}`;
|
||||
const set = await this.redis.set(dedupeKey, "1", "EX", 86400, "NX");
|
||||
if (set !== "OK") {
|
||||
logger.debug(
|
||||
{ eventId: event.event_id },
|
||||
"Kafka event already processed (deduped)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 指标记录
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_event_consumed_total")
|
||||
?.inc({ topic, event_type: event.event_type });
|
||||
|
||||
// 失效相关缓存 + 推送给学生
|
||||
const studentId = event.student_id ?? event.user_id;
|
||||
if (studentId) {
|
||||
await this.invalidateCache(topic, studentId);
|
||||
await this.pushGateway.pushToStudent(studentId, topic, event.event_type, event.payload, event.timestamp);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, topic, partition, offset: message.offset },
|
||||
"Failed to process Kafka event",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 topic 失效相关缓存.
|
||||
*/
|
||||
private async invalidateCache(topic: string, studentId: string): Promise<void> {
|
||||
try {
|
||||
if (topic.startsWith("edu.teaching.homework")) {
|
||||
await this.cacheService.invalidate("homework", studentId);
|
||||
await this.cacheService.invalidate("dashboard", studentId);
|
||||
} else if (topic.startsWith("edu.teaching.exam")) {
|
||||
await this.cacheService.invalidateByPrefix(`exams:${studentId}`);
|
||||
await this.cacheService.invalidate("dashboard", studentId);
|
||||
} else if (topic.startsWith("edu.teaching.grade")) {
|
||||
await this.cacheService.invalidateByPrefix(`grades:${studentId}`);
|
||||
await this.cacheService.invalidate("dashboard", studentId);
|
||||
} else if (topic === "edu.identity.user.role_changed") {
|
||||
await this.cacheService.invalidate("viewports", studentId);
|
||||
await this.cacheService.invalidate("dashboard", studentId);
|
||||
} else if (topic === "edu.notification.sent") {
|
||||
await this.cacheService.invalidateByPrefix(`notifications:${studentId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err, topic, studentId }, "Cache invalidation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
22
services/student-bff/src/student/events/event.module.ts
Normal file
22
services/student-bff/src/student/events/event.module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* EventModule - Kafka 事件订阅模块.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B7 (P2-P4 不订阅, P5 后订阅)
|
||||
* - president-final-rulings §2.4 (Kafka 启动失败软处理)
|
||||
* - workline §5.4 (P5.4 Kafka EventSubscriber)
|
||||
*
|
||||
* 依赖:
|
||||
* - REDIS_CLIENT (CacheModule Global 提供): 幂等去重 + 缓存失效
|
||||
* - PushGatewayService (PushGatewayModule 提供): 事件推送
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { EventSubscriberService } from "./event-subscriber.js";
|
||||
import { PushGatewayModule } from "../push/push-gateway.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [PushGatewayModule],
|
||||
providers: [EventSubscriberService],
|
||||
exports: [EventSubscriberService],
|
||||
})
|
||||
export class EventModule {}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* AuthorizationGuard 单元测试 - B4 自我越权防御.
|
||||
*
|
||||
* 测试覆盖:
|
||||
* - extractUserIdFromRequest: x-user-id 头提取
|
||||
* - extractTraceIdFromRequest: x-request-id 头提取
|
||||
* - extractUserRolesFromRequest: x-user-roles 头提取
|
||||
* - assertOwnData: 场景 A (资源无归属)
|
||||
* - assertIdentityMatch: 场景 B (身份不一致)
|
||||
* - DEV_MODE 放行
|
||||
* - AuthorizationGuard.canActivate
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { Request } from "express";
|
||||
|
||||
// Mock env module
|
||||
vi.mock("../../config/env.js", () => ({
|
||||
env: {
|
||||
DEV_MODE: false,
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
}));
|
||||
|
||||
// Re-import after mock
|
||||
const { env } = await import("../../config/env.js");
|
||||
const {
|
||||
AuthorizationGuard,
|
||||
extractUserIdFromRequest,
|
||||
extractTraceIdFromRequest,
|
||||
extractUserRolesFromRequest,
|
||||
assertOwnData,
|
||||
assertIdentityMatch,
|
||||
} = await import("./authorization.guard.js");
|
||||
const { ForbiddenResourceError, IdentityMismatchError, UnauthorizedError } = await import(
|
||||
"../../shared/errors/application-error.js"
|
||||
);
|
||||
|
||||
function mockRequest(headers: Record<string, string | undefined> = {}): Request {
|
||||
return { headers } as unknown as Request;
|
||||
}
|
||||
|
||||
describe("AuthorizationGuard", () => {
|
||||
describe("extractUserIdFromRequest", () => {
|
||||
it("should extract x-user-id header", () => {
|
||||
const req = mockRequest({ "x-user-id": "u-stu-001" });
|
||||
expect(extractUserIdFromRequest(req)).toBe("u-stu-001");
|
||||
});
|
||||
|
||||
it("should return null when header is missing", () => {
|
||||
const req = mockRequest({});
|
||||
expect(extractUserIdFromRequest(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when header is empty string", () => {
|
||||
const req = mockRequest({ "x-user-id": "" });
|
||||
expect(extractUserIdFromRequest(req)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTraceIdFromRequest", () => {
|
||||
it("should extract x-request-id header", () => {
|
||||
const req = mockRequest({ "x-request-id": "trace-abc" });
|
||||
expect(extractTraceIdFromRequest(req)).toBe("trace-abc");
|
||||
});
|
||||
|
||||
it("should return 'unknown' when header is missing", () => {
|
||||
const req = mockRequest({});
|
||||
expect(extractTraceIdFromRequest(req)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("should return 'unknown' when header is empty", () => {
|
||||
const req = mockRequest({ "x-request-id": "" });
|
||||
expect(extractTraceIdFromRequest(req)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractUserRolesFromRequest", () => {
|
||||
it("should extract comma-separated roles", () => {
|
||||
const req = mockRequest({ "x-user-roles": "student,monitor" });
|
||||
expect(extractUserRolesFromRequest(req)).toEqual(["student", "monitor"]);
|
||||
});
|
||||
|
||||
it("should trim whitespace", () => {
|
||||
const req = mockRequest({ "x-user-roles": " student , monitor " });
|
||||
expect(extractUserRolesFromRequest(req)).toEqual(["student", "monitor"]);
|
||||
});
|
||||
|
||||
it("should return empty array when header is missing", () => {
|
||||
const req = mockRequest({});
|
||||
expect(extractUserRolesFromRequest(req)).toEqual([]);
|
||||
});
|
||||
|
||||
it("should filter out empty entries", () => {
|
||||
const req = mockRequest({ "x-user-roles": "student,,monitor," });
|
||||
expect(extractUserRolesFromRequest(req)).toEqual(["student", "monitor"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertOwnData (场景 A)", () => {
|
||||
beforeEach(() => {
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
|
||||
it("should pass when requestedStudentId matches userId", () => {
|
||||
expect(() => assertOwnData("u-001", "u-001")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should pass when requestedStudentId is undefined", () => {
|
||||
expect(() => assertOwnData("u-001", undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should pass when requestedStudentId is null", () => {
|
||||
expect(() => assertOwnData("u-001", null)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw ForbiddenResourceError when studentId differs", () => {
|
||||
expect(() => assertOwnData("u-001", "u-002")).toThrow(ForbiddenResourceError);
|
||||
});
|
||||
|
||||
it("should not throw when DEV_MODE is true", () => {
|
||||
env.DEV_MODE = true;
|
||||
expect(() => assertOwnData("u-001", "u-002")).not.toThrow();
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertIdentityMatch (场景 B)", () => {
|
||||
beforeEach(() => {
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
|
||||
it("should pass when bodyUserId matches userId", () => {
|
||||
expect(() => assertIdentityMatch("u-001", "u-001")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should pass when bodyUserId is undefined", () => {
|
||||
expect(() => assertIdentityMatch("u-001", undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should pass when bodyUserId is null", () => {
|
||||
expect(() => assertIdentityMatch("u-001", null)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw IdentityMismatchError when bodyUserId differs", () => {
|
||||
expect(() => assertIdentityMatch("u-001", "u-002")).toThrow(IdentityMismatchError);
|
||||
});
|
||||
|
||||
it("should not throw when DEV_MODE is true", () => {
|
||||
env.DEV_MODE = true;
|
||||
expect(() => assertIdentityMatch("u-001", "u-002")).not.toThrow();
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthorizationGuard.canActivate", () => {
|
||||
let guard: InstanceType<typeof AuthorizationGuard>;
|
||||
|
||||
beforeEach(() => {
|
||||
guard = new AuthorizationGuard();
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
env.DEV_MODE = false;
|
||||
});
|
||||
|
||||
it("should return true when DEV_MODE is true", () => {
|
||||
env.DEV_MODE = true;
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: () => mockRequest({}) }),
|
||||
};
|
||||
expect(guard.canActivate(ctx as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when x-user-id is present", () => {
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: () => mockRequest({ "x-user-id": "u-001" }) }),
|
||||
};
|
||||
expect(guard.canActivate(ctx as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedError when x-user-id is missing", () => {
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: () => mockRequest({}) }),
|
||||
};
|
||||
expect(() => guard.canActivate(ctx as never)).toThrow(UnauthorizedError);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedError when x-user-id is empty", () => {
|
||||
const ctx = {
|
||||
switchToHttp: () => ({ getRequest: () => mockRequest({ "x-user-id": "" }) }),
|
||||
};
|
||||
expect(() => guard.canActivate(ctx as never)).toThrow(UnauthorizedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
146
services/student-bff/src/student/guards/authorization.guard.ts
Normal file
146
services/student-bff/src/student/guards/authorization.guard.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* student-bff AuthorizationGuard - B4 自我越权防御.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B4 (全部 BFF 强制自我越权防御)
|
||||
* - president-final-rulings §2.9 (越权防御 P3 实现方式: 方案 D)
|
||||
* - president-final-rulings §2.7 (3 类越权防御错误码)
|
||||
*
|
||||
* 防御策略:
|
||||
* - 场景 A (资源无归属): 学生请求的 studentId 与 JWT userId 不一致
|
||||
* → ForbiddenResourceError (403, BFF_STUDENT_FORBIDDEN_RESOURCE)
|
||||
* - 场景 B (身份不一致): JWT userId 与请求 body userId 不一致
|
||||
* → IdentityMismatchError (403, BFF_STUDENT_IDENTITY_MISMATCH)
|
||||
*
|
||||
* DEV_MODE=true 时放行 (president §2.9 方案 D):
|
||||
* - 本地开发无 JWT 时, DEV_MODE=true 跳过越权校验
|
||||
* - 生产环境 DEV_MODE=false 强制校验
|
||||
*/
|
||||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { env } from "../../config/env.js";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ForbiddenResourceError,
|
||||
IdentityMismatchError,
|
||||
} from "../../shared/errors/application-error.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
|
||||
/**
|
||||
* 学生越权防御 Guard.
|
||||
*
|
||||
* 使用方式 (NestJS):
|
||||
* @UseGuards(AuthorizationGuard)
|
||||
* @Query(() => StudentDashboard)
|
||||
* async studentDashboard(@Context('userId') userId: string, ...) {}
|
||||
*
|
||||
* GraphQL Yoga 集成: 在 context 构建时调用 extractUserIdFromRequest,
|
||||
* 并在 Resolver 内显式调用 assertOwnData(userId, requestedStudentId).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthorizationGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (env.DEV_MODE) {
|
||||
// 方案 D: DEV_MODE=true 跳过越权校验 (本地开发无 JWT)
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const userId = extractUserIdFromRequest(request);
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Express Request 提取 userId (由 api-gateway 注入 x-user-id 头).
|
||||
*
|
||||
* BFF 不验签 JWT, 仅读 header (B3 裁决).
|
||||
*/
|
||||
export function extractUserIdFromRequest(request: Request): string | null {
|
||||
const header = request.headers["x-user-id"];
|
||||
if (typeof header === "string" && header.length > 0) {
|
||||
return header;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Express Request 提取 traceId (由 api-gateway 注入 x-request-id 头).
|
||||
*/
|
||||
export function extractTraceIdFromRequest(request: Request): string {
|
||||
const header = request.headers["x-request-id"];
|
||||
return typeof header === "string" && header.length > 0 ? header : "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 x-user-roles 头 (列表, 逗号分隔).
|
||||
*/
|
||||
export function extractUserRolesFromRequest(request: Request): string[] {
|
||||
const header = request.headers["x-user-roles"];
|
||||
if (typeof header === "string" && header.length > 0) {
|
||||
return header.split(",").map((r) => r.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制 userId = requestedStudentId (B4 自我越权防御场景 A).
|
||||
*
|
||||
* 学生只能查/操作自己的数据. 在 Resolver 内调用:
|
||||
* assertOwnData(userId, args.studentId);
|
||||
*
|
||||
* @param userId JWT 中的 userId (从 x-user-id 头)
|
||||
* @param requestedStudentId 请求参数中的 studentId (args / input)
|
||||
* @throws ForbiddenResourceError 当 requestedStudentId ≠ userId
|
||||
*/
|
||||
export function assertOwnData(
|
||||
userId: string,
|
||||
requestedStudentId?: string | null,
|
||||
): void {
|
||||
if (env.DEV_MODE) {
|
||||
return;
|
||||
}
|
||||
if (requestedStudentId && requestedStudentId !== userId) {
|
||||
logger.warn(
|
||||
{ userId, requestedStudentId },
|
||||
"Authorization blocked: student data scope violation",
|
||||
);
|
||||
throw new ForbiddenResourceError(
|
||||
"Students can only access their own data",
|
||||
{ requested: requestedStudentId, actual: userId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 JWT userId 与 body userId 一致 (B4 场景 B).
|
||||
*
|
||||
* 用于 Mutation 输入校验: 如 submitHomework input 内 studentId 必须 = JWT userId.
|
||||
*
|
||||
* @param userId JWT 中的 userId
|
||||
* @param bodyUserId 请求 body 中的 userId 字段
|
||||
* @throws IdentityMismatchError 当 bodyUserId ≠ userId
|
||||
*/
|
||||
export function assertIdentityMatch(
|
||||
userId: string,
|
||||
bodyUserId?: string | null,
|
||||
): void {
|
||||
if (env.DEV_MODE) {
|
||||
return;
|
||||
}
|
||||
if (bodyUserId && bodyUserId !== userId) {
|
||||
logger.warn(
|
||||
{ userId, bodyUserId },
|
||||
"Authorization blocked: identity mismatch",
|
||||
);
|
||||
throw new IdentityMismatchError(
|
||||
"JWT userId does not match request body userId",
|
||||
{ jwt: userId, body: bodyUserId },
|
||||
);
|
||||
}
|
||||
}
|
||||
16
services/student-bff/src/student/push/push-gateway.module.ts
Normal file
16
services/student-bff/src/student/push/push-gateway.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* PushGatewayModule - push-gateway 推送模块.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - workline §5.5 (P5.5 push-gateway 推送通道)
|
||||
*
|
||||
* 提供 PushGatewayService 供 EventSubscriber 和其他需要推送的场景使用.
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PushGatewayService } from "./push-gateway.service.js";
|
||||
|
||||
@Module({
|
||||
providers: [PushGatewayService],
|
||||
exports: [PushGatewayService],
|
||||
})
|
||||
export class PushGatewayModule {}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* PushGatewayService 单元测试.
|
||||
*
|
||||
* 测试覆盖:
|
||||
* - pushToStudent 正常成功
|
||||
* - pushToStudent HTTP 错误状态
|
||||
* - pushToStudent 网络错误 (软失败)
|
||||
* - 超时处理
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock env
|
||||
vi.mock("../../config/env.js", () => ({
|
||||
env: {
|
||||
PUSH_GATEWAY_URL: "http://localhost:8081",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// Mock metrics registry
|
||||
vi.mock("../../shared/observability/metrics.js", () => ({
|
||||
metricsRegistry: {
|
||||
getSingleMetric: vi.fn(() => ({
|
||||
inc: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock("../../shared/observability/logger.js", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { PushGatewayService } = await import("./push-gateway.service.js");
|
||||
|
||||
describe("PushGatewayService", () => {
|
||||
let service: InstanceType<typeof PushGatewayService>;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PushGatewayService();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return success=true when push-gateway returns 200", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const result = await service.pushToStudent(
|
||||
"u-stu-001",
|
||||
"edu.teaching.homework.graded",
|
||||
"homework.graded",
|
||||
{ homeworkId: "hw-001", grade: 90 },
|
||||
"2026-07-10T10:00:00Z",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:8081/push/user/u-stu-001",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return success=false when push-gateway returns non-OK status", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const result = await service.pushToStudent(
|
||||
"u-stu-001",
|
||||
"edu.notification.sent",
|
||||
"notification.sent",
|
||||
{ notificationId: "n-001" },
|
||||
"2026-07-10T10:00:00Z",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return success=false with error message when fetch throws (soft failure)", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
|
||||
const result = await service.pushToStudent(
|
||||
"u-stu-001",
|
||||
"edu.teaching.exam.published",
|
||||
"exam.published",
|
||||
{ examId: "e-001" },
|
||||
"2026-07-10T10:00:00Z",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.error).toBe("ECONNREFUSED");
|
||||
});
|
||||
|
||||
it("should return success=false when fetch throws AbortError (timeout)", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("The operation was aborted due to timeout"));
|
||||
|
||||
const result = await service.pushToStudent(
|
||||
"u-stu-001",
|
||||
"edu.teaching.grade.recorded",
|
||||
"grade.recorded",
|
||||
{ gradeId: "g-001" },
|
||||
"2026-07-10T10:00:00Z",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("aborted");
|
||||
});
|
||||
|
||||
it("should serialize message body as JSON", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
await service.pushToStudent(
|
||||
"u-stu-001",
|
||||
"edu.teaching.homework.assigned",
|
||||
"homework.assigned",
|
||||
{ homeworkId: "hw-001", title: "Math Chapter 3" },
|
||||
"2026-07-10T10:00:00Z",
|
||||
);
|
||||
|
||||
const callArgs = fetchMock.mock.calls[0];
|
||||
const body = JSON.parse(callArgs[1].body as string);
|
||||
expect(body.type).toBe("homework.assigned");
|
||||
expect(body.topic).toBe("edu.teaching.homework.assigned");
|
||||
expect(body.payload).toEqual({ homeworkId: "hw-001", title: "Math Chapter 3" });
|
||||
expect(body.timestamp).toBe("2026-07-10T10:00:00Z");
|
||||
});
|
||||
});
|
||||
111
services/student-bff/src/student/push/push-gateway.service.ts
Normal file
111
services/student-bff/src/student/push/push-gateway.service.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* PushGatewayService - push-gateway 推送通道封装.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - workline §5.5 (P5.5 push-gateway 推送通道)
|
||||
* - president-final-rulings §2.4 (软失败: 推送失败不阻塞主流程)
|
||||
*
|
||||
* 职责:
|
||||
* 1. 封装 push-gateway HTTP 调用 (POST /push/user/:userId)
|
||||
* 2. 超时控制 (3s AbortSignal.timeout)
|
||||
* 3. 指标记录 (student_bff_event_pushed_total)
|
||||
* 4. 软失败处理 (失败仅 warn 日志, 不抛异常)
|
||||
*
|
||||
* 消费方:
|
||||
* - EventSubscriberService: Kafka 事件消费后推送
|
||||
* - 未来可直接由 resolver 调用 (如主动推送通知)
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { metricsRegistry } from "../../shared/observability/metrics.js";
|
||||
|
||||
/**
|
||||
* 推送消息结构.
|
||||
*/
|
||||
export interface PushMessage {
|
||||
type: string;
|
||||
topic: string;
|
||||
payload: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* push-gateway 推送结果.
|
||||
*/
|
||||
export interface PushResult {
|
||||
success: boolean;
|
||||
status: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PushGatewayService {
|
||||
/**
|
||||
* 推送消息给指定学生.
|
||||
*
|
||||
* 调用 push-gateway POST /push/user/:userId,
|
||||
* 失败软处理 (president §2.4), 不抛异常.
|
||||
*
|
||||
* @param studentId 学生 ID
|
||||
* @param topic Kafka topic 名
|
||||
* @param eventType 事件类型
|
||||
* @param payload 事件载荷
|
||||
* @param timestamp 事件时间戳
|
||||
* @returns PushResult 推送结果
|
||||
*/
|
||||
async pushToStudent(
|
||||
studentId: string,
|
||||
topic: string,
|
||||
eventType: string,
|
||||
payload: unknown,
|
||||
timestamp: string,
|
||||
): Promise<PushResult> {
|
||||
const message: PushMessage = {
|
||||
type: eventType,
|
||||
topic,
|
||||
payload,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${env.PUSH_GATEWAY_URL}/push/user/${studentId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(message),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
},
|
||||
);
|
||||
|
||||
const pushStatus = response.ok ? "success" : `http_${response.status}`;
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_event_pushed_total")
|
||||
?.inc({ topic, push_status: pushStatus });
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
{ studentId, topic, status: response.status },
|
||||
"Push-gateway returned non-OK status",
|
||||
);
|
||||
}
|
||||
|
||||
return { success: response.ok, status: response.status };
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, studentId, topic },
|
||||
"Push-gateway push failed (soft failure)",
|
||||
);
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_event_pushed_total")
|
||||
?.inc({ topic, push_status: "error" });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: 0,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
138
services/student-bff/src/student/resolvers/ai-stream.resolver.ts
Normal file
138
services/student-bff/src/student/resolvers/ai-stream.resolver.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* AI Stream Resolver - SSE 流式 AI 答疑 (P5).
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B1 (GraphQL Yoga 原生支持 SSE Subscription)
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 ai.StreamChat)
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御)
|
||||
* - workline §5.2 (P5.2 ai gRPC client chat/streamChat SSE)
|
||||
*
|
||||
* 实现: GraphQL Subscription → AsyncIterator, 透传 ai.StreamChat gRPC 流.
|
||||
* GraphQL Yoga 通过 SSE 传输协议将 Subscription 事件推给客户端.
|
||||
*
|
||||
* 客户端使用:
|
||||
* subscription AiStreamChat($input: AIStreamChatInput!) {
|
||||
* aiStreamChat(input: $input) { content done model usage }
|
||||
* }
|
||||
*
|
||||
* 传输协议: SSE (text/event-stream), Yoga 自动处理.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { UnauthorizedError, ValidationError } from "../../shared/errors/application-error.js";
|
||||
|
||||
const AIStreamChatInputSchema = z.object({
|
||||
messages: z
|
||||
.array(
|
||||
z.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string().min(1).max(8000),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(20),
|
||||
model: z
|
||||
.enum(["gpt-4o-mini", "baichuan-53b", "local-qwen-7b"])
|
||||
.default("gpt-4o-mini"),
|
||||
context: z
|
||||
.object({
|
||||
subject: z.string().optional(),
|
||||
knowledgePointId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* AI 流式响应 chunk 结构 (对齐 ai.StreamChat gRPC stream).
|
||||
*/
|
||||
interface AIStreamChunk {
|
||||
content: string;
|
||||
done: boolean;
|
||||
model?: string;
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const aiStreamResolvers = {
|
||||
Subscription: {
|
||||
/**
|
||||
* aiStreamChat: AI 答疑流式响应.
|
||||
*
|
||||
* 通过 GraphQL Subscription + SSE 传输,
|
||||
* 透传 ai.StreamChat gRPC server-streaming RPC.
|
||||
*
|
||||
* @permission STUDENT_AI_CHAT
|
||||
* @dataScope OWN
|
||||
*/
|
||||
aiStreamChat: {
|
||||
subscribe(
|
||||
_parent: unknown,
|
||||
args: { input: unknown },
|
||||
ctx: StudentBffContext,
|
||||
): AsyncIterable<{ aiStreamChat: AIStreamChunk }> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const parseResult = AIStreamChatInputSchema.safeParse(args.input);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError(
|
||||
"Invalid aiStreamChat input",
|
||||
parseResult.error.flatten(),
|
||||
);
|
||||
}
|
||||
const input = parseResult.data;
|
||||
|
||||
return (async function* (): AsyncGenerator<{ aiStreamChat: AIStreamChunk }> {
|
||||
try {
|
||||
// 调用 ai.StreamChat (gRPC server-streaming)
|
||||
// DownstreamClient.callStream 返回 AsyncIterable
|
||||
const stream = ctx.downstream.callStream("ai", "StreamChat", {
|
||||
userId: ctx.userId,
|
||||
messages: input.messages,
|
||||
model: input.model,
|
||||
context: input.context,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
timeoutMs: 60000, // 流式调用 60s 超时
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const typed = chunk as {
|
||||
content?: string;
|
||||
done?: boolean;
|
||||
model?: string;
|
||||
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||
};
|
||||
|
||||
yield {
|
||||
aiStreamChat: {
|
||||
content: typed.content ?? "",
|
||||
done: typed.done ?? false,
|
||||
model: typed.model,
|
||||
usage: typed.usage,
|
||||
},
|
||||
};
|
||||
|
||||
if (typed.done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 流式错误: 发送一个 done=true 的错误 chunk, 客户端关闭流
|
||||
yield {
|
||||
aiStreamChat: {
|
||||
content: `[stream error] ${(err as Error).message}`,
|
||||
done: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
93
services/student-bff/src/student/resolvers/ai.resolver.ts
Normal file
93
services/student-bff/src/student/resolvers/ai.resolver.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* AI Resolver - aiChat Query/Mutation (P5 扩展).
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql (待补充 aiChat Query)
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 ai)
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御)
|
||||
* - president-final-rulings §2.3 (跨阶段扩展例外)
|
||||
*
|
||||
* AI 答疑流式响应 (StreamChat) 通过 SSE 端点单独实现 (P5),
|
||||
* 本 Resolver 仅处理同步 Chat.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "../../shared/errors/application-error.js";
|
||||
|
||||
const AIChatInputSchema = z.object({
|
||||
messages: z
|
||||
.array(
|
||||
z.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string().min(1).max(8000),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(20),
|
||||
model: z
|
||||
.enum(["gpt-4o-mini", "baichuan-53b", "local-qwen-7b"])
|
||||
.default("gpt-4o-mini"),
|
||||
context: z
|
||||
.object({
|
||||
subject: z.string().optional(),
|
||||
knowledgePointId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const aiResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* aiChat: AI 答疑 (同步).
|
||||
* @permission STUDENT_AI_CHAT
|
||||
* @dataScope OWN
|
||||
*
|
||||
* 实际为 Mutation (写操作, 消耗 AI 配额), 但 schema 设计为 Query 便于前端 GET 缓存.
|
||||
* 后续如需限流, 改为 Mutation.
|
||||
*/
|
||||
async aiChat(
|
||||
_parent: unknown,
|
||||
args: { input: unknown },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const parseResult = AIChatInputSchema.safeParse(args.input);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError(
|
||||
"Invalid aiChat input",
|
||||
parseResult.error.flatten(),
|
||||
);
|
||||
}
|
||||
const input = parseResult.data;
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("ai", "Chat", {
|
||||
userId: ctx.userId,
|
||||
messages: input.messages,
|
||||
model: input.model,
|
||||
context: input.context,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
// AI 调用可能耗时较长, 延长超时
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to chat with AI: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
107
services/student-bff/src/student/resolvers/analytics.resolver.ts
Normal file
107
services/student-bff/src/student/resolvers/analytics.resolver.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Analytics Resolver - myWeakness / myTrend Queries (P4 扩展).
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myWeakness / myTrend / studentDashboard
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 data-ana)
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御, 学情诊断仅本人可查)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
* - president-final-rulings §2.3 (跨阶段扩展例外)
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
import { assertOwnData } from "../guards/authorization.guard.js";
|
||||
|
||||
export const analyticsResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myWeakness: 学情诊断 (薄弱知识点).
|
||||
* @permission STUDENT_ANALYTICS_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myWeakness(
|
||||
_parent: unknown,
|
||||
args: { studentId?: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
assertOwnData(ctx.userId, args.studentId);
|
||||
|
||||
const cached = await ctx.redis.get<unknown>("analytics:weakness", ctx.userId);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("data-ana", "GetStudentWeakness", {
|
||||
studentId: ctx.userId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
await ctx.redis.set(
|
||||
"analytics:weakness",
|
||||
result,
|
||||
CacheTTL.ANALYTICS_WEAKNESS,
|
||||
ctx.userId,
|
||||
);
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch weakness: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* myTrend: 学习趋势.
|
||||
* @permission STUDENT_ANALYTICS_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myTrend(
|
||||
_parent: unknown,
|
||||
args: { studentId?: string; range?: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
assertOwnData(ctx.userId, args.studentId);
|
||||
|
||||
const range = args.range ?? "30d";
|
||||
const cacheKey = `${ctx.userId}:${range}`;
|
||||
const cached = await ctx.redis.get<unknown>("analytics:trend", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("data-ana", "GetLearningTrend", {
|
||||
studentId: ctx.userId,
|
||||
range,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
await ctx.redis.set("analytics:trend", result, CacheTTL.ANALYTICS_TREND, cacheKey);
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch trend: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
120
services/student-bff/src/student/resolvers/auth.resolver.ts
Normal file
120
services/student-bff/src/student/resolvers/auth.resolver.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Auth Resolver - currentUser Query.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.currentUser
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 iam)
|
||||
* - coord-final-decisions §2 B3 (BFF 豁免 @RequirePermission, 仅校验 x-user-id)
|
||||
*
|
||||
* 聚合: iam.GetUserInfo + iam.GetEffectivePermissions + iam.GetViewports
|
||||
* 并行调用 (Promise.allSettled), 部分失败走降级模式方案 B.
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import type { DownstreamResponse } from "@edu/shared-ts/bff";
|
||||
import { ok, fail, degraded, DegradedReason } from "../../shared/action-state.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
|
||||
export const authResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* currentUser: 获取当前学生信息 + 权限 + 视口.
|
||||
* @permission STUDENT_DASHBOARD_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async currentUser(
|
||||
_parent: unknown,
|
||||
_args: unknown,
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const results = await ctx.downstream.callAll([
|
||||
{
|
||||
service: "iam",
|
||||
method: "GetUserInfo",
|
||||
request: { userId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "iam",
|
||||
method: "GetEffectivePermissions",
|
||||
request: { userId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "iam",
|
||||
method: "GetViewports",
|
||||
request: { userId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
] as const);
|
||||
|
||||
const [userInfoResp, permsResp, viewportsResp] = results as [
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
];
|
||||
|
||||
const degradedFields: string[] = [];
|
||||
if (!userInfoResp.success) degradedFields.push("user");
|
||||
if (!permsResp.success) degradedFields.push("permissions");
|
||||
if (!viewportsResp.success) degradedFields.push("viewport");
|
||||
|
||||
// 必需字段失败时返回错误
|
||||
if (!userInfoResp.success) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch user info: ${userInfoResp.error.message}`,
|
||||
{
|
||||
details: {
|
||||
service: userInfoResp.error.service,
|
||||
method: userInfoResp.error.method,
|
||||
traceId: ctx.traceId,
|
||||
},
|
||||
i18nKey: "error.bffStudent.bad_gateway",
|
||||
traceId: ctx.traceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const userInfo = userInfoResp.data as {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
roles: string[];
|
||||
};
|
||||
const permissions = permsResp.success
|
||||
? (permsResp.data as { permissions: string[] }).permissions
|
||||
: [];
|
||||
const viewports = viewportsResp.success
|
||||
? (viewportsResp.data as { navigation: unknown[]; dataScope: unknown })
|
||||
: { navigation: [], dataScope: {} };
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: userInfo.userId,
|
||||
email: userInfo.email,
|
||||
name: userInfo.name,
|
||||
avatar: userInfo.avatar,
|
||||
roles: userInfo.roles,
|
||||
},
|
||||
permissions,
|
||||
viewport: viewports,
|
||||
};
|
||||
|
||||
if (degradedFields.length > 0) {
|
||||
return degraded(
|
||||
data,
|
||||
DegradedReason.DOWNSTREAM_PARTIAL_FAILURE,
|
||||
degradedFields,
|
||||
{ traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Classes Resolver - myClasses Query.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myClasses
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 core-edu)
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
|
||||
export const classesResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myClasses: 我所在班级列表.
|
||||
* @permission STUDENT_DASHBOARD_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myClasses(
|
||||
_parent: unknown,
|
||||
_args: unknown,
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "GetClassesByStudent", {
|
||||
studentId: ctx.userId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { classes: unknown[] };
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch classes: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
133
services/student-bff/src/student/resolvers/content.resolver.ts
Normal file
133
services/student-bff/src/student/resolvers/content.resolver.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Content Resolver - textbooks / chapters / learningPath Queries (P4 扩展).
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.textbooks / chapters / learningPath
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 content)
|
||||
* - president-final-rulings §2.3 (跨阶段扩展例外: 新增下游 gRPC 调用允许)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
*
|
||||
* P3 时下游 content 未就绪, env.MOCK_UPSTREAM=true 返回 mock; 上游就绪后切换真实调用.
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
|
||||
export const contentResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* textbooks: 教材列表.
|
||||
* @permission STUDENT_CONTENT_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async textbooks(
|
||||
_parent: unknown,
|
||||
args: { gradeId?: string; subjectId?: string; page?: number; pageSize?: number },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const cacheKey = `${args.gradeId ?? "all"}:${args.subjectId ?? "all"}`;
|
||||
const cached = await ctx.redis.get<unknown>("textbooks", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("content", "ListTextbooks", {
|
||||
gradeId: args.gradeId,
|
||||
subjectId: args.subjectId,
|
||||
page: args.page ?? 1,
|
||||
pageSize: Math.min(args.pageSize ?? 20, 50),
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { textbooks: unknown[] };
|
||||
await ctx.redis.set("textbooks", data, CacheTTL.TEXTBOOKS, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch textbooks: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* chapters: 教材章节树.
|
||||
* @permission STUDENT_CONTENT_READ
|
||||
*/
|
||||
async chapters(
|
||||
_parent: unknown,
|
||||
args: { textbookId: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const cached = await ctx.redis.get<unknown>("chapters", args.textbookId);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("content", "ListChapters", {
|
||||
textbookId: args.textbookId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { chapters: unknown[] };
|
||||
await ctx.redis.set("chapters", data, CacheTTL.CHAPTERS, args.textbookId);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch chapters: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* learningPath: 个性化学习路径 (基于学情诊断).
|
||||
* @permission STUDENT_CONTENT_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async learningPath(
|
||||
_parent: unknown,
|
||||
args: { knowledgePointId: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("content", "GetLearningPath", {
|
||||
studentId: ctx.userId,
|
||||
knowledgePointId: args.knowledgePointId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch learning path: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
158
services/student-bff/src/student/resolvers/dashboard.resolver.ts
Normal file
158
services/student-bff/src/student/resolvers/dashboard.resolver.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Dashboard Resolver - studentDashboard Query.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.studentDashboard
|
||||
* - president-final-rulings §2.8 (Dashboard Query Resolver P2 即定型, 内部按阶段扩展)
|
||||
* - president-final-rulings §2.6 (降级模式方案 B: data 内 degraded=true)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
*
|
||||
* 聚合:
|
||||
* - iam.GetUserInfo (用户基础信息)
|
||||
* - core-edu.ListHomeworkByStudent (待办作业)
|
||||
* - core-edu.ListExamsByClass (即将到来的考试)
|
||||
* - core-edu.ListGradesByStudent (最近一次成绩)
|
||||
* - data-ana.GetStudentDashboard (P4 学情汇总, P3 返回 null)
|
||||
* - msg.ListNotifications (未读通知数, P5)
|
||||
*
|
||||
* 缓存: student:dashboard:{userId} TTL 15s
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import type { DownstreamResponse } from "@edu/shared-ts/bff";
|
||||
import { ok, fail, degraded, DegradedReason } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
|
||||
export const dashboardResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* studentDashboard: 学生首页聚合.
|
||||
* @permission STUDENT_DASHBOARD_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async studentDashboard(
|
||||
_parent: unknown,
|
||||
_args: unknown,
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
// 缓存命中检查
|
||||
const cacheKey = ["dashboard", ctx.userId] as const;
|
||||
const cached = await ctx.redis.get<unknown>("dashboard", ctx.userId);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
// 并行调用下游 (Promise.allSettled 容错)
|
||||
const results = await ctx.downstream.callAll([
|
||||
{
|
||||
service: "iam",
|
||||
method: "GetUserInfo",
|
||||
request: { userId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "core-edu",
|
||||
method: "ListHomeworkByStudent",
|
||||
request: { studentId: ctx.userId, status: "pending" },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "core-edu",
|
||||
method: "ListExamsByClass",
|
||||
request: { studentId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "core-edu",
|
||||
method: "ListGradesByStudent",
|
||||
request: { studentId: ctx.userId, page: 1, pageSize: 1 },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
{
|
||||
service: "data-ana",
|
||||
method: "GetStudentDashboard",
|
||||
request: { studentId: ctx.userId },
|
||||
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } },
|
||||
},
|
||||
] as const);
|
||||
|
||||
const [userInfoResp, homeworkResp, examsResp, gradesResp, dashboardAnaResp] =
|
||||
results as [
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
];
|
||||
|
||||
// 必需字段失败检查
|
||||
if (!userInfoResp.success) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch user info: ${userInfoResp.error.message}`,
|
||||
{
|
||||
i18nKey: "error.bffStudent.bad_gateway",
|
||||
traceId: ctx.traceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const userInfo = userInfoResp.data as {
|
||||
userId: string;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
classId: string;
|
||||
className: string;
|
||||
grade: string;
|
||||
};
|
||||
|
||||
// 容错聚合: 各字段独立降级
|
||||
const degradedFields: string[] = [];
|
||||
const pendingHomework = homeworkResp.success
|
||||
? (homeworkResp.data as { homework: unknown[] }).homework ?? []
|
||||
: (degradedFields.push("pendingHomework"), []);
|
||||
const upcomingExams = examsResp.success
|
||||
? (examsResp.data as { exams: unknown[] }).exams ?? []
|
||||
: (degradedFields.push("upcomingExams"), []);
|
||||
const lastGrade = gradesResp.success
|
||||
? ((gradesResp.data as { grades: unknown[] }).grades ?? [])[0] ?? null
|
||||
: (degradedFields.push("lastGrade"), null);
|
||||
const analyticsSummary = dashboardAnaResp.success
|
||||
? dashboardAnaResp.data
|
||||
: (degradedFields.push("analyticsSummary"), null);
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: userInfo.userId,
|
||||
name: userInfo.name,
|
||||
avatar: userInfo.avatar,
|
||||
grade: userInfo.grade,
|
||||
class: { id: userInfo.classId, name: userInfo.className },
|
||||
},
|
||||
pendingHomework,
|
||||
upcomingExams,
|
||||
lastGrade,
|
||||
analyticsSummary,
|
||||
unreadNotifications: 0, // P5 msg 服务启用后填充
|
||||
};
|
||||
|
||||
// 写缓存
|
||||
await ctx.redis.set("dashboard", data, CacheTTL.DASHBOARD, ctx.userId);
|
||||
|
||||
if (degradedFields.length > 0) {
|
||||
return degraded(
|
||||
data,
|
||||
DegradedReason.DOWNSTREAM_PARTIAL_FAILURE,
|
||||
degradedFields,
|
||||
{ traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
},
|
||||
},
|
||||
};
|
||||
58
services/student-bff/src/student/resolvers/exams.resolver.ts
Normal file
58
services/student-bff/src/student/resolvers/exams.resolver.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Exams Resolver - myExams Query.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myExams
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 core-edu)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
|
||||
export const examsResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myExams: 即将到来的考试列表.
|
||||
* @permission STUDENT_EXAM_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myExams(
|
||||
_parent: unknown,
|
||||
args: { status?: string; classId?: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const cacheKey = `${ctx.userId}:${args.classId ?? "all"}:${args.status ?? "all"}`;
|
||||
const cached = await ctx.redis.get<unknown>("exams", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "ListExamsByClass", {
|
||||
studentId: ctx.userId,
|
||||
classId: args.classId,
|
||||
status: args.status ?? "upcoming",
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { exams: unknown[] };
|
||||
await ctx.redis.set("exams", data, CacheTTL.EXAMS, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch exams: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Grades Resolver - myGrades Query.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myGrades
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御, 学生只能查自己成绩)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
*/
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import { UnauthorizedError } from "../../shared/errors/application-error.js";
|
||||
import { assertOwnData } from "../guards/authorization.guard.js";
|
||||
|
||||
export const gradesResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myGrades: 我的成绩列表.
|
||||
* @permission STUDENT_GRADE_READ
|
||||
* @dataScope OWN (B4 强制 studentId = userId)
|
||||
*/
|
||||
async myGrades(
|
||||
_parent: unknown,
|
||||
args: {
|
||||
studentId?: string;
|
||||
subject?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
// B4 越权防御: args.studentId 必须 = JWT userId
|
||||
assertOwnData(ctx.userId, args.studentId);
|
||||
|
||||
const page = args.page ?? 1;
|
||||
const pageSize = Math.min(args.pageSize ?? 20, 50);
|
||||
const cacheKey = `${ctx.userId}:${page}:${args.subject ?? "all"}`;
|
||||
|
||||
const cached = await ctx.redis.get<unknown>("grades", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "ListGradesByStudent", {
|
||||
studentId: ctx.userId,
|
||||
subject: args.subject,
|
||||
page,
|
||||
pageSize,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { grades: unknown[]; totalCount: number };
|
||||
await ctx.redis.set("grades", data, CacheTTL.GRADES, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch grades: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Homework Resolver 单元测试.
|
||||
*
|
||||
* 测试覆盖:
|
||||
* - myHomework Query: 缓存命中 / 缓存未命中 / 下游失败
|
||||
* - submitHomework Mutation: 正常提交 / Zod 校验失败 / 越权防御 / 下游失败 / 缓存失效
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { homeworkResolvers } from "./homework.resolver.js";
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { UnauthorizedError, ValidationError, ForbiddenResourceError } from "../../shared/errors/application-error.js";
|
||||
|
||||
// Mock env to disable DEV_MODE
|
||||
vi.mock("../../config/env.js", () => ({
|
||||
env: { DEV_MODE: false, NODE_ENV: "test" },
|
||||
}));
|
||||
|
||||
function mockContext(overrides: Partial<StudentBffContext> = {}): StudentBffContext {
|
||||
const downstream = {
|
||||
call: vi.fn(),
|
||||
callStream: vi.fn(),
|
||||
callAll: vi.fn(),
|
||||
};
|
||||
const redis = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
invalidate: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
|
||||
ping: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const dataLoaders = {} as never;
|
||||
return {
|
||||
userId: "u-stu-001",
|
||||
traceId: "trace-test-001",
|
||||
userRoles: ["student"],
|
||||
downstream: downstream as never,
|
||||
redis: redis as never,
|
||||
dataLoaders,
|
||||
requestId: "trace-test-001",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("homeworkResolvers", () => {
|
||||
let ctx: StudentBffContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = mockContext();
|
||||
});
|
||||
|
||||
describe("Query.myHomework", () => {
|
||||
it("should throw UnauthorizedError when userId is null", async () => {
|
||||
ctx.userId = null;
|
||||
await expect(
|
||||
homeworkResolvers.Query.myHomework(null, {}, ctx),
|
||||
).rejects.toThrow(UnauthorizedError);
|
||||
});
|
||||
|
||||
it("should return cached data when cache hits", async () => {
|
||||
const cachedData = { homework: [{ id: "hw-001" }] };
|
||||
ctx.redis.get = vi.fn().mockResolvedValue(cachedData);
|
||||
|
||||
const result = await homeworkResolvers.Query.myHomework(null, {}, ctx);
|
||||
expect(result.success).toBe(true);
|
||||
expect((result as { data: unknown }).data).toEqual(cachedData);
|
||||
expect(ctx.downstream.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call downstream when cache misses", async () => {
|
||||
const downstreamData = { homework: [{ id: "hw-001", title: "Math HW" }] };
|
||||
ctx.downstream.call = vi.fn().mockResolvedValue(downstreamData);
|
||||
|
||||
const result = await homeworkResolvers.Query.myHomework(
|
||||
null,
|
||||
{ status: "ASSIGNED", classId: "c-001" },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(ctx.downstream.call).toHaveBeenCalledWith(
|
||||
"core-edu",
|
||||
"ListHomeworkByStudent",
|
||||
{ studentId: "u-stu-001", status: "ASSIGNED", classId: "c-001" },
|
||||
expect.objectContaining({
|
||||
traceId: "trace-test-001",
|
||||
metadata: { "x-user-id": "u-stu-001" },
|
||||
}),
|
||||
);
|
||||
expect(ctx.redis.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return fail response when downstream fails", async () => {
|
||||
ctx.downstream.call = vi.fn().mockRejectedValue(new Error("gRPC unavailable"));
|
||||
|
||||
const result = await homeworkResolvers.Query.myHomework(null, {}, ctx);
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as { error: { code: string } }).error.code).toBe("BFF_STUDENT_BAD_GATEWAY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mutation.submitHomework", () => {
|
||||
const validInput = {
|
||||
homeworkId: "hw-001",
|
||||
studentId: "u-stu-001",
|
||||
answers: [
|
||||
{ questionId: "q-001", content: "My answer" },
|
||||
],
|
||||
};
|
||||
|
||||
it("should throw UnauthorizedError when userId is null", async () => {
|
||||
ctx.userId = null;
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(null, { input: validInput }, ctx),
|
||||
).rejects.toThrow(UnauthorizedError);
|
||||
});
|
||||
|
||||
it("should throw ValidationError when input is invalid", async () => {
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: { homeworkId: "", answers: [] } },
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenResourceError when studentId does not match userId (B4)", async () => {
|
||||
const maliciousInput = {
|
||||
...validInput,
|
||||
studentId: "u-stu-002",
|
||||
};
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(null, { input: maliciousInput }, ctx),
|
||||
).rejects.toThrow(ForbiddenResourceError);
|
||||
});
|
||||
|
||||
it("should submit successfully when studentId matches userId", async () => {
|
||||
const submitResult = {
|
||||
submissionId: "sub-001",
|
||||
homeworkId: "hw-001",
|
||||
submittedAt: "2026-07-10T10:00:00Z",
|
||||
status: "SUBMITTED",
|
||||
};
|
||||
ctx.downstream.call = vi.fn().mockResolvedValue(submitResult);
|
||||
|
||||
const result = await homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: validInput },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(ctx.downstream.call).toHaveBeenCalledWith(
|
||||
"core-edu",
|
||||
"SubmitHomework",
|
||||
{
|
||||
homeworkId: "hw-001",
|
||||
studentId: "u-stu-001",
|
||||
answers: validInput.answers,
|
||||
},
|
||||
expect.objectContaining({
|
||||
metadata: { "x-user-id": "u-stu-001" },
|
||||
}),
|
||||
);
|
||||
expect(ctx.redis.invalidate).toHaveBeenCalledWith("homework", "u-stu-001");
|
||||
expect(ctx.redis.invalidate).toHaveBeenCalledWith("dashboard", "u-stu-001");
|
||||
});
|
||||
|
||||
it("should submit successfully when studentId is not provided in input", async () => {
|
||||
const inputWithoutStudentId = {
|
||||
homeworkId: "hw-001",
|
||||
answers: [{ questionId: "q-001", content: "Answer" }],
|
||||
};
|
||||
ctx.downstream.call = vi.fn().mockResolvedValue({
|
||||
submissionId: "sub-002",
|
||||
homeworkId: "hw-001",
|
||||
submittedAt: "2026-07-10T10:00:00Z",
|
||||
status: "SUBMITTED",
|
||||
});
|
||||
|
||||
const result = await homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: inputWithoutStudentId },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should return fail response when downstream fails", async () => {
|
||||
ctx.downstream.call = vi.fn().mockRejectedValue(new Error("Submission failed"));
|
||||
|
||||
const result = await homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: validInput },
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect((result as { error: { code: string } }).error.code).toBe("BFF_STUDENT_BAD_GATEWAY");
|
||||
});
|
||||
|
||||
it("should validate answer content max length", async () => {
|
||||
const longContentInput = {
|
||||
homeworkId: "hw-001",
|
||||
studentId: "u-stu-001",
|
||||
answers: [{ questionId: "q-001", content: "x".repeat(10001) }],
|
||||
};
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(null, { input: longContentInput }, ctx),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("should validate attachments are valid URLs", async () => {
|
||||
const invalidAttachmentInput = {
|
||||
homeworkId: "hw-001",
|
||||
studentId: "u-stu-001",
|
||||
answers: [
|
||||
{
|
||||
questionId: "q-001",
|
||||
content: "Answer",
|
||||
attachments: ["not-a-url"],
|
||||
},
|
||||
],
|
||||
};
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(null, { input: invalidAttachmentInput }, ctx),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
services/student-bff/src/student/resolvers/homework.resolver.ts
Normal file
142
services/student-bff/src/student/resolvers/homework.resolver.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Homework Resolver - myHomework Query + submitHomework Mutation.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myHomework + Mutation.submitHomework
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御, 学生只能查/操作自己数据)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
* - president-final-rulings §2.9 (越权防御 P3 实现方式: 方案 D)
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "../../shared/errors/application-error.js";
|
||||
import { assertOwnData } from "../guards/authorization.guard.js";
|
||||
|
||||
/**
|
||||
* 提交作业输入 Zod schema (G7 Zod 验证).
|
||||
*/
|
||||
const SubmitHomeworkInputSchema = z.object({
|
||||
homeworkId: z.string().min(1),
|
||||
studentId: z.string().min(1).optional(),
|
||||
answers: z
|
||||
.array(
|
||||
z.object({
|
||||
questionId: z.string().min(1),
|
||||
content: z.string().min(1).max(10000),
|
||||
attachments: z.array(z.string().url()).max(5).optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
});
|
||||
|
||||
export const homeworkResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myHomework: 我的作业列表.
|
||||
* @permission STUDENT_HOMEWORK_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myHomework(
|
||||
_parent: unknown,
|
||||
args: { status?: string; classId?: string },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
// 缓存命中
|
||||
const cacheKey = ctx.userId + (args.classId ? `:${args.classId}` : "");
|
||||
const cached = await ctx.redis.get<unknown>("homework", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "ListHomeworkByStudent", {
|
||||
studentId: ctx.userId,
|
||||
status: args.status,
|
||||
classId: args.classId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { homework: unknown[] };
|
||||
await ctx.redis.set("homework", data, CacheTTL.HOMEWORK, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch homework: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
/**
|
||||
* submitHomework: 提交作业.
|
||||
* @permission STUDENT_HOMEWORK_SUBMIT
|
||||
* @dataScope OWN (B4 强制 studentId = userId)
|
||||
*/
|
||||
async submitHomework(
|
||||
_parent: unknown,
|
||||
args: { input: unknown },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
// G7 Zod 校验
|
||||
const parseResult = SubmitHomeworkInputSchema.safeParse(args.input);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError(
|
||||
"Invalid submitHomework input",
|
||||
parseResult.error.flatten(),
|
||||
);
|
||||
}
|
||||
const input = parseResult.data;
|
||||
|
||||
// B4 自我越权防御: body 中 studentId 必须 = JWT userId
|
||||
assertOwnData(ctx.userId, input.studentId);
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "SubmitHomework", {
|
||||
homeworkId: input.homeworkId,
|
||||
studentId: ctx.userId,
|
||||
answers: input.answers,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
// 失效相关缓存
|
||||
await ctx.redis.invalidate("homework", ctx.userId);
|
||||
await ctx.redis.invalidate("dashboard", ctx.userId);
|
||||
|
||||
const data = result as {
|
||||
submissionId: string;
|
||||
homeworkId: string;
|
||||
submittedAt: string;
|
||||
status: string;
|
||||
};
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to submit homework: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
56
services/student-bff/src/student/resolvers/index.ts
Normal file
56
services/student-bff/src/student/resolvers/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Student BFF Resolver 装配入口.
|
||||
*
|
||||
* 将所有 Resolver 合并为一个 GraphQL Resolver 映射表,
|
||||
* 供 GraphQL Yoga makeExecutableSchema 使用.
|
||||
*
|
||||
* Resolver 清单 (按 schema 第一版):
|
||||
* Query:
|
||||
* - currentUser (auth)
|
||||
* - studentDashboard (dashboard)
|
||||
* - myHomework (homework)
|
||||
* - myGrades (grades)
|
||||
* - myExams (exams)
|
||||
* - myClasses (classes)
|
||||
* - textbooks / chapters / learningPath (content, P4)
|
||||
* - myWeakness / myTrend (analytics, P4)
|
||||
* - myNotifications / myNotificationUnreadCount (notifications, P5)
|
||||
* - aiChat (ai, P5)
|
||||
* Mutation:
|
||||
* - submitHomework (homework)
|
||||
* - markNotificationAsRead (notifications, P5)
|
||||
* Subscription:
|
||||
* - aiStreamChat (ai-stream, P5 SSE)
|
||||
*/
|
||||
import { mergeResolvers } from "@graphql-tools/merge";
|
||||
import { authResolvers } from "./auth.resolver.js";
|
||||
import { dashboardResolvers } from "./dashboard.resolver.js";
|
||||
import { homeworkResolvers } from "./homework.resolver.js";
|
||||
import { gradesResolvers } from "./grades.resolver.js";
|
||||
import { examsResolvers } from "./exams.resolver.js";
|
||||
import { classesResolvers } from "./classes.resolver.js";
|
||||
import { contentResolvers } from "./content.resolver.js";
|
||||
import { analyticsResolvers } from "./analytics.resolver.js";
|
||||
import { notificationsResolvers } from "./notifications.resolver.js";
|
||||
import { aiResolvers } from "./ai.resolver.js";
|
||||
import { aiStreamResolvers } from "./ai-stream.resolver.js";
|
||||
|
||||
/**
|
||||
* 全部 Resolver 合并.
|
||||
*
|
||||
* 注意: 各 resolver 文件导出的对象结构为 { Query: {...}, Mutation: {...}, Subscription: {...} },
|
||||
* mergeResolvers 自动合并同名 Query/Mutation/Subscription 字段.
|
||||
*/
|
||||
export const studentBffResolvers = mergeResolvers([
|
||||
authResolvers,
|
||||
dashboardResolvers,
|
||||
homeworkResolvers,
|
||||
gradesResolvers,
|
||||
examsResolvers,
|
||||
classesResolvers,
|
||||
contentResolvers,
|
||||
analyticsResolvers,
|
||||
notificationsResolvers,
|
||||
aiResolvers,
|
||||
aiStreamResolvers,
|
||||
]);
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Notifications Resolver - myNotifications Query + markNotificationAsRead Mutation (P5 扩展).
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - student-bff.schema.graphql Query.myNotifications / myNotificationUnreadCount
|
||||
* + Mutation.markNotificationAsRead
|
||||
* - coord-final-decisions §2 B2 (gRPC 调用 msg)
|
||||
* - coord-final-decisions §2 B4 (强制自我越权防御, 通知仅本人可读/操作)
|
||||
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
|
||||
* - president-final-rulings §2.3 (跨阶段扩展例外)
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { StudentBffContext } from "../../shared/graphql/yoga.js";
|
||||
import { ok, fail } from "../../shared/action-state.js";
|
||||
import { CacheTTL } from "../../shared/cache/cache.module.js";
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "../../shared/errors/application-error.js";
|
||||
import { assertOwnData } from "../guards/authorization.guard.js";
|
||||
|
||||
const MarkNotificationReadInputSchema = z.object({
|
||||
notificationId: z.string().min(1),
|
||||
studentId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const notificationsResolvers = {
|
||||
Query: {
|
||||
/**
|
||||
* myNotifications: 消息列表.
|
||||
* @permission STUDENT_NOTIFICATION_READ
|
||||
* @dataScope OWN
|
||||
*/
|
||||
async myNotifications(
|
||||
_parent: unknown,
|
||||
args: { page?: number; pageSize?: number; unreadOnly?: boolean },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const page = args.page ?? 1;
|
||||
const pageSize = Math.min(args.pageSize ?? 20, 50);
|
||||
const cacheKey = `${ctx.userId}:${page}:${args.unreadOnly ?? false}`;
|
||||
|
||||
const cached = await ctx.redis.get<unknown>("notifications", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("msg", "ListNotifications", {
|
||||
userId: ctx.userId,
|
||||
page,
|
||||
pageSize,
|
||||
unreadOnly: args.unreadOnly ?? false,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as {
|
||||
notifications: unknown[];
|
||||
totalCount: number;
|
||||
unreadCount: number;
|
||||
};
|
||||
await ctx.redis.set("notifications", data, CacheTTL.NOTIFICATIONS, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch notifications: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* myNotificationUnreadCount: 未读通知数.
|
||||
* @permission STUDENT_NOTIFICATION_READ
|
||||
*/
|
||||
async myNotificationUnreadCount(
|
||||
_parent: unknown,
|
||||
_args: unknown,
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("msg", "GetUnreadCount", {
|
||||
userId: ctx.userId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
const data = result as { unreadCount: number };
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to fetch unread count: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
/**
|
||||
* markNotificationAsRead: 标记通知已读.
|
||||
* @permission STUDENT_NOTIFICATION_READ
|
||||
* @dataScope OWN (B4 防御 body 中 studentId)
|
||||
*/
|
||||
async markNotificationAsRead(
|
||||
_parent: unknown,
|
||||
args: { input: unknown },
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
const parseResult = MarkNotificationReadInputSchema.safeParse(args.input);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError(
|
||||
"Invalid markNotificationAsRead input",
|
||||
parseResult.error.flatten(),
|
||||
);
|
||||
}
|
||||
const input = parseResult.data;
|
||||
|
||||
// B4 越权防御
|
||||
assertOwnData(ctx.userId, input.studentId);
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("msg", "MarkNotificationAsRead", {
|
||||
notificationId: input.notificationId,
|
||||
userId: ctx.userId,
|
||||
}, {
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
});
|
||||
|
||||
// 失效通知缓存
|
||||
await ctx.redis.invalidateByPrefix(`notifications:${ctx.userId}`);
|
||||
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
"BFF_STUDENT_BAD_GATEWAY",
|
||||
`Failed to mark notification as read: ${(err as Error).message}`,
|
||||
{ i18nKey: "error.bffStudent.bad_gateway", traceId: ctx.traceId },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
48
services/student-bff/src/student/student.module.ts
Normal file
48
services/student-bff/src/student/student.module.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* StudentModule - 装配 GraphQL Yoga + Resolver.
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §2 B1 (P2 起直接 GraphQL Yoga + DataLoader)
|
||||
* - coord-final-decisions §2 B8 (复用 shared-ts DownstreamClient)
|
||||
*
|
||||
* 职责:
|
||||
* 1. 启动时创建 GraphQL Yoga 实例 (加载 schema + 装配 resolver)
|
||||
* 2. 提供 Yoga Express middleware 挂载钩子 (由 main.ts 调用)
|
||||
* 3. 不直接持有 resolver 实例 (resolver 是纯函数, 通过 context 注入依赖)
|
||||
*/
|
||||
import { Module, OnModuleInit } from "@nestjs/common";
|
||||
import { Inject } from "@nestjs/common";
|
||||
import { DownstreamClient } from "@edu/shared-ts/bff";
|
||||
import { REDIS_CLIENT } from "../shared/cache/cache.module.js";
|
||||
import type { Redis } from "ioredis";
|
||||
import { createStudentBffYoga, type StudentBffContext } from "../shared/graphql/yoga.js";
|
||||
import { studentBffResolvers } from "./resolvers/index.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
import type { YogaServerInstance } from "graphql-yoga";
|
||||
|
||||
export const GRAPHQL_YOGA = Symbol("GRAPHQL_YOGA");
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: GRAPHQL_YOGA,
|
||||
useFactory: async (
|
||||
downstream: DownstreamClient,
|
||||
redis: Redis,
|
||||
): Promise<YogaServerInstance<Record<string, unknown>, StudentBffContext>> => {
|
||||
return createStudentBffYoga(studentBffResolvers, downstream, redis);
|
||||
},
|
||||
inject: [DownstreamClient, REDIS_CLIENT],
|
||||
},
|
||||
],
|
||||
exports: [GRAPHQL_YOGA],
|
||||
})
|
||||
export class StudentModule implements OnModuleInit {
|
||||
constructor(@Inject(GRAPHQL_YOGA) private readonly yoga: Promise<unknown>) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 确保 Yoga 实例初始化完成
|
||||
await this.yoga;
|
||||
logger.info("StudentModule initialized, GraphQL Yoga ready");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user