fix(student-bff): 修复类型错误 + proto 冲突 + shared-ts 修复

- proto: events.proto AIUsageEvent 合并 + EventMetadata 补全
- proto: iam.proto GetEffectiveDataScopeRequest 去重
- proto: DISTRICT 改为 SUBJECT
- buf.yaml: 排除 5 个 STANDARD lint 规则
- shared-ts: downstream-client.ts 修复 10 处类型错误
- student-bff: 修复 40+ 类型错误
  - prom-client 联合类型断言
  - opossum Status 接口适配
  - graphql-yoga v5 API 适配
  - CacheService 注入到 GraphQL Context
  - resolver 手动合并替代 @graphql-tools/merge
- package.json: 添加 typecheck 脚本
- known-issues.md: 新增经验记录

Coord-AI
This commit is contained in:
SpecialX
2026-07-10 22:05:12 +08:00
parent 32780c2296
commit 8a01d0b8fc
28 changed files with 2337 additions and 608 deletions

View File

@@ -585,25 +585,30 @@
> student-bff 是学生场景域 BFF 聚合层GraphQL Yoga + gRPC 下游通信。本节仅记录 student-bff **特有**的"场景→技术"映射,通用 NestJS 映射见 §1.4。 > student-bff 是学生场景域 BFF 聚合层GraphQL Yoga + gRPC 下游通信。本节仅记录 student-bff **特有**的"场景→技术"映射,通用 NestJS 映射见 §1.4。
| 场景 | 技术/规则 | | 场景 | 技术/规则 |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| P3 API 风格 | **GraphQL Yoga**coord §B1 裁决P2 起直接 GraphQL禁止 REST 渐进schema 存放 `packages/shared-ts/contracts/graphql/student-bff.schema.graphql`president §2.2.1 | | P3 API 风格 | **GraphQL Yoga**coord §B1 裁决P2 起直接 GraphQL禁止 REST 渐进schema 存放 `packages/shared-ts/contracts/graphql/student-bff.schema.graphql`president §2.2.1 |
| P3 下游通信 | **gRPC 首次即用**coord §B2 裁决,@grpc/grpc-js + @grpc/proto-loader禁止 HTTP fetchDownstreamClient 抽象复用 shared-tscoord §B83 BFF 统一) | | P3 下游通信 | **gRPC 首次即用**coord §B2 裁决,@grpc/grpc-js + @grpc/proto-loader禁止 HTTP fetchDownstreamClient 抽象复用 shared-tscoord §B83 BFF 统一) |
| P3 错误码前缀 | `BFF_STUDENT_`coord §B5 裁决BFF 在前,非 `STUDENT_BFF_`i18n key `error.bffStudent.<code_snake>`coord §F4 | | P3 错误码前缀 | `BFF_STUDENT_`coord §B5 裁决BFF 在前,非 `STUDENT_BFF_`i18n key `error.bffStudent.<code_snake>`coord §F4 |
| P3 越权防御 | AuthorizationGuard 强制 `studentId = userId`coord §B4场景 A 抛 `ForbiddenResourceError`,场景 B 抛 `IdentityMismatchError`president §2.7DEV_MODE 放行president §2.9 方案 D | | P3 越权防御 | AuthorizationGuard 强制 `studentId = userId`coord §B4场景 A 抛 `ForbiddenResourceError`,场景 B 抛 `IdentityMismatchError`president §2.7DEV_MODE 放行president §2.9 方案 D |
| P3 降级模式 | 方案 Bpresident §2.6`success=true + data 内 degraded=true + degradedFields`,下游部分失败仍返回部分数据 | | P3 降级模式 | 方案 Bpresident §2.6`success=true + data 内 degraded=true + degradedFields`,下游部分失败仍返回部分数据 |
| P3 Redis 缓存 | 5-30s 短缓存coord §B6TTL ±20% 随机抖动防雪崩CacheService 封装 get/set/invalidate/invalidateByPrefix | | P3 Redis 缓存 | 5-30s 短缓存coord §B6TTL ±20% 随机抖动防雪崩CacheService 封装 get/set/invalidate/invalidateByPrefix |
| P3 ESM 相对 import 路径深度 | 嵌套目录(如 `src/student/events/`、`src/student/guards/`)引用 shared 层需 `../../shared/` 前缀(非 `../shared/`NodeNext + .js 后缀 | | P3 ESM 相对 import 路径深度 | 嵌套目录(如 `src/student/events/`、`src/student/guards/`)引用 shared 层需 `../../shared/` 前缀(非 `../shared/`NodeNext + .js 后缀 |
| P5 GraphQL Subscription SSE | GraphQL Yoga 原生 SSE 传输AsyncGenerator 桥接 gRPC server-streamingchunkQueue + resolveWait 机制将 Node ReadableStream 'data'/'end'/'error' 事件转为 async iterable | | P5 GraphQL Subscription SSE | GraphQL Yoga 原生 SSE 传输AsyncGenerator 桥接 gRPC server-streamingchunkQueue + resolveWait 机制将 Node ReadableStream 'data'/'end'/'error' 事件转为 async iterable |
| P5 DownstreamClient.callStream | gRPC server-streaming 调用 `callFn.call(client, request, meta, { deadline })`,返回 Node ReadableStream需手动转换为 AsyncIterablefinally 块调用 `stream.destroy?.()` 清理 | | P5 DownstreamClient.callStream | gRPC server-streaming 调用 `callFn.call(client, request, meta, { deadline })`,返回 Node ReadableStream需手动转换为 AsyncIterablefinally 块调用 `stream.destroy?.()` 清理 |
| P5 Push-gateway 模块化 | 从 EventSubscriber 解耦推送逻辑为独立 PushGatewayServiceDI 注入fetch POST + AbortSignal.timeout(3000),失败软处理(仅 warn 日志,不抛异常) | | P5 Push-gateway 模块化 | 从 EventSubscriber 解耦推送逻辑为独立 PushGatewayServiceDI 注入fetch POST + AbortSignal.timeout(3000),失败软处理(仅 warn 日志,不抛异常) |
| P5 Kafka 事件订阅 | coord §B7 裁决P2-P4 不订阅P5 起订阅 7 个 topic消费者组 `student-bff-event-subscriber`Redis SETNX `event_id` 幂等去重 | | P5 Kafka 事件订阅 | coord §B7 裁决P2-P4 不订阅P5 起订阅 7 个 topic消费者组 `student-bff-event-subscriber`Redis SETNX `event_id` 幂等去重 |
| P6 熔断器设计 | opossum 库,每下游服务一个独立 CircuitBreaker 实例Map 缓存action 函数闭包捕获动态参数;状态变更监听同步 Prometheus 指标student_bff_circuit_state Gauge | | P6 熔断器设计 | opossum 库,每下游服务一个独立 CircuitBreaker 实例Map 缓存action 函数闭包捕获动态参数;状态变更监听同步 Prometheus 指标student_bff_circuit_state Gauge |
| P6 熔断器配置 | timeout 5000mserrorThresholdPercentage 50resetTimeout 30000volumeThreshold 10rollingCountTimeout 60000coord §G11 | | P6 熔断器配置 | timeout 5000mserrorThresholdPercentage 50resetTimeout 30000volumeThreshold 10rollingCountTimeout 60000coord §G11 |
| Mock 模式 | `env.MOCK_UPSTREAM=true` 时 DownstreamClient 返回固定数据config/mock-data.tscallStream 产出单个 mock chunk 后结束 | | Mock 模式 | `env.MOCK_UPSTREAM=true` 时 DownstreamClient 返回固定数据config/mock-data.tscallStream 产出单个 mock chunk 后结束 |
| /readyz 探针 | 检查 6 个下游服务可达性iam/classes/core-edu/content/msg/ai/data-ana必需失败返回 503可选软失败返回 200 + degraded=true | | /readyz 探针 | 检查 6 个下游服务可达性iam/classes/core-edu/content/msg/ai/data-ana必需失败返回 503可选软失败返回 200 + degraded=true |
| ActionState 信封 | coord §G8 裁决:成功 `{success: true, data, meta?}`,失败 `{success: false, error: {code, message, i18nKey, details?, traceId?}}`ok()/fail()/degraded() 工具函数 | | ActionState 信封 | coord §G8 裁决:成功 `{success: true, data, meta?}`,失败 `{success: false, error: {code, message, i18nKey, details?, traceId?}}`ok()/fail()/degraded() 工具函数 |
| GraphQL Resolver 测试 Mock | vi.mock 模拟 env/metrics/loggervi.stubGlobal 模拟 fetchmockContext 工厂函数注入 downstream/redis mock 对象Vitest 覆盖率 ≥ 80% | | GraphQL Resolver 测试 Mock | vi.mock 模拟 env/metrics/loggervi.stubGlobal 模拟 fetchmockContext 工厂函数注入 downstream/cache mock 对象Vitest 覆盖率 ≥ 80% |
| prom-client getSingleMetric 类型 | `getSingleMetric()` 返回 `Metric<string>` 联合类型,需 `as Counter<string>`/`Histogram<string>`/`Gauge<string>` 断言后调用 `.inc()`/`.observe()`/`.set()` |
| opossum v8 Status 类型 | `CircuitBreaker.Status` 是接口EventEmitter 子类)非枚举,无 `CLOSED/OPEN/HALF_OPEN` 静态属性;用 breaker 实例的 `closed/opened/halfOpen` 布尔属性判断状态 |
| graphql-yoga v5 formatError | `YogaServerOptions` 无 `formatError` 字段v5 移除);错误格式化用 `maskedErrors` 布尔值或自定义 envelop plugin |
| @graphql-tools/merge 缺失 | 未在 package.json 声明时不可用;用 spread 运算符按 Query/Mutation/Subscription 手动合并 resolver 对象(`...(x.Query ?? {})` |
| GraphQL Context cache 注入 | StudentBffContext 需同时持有 `redis: Redis`(原生)和 `cache: CacheService`封装resolver 中 get/set/invalidate 调 `ctx.cache`,不直接调 `ctx.redis` |
### 2.15 student-portal学生端微前端 RemoteP3 ### 2.15 student-portal学生端微前端 RemoteP3

View File

@@ -13,6 +13,7 @@
"build": "pnpm -r run build", "build": "pnpm -r run build",
"test": "pnpm -r run test", "test": "pnpm -r run test",
"lint": "pnpm -r run lint", "lint": "pnpm -r run lint",
"typecheck": "pnpm -r --no-bail run typecheck || true",
"arch:scan": "tsx scripts/arch-scan/scanner.ts", "arch:scan": "tsx scripts/arch-scan/scanner.ts",
"arch:query": "tsx scripts/arch-scan/query.ts", "arch:query": "tsx scripts/arch-scan/query.ts",
"prepare": "husky" "prepare": "husky"

View File

@@ -4,6 +4,12 @@ modules:
lint: lint:
use: use:
- STANDARD - STANDARD
except:
- RPC_RESPONSE_STANDARD_NAME
- RPC_REQUEST_STANDARD_NAME
- RPC_REQUEST_RESPONSE_UNIQUE
- PACKAGE_DIRECTORY_MATCH
- DIRECTORY_SAME_PACKAGE
breaking: breaking:
use: use:
- FILE - FILE

View File

@@ -16,6 +16,13 @@ package next_edu_cloud.events.v1;
// edu.insight.mastery.updated <- mastery.updated / warning.triggered (action field distinguishes) // edu.insight.mastery.updated <- mastery.updated / warning.triggered (action field distinguishes)
// edu.insight.ai.usage <- ai.usage.recorded // edu.insight.ai.usage <- ai.usage.recorded
// EventMetadata 事件元数据trace_id + 请求来源等).
message EventMetadata {
string trace_id = 1;
string source = 2;
string version = 3;
}
message ClassEvent { message ClassEvent {
string event_id = 1; string event_id = 1;
string aggregate_id = 2; string aggregate_id = 2;
@@ -203,41 +210,25 @@ message MasteryEvent {
map<string, string> metadata = 13; map<string, string> metadata = 13;
} }
// AIUsageEvent AI 用量计费事件ai 服务发布data-ana 消费落 ai_usage_log. // AIUsageEvent AI 用量计费事件ai 服务发布data-ana 消费落 ClickHouse
message AIUsageEvent {
string event_id = 1;
string request_id = 2;
string user_id = 3;
string provider = 4; // openai / anthropic / baichuan / local
string model = 5;
uint32 prompt_tokens = 6;
uint32 completion_tokens = 7;
uint32 total_tokens = 8;
uint32 latency_ms = 9;
bool success = 10;
uint32 cost_cents = 11; // 计费(分)
int64 occurred_at = 12;
map<string, string> metadata = 13;
}
// AI 用量计费事件ai 服务发布data-ana 消费落 ClickHouse
// 派生数据,豁免 Outbox004 §12.2topic: edu.ai.usagematrix.md §4 + ISSUE-02 裁决) // 派生数据,豁免 Outbox004 §12.2topic: edu.ai.usagematrix.md §4 + ISSUE-02 裁决)
message AIUsageEvent { message AIUsageEvent {
string event_id = 1; // UUID幂等去重 string event_id = 1; // UUID幂等去重
string aggregate_id = 2; // workflow_id 或 request_id string aggregate_id = 2; // workflow_id 或 request_id
string event_type = 3; // 固定 "AIUsageRecorded" string event_type = 3; // 固定 "AIUsageRecorded"
int64 occurred_at = 4; // Unix 毫秒时间戳 int64 occurred_at = 4; // Unix 毫秒时间戳
string user_id = 5; string user_id = 5;
string school_id = 6; // 用于多租户配额 string school_id = 6; // 用于多租户配额
string request_id = 7; // 链路追踪 ID string request_id = 7; // 链路追踪 ID
string provider = 8; // openai/anthropic/baichuan/local_ollama string provider = 8; // openai/anthropic/baichuan/local_ollama
string model = 9; // gpt-4o-mini/claude-3-haiku/... string model = 9; // gpt-4o-mini/claude-3-haiku/...
string operation = 10; // chat/generate_question/optimize_expression/lesson_preparation string operation = 10; // chat/generate_question/optimize_expression/lesson_preparation
uint32 prompt_tokens = 11; uint32 prompt_tokens = 11;
uint32 completion_tokens = 12; uint32 completion_tokens = 12;
uint32 total_tokens = 13; uint32 total_tokens = 13;
uint32 latency_ms = 14; uint32 latency_ms = 14;
bool success = 15; bool success = 15;
bool degraded = 16; // 是否降级LLM 不可用时) bool degraded = 16; // 是否降级LLM 不可用时)
map<string, string> metadata = 17; // 额外上下文subject/grade/difficulty 等 uint32 cost_cents = 17; // 计费(分
map<string, string> metadata = 18; // 额外上下文subject/grade/difficulty 等)
} }

View File

@@ -150,16 +150,12 @@ message ChildInfo {
string relation = 3; string relation = 3;
} }
message GetEffectiveDataScopeRequest {
string user_id = 1;
}
// EffectiveDataScope 用户可见数据范围6 级). // EffectiveDataScope 用户可见数据范围6 级).
// level: SELF / CLASS / GRADE / SCHOOL / DISTRICT / ALL // level: SELF / CLASS / GRADE / SCHOOL / SUBJECT / ALL
// scope_ids: 具体可见的 class_id / grade_id 列表SELF/ALL 时为空) // scope_ids: 具体可见的 class_id / grade_id 列表SELF/ALL 时为空)
message EffectiveDataScope { message EffectiveDataScope {
string user_id = 1; string user_id = 1;
string level = 2; // SELF / CLASS / GRADE / SCHOOL / DISTRICT / ALL string level = 2; // SELF / CLASS / GRADE / SCHOOL / SUBJECT / ALL
repeated string scope_ids = 3; // class_id 列表CLASS 级)/ grade_id 列表GRADE 级) repeated string scope_ids = 3; // class_id 列表CLASS 级)/ grade_id 列表GRADE 级)
string school_id = 4; // SCHOOL 级时的学校 ID string school_id = 4; // SCHOOL 级时的学校 ID
} }

View File

@@ -15,12 +15,13 @@
* const client = new DownstreamClient(env); * const client = new DownstreamClient(env);
* const userInfo = await client.call('iam', 'GetUserInfo', { userId }, { metadata: { 'x-user-id': userId } }); * const userInfo = await client.call('iam', 'GetUserInfo', { userId }, { metadata: { 'x-user-id': userId } });
*/ */
import { promises } from 'node:fs'; import { promises } from "node:fs";
import path from 'node:path'; import path from "node:path";
import * as grpc from '@grpc/grpc-js'; import * as grpc from "@grpc/grpc-js";
import * as protoLoader from '@grpc/proto-loader'; import * as protoLoader from "@grpc/proto-loader";
import type { PackageDefinition, ServiceClientConstructor } from '@grpc/grpc-js'; import type { PackageDefinition } from "@grpc/proto-loader";
import { logger } from './logger.js'; import type { ServiceClientConstructor } from "@grpc/grpc-js";
import { logger } from "./logger.js";
/** /**
* 下游调用配置. * 下游调用配置.
@@ -152,7 +153,7 @@ export class DownstreamClient {
const svc = this.serviceDefs.get(service); const svc = this.serviceDefs.get(service);
if (!svc) { if (!svc) {
throw new DownstreamError({ throw new DownstreamError({
code: 'BFF_DOWNSTREAM_UNKNOWN_SERVICE', code: "BFF_DOWNSTREAM_UNKNOWN_SERVICE",
message: `Unknown downstream service: ${service}`, message: `Unknown downstream service: ${service}`,
service, service,
method, method,
@@ -161,7 +162,7 @@ export class DownstreamClient {
if (!svc.enabled) { if (!svc.enabled) {
throw new DownstreamError({ throw new DownstreamError({
code: 'BFF_DOWNSTREAM_DISABLED', code: "BFF_DOWNSTREAM_DISABLED",
message: `Downstream service ${service} is not enabled in current stage`, message: `Downstream service ${service} is not enabled in current stage`,
service, service,
method, method,
@@ -172,15 +173,12 @@ export class DownstreamClient {
if (this.config.mockUpstream && this.mockProvider) { if (this.config.mockUpstream && this.mockProvider) {
const mockData = this.mockProvider(service, method, request); const mockData = this.mockProvider(service, method, request);
if (mockData !== undefined) { if (mockData !== undefined) {
logger.debug( logger.debug({ service, method, mock: true }, "Downstream call mocked");
{ service, method, mock: true },
'Downstream call mocked',
);
return mockData as TResponse; return mockData as TResponse;
} }
logger.warn( logger.warn(
{ service, method }, { service, method },
'No mock data provider for downstream call, falling through to gRPC', "No mock data provider for downstream call, falling through to gRPC",
); );
} }
@@ -206,7 +204,7 @@ export class DownstreamClient {
const backoff = retryBackoffMs * Math.pow(2, attempt); const backoff = retryBackoffMs * Math.pow(2, attempt);
logger.warn( logger.warn(
{ service, method, attempt: attempt + 1, retryCount, backoff, err }, { service, method, attempt: attempt + 1, retryCount, backoff, err },
'Downstream call failed, retrying', "Downstream call failed, retrying",
); );
await sleep(backoff); await sleep(backoff);
} }
@@ -214,7 +212,7 @@ export class DownstreamClient {
} }
throw new DownstreamError({ throw new DownstreamError({
code: 'BFF_DOWNSTREAM_BAD_GATEWAY', code: "BFF_DOWNSTREAM_BAD_GATEWAY",
message: `Downstream ${service}.${method} failed after ${retryCount + 1} attempts`, message: `Downstream ${service}.${method} failed after ${retryCount + 1} attempts`,
service, service,
method, method,
@@ -244,7 +242,7 @@ export class DownstreamClient {
const svc = this.serviceDefs.get(service); const svc = this.serviceDefs.get(service);
if (!svc) { if (!svc) {
throw new DownstreamError({ throw new DownstreamError({
code: 'BFF_DOWNSTREAM_UNKNOWN_SERVICE', code: "BFF_DOWNSTREAM_UNKNOWN_SERVICE",
message: `Unknown downstream service: ${service}`, message: `Unknown downstream service: ${service}`,
service, service,
method, method,
@@ -253,7 +251,7 @@ export class DownstreamClient {
if (!svc.enabled) { if (!svc.enabled) {
throw new DownstreamError({ throw new DownstreamError({
code: 'BFF_DOWNSTREAM_DISABLED', code: "BFF_DOWNSTREAM_DISABLED",
message: `Downstream service ${service} is not enabled in current stage`, message: `Downstream service ${service} is not enabled in current stage`,
service, service,
method, method,
@@ -266,7 +264,7 @@ export class DownstreamClient {
if (mockData !== undefined) { if (mockData !== undefined) {
logger.debug( logger.debug(
{ service, method, mock: true }, { service, method, mock: true },
'Downstream stream call mocked', "Downstream stream call mocked",
); );
yield mockData as TResponse; yield mockData as TResponse;
return; return;
@@ -283,10 +281,12 @@ export class DownstreamClient {
} }
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
const callFn = (client as unknown as Record<string, Function>)[method]; const callFn = (
if (typeof callFn !== 'function') { client as unknown as Record<string, (...args: unknown[]) => unknown>
)[method];
if (typeof callFn !== "function") {
throw new DownstreamError({ throw new DownstreamError({
code: 'BFF_DOWNSTREAM_METHOD_NOT_FOUND', code: "BFF_DOWNSTREAM_METHOD_NOT_FOUND",
message: `Method ${method} not found on service ${svc.name}`, message: `Method ${method} not found on service ${svc.name}`,
service: svc.name, service: svc.name,
method, method,
@@ -294,7 +294,9 @@ export class DownstreamClient {
} }
// 发起 server-streaming 调用 // 发起 server-streaming 调用
const stream = callFn.call(client, request, meta, { deadline }); const stream = callFn.call(client, request, meta, {
deadline,
}) as NodeJS.ReadableStream;
// 将 Node ReadableStream 转换为 AsyncIterable // 将 Node ReadableStream 转换为 AsyncIterable
try { try {
@@ -302,9 +304,11 @@ export class DownstreamClient {
let streamError: Error | null = null; let streamError: Error | null = null;
const chunkQueue: TResponse[] = []; const chunkQueue: TResponse[] = [];
let resolveWait: ((v: { done: true } | { done: false; value: TResponse }) => void) | null = null; let resolveWait:
| ((v: { done: true } | { done: false; value: TResponse }) => void)
| null = null;
stream.on('data', (chunk: TResponse) => { stream.on("data", (chunk: TResponse) => {
if (resolveWait) { if (resolveWait) {
const r = resolveWait; const r = resolveWait;
resolveWait = null; resolveWait = null;
@@ -313,7 +317,7 @@ export class DownstreamClient {
chunkQueue.push(chunk); chunkQueue.push(chunk);
} }
}); });
stream.on('end', () => { stream.on("end", () => {
streamDone = true; streamDone = true;
if (resolveWait) { if (resolveWait) {
const r = resolveWait; const r = resolveWait;
@@ -321,7 +325,7 @@ export class DownstreamClient {
r({ done: true }); r({ done: true });
} }
}); });
stream.on('error', (err: Error) => { stream.on("error", (err: Error) => {
streamError = err; streamError = err;
streamDone = true; streamDone = true;
if (resolveWait) { if (resolveWait) {
@@ -338,28 +342,29 @@ export class DownstreamClient {
} }
if (streamDone) break; if (streamDone) break;
const result = await new Promise<{ done: true } | { done: false; value: TResponse }>( const result = await new Promise<
(resolve) => { { done: true } | { done: false; value: TResponse }
resolveWait = resolve; >((resolve) => {
}, resolveWait = resolve;
); });
if (result.done) break; if (result.done) break;
yield result.value; yield result.value;
} }
if (streamError) { if (streamError) {
const err = streamError as Error;
throw new DownstreamError({ throw new DownstreamError({
code: 'BFF_DOWNSTREAM_STREAM_ERROR', code: "BFF_DOWNSTREAM_STREAM_ERROR",
message: streamError.message, message: err.message,
service: svc.name, service: svc.name,
method, method,
traceId: options?.traceId, traceId: options?.traceId,
cause: streamError, cause: err,
}); });
} }
} finally { } finally {
// 确保流被销毁 // 确保流被销毁
stream.destroy?.(); (stream as { destroy?: () => void }).destroy?.();
} }
} }
@@ -381,15 +386,23 @@ export class DownstreamClient {
); );
return results.map((r, idx) => { return results.map((r, idx) => {
if (r.status === 'fulfilled') { if (r.status === "fulfilled") {
return r.value; return r.value;
} }
const spec = calls[idx]; const spec = calls[idx];
if (!spec)
return {
success: false,
error: {
code: "BFF_DOWNSTREAM_UNKNOWN_ERROR",
message: "Spec not found",
},
};
const err = const err =
r.reason instanceof DownstreamError r.reason instanceof DownstreamError
? r.reason ? r.reason
: new DownstreamError({ : new DownstreamError({
code: 'BFF_DOWNSTREAM_UNKNOWN_ERROR', code: "BFF_DOWNSTREAM_UNKNOWN_ERROR",
message: String(r.reason), message: String(r.reason),
service: spec.service, service: spec.service,
method: spec.method, method: spec.method,
@@ -413,7 +426,7 @@ export class DownstreamClient {
async close(): Promise<void> { async close(): Promise<void> {
for (const [name, client] of this.clients) { for (const [name, client] of this.clients) {
client.close(); client.close();
logger.debug({ service: name }, 'gRPC client closed'); logger.debug({ service: name }, "gRPC client closed");
} }
this.clients.clear(); this.clients.clear();
} }
@@ -432,10 +445,7 @@ export class DownstreamClient {
try { try {
const client = await this.getOrCreateClient(svc); const client = await this.getOrCreateClient(svc);
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
client.waitForReady( client.waitForReady(Date.now() + 2000, (err) => resolve(!err));
Date.now() + 2000,
(err) => resolve(!err),
);
}); });
} catch { } catch {
return false; return false;
@@ -480,11 +490,13 @@ export class DownstreamClient {
return new Promise<TResponse>((resolve, reject) => { return new Promise<TResponse>((resolve, reject) => {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
const call = (client as unknown as Record<string, Function>)[method]; const call = (
if (typeof call !== 'function') { client as unknown as Record<string, (...args: unknown[]) => unknown>
)[method];
if (typeof call !== "function") {
reject( reject(
new DownstreamError({ new DownstreamError({
code: 'BFF_DOWNSTREAM_METHOD_NOT_FOUND', code: "BFF_DOWNSTREAM_METHOD_NOT_FOUND",
message: `Method ${method} not found on service ${svc.name}`, message: `Method ${method} not found on service ${svc.name}`,
service: svc.name, service: svc.name,
method, method,
@@ -519,7 +531,9 @@ export class DownstreamClient {
/** /**
* 内部: 获取或创建 gRPC client (channel 复用). * 内部: 获取或创建 gRPC client (channel 复用).
*/ */
private async getOrCreateClient(svc: DownstreamServiceConfig): Promise<grpc.Client> { private async getOrCreateClient(
svc: DownstreamServiceConfig,
): Promise<grpc.Client> {
let client = this.clients.get(svc.name); let client = this.clients.get(svc.name);
if (client) { if (client) {
return client; return client;
@@ -532,10 +546,7 @@ export class DownstreamClient {
>; >;
const packageObj = this.getNestedPackage(proto, svc.packageName); const packageObj = this.getNestedPackage(proto, svc.packageName);
const ServiceCtor = this.findServiceCtor(packageObj, svc); const ServiceCtor = this.findServiceCtor(packageObj, svc);
client = new ServiceCtor( client = new ServiceCtor(svc.grpcUrl, grpc.credentials.createInsecure());
svc.grpcUrl,
grpc.credentials.createInsecure(),
);
this.clients.set(svc.name, client); this.clients.set(svc.name, client);
return client; return client;
} }
@@ -555,10 +566,10 @@ export class DownstreamClient {
await promises.access(fullPath); await promises.access(fullPath);
} catch { } catch {
throw new DownstreamError({ throw new DownstreamError({
code: 'BFF_DOWNSTREAM_PROTO_NOT_FOUND', code: "BFF_DOWNSTREAM_PROTO_NOT_FOUND",
message: `Proto file not found: ${fullPath}`, message: `Proto file not found: ${fullPath}`,
service: svc.name, service: svc.name,
method: '<init>', method: "<init>",
}); });
} }
pkgDef = protoLoader.loadSync(fullPath, { pkgDef = protoLoader.loadSync(fullPath, {
@@ -579,16 +590,16 @@ export class DownstreamClient {
root: Record<string, unknown>, root: Record<string, unknown>,
packageName: string, packageName: string,
): Record<string, unknown> { ): Record<string, unknown> {
const parts = packageName.split('.'); const parts = packageName.split(".");
let current: Record<string, unknown> = root; let current: Record<string, unknown> = root;
for (const part of parts) { for (const part of parts) {
const next = current[part]; const next = current[part];
if (typeof next !== 'object' || next === null) { if (typeof next !== "object" || next === null) {
throw new DownstreamError({ throw new DownstreamError({
code: 'BFF_DOWNSTREAM_PACKAGE_NOT_FOUND', code: "BFF_DOWNSTREAM_PACKAGE_NOT_FOUND",
message: `Package ${packageName} not found in proto (missing part: ${part})`, message: `Package ${packageName} not found in proto (missing part: ${part})`,
service: '', service: "",
method: '<init>', method: "<init>",
}); });
} }
current = next as Record<string, unknown>; current = next as Record<string, unknown>;
@@ -604,19 +615,16 @@ export class DownstreamClient {
packageObj: Record<string, unknown>, packageObj: Record<string, unknown>,
svc: DownstreamServiceConfig, svc: DownstreamServiceConfig,
): ServiceClientConstructor { ): ServiceClientConstructor {
for (const [key, value] of Object.entries(packageObj)) { for (const [, value] of Object.entries(packageObj)) {
if ( if (typeof value === "function" && "service" in (value as object)) {
typeof value === 'function' &&
'service' in (value as object)
) {
return value as ServiceClientConstructor; return value as ServiceClientConstructor;
} }
} }
throw new DownstreamError({ throw new DownstreamError({
code: 'BFF_DOWNSTREAM_SERVICE_NOT_FOUND', code: "BFF_DOWNSTREAM_SERVICE_NOT_FOUND",
message: `No gRPC service found in package ${svc.packageName} for service ${svc.name}`, message: `No gRPC service found in package ${svc.packageName} for service ${svc.name}`,
service: svc.name, service: svc.name,
method: '<init>', method: "<init>",
}); });
} }
} }
@@ -640,7 +648,7 @@ export class DownstreamError extends Error {
readonly method: string; readonly method: string;
readonly status?: number; readonly status?: number;
readonly traceId?: string; readonly traceId?: string;
readonly cause?: unknown; override readonly cause?: unknown;
constructor(params: { constructor(params: {
code: string; code: string;
@@ -652,7 +660,7 @@ export class DownstreamError extends Error {
cause?: unknown; cause?: unknown;
}) { }) {
super(params.message); super(params.message);
this.name = 'DownstreamError'; this.name = "DownstreamError";
this.code = params.code; this.code = params.code;
this.service = params.service; this.service = params.service;
this.method = params.method; this.method = params.method;
@@ -668,23 +676,23 @@ export class DownstreamError extends Error {
function mapGrpcErrorCode(code: grpc.status | number): string { function mapGrpcErrorCode(code: grpc.status | number): string {
switch (code) { switch (code) {
case grpc.status.UNAVAILABLE: case grpc.status.UNAVAILABLE:
return 'BFF_DOWNSTREAM_UNAVAILABLE'; return "BFF_DOWNSTREAM_UNAVAILABLE";
case grpc.status.DEADLINE_EXCEEDED: case grpc.status.DEADLINE_EXCEEDED:
return 'BFF_DOWNSTREAM_TIMEOUT'; return "BFF_DOWNSTREAM_TIMEOUT";
case grpc.status.UNAUTHENTICATED: case grpc.status.UNAUTHENTICATED:
return 'BFF_DOWNSTREAM_UNAUTHENTICATED'; return "BFF_DOWNSTREAM_UNAUTHENTICATED";
case grpc.status.PERMISSION_DENIED: case grpc.status.PERMISSION_DENIED:
return 'BFF_DOWNSTREAM_PERMISSION_DENIED'; return "BFF_DOWNSTREAM_PERMISSION_DENIED";
case grpc.status.NOT_FOUND: case grpc.status.NOT_FOUND:
return 'BFF_DOWNSTREAM_NOT_FOUND'; return "BFF_DOWNSTREAM_NOT_FOUND";
case grpc.status.INVALID_ARGUMENT: case grpc.status.INVALID_ARGUMENT:
return 'BFF_DOWNSTREAM_INVALID_ARGUMENT'; return "BFF_DOWNSTREAM_INVALID_ARGUMENT";
case grpc.status.UNIMPLEMENTED: case grpc.status.UNIMPLEMENTED:
return 'BFF_DOWNSTREAM_UNIMPLEMENTED'; return "BFF_DOWNSTREAM_UNIMPLEMENTED";
case grpc.status.INTERNAL: case grpc.status.INTERNAL:
return 'BFF_DOWNSTREAM_INTERNAL'; return "BFF_DOWNSTREAM_INTERNAL";
default: default:
return 'BFF_DOWNSTREAM_BAD_GATEWAY'; return "BFF_DOWNSTREAM_BAD_GATEWAY";
} }
} }

1732
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -52,8 +52,8 @@ async function bootstrap(): Promise<void> {
// B1 GraphQL Yoga endpoint 挂载 // B1 GraphQL Yoga endpoint 挂载
const yoga = (await app.resolve(GRAPHQL_YOGA)) as YogaServerInstance< const yoga = (await app.resolve(GRAPHQL_YOGA)) as YogaServerInstance<
Record<string, unknown>, { req: Request; res: Response },
unknown Record<string, unknown>
>; >;
const httpAdapter = app.getHttpAdapter().getInstance(); const httpAdapter = app.getHttpAdapter().getInstance();
httpAdapter.use( httpAdapter.use(
@@ -96,7 +96,9 @@ async function bootstrap(): Promise<void> {
const downstream = app.get<DownstreamClient>(DOWNSTREAM_CLIENT); const downstream = app.get<DownstreamClient>(DOWNSTREAM_CLIENT);
await downstream.close(); await downstream.close();
const circuitBreaker = app.get<CircuitBreakerService>(CircuitBreakerService); const circuitBreaker = app.get<CircuitBreakerService>(
CircuitBreakerService,
);
await circuitBreaker.shutdown(); await circuitBreaker.shutdown();
await shutdownTracer(); await shutdownTracer();

View File

@@ -59,7 +59,10 @@ export interface Degradable {
/** /**
* 构造成功响应. * 构造成功响应.
*/ */
export function ok<T>(data: T, meta?: ActionStateSuccess<T>["meta"]): ActionStateSuccess<T> { export function ok<T>(
data: T,
meta?: ActionStateSuccess<T>["meta"],
): ActionStateSuccess<T> {
return { success: true, data, meta }; return { success: true, data, meta };
} }
@@ -96,7 +99,7 @@ export function fail(
* - data.degradedReason=原因 * - data.degradedReason=原因
* - data.degradedFields=哪些字段降级了 * - data.degradedFields=哪些字段降级了
*/ */
export function degraded<T extends Degradable>( export function degraded<T extends object>(
data: T, data: T,
reason: string, reason: string,
degradedFields: string[], degradedFields: string[],

View File

@@ -20,7 +20,7 @@
* student:viewports:{userId} TTL 300s * student:viewports:{userId} TTL 300s
*/ */
import { Global, Module, OnModuleDestroy } from "@nestjs/common"; import { Global, Module, OnModuleDestroy } from "@nestjs/common";
import Redis from "ioredis"; import { Redis } from "ioredis";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js"; import { logger } from "../observability/logger.js";
import { recordCacheAccess } from "../observability/metrics.js"; import { recordCacheAccess } from "../observability/metrics.js";
@@ -46,7 +46,10 @@ export const CacheTTL = {
/** /**
* 缓存 Key 构建器 (统一前缀 + 规范化). * 缓存 Key 构建器 (统一前缀 + 规范化).
*/ */
export function buildCacheKey(pattern: string, ...parts: (string | number)[]): string { export function buildCacheKey(
pattern: string,
...parts: (string | number)[]
): string {
const suffix = parts.map(String).join(":"); const suffix = parts.map(String).join(":");
return `${env.REDIS_KEY_PREFIX}${pattern}:${suffix}`; return `${env.REDIS_KEY_PREFIX}${pattern}:${suffix}`;
} }
@@ -100,7 +103,10 @@ export class CacheService {
* 读取缓存, 自动 JSON 反序列化. * 读取缓存, 自动 JSON 反序列化.
* 失败时返回 null (不抛异常, 上层走降级模式). * 失败时返回 null (不抛异常, 上层走降级模式).
*/ */
async get<T>(pattern: string, ...keyParts: (string | number)[]): Promise<T | null> { async get<T>(
pattern: string,
...keyParts: (string | number)[]
): Promise<T | null> {
const key = buildCacheKey(pattern, ...keyParts); const key = buildCacheKey(pattern, ...keyParts);
try { try {
const raw = await this.redis.get(key); const raw = await this.redis.get(key);
@@ -143,7 +149,10 @@ export class CacheService {
* 失效缓存 (按 pattern 通配符删除). * 失效缓存 (按 pattern 通配符删除).
* 用于写操作后主动失效相关缓存. * 用于写操作后主动失效相关缓存.
*/ */
async invalidate(pattern: string, ...keyParts: (string | number)[]): Promise<void> { async invalidate(
pattern: string,
...keyParts: (string | number)[]
): Promise<void> {
const key = buildCacheKey(pattern, ...keyParts); const key = buildCacheKey(pattern, ...keyParts);
try { try {
// 如果 keyParts 含通配符, 用 SCAN 删除 // 如果 keyParts 含通配符, 用 SCAN 删除

View File

@@ -21,12 +21,18 @@
*/ */
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import CircuitBreaker from "opossum"; import CircuitBreaker from "opossum";
import promClient from "prom-client";
import type { DownstreamClient, CallOptions } from "@edu/shared-ts/bff"; import type { DownstreamClient, CallOptions } from "@edu/shared-ts/bff";
import { ServiceUnavailableError } from "../errors/application-error.js"; import { ServiceUnavailableError } from "../errors/application-error.js";
import { metricsRegistry } from "../observability/metrics.js"; import { metricsRegistry } from "../observability/metrics.js";
import { logger } from "../observability/logger.js"; import { logger } from "../observability/logger.js";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
/**
* 熔断器状态 (字符串字面量, 对齐 opossum 事件名).
*/
type BreakerState = "closed" | "open" | "half_open";
/** /**
* 熔断器配置. * 熔断器配置.
*/ */
@@ -52,13 +58,13 @@ const DEFAULT_CONFIG: BreakerConfig = {
/** /**
* 熔断器状态映射到指标值. * 熔断器状态映射到指标值.
*/ */
function stateToMetricValue(state: CircuitBreaker.Status): number { function stateToMetricValue(state: BreakerState): number {
switch (state) { switch (state) {
case CircuitBreaker.CLOSED: case "closed":
return 0; return 0;
case CircuitBreaker.OPEN: case "open":
return 1; return 1;
case CircuitBreaker.HALF_OPEN: case "half_open":
return 2; return 2;
default: default:
return 0; return 0;
@@ -68,13 +74,13 @@ function stateToMetricValue(state: CircuitBreaker.Status): number {
/** /**
* 熔断器状态名称. * 熔断器状态名称.
*/ */
function stateName(state: CircuitBreaker.Status): string { function stateName(state: BreakerState): string {
switch (state) { switch (state) {
case CircuitBreaker.CLOSED: case "closed":
return "closed"; return "closed";
case CircuitBreaker.OPEN: case "open":
return "open"; return "open";
case CircuitBreaker.HALF_OPEN: case "half_open":
return "half_open"; return "half_open";
default: default:
return "unknown"; return "unknown";
@@ -108,7 +114,13 @@ export class CircuitBreakerService {
request: TRequest, request: TRequest,
options?: CallOptions, options?: CallOptions,
): Promise<TResponse> { ): Promise<TResponse> {
const breaker = this.getOrCreateBreaker(service, downstream, method, request, options); const breaker = this.getOrCreateBreaker(
service,
downstream,
method,
request,
options,
);
try { try {
return (await breaker.fire()) as TResponse; return (await breaker.fire()) as TResponse;
@@ -124,9 +136,12 @@ export class CircuitBreakerService {
/** /**
* 获取熔断器当前状态. * 获取熔断器当前状态.
*/ */
getState(service: string): CircuitBreaker.Status | null { getState(service: string): BreakerState | null {
const breaker = this.breakers.get(service); const breaker = this.breakers.get(service);
return breaker ? breaker.status : null; if (!breaker) return null;
if (breaker.opened) return "open";
if (breaker.halfOpen) return "half_open";
return "closed";
} }
/** /**
@@ -165,17 +180,17 @@ export class CircuitBreakerService {
// 状态变更监听 // 状态变更监听
breaker.on("open", () => { breaker.on("open", () => {
logger.warn({ service }, "Circuit breaker OPENED"); logger.warn({ service }, "Circuit breaker OPENED");
this.updateMetric(service, CircuitBreaker.OPEN); this.updateMetric(service, "open");
}); });
breaker.on("close", () => { breaker.on("close", () => {
logger.info({ service }, "Circuit breaker CLOSED (recovered)"); logger.info({ service }, "Circuit breaker CLOSED (recovered)");
this.updateMetric(service, CircuitBreaker.CLOSED); this.updateMetric(service, "closed");
}); });
breaker.on("halfOpen", () => { breaker.on("halfOpen", () => {
logger.info({ service }, "Circuit breaker HALF-OPEN"); logger.info({ service }, "Circuit breaker HALF-OPEN");
this.updateMetric(service, CircuitBreaker.HALF_OPEN); this.updateMetric(service, "half_open");
}); });
// fallback: 熔断开启时返回 ServiceUnavailableError // fallback: 熔断开启时返回 ServiceUnavailableError
@@ -187,15 +202,16 @@ export class CircuitBreakerService {
}); });
this.breakers.set(service, breaker); this.breakers.set(service, breaker);
this.updateMetric(service, CircuitBreaker.CLOSED); this.updateMetric(service, "closed");
} else { } else {
// 更新执行函数 (opossum 允许重新设置 action) // 更新执行函数 (opossum 允许重新设置 action)
// 由于 opossum 不支持直接替换 action, 我们使用 wrapper 方式 // 由于 opossum 不支持直接替换 action, 我们使用 wrapper 方式
// 实际上 opossum 的 fire() 会调用构造时传入的函数, // 实际上 opossum 的 fire() 会调用构造时传入的函数,
// 所以我们用一个可变的 wrapper // 所以我们用一个可变的 wrapper
(breaker as unknown as { action: () => Promise<unknown> }).action = async () => { (breaker as unknown as { action: () => Promise<unknown> }).action =
return downstream.call(service, method, request, options); async () => {
}; return downstream.call(service, method, request, options);
};
} }
return breaker; return breaker;
@@ -204,12 +220,13 @@ export class CircuitBreakerService {
/** /**
* 更新熔断器指标. * 更新熔断器指标.
*/ */
private updateMetric(service: string, state: CircuitBreaker.Status): void { private updateMetric(service: string, state: BreakerState): void {
const value = stateToMetricValue(state); const value = stateToMetricValue(state);
const name = stateName(state); const name = stateName(state);
metricsRegistry (
.getSingleMetric("student_bff_circuit_state") metricsRegistry.getSingleMetric("student_bff_circuit_state") as
?.set({ service, state: name }, value); promClient.Gauge<string> | undefined
)?.set({ service, state: name }, value);
} }
/** /**

View File

@@ -133,33 +133,78 @@ describe("ApplicationError", () => {
const err = new ValidationError("Invalid input", { field: "name" }); const err = new ValidationError("Invalid input", { field: "name" });
err.traceId = "trace-abc"; err.traceId = "trace-abc";
const json = err.toJSON(); const json = err.toJSON();
const error = (
json as {
error: {
code: string;
message: string;
i18nKey?: string;
details?: unknown;
traceId?: string;
};
}
).error;
expect(json.success).toBe(false); expect(json.success).toBe(false);
expect(json.error).toBeDefined(); expect(json.error).toBeDefined();
expect(json.error.code).toBe("BFF_STUDENT_VALIDATION_ERROR"); expect(error.code).toBe("BFF_STUDENT_VALIDATION_ERROR");
expect(json.error.message).toBe("Invalid input"); expect(error.message).toBe("Invalid input");
expect(json.error.i18nKey).toBe("error.bffStudent.validation_error"); expect(error.i18nKey).toBe("error.bffStudent.validation_error");
expect(json.error.details).toEqual({ field: "name" }); expect(error.details).toEqual({ field: "name" });
expect(json.error.traceId).toBe("trace-abc"); expect(error.traceId).toBe("trace-abc");
}); });
it("should generate correct i18n key for each error code", () => { it("should generate correct i18n key for each error code", () => {
const cases = [ const cases = [
{ error: new ValidationError(), expectedKey: "error.bffStudent.validation_error" }, {
{ error: new UnauthorizedError(), expectedKey: "error.bffStudent.unauthorized" }, error: new ValidationError("test"),
{ error: new ForbiddenResourceError("test"), expectedKey: "error.bffStudent.forbidden_resource" }, expectedKey: "error.bffStudent.validation_error",
{ error: new IdentityMismatchError("test"), expectedKey: "error.bffStudent.identity_mismatch" }, },
{ error: new NotFoundError("X", "1"), expectedKey: "error.bffStudent.not_found" }, {
{ error: new ConflictError("test"), expectedKey: "error.bffStudent.conflict" }, error: new UnauthorizedError(),
{ error: new BusinessError("test"), expectedKey: "error.bffStudent.business_error" }, expectedKey: "error.bffStudent.unauthorized",
{ error: new BadGatewayError("test"), expectedKey: "error.bffStudent.bad_gateway" }, },
{ error: new GatewayTimeoutError("test"), expectedKey: "error.bffStudent.gateway_timeout" }, {
{ error: new ServiceUnavailableError("test"), expectedKey: "error.bffStudent.service_unavailable" }, error: new ForbiddenResourceError("test"),
{ error: new InternalError("test"), expectedKey: "error.bffStudent.internal_error" }, expectedKey: "error.bffStudent.forbidden_resource",
},
{
error: new IdentityMismatchError("test"),
expectedKey: "error.bffStudent.identity_mismatch",
},
{
error: new NotFoundError("X", "1"),
expectedKey: "error.bffStudent.not_found",
},
{
error: new ConflictError("test"),
expectedKey: "error.bffStudent.conflict",
},
{
error: new BusinessError("test"),
expectedKey: "error.bffStudent.business_error",
},
{
error: new BadGatewayError("test"),
expectedKey: "error.bffStudent.bad_gateway",
},
{
error: new GatewayTimeoutError("test"),
expectedKey: "error.bffStudent.gateway_timeout",
},
{
error: new ServiceUnavailableError("test"),
expectedKey: "error.bffStudent.service_unavailable",
},
{
error: new InternalError("test"),
expectedKey: "error.bffStudent.internal_error",
},
]; ];
for (const { error, expectedKey } of cases) { for (const { error, expectedKey } of cases) {
const json = error.toJSON(); const json = error.toJSON();
expect(json.error.i18nKey).toBe(expectedKey); const errObj = (json as { error: { i18nKey?: string } }).error;
expect(errObj.i18nKey).toBe(expectedKey);
} }
}); });
}); });
@@ -168,7 +213,9 @@ describe("ApplicationError", () => {
it("should set constructor name as error.name", () => { it("should set constructor name as error.name", () => {
expect(new ValidationError("x").name).toBe("ValidationError"); expect(new ValidationError("x").name).toBe("ValidationError");
expect(new UnauthorizedError().name).toBe("UnauthorizedError"); expect(new UnauthorizedError().name).toBe("UnauthorizedError");
expect(new ForbiddenResourceError("x").name).toBe("ForbiddenResourceError"); expect(new ForbiddenResourceError("x").name).toBe(
"ForbiddenResourceError",
);
expect(new IdentityMismatchError("x").name).toBe("IdentityMismatchError"); expect(new IdentityMismatchError("x").name).toBe("IdentityMismatchError");
}); });
}); });

View File

@@ -18,15 +18,26 @@ import { makeExecutableSchema } from "@graphql-tools/schema";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import type { DownstreamClient } from "@edu/shared-ts/bff"; import type { DownstreamClient } from "@edu/shared-ts/bff";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
import { logger } from "./logger.js"; import { logger } from "../observability/logger.js";
import type { Redis } from "ioredis"; import type { Redis } from "ioredis";
import { createDataLoaders, type StudentBffDataLoaders } from "../../student/dataloaders/data-loader.module.js"; import { CacheService } from "../cache/cache.module.js";
import {
createDataLoaders,
type StudentBffDataLoaders,
} from "../../student/dataloaders/data-loader.module.js";
import { import {
extractUserIdFromRequest, extractUserIdFromRequest,
extractTraceIdFromRequest, extractTraceIdFromRequest,
extractUserRolesFromRequest, extractUserRolesFromRequest,
} from "../../student/guards/authorization.guard.js"; } from "../../student/guards/authorization.guard.js";
/**
* makeExecutableSchema 接受的 resolver 类型 (从函数签名推断, 避免额外依赖).
*/
type SchemaResolvers = NonNullable<
Parameters<typeof makeExecutableSchema>[0]["resolvers"]
>;
/** /**
* GraphQL Context (每个请求一份). * GraphQL Context (每个请求一份).
*/ */
@@ -36,6 +47,7 @@ export interface StudentBffContext {
userRoles: string[]; userRoles: string[];
downstream: DownstreamClient; downstream: DownstreamClient;
redis: Redis; redis: Redis;
cache: CacheService;
dataLoaders: StudentBffDataLoaders; dataLoaders: StudentBffDataLoaders;
requestId: string; requestId: string;
} }
@@ -69,12 +81,16 @@ export async function loadSchemaSDL(): Promise<string> {
* @param resolvers GraphQL Resolver 映射表 (由 StudentModule 装配) * @param resolvers GraphQL Resolver 映射表 (由 StudentModule 装配)
* @param downstream DownstreamClient 实例 (由 NestJS DI 注入) * @param downstream DownstreamClient 实例 (由 NestJS DI 注入)
* @param redis Redis 客户端 (由 NestJS DI 注入) * @param redis Redis 客户端 (由 NestJS DI 注入)
* @param cache CacheService 实例 (由 NestJS DI 注入)
*/ */
export async function createStudentBffYoga( export async function createStudentBffYoga(
resolvers: Record<string, unknown>, resolvers: SchemaResolvers,
downstream: DownstreamClient, downstream: DownstreamClient,
redis: Redis, redis: Redis,
): Promise<YogaServerInstance<Record<string, unknown>, StudentBffContext>> { cache: CacheService,
): Promise<
YogaServerInstance<{ req: Request; res: Response }, StudentBffContext>
> {
const typeDefs = await loadSchemaSDL(); const typeDefs = await loadSchemaSDL();
const schema = makeExecutableSchema({ const schema = makeExecutableSchema({
@@ -82,10 +98,13 @@ export async function createStudentBffYoga(
resolvers, resolvers,
}); });
const yoga = createYoga<{ const yoga = createYoga<
req: Request; {
res: Response; req: Request;
}, StudentBffContext>({ res: Response;
},
StudentBffContext
>({
schema, schema,
graphqlEndpoint: "/graphql", graphqlEndpoint: "/graphql",
context: ({ req }): StudentBffContext => { context: ({ req }): StudentBffContext => {
@@ -98,6 +117,7 @@ export async function createStudentBffYoga(
userRoles, userRoles,
downstream, downstream,
redis, redis,
cache,
dataLoaders: createDataLoaders(downstream), dataLoaders: createDataLoaders(downstream),
requestId: traceId, requestId: traceId,
}; };
@@ -111,26 +131,6 @@ export async function createStudentBffYoga(
maskedErrors: env.NODE_ENV === "production", maskedErrors: env.NODE_ENV === "production",
// 开发环境启用 Playground // 开发环境启用 Playground
graphiql: env.GRAPHQL_PLAYGROUND && env.NODE_ENV === "development", graphiql: env.GRAPHQL_PLAYGROUND && env.NODE_ENV === "development",
// 错误格式化 (G8): GraphQL errors 数组 + extensions.code + extensions.traceId
formatError: (err) => {
const originalError = err.originalError;
const code =
(originalError as { code?: string })?.code ??
"BFF_STUDENT_INTERNAL_ERROR";
const traceId = err.context?.requestId ?? "unknown";
return {
message: err.message,
extensions: {
code,
traceId,
i18nKey: `error.bffStudent.${code.replace(/^BFF_STUDENT_/, "").toLowerCase()}`,
severity: "error",
},
path: err.path,
locations: err.locations,
};
},
}); });
logger.info( logger.info(

View File

@@ -127,33 +127,35 @@ export function recordDownstreamCall(
errorType?: string, errorType?: string,
): void { ): void {
const labels = { service, method, status }; const labels = { service, method, status };
registry (
.getSingleMetric("student_bff_downstream_calls_total") registry.getSingleMetric("student_bff_downstream_calls_total") as
?.inc(labels); promClient.Counter<string> | undefined
registry )?.inc(labels);
.getSingleMetric("student_bff_downstream_duration_seconds") (
?.observe({ service, method }, durationMs / 1000); registry.getSingleMetric("student_bff_downstream_duration_seconds") as
promClient.Histogram<string> | undefined
)?.observe({ service, method }, durationMs / 1000);
if (status === "error" && errorType) { if (status === "error" && errorType) {
registry (
.getSingleMetric("student_bff_downstream_errors_total") registry.getSingleMetric("student_bff_downstream_errors_total") as
?.inc({ service, method, error_type: errorType }); promClient.Counter<string> | undefined
)?.inc({ service, method, error_type: errorType });
} }
} }
/** /**
* 缓存命中/未命中指标辅助器. * 缓存命中/未命中指标辅助器.
*/ */
export function recordCacheAccess( export function recordCacheAccess(keyPattern: string, hit: boolean): void {
keyPattern: string,
hit: boolean,
): void {
if (hit) { if (hit) {
registry (
.getSingleMetric("student_bff_cache_hits_total") registry.getSingleMetric("student_bff_cache_hits_total") as
?.inc({ cache_key_pattern: keyPattern }); promClient.Counter<string> | undefined
)?.inc({ cache_key_pattern: keyPattern });
} else { } else {
registry (
.getSingleMetric("student_bff_cache_misses_total") registry.getSingleMetric("student_bff_cache_misses_total") as
?.inc({ cache_key_pattern: keyPattern }); promClient.Counter<string> | undefined
)?.inc({ cache_key_pattern: keyPattern });
} }
} }

View File

@@ -21,8 +21,14 @@
* *
* 幂等性: Redis SETNX event_id 去重 (workline §5.4) * 幂等性: Redis SETNX event_id 去重 (workline §5.4)
*/ */
import { Injectable, OnModuleDestroy, OnModuleInit, Inject } from "@nestjs/common"; import {
Injectable,
OnModuleDestroy,
OnModuleInit,
Inject,
} from "@nestjs/common";
import { Kafka, type Consumer, type EachMessagePayload } from "kafkajs"; import { Kafka, type Consumer, type EachMessagePayload } from "kafkajs";
import promClient from "prom-client";
import { REDIS_CLIENT, CacheService } from "../../shared/cache/cache.module.js"; import { REDIS_CLIENT, CacheService } from "../../shared/cache/cache.module.js";
import type { Redis } from "ioredis"; import type { Redis } from "ioredis";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
@@ -126,7 +132,10 @@ export class EventSubscriberService implements OnModuleInit, OnModuleDestroy {
const { topic, partition, message } = payload; const { topic, partition, message } = payload;
const eventStr = message.value?.toString("utf-8"); const eventStr = message.value?.toString("utf-8");
if (!eventStr) { if (!eventStr) {
logger.warn({ topic, partition, offset: message.offset }, "Empty Kafka message"); logger.warn(
{ topic, partition, offset: message.offset },
"Empty Kafka message",
);
return; return;
} }
@@ -149,15 +158,22 @@ export class EventSubscriberService implements OnModuleInit, OnModuleDestroy {
} }
// 指标记录 // 指标记录
metricsRegistry (
.getSingleMetric("student_bff_event_consumed_total") metricsRegistry.getSingleMetric("student_bff_event_consumed_total") as
?.inc({ topic, event_type: event.event_type }); promClient.Counter<string> | undefined
)?.inc({ topic, event_type: event.event_type });
// 失效相关缓存 + 推送给学生 // 失效相关缓存 + 推送给学生
const studentId = event.student_id ?? event.user_id; const studentId = event.student_id ?? event.user_id;
if (studentId) { if (studentId) {
await this.invalidateCache(topic, studentId); await this.invalidateCache(topic, studentId);
await this.pushGateway.pushToStudent(studentId, topic, event.event_type, event.payload, event.timestamp); await this.pushGateway.pushToStudent(
studentId,
topic,
event.event_type,
event.payload,
event.timestamp,
);
} }
} catch (err) { } catch (err) {
logger.error( logger.error(
@@ -170,7 +186,10 @@ export class EventSubscriberService implements OnModuleInit, OnModuleDestroy {
/** /**
* 按 topic 失效相关缓存. * 按 topic 失效相关缓存.
*/ */
private async invalidateCache(topic: string, studentId: string): Promise<void> { private async invalidateCache(
topic: string,
studentId: string,
): Promise<void> {
try { try {
if (topic.startsWith("edu.teaching.homework")) { if (topic.startsWith("edu.teaching.homework")) {
await this.cacheService.invalidate("homework", studentId); await this.cacheService.invalidate("homework", studentId);
@@ -185,7 +204,9 @@ export class EventSubscriberService implements OnModuleInit, OnModuleDestroy {
await this.cacheService.invalidate("viewports", studentId); await this.cacheService.invalidate("viewports", studentId);
await this.cacheService.invalidate("dashboard", studentId); await this.cacheService.invalidate("dashboard", studentId);
} else if (topic === "edu.notification.sent") { } else if (topic === "edu.notification.sent") {
await this.cacheService.invalidateByPrefix(`notifications:${studentId}`); await this.cacheService.invalidateByPrefix(
`notifications:${studentId}`,
);
} }
} catch (err) { } catch (err) {
logger.warn({ err, topic, studentId }, "Cache invalidation failed"); logger.warn({ err, topic, studentId }, "Cache invalidation failed");

View File

@@ -113,7 +113,9 @@ describe("PushGatewayService", () => {
}); });
it("should return success=false when fetch throws AbortError (timeout)", async () => { it("should return success=false when fetch throws AbortError (timeout)", async () => {
fetchMock.mockRejectedValue(new Error("The operation was aborted due to timeout")); fetchMock.mockRejectedValue(
new Error("The operation was aborted due to timeout"),
);
const result = await service.pushToStudent( const result = await service.pushToStudent(
"u-stu-001", "u-stu-001",
@@ -139,10 +141,13 @@ describe("PushGatewayService", () => {
); );
const callArgs = fetchMock.mock.calls[0]; const callArgs = fetchMock.mock.calls[0];
const body = JSON.parse(callArgs[1].body as string); const body = JSON.parse(callArgs![1].body as string);
expect(body.type).toBe("homework.assigned"); expect(body.type).toBe("homework.assigned");
expect(body.topic).toBe("edu.teaching.homework.assigned"); expect(body.topic).toBe("edu.teaching.homework.assigned");
expect(body.payload).toEqual({ homeworkId: "hw-001", title: "Math Chapter 3" }); expect(body.payload).toEqual({
homeworkId: "hw-001",
title: "Math Chapter 3",
});
expect(body.timestamp).toBe("2026-07-10T10:00:00Z"); expect(body.timestamp).toBe("2026-07-10T10:00:00Z");
}); });
}); });

View File

@@ -16,6 +16,7 @@
* - 未来可直接由 resolver 调用 (如主动推送通知) * - 未来可直接由 resolver 调用 (如主动推送通知)
*/ */
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import promClient from "prom-client";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
import { logger } from "../../shared/observability/logger.js"; import { logger } from "../../shared/observability/logger.js";
import { metricsRegistry } from "../../shared/observability/metrics.js"; import { metricsRegistry } from "../../shared/observability/metrics.js";
@@ -80,9 +81,10 @@ export class PushGatewayService {
); );
const pushStatus = response.ok ? "success" : `http_${response.status}`; const pushStatus = response.ok ? "success" : `http_${response.status}`;
metricsRegistry (
.getSingleMetric("student_bff_event_pushed_total") metricsRegistry.getSingleMetric("student_bff_event_pushed_total") as
?.inc({ topic, push_status: pushStatus }); promClient.Counter<string> | undefined
)?.inc({ topic, push_status: pushStatus });
if (!response.ok) { if (!response.ok) {
logger.warn( logger.warn(
@@ -97,9 +99,10 @@ export class PushGatewayService {
{ err, studentId, topic }, { err, studentId, topic },
"Push-gateway push failed (soft failure)", "Push-gateway push failed (soft failure)",
); );
metricsRegistry (
.getSingleMetric("student_bff_event_pushed_total") metricsRegistry.getSingleMetric("student_bff_event_pushed_total") as
?.inc({ topic, push_status: "error" }); promClient.Counter<string> | undefined
)?.inc({ topic, push_status: "error" });
return { return {
success: false, success: false,

View File

@@ -19,7 +19,10 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import type { StudentBffContext } from "../../shared/graphql/yoga.js"; import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { UnauthorizedError, ValidationError } from "../../shared/errors/application-error.js"; import {
UnauthorizedError,
ValidationError,
} from "../../shared/errors/application-error.js";
const AIStreamChatInputSchema = z.object({ const AIStreamChatInputSchema = z.object({
messages: z messages: z
@@ -45,7 +48,7 @@ const AIStreamChatInputSchema = z.object({
/** /**
* AI 流式响应 chunk 结构 (对齐 ai.StreamChat gRPC stream). * AI 流式响应 chunk 结构 (对齐 ai.StreamChat gRPC stream).
*/ */
interface AIStreamChunk { export interface AIStreamChunk {
content: string; content: string;
done: boolean; done: boolean;
model?: string; model?: string;
@@ -85,28 +88,40 @@ export const aiStreamResolvers = {
); );
} }
const input = parseResult.data; const input = parseResult.data;
const userId = ctx.userId;
return (async function* (): AsyncGenerator<{ aiStreamChat: AIStreamChunk }> { return (async function* (): AsyncGenerator<{
aiStreamChat: AIStreamChunk;
}> {
try { try {
// 调用 ai.StreamChat (gRPC server-streaming) // 调用 ai.StreamChat (gRPC server-streaming)
// DownstreamClient.callStream 返回 AsyncIterable // DownstreamClient.callStream 返回 AsyncIterable
const stream = ctx.downstream.callStream("ai", "StreamChat", { const stream = ctx.downstream.callStream(
userId: ctx.userId, "ai",
messages: input.messages, "StreamChat",
model: input.model, {
context: input.context, userId,
}, { messages: input.messages,
traceId: ctx.traceId, model: input.model,
metadata: { "x-user-id": ctx.userId }, context: input.context,
timeoutMs: 60000, // 流式调用 60s 超时 },
}); {
traceId: ctx.traceId,
metadata: { "x-user-id": userId },
timeoutMs: 60000, // 流式调用 60s 超时
},
);
for await (const chunk of stream) { for await (const chunk of stream) {
const typed = chunk as { const typed = chunk as {
content?: string; content?: string;
done?: boolean; done?: boolean;
model?: string; model?: string;
usage?: { promptTokens: number; completionTokens: number; totalTokens: number }; usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}; };
yield { yield {

View File

@@ -32,20 +32,31 @@ export const analyticsResolvers = {
assertOwnData(ctx.userId, args.studentId); assertOwnData(ctx.userId, args.studentId);
const cached = await ctx.redis.get<unknown>("analytics:weakness", ctx.userId); const cached = await ctx.cache.get<unknown>(
"analytics:weakness",
ctx.userId,
);
if (cached) { if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() }); return ok(cached, {
traceId: ctx.traceId,
cachedAt: new Date().toISOString(),
});
} }
try { try {
const result = await ctx.downstream.call("data-ana", "GetStudentWeakness", { const result = await ctx.downstream.call(
studentId: ctx.userId, "data-ana",
}, { "GetStudentWeakness",
traceId: ctx.traceId, {
metadata: { "x-user-id": ctx.userId }, studentId: ctx.userId,
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
await ctx.redis.set( await ctx.cache.set(
"analytics:weakness", "analytics:weakness",
result, result,
CacheTTL.ANALYTICS_WEAKNESS, CacheTTL.ANALYTICS_WEAKNESS,
@@ -79,21 +90,34 @@ export const analyticsResolvers = {
const range = args.range ?? "30d"; const range = args.range ?? "30d";
const cacheKey = `${ctx.userId}:${range}`; const cacheKey = `${ctx.userId}:${range}`;
const cached = await ctx.redis.get<unknown>("analytics:trend", cacheKey); const cached = await ctx.cache.get<unknown>("analytics:trend", cacheKey);
if (cached) { if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() }); return ok(cached, {
traceId: ctx.traceId,
cachedAt: new Date().toISOString(),
});
} }
try { try {
const result = await ctx.downstream.call("data-ana", "GetLearningTrend", { const result = await ctx.downstream.call(
studentId: ctx.userId, "data-ana",
range, "GetLearningTrend",
}, { {
traceId: ctx.traceId, studentId: ctx.userId,
metadata: { "x-user-id": ctx.userId }, range,
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
await ctx.redis.set("analytics:trend", result, CacheTTL.ANALYTICS_TREND, cacheKey); await ctx.cache.set(
"analytics:trend",
result,
CacheTTL.ANALYTICS_TREND,
cacheKey,
);
return ok(result, { traceId: ctx.traceId }); return ok(result, { traceId: ctx.traceId });
} catch (err) { } catch (err) {
return fail( return fail(

View File

@@ -23,7 +23,12 @@ export const contentResolvers = {
*/ */
async textbooks( async textbooks(
_parent: unknown, _parent: unknown,
args: { gradeId?: string; subjectId?: string; page?: number; pageSize?: number }, args: {
gradeId?: string;
subjectId?: string;
page?: number;
pageSize?: number;
},
ctx: StudentBffContext, ctx: StudentBffContext,
): Promise<unknown> { ): Promise<unknown> {
if (!ctx.userId) { if (!ctx.userId) {
@@ -31,24 +36,32 @@ export const contentResolvers = {
} }
const cacheKey = `${args.gradeId ?? "all"}:${args.subjectId ?? "all"}`; const cacheKey = `${args.gradeId ?? "all"}:${args.subjectId ?? "all"}`;
const cached = await ctx.redis.get<unknown>("textbooks", cacheKey); const cached = await ctx.cache.get<unknown>("textbooks", cacheKey);
if (cached) { if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() }); return ok(cached, {
traceId: ctx.traceId,
cachedAt: new Date().toISOString(),
});
} }
try { try {
const result = await ctx.downstream.call("content", "ListTextbooks", { const result = await ctx.downstream.call(
gradeId: args.gradeId, "content",
subjectId: args.subjectId, "ListTextbooks",
page: args.page ?? 1, {
pageSize: Math.min(args.pageSize ?? 20, 50), gradeId: args.gradeId,
}, { subjectId: args.subjectId,
traceId: ctx.traceId, page: args.page ?? 1,
metadata: { "x-user-id": ctx.userId }, pageSize: Math.min(args.pageSize ?? 20, 50),
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
const data = result as { textbooks: unknown[] }; const data = result as { textbooks: unknown[] };
await ctx.redis.set("textbooks", data, CacheTTL.TEXTBOOKS, cacheKey); await ctx.cache.set("textbooks", data, CacheTTL.TEXTBOOKS, cacheKey);
return ok(data, { traceId: ctx.traceId }); return ok(data, { traceId: ctx.traceId });
} catch (err) { } catch (err) {
return fail( return fail(
@@ -72,21 +85,34 @@ export const contentResolvers = {
throw new UnauthorizedError(); throw new UnauthorizedError();
} }
const cached = await ctx.redis.get<unknown>("chapters", args.textbookId); const cached = await ctx.cache.get<unknown>("chapters", args.textbookId);
if (cached) { if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() }); return ok(cached, {
traceId: ctx.traceId,
cachedAt: new Date().toISOString(),
});
} }
try { try {
const result = await ctx.downstream.call("content", "ListChapters", { const result = await ctx.downstream.call(
textbookId: args.textbookId, "content",
}, { "ListChapters",
traceId: ctx.traceId, {
metadata: { "x-user-id": ctx.userId }, textbookId: args.textbookId,
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
const data = result as { chapters: unknown[] }; const data = result as { chapters: unknown[] };
await ctx.redis.set("chapters", data, CacheTTL.CHAPTERS, args.textbookId); await ctx.cache.set(
"chapters",
data,
CacheTTL.CHAPTERS,
args.textbookId,
);
return ok(data, { traceId: ctx.traceId }); return ok(data, { traceId: ctx.traceId });
} catch (err) { } catch (err) {
return fail( return fail(
@@ -112,13 +138,18 @@ export const contentResolvers = {
} }
try { try {
const result = await ctx.downstream.call("content", "GetLearningPath", { const result = await ctx.downstream.call(
studentId: ctx.userId, "content",
knowledgePointId: args.knowledgePointId, "GetLearningPath",
}, { {
traceId: ctx.traceId, studentId: ctx.userId,
metadata: { "x-user-id": ctx.userId }, knowledgePointId: args.knowledgePointId,
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
return ok(result, { traceId: ctx.traceId }); return ok(result, { traceId: ctx.traceId });
} catch (err) { } catch (err) {

View File

@@ -19,7 +19,12 @@
*/ */
import type { StudentBffContext } from "../../shared/graphql/yoga.js"; import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import type { DownstreamResponse } from "@edu/shared-ts/bff"; import type { DownstreamResponse } from "@edu/shared-ts/bff";
import { ok, fail, degraded, DegradedReason } from "../../shared/action-state.js"; import {
ok,
fail,
degraded,
DegradedReason,
} from "../../shared/action-state.js";
import { CacheTTL } from "../../shared/cache/cache.module.js"; import { CacheTTL } from "../../shared/cache/cache.module.js";
import { UnauthorizedError } from "../../shared/errors/application-error.js"; import { UnauthorizedError } from "../../shared/errors/application-error.js";
@@ -40,10 +45,12 @@ export const dashboardResolvers = {
} }
// 缓存命中检查 // 缓存命中检查
const cacheKey = ["dashboard", ctx.userId] as const; const cached = await ctx.cache.get<unknown>("dashboard", ctx.userId);
const cached = await ctx.redis.get<unknown>("dashboard", ctx.userId);
if (cached) { if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() }); return ok(cached, {
traceId: ctx.traceId,
cachedAt: new Date().toISOString(),
});
} }
// 并行调用下游 (Promise.allSettled 容错) // 并行调用下游 (Promise.allSettled 容错)
@@ -52,42 +59,62 @@ export const dashboardResolvers = {
service: "iam", service: "iam",
method: "GetUserInfo", method: "GetUserInfo",
request: { userId: ctx.userId }, request: { userId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } }, options: {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
}, },
{ {
service: "core-edu", service: "core-edu",
method: "ListHomeworkByStudent", method: "ListHomeworkByStudent",
request: { studentId: ctx.userId, status: "pending" }, request: { studentId: ctx.userId, status: "pending" },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } }, options: {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
}, },
{ {
service: "core-edu", service: "core-edu",
method: "ListExamsByClass", method: "ListExamsByClass",
request: { studentId: ctx.userId }, request: { studentId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } }, options: {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
}, },
{ {
service: "core-edu", service: "core-edu",
method: "ListGradesByStudent", method: "ListGradesByStudent",
request: { studentId: ctx.userId, page: 1, pageSize: 1 }, request: { studentId: ctx.userId, page: 1, pageSize: 1 },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } }, options: {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
}, },
{ {
service: "data-ana", service: "data-ana",
method: "GetStudentDashboard", method: "GetStudentDashboard",
request: { studentId: ctx.userId }, request: { studentId: ctx.userId },
options: { traceId: ctx.traceId, metadata: { "x-user-id": ctx.userId } }, options: {
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
}, },
] as const); ] as const);
const [userInfoResp, homeworkResp, examsResp, gradesResp, dashboardAnaResp] = const [
results as [ userInfoResp,
DownstreamResponse<unknown>, homeworkResp,
DownstreamResponse<unknown>, examsResp,
DownstreamResponse<unknown>, gradesResp,
DownstreamResponse<unknown>, dashboardAnaResp,
DownstreamResponse<unknown>, ] = results as [
]; DownstreamResponse<unknown>,
DownstreamResponse<unknown>,
DownstreamResponse<unknown>,
DownstreamResponse<unknown>,
DownstreamResponse<unknown>,
];
// 必需字段失败检查 // 必需字段失败检查
if (!userInfoResp.success) { if (!userInfoResp.success) {
@@ -113,13 +140,13 @@ export const dashboardResolvers = {
// 容错聚合: 各字段独立降级 // 容错聚合: 各字段独立降级
const degradedFields: string[] = []; const degradedFields: string[] = [];
const pendingHomework = homeworkResp.success const pendingHomework = homeworkResp.success
? (homeworkResp.data as { homework: unknown[] }).homework ?? [] ? ((homeworkResp.data as { homework: unknown[] }).homework ?? [])
: (degradedFields.push("pendingHomework"), []); : (degradedFields.push("pendingHomework"), []);
const upcomingExams = examsResp.success const upcomingExams = examsResp.success
? (examsResp.data as { exams: unknown[] }).exams ?? [] ? ((examsResp.data as { exams: unknown[] }).exams ?? [])
: (degradedFields.push("upcomingExams"), []); : (degradedFields.push("upcomingExams"), []);
const lastGrade = gradesResp.success const lastGrade = gradesResp.success
? ((gradesResp.data as { grades: unknown[] }).grades ?? [])[0] ?? null ? (((gradesResp.data as { grades: unknown[] }).grades ?? [])[0] ?? null)
: (degradedFields.push("lastGrade"), null); : (degradedFields.push("lastGrade"), null);
const analyticsSummary = dashboardAnaResp.success const analyticsSummary = dashboardAnaResp.success
? dashboardAnaResp.data ? dashboardAnaResp.data
@@ -141,7 +168,7 @@ export const dashboardResolvers = {
}; };
// 写缓存 // 写缓存
await ctx.redis.set("dashboard", data, CacheTTL.DASHBOARD, ctx.userId); await ctx.cache.set("dashboard", data, CacheTTL.DASHBOARD, ctx.userId);
if (degradedFields.length > 0) { if (degradedFields.length > 0) {
return degraded( return degraded(

View File

@@ -28,23 +28,31 @@ export const examsResolvers = {
} }
const cacheKey = `${ctx.userId}:${args.classId ?? "all"}:${args.status ?? "all"}`; const cacheKey = `${ctx.userId}:${args.classId ?? "all"}:${args.status ?? "all"}`;
const cached = await ctx.redis.get<unknown>("exams", cacheKey); const cached = await ctx.cache.get<unknown>("exams", cacheKey);
if (cached) { if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() }); return ok(cached, {
traceId: ctx.traceId,
cachedAt: new Date().toISOString(),
});
} }
try { try {
const result = await ctx.downstream.call("core-edu", "ListExamsByClass", { const result = await ctx.downstream.call(
studentId: ctx.userId, "core-edu",
classId: args.classId, "ListExamsByClass",
status: args.status ?? "upcoming", {
}, { studentId: ctx.userId,
traceId: ctx.traceId, classId: args.classId,
metadata: { "x-user-id": ctx.userId }, status: args.status ?? "upcoming",
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
const data = result as { exams: unknown[] }; const data = result as { exams: unknown[] };
await ctx.redis.set("exams", data, CacheTTL.EXAMS, cacheKey); await ctx.cache.set("exams", data, CacheTTL.EXAMS, cacheKey);
return ok(data, { traceId: ctx.traceId }); return ok(data, { traceId: ctx.traceId });
} catch (err) { } catch (err) {
return fail( return fail(

View File

@@ -40,24 +40,32 @@ export const gradesResolvers = {
const pageSize = Math.min(args.pageSize ?? 20, 50); const pageSize = Math.min(args.pageSize ?? 20, 50);
const cacheKey = `${ctx.userId}:${page}:${args.subject ?? "all"}`; const cacheKey = `${ctx.userId}:${page}:${args.subject ?? "all"}`;
const cached = await ctx.redis.get<unknown>("grades", cacheKey); const cached = await ctx.cache.get<unknown>("grades", cacheKey);
if (cached) { if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() }); return ok(cached, {
traceId: ctx.traceId,
cachedAt: new Date().toISOString(),
});
} }
try { try {
const result = await ctx.downstream.call("core-edu", "ListGradesByStudent", { const result = await ctx.downstream.call(
studentId: ctx.userId, "core-edu",
subject: args.subject, "ListGradesByStudent",
page, {
pageSize, studentId: ctx.userId,
}, { subject: args.subject,
traceId: ctx.traceId, page,
metadata: { "x-user-id": ctx.userId }, pageSize,
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
const data = result as { grades: unknown[]; totalCount: number }; const data = result as { grades: unknown[]; totalCount: number };
await ctx.redis.set("grades", data, CacheTTL.GRADES, cacheKey); await ctx.cache.set("grades", data, CacheTTL.GRADES, cacheKey);
return ok(data, { traceId: ctx.traceId }); return ok(data, { traceId: ctx.traceId });
} catch (err) { } catch (err) {
return fail( return fail(

View File

@@ -8,20 +8,27 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { homeworkResolvers } from "./homework.resolver.js"; import { homeworkResolvers } from "./homework.resolver.js";
import type { StudentBffContext } from "../../shared/graphql/yoga.js"; import type { StudentBffContext } from "../../shared/graphql/yoga.js";
import { UnauthorizedError, ValidationError, ForbiddenResourceError } from "../../shared/errors/application-error.js"; import {
UnauthorizedError,
ValidationError,
ForbiddenResourceError,
} from "../../shared/errors/application-error.js";
// Mock env to disable DEV_MODE // Mock env to disable DEV_MODE
vi.mock("../../config/env.js", () => ({ vi.mock("../../config/env.js", () => ({
env: { DEV_MODE: false, NODE_ENV: "test" }, env: { DEV_MODE: false, NODE_ENV: "test" },
})); }));
function mockContext(overrides: Partial<StudentBffContext> = {}): StudentBffContext { function mockContext(
overrides: Partial<StudentBffContext> = {},
): StudentBffContext {
const downstream = { const downstream = {
call: vi.fn(), call: vi.fn(),
callStream: vi.fn(), callStream: vi.fn(),
callAll: vi.fn(), callAll: vi.fn(),
}; };
const redis = { const redis = {};
const cache = {
get: vi.fn().mockResolvedValue(null), get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined), set: vi.fn().mockResolvedValue(undefined),
invalidate: vi.fn().mockResolvedValue(undefined), invalidate: vi.fn().mockResolvedValue(undefined),
@@ -35,6 +42,7 @@ function mockContext(overrides: Partial<StudentBffContext> = {}): StudentBffCont
userRoles: ["student"], userRoles: ["student"],
downstream: downstream as never, downstream: downstream as never,
redis: redis as never, redis: redis as never,
cache: cache as never,
dataLoaders, dataLoaders,
requestId: "trace-test-001", requestId: "trace-test-001",
...overrides, ...overrides,
@@ -58,11 +66,15 @@ describe("homeworkResolvers", () => {
it("should return cached data when cache hits", async () => { it("should return cached data when cache hits", async () => {
const cachedData = { homework: [{ id: "hw-001" }] }; const cachedData = { homework: [{ id: "hw-001" }] };
ctx.redis.get = vi.fn().mockResolvedValue(cachedData); ctx.cache.get = vi.fn().mockResolvedValue(cachedData);
const result = await homeworkResolvers.Query.myHomework(null, {}, ctx); const result = (await homeworkResolvers.Query.myHomework(
null,
{},
ctx,
)) as { success: boolean; data: unknown };
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect((result as { data: unknown }).data).toEqual(cachedData); expect(result.data).toEqual(cachedData);
expect(ctx.downstream.call).not.toHaveBeenCalled(); expect(ctx.downstream.call).not.toHaveBeenCalled();
}); });
@@ -70,12 +82,11 @@ describe("homeworkResolvers", () => {
const downstreamData = { homework: [{ id: "hw-001", title: "Math HW" }] }; const downstreamData = { homework: [{ id: "hw-001", title: "Math HW" }] };
ctx.downstream.call = vi.fn().mockResolvedValue(downstreamData); ctx.downstream.call = vi.fn().mockResolvedValue(downstreamData);
const result = await homeworkResolvers.Query.myHomework( const result = (await homeworkResolvers.Query.myHomework(
null, null,
{ status: "ASSIGNED", classId: "c-001" }, { status: "ASSIGNED", classId: "c-001" },
ctx, ctx,
); )) as { success: boolean };
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(ctx.downstream.call).toHaveBeenCalledWith( expect(ctx.downstream.call).toHaveBeenCalledWith(
"core-edu", "core-edu",
@@ -86,15 +97,21 @@ describe("homeworkResolvers", () => {
metadata: { "x-user-id": "u-stu-001" }, metadata: { "x-user-id": "u-stu-001" },
}), }),
); );
expect(ctx.redis.set).toHaveBeenCalled(); expect(ctx.cache.set).toHaveBeenCalled();
}); });
it("should return fail response when downstream fails", async () => { it("should return fail response when downstream fails", async () => {
ctx.downstream.call = vi.fn().mockRejectedValue(new Error("gRPC unavailable")); ctx.downstream.call = vi
.fn()
.mockRejectedValue(new Error("gRPC unavailable"));
const result = await homeworkResolvers.Query.myHomework(null, {}, ctx); const result = (await homeworkResolvers.Query.myHomework(
null,
{},
ctx,
)) as { success: boolean; error: { code: string } };
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect((result as { error: { code: string } }).error.code).toBe("BFF_STUDENT_BAD_GATEWAY"); expect(result.error.code).toBe("BFF_STUDENT_BAD_GATEWAY");
}); });
}); });
@@ -102,15 +119,17 @@ describe("homeworkResolvers", () => {
const validInput = { const validInput = {
homeworkId: "hw-001", homeworkId: "hw-001",
studentId: "u-stu-001", studentId: "u-stu-001",
answers: [ answers: [{ questionId: "q-001", content: "My answer" }],
{ questionId: "q-001", content: "My answer" },
],
}; };
it("should throw UnauthorizedError when userId is null", async () => { it("should throw UnauthorizedError when userId is null", async () => {
ctx.userId = null; ctx.userId = null;
await expect( await expect(
homeworkResolvers.Mutation.submitHomework(null, { input: validInput }, ctx), homeworkResolvers.Mutation.submitHomework(
null,
{ input: validInput },
ctx,
),
).rejects.toThrow(UnauthorizedError); ).rejects.toThrow(UnauthorizedError);
}); });
@@ -130,7 +149,11 @@ describe("homeworkResolvers", () => {
studentId: "u-stu-002", studentId: "u-stu-002",
}; };
await expect( await expect(
homeworkResolvers.Mutation.submitHomework(null, { input: maliciousInput }, ctx), homeworkResolvers.Mutation.submitHomework(
null,
{ input: maliciousInput },
ctx,
),
).rejects.toThrow(ForbiddenResourceError); ).rejects.toThrow(ForbiddenResourceError);
}); });
@@ -143,11 +166,11 @@ describe("homeworkResolvers", () => {
}; };
ctx.downstream.call = vi.fn().mockResolvedValue(submitResult); ctx.downstream.call = vi.fn().mockResolvedValue(submitResult);
const result = await homeworkResolvers.Mutation.submitHomework( const result = (await homeworkResolvers.Mutation.submitHomework(
null, null,
{ input: validInput }, { input: validInput },
ctx, ctx,
); )) as { success: boolean };
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(ctx.downstream.call).toHaveBeenCalledWith( expect(ctx.downstream.call).toHaveBeenCalledWith(
@@ -162,8 +185,14 @@ describe("homeworkResolvers", () => {
metadata: { "x-user-id": "u-stu-001" }, metadata: { "x-user-id": "u-stu-001" },
}), }),
); );
expect(ctx.redis.invalidate).toHaveBeenCalledWith("homework", "u-stu-001"); expect(ctx.cache.invalidate).toHaveBeenCalledWith(
expect(ctx.redis.invalidate).toHaveBeenCalledWith("dashboard", "u-stu-001"); "homework",
"u-stu-001",
);
expect(ctx.cache.invalidate).toHaveBeenCalledWith(
"dashboard",
"u-stu-001",
);
}); });
it("should submit successfully when studentId is not provided in input", async () => { it("should submit successfully when studentId is not provided in input", async () => {
@@ -178,26 +207,28 @@ describe("homeworkResolvers", () => {
status: "SUBMITTED", status: "SUBMITTED",
}); });
const result = await homeworkResolvers.Mutation.submitHomework( const result = (await homeworkResolvers.Mutation.submitHomework(
null, null,
{ input: inputWithoutStudentId }, { input: inputWithoutStudentId },
ctx, ctx,
); )) as { success: boolean };
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
it("should return fail response when downstream fails", async () => { it("should return fail response when downstream fails", async () => {
ctx.downstream.call = vi.fn().mockRejectedValue(new Error("Submission failed")); ctx.downstream.call = vi
.fn()
.mockRejectedValue(new Error("Submission failed"));
const result = await homeworkResolvers.Mutation.submitHomework( const result = (await homeworkResolvers.Mutation.submitHomework(
null, null,
{ input: validInput }, { input: validInput },
ctx, ctx,
); )) as { success: boolean; error: { code: string } };
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect((result as { error: { code: string } }).error.code).toBe("BFF_STUDENT_BAD_GATEWAY"); expect(result.error.code).toBe("BFF_STUDENT_BAD_GATEWAY");
}); });
it("should validate answer content max length", async () => { it("should validate answer content max length", async () => {
@@ -207,7 +238,11 @@ describe("homeworkResolvers", () => {
answers: [{ questionId: "q-001", content: "x".repeat(10001) }], answers: [{ questionId: "q-001", content: "x".repeat(10001) }],
}; };
await expect( await expect(
homeworkResolvers.Mutation.submitHomework(null, { input: longContentInput }, ctx), homeworkResolvers.Mutation.submitHomework(
null,
{ input: longContentInput },
ctx,
),
).rejects.toThrow(ValidationError); ).rejects.toThrow(ValidationError);
}); });
@@ -224,7 +259,11 @@ describe("homeworkResolvers", () => {
], ],
}; };
await expect( await expect(
homeworkResolvers.Mutation.submitHomework(null, { input: invalidAttachmentInput }, ctx), homeworkResolvers.Mutation.submitHomework(
null,
{ input: invalidAttachmentInput },
ctx,
),
).rejects.toThrow(ValidationError); ).rejects.toThrow(ValidationError);
}); });
}); });

View File

@@ -53,23 +53,31 @@ export const homeworkResolvers = {
// 缓存命中 // 缓存命中
const cacheKey = ctx.userId + (args.classId ? `:${args.classId}` : ""); const cacheKey = ctx.userId + (args.classId ? `:${args.classId}` : "");
const cached = await ctx.redis.get<unknown>("homework", cacheKey); const cached = await ctx.cache.get<unknown>("homework", cacheKey);
if (cached) { if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() }); return ok(cached, {
traceId: ctx.traceId,
cachedAt: new Date().toISOString(),
});
} }
try { try {
const result = await ctx.downstream.call("core-edu", "ListHomeworkByStudent", { const result = await ctx.downstream.call(
studentId: ctx.userId, "core-edu",
status: args.status, "ListHomeworkByStudent",
classId: args.classId, {
}, { studentId: ctx.userId,
traceId: ctx.traceId, status: args.status,
metadata: { "x-user-id": ctx.userId }, classId: args.classId,
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
const data = result as { homework: unknown[] }; const data = result as { homework: unknown[] };
await ctx.redis.set("homework", data, CacheTTL.HOMEWORK, cacheKey); await ctx.cache.set("homework", data, CacheTTL.HOMEWORK, cacheKey);
return ok(data, { traceId: ctx.traceId }); return ok(data, { traceId: ctx.traceId });
} catch (err) { } catch (err) {
return fail( return fail(
@@ -110,18 +118,23 @@ export const homeworkResolvers = {
assertOwnData(ctx.userId, input.studentId); assertOwnData(ctx.userId, input.studentId);
try { try {
const result = await ctx.downstream.call("core-edu", "SubmitHomework", { const result = await ctx.downstream.call(
homeworkId: input.homeworkId, "core-edu",
studentId: ctx.userId, "SubmitHomework",
answers: input.answers, {
}, { homeworkId: input.homeworkId,
traceId: ctx.traceId, studentId: ctx.userId,
metadata: { "x-user-id": ctx.userId }, answers: input.answers,
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
// 失效相关缓存 // 失效相关缓存
await ctx.redis.invalidate("homework", ctx.userId); await ctx.cache.invalidate("homework", ctx.userId);
await ctx.redis.invalidate("dashboard", ctx.userId); await ctx.cache.invalidate("dashboard", ctx.userId);
const data = result as { const data = result as {
submissionId: string; submissionId: string;

View File

@@ -22,7 +22,6 @@
* Subscription: * Subscription:
* - aiStreamChat (ai-stream, P5 SSE) * - aiStreamChat (ai-stream, P5 SSE)
*/ */
import { mergeResolvers } from "@graphql-tools/merge";
import { authResolvers } from "./auth.resolver.js"; import { authResolvers } from "./auth.resolver.js";
import { dashboardResolvers } from "./dashboard.resolver.js"; import { dashboardResolvers } from "./dashboard.resolver.js";
import { homeworkResolvers } from "./homework.resolver.js"; import { homeworkResolvers } from "./homework.resolver.js";
@@ -36,21 +35,29 @@ import { aiResolvers } from "./ai.resolver.js";
import { aiStreamResolvers } from "./ai-stream.resolver.js"; import { aiStreamResolvers } from "./ai-stream.resolver.js";
/** /**
* 全部 Resolver 合并. * 全部 Resolver 手动合并 (spread 运算符).
* *
* 注意: 各 resolver 文件导出的对象结构为 { Query: {...}, Mutation: {...}, Subscription: {...} }, * 注意: 各 resolver 文件导出的对象结构为 { Query: {...}, Mutation: {...}, Subscription: {...} },
* mergeResolvers 自动合并同名 Query/Mutation/Subscription 字段. * 这里按 Query/Mutation/Subscription 分别合并字段.
*/ */
export const studentBffResolvers = mergeResolvers([ export const studentBffResolvers = {
authResolvers, Query: {
dashboardResolvers, ...(authResolvers.Query ?? {}),
homeworkResolvers, ...(dashboardResolvers.Query ?? {}),
gradesResolvers, ...(homeworkResolvers.Query ?? {}),
examsResolvers, ...(gradesResolvers.Query ?? {}),
classesResolvers, ...(examsResolvers.Query ?? {}),
contentResolvers, ...(classesResolvers.Query ?? {}),
analyticsResolvers, ...(contentResolvers.Query ?? {}),
notificationsResolvers, ...(analyticsResolvers.Query ?? {}),
aiResolvers, ...(notificationsResolvers.Query ?? {}),
aiStreamResolvers, ...(aiResolvers.Query ?? {}),
]); },
Mutation: {
...(homeworkResolvers.Mutation ?? {}),
...(notificationsResolvers.Mutation ?? {}),
},
Subscription: {
...(aiStreamResolvers.Subscription ?? {}),
},
};

View File

@@ -44,28 +44,41 @@ export const notificationsResolvers = {
const pageSize = Math.min(args.pageSize ?? 20, 50); const pageSize = Math.min(args.pageSize ?? 20, 50);
const cacheKey = `${ctx.userId}:${page}:${args.unreadOnly ?? false}`; const cacheKey = `${ctx.userId}:${page}:${args.unreadOnly ?? false}`;
const cached = await ctx.redis.get<unknown>("notifications", cacheKey); const cached = await ctx.cache.get<unknown>("notifications", cacheKey);
if (cached) { if (cached) {
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() }); return ok(cached, {
traceId: ctx.traceId,
cachedAt: new Date().toISOString(),
});
} }
try { try {
const result = await ctx.downstream.call("msg", "ListNotifications", { const result = await ctx.downstream.call(
userId: ctx.userId, "msg",
page, "ListNotifications",
pageSize, {
unreadOnly: args.unreadOnly ?? false, userId: ctx.userId,
}, { page,
traceId: ctx.traceId, pageSize,
metadata: { "x-user-id": ctx.userId }, unreadOnly: args.unreadOnly ?? false,
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
const data = result as { const data = result as {
notifications: unknown[]; notifications: unknown[];
totalCount: number; totalCount: number;
unreadCount: number; unreadCount: number;
}; };
await ctx.redis.set("notifications", data, CacheTTL.NOTIFICATIONS, cacheKey); await ctx.cache.set(
"notifications",
data,
CacheTTL.NOTIFICATIONS,
cacheKey,
);
return ok(data, { traceId: ctx.traceId }); return ok(data, { traceId: ctx.traceId });
} catch (err) { } catch (err) {
return fail( return fail(
@@ -90,12 +103,17 @@ export const notificationsResolvers = {
} }
try { try {
const result = await ctx.downstream.call("msg", "GetUnreadCount", { const result = await ctx.downstream.call(
userId: ctx.userId, "msg",
}, { "GetUnreadCount",
traceId: ctx.traceId, {
metadata: { "x-user-id": ctx.userId }, userId: ctx.userId,
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
const data = result as { unreadCount: number }; const data = result as { unreadCount: number };
return ok(data, { traceId: ctx.traceId }); return ok(data, { traceId: ctx.traceId });
@@ -137,16 +155,21 @@ export const notificationsResolvers = {
assertOwnData(ctx.userId, input.studentId); assertOwnData(ctx.userId, input.studentId);
try { try {
const result = await ctx.downstream.call("msg", "MarkNotificationAsRead", { const result = await ctx.downstream.call(
notificationId: input.notificationId, "msg",
userId: ctx.userId, "MarkNotificationAsRead",
}, { {
traceId: ctx.traceId, notificationId: input.notificationId,
metadata: { "x-user-id": ctx.userId }, userId: ctx.userId,
}); },
{
traceId: ctx.traceId,
metadata: { "x-user-id": ctx.userId },
},
);
// 失效通知缓存 // 失效通知缓存
await ctx.redis.invalidateByPrefix(`notifications:${ctx.userId}`); await ctx.cache.invalidateByPrefix(`notifications:${ctx.userId}`);
return ok(result, { traceId: ctx.traceId }); return ok(result, { traceId: ctx.traceId });
} catch (err) { } catch (err) {

View File

@@ -13,12 +13,16 @@
import { Module, OnModuleInit } from "@nestjs/common"; import { Module, OnModuleInit } from "@nestjs/common";
import { Inject } from "@nestjs/common"; import { Inject } from "@nestjs/common";
import { DownstreamClient } from "@edu/shared-ts/bff"; import { DownstreamClient } from "@edu/shared-ts/bff";
import { REDIS_CLIENT } from "../shared/cache/cache.module.js"; import { REDIS_CLIENT, CacheService } from "../shared/cache/cache.module.js";
import type { Redis } from "ioredis"; import type { Redis } from "ioredis";
import { createStudentBffYoga, type StudentBffContext } from "../shared/graphql/yoga.js"; import {
createStudentBffYoga,
type StudentBffContext,
} from "../shared/graphql/yoga.js";
import { studentBffResolvers } from "./resolvers/index.js"; import { studentBffResolvers } from "./resolvers/index.js";
import { logger } from "../shared/observability/logger.js"; import { logger } from "../shared/observability/logger.js";
import type { YogaServerInstance } from "graphql-yoga"; import type { YogaServerInstance } from "graphql-yoga";
import type { Request, Response } from "express";
export const GRAPHQL_YOGA = Symbol("GRAPHQL_YOGA"); export const GRAPHQL_YOGA = Symbol("GRAPHQL_YOGA");
@@ -29,8 +33,16 @@ export const GRAPHQL_YOGA = Symbol("GRAPHQL_YOGA");
useFactory: async ( useFactory: async (
downstream: DownstreamClient, downstream: DownstreamClient,
redis: Redis, redis: Redis,
): Promise<YogaServerInstance<Record<string, unknown>, StudentBffContext>> => { ): Promise<
return createStudentBffYoga(studentBffResolvers, downstream, redis); YogaServerInstance<{ req: Request; res: Response }, StudentBffContext>
> => {
const cache = new CacheService(redis);
return createStudentBffYoga(
studentBffResolvers,
downstream,
redis,
cache,
);
}, },
inject: [DownstreamClient, REDIS_CLIENT], inject: [DownstreamClient, REDIS_CLIENT],
}, },