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:
@@ -586,7 +586,7 @@
|
||||
> 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 下游通信 | **gRPC 首次即用**(coord §B2 裁决,@grpc/grpc-js + @grpc/proto-loader,禁止 HTTP fetch);DownstreamClient 抽象复用 shared-ts(coord §B8,3 BFF 统一) |
|
||||
| P3 错误码前缀 | `BFF_STUDENT_`(coord §B5 裁决,BFF 在前,非 `STUDENT_BFF_`);i18n key `error.bffStudent.<code_snake>`(coord §F4) |
|
||||
@@ -603,7 +603,12 @@
|
||||
| Mock 模式 | `env.MOCK_UPSTREAM=true` 时 DownstreamClient 返回固定数据(config/mock-data.ts),callStream 产出单个 mock chunk 后结束 |
|
||||
| /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() 工具函数 |
|
||||
| GraphQL Resolver 测试 Mock | vi.mock 模拟 env/metrics/logger,vi.stubGlobal 模拟 fetch,mockContext 工厂函数注入 downstream/redis mock 对象;Vitest 覆盖率 ≥ 80% |
|
||||
| GraphQL Resolver 测试 Mock | vi.mock 模拟 env/metrics/logger,vi.stubGlobal 模拟 fetch,mockContext 工厂函数注入 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(学生端微前端 Remote,P3)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"build": "pnpm -r run build",
|
||||
"test": "pnpm -r run test",
|
||||
"lint": "pnpm -r run lint",
|
||||
"typecheck": "pnpm -r --no-bail run typecheck || true",
|
||||
"arch:scan": "tsx scripts/arch-scan/scanner.ts",
|
||||
"arch:query": "tsx scripts/arch-scan/query.ts",
|
||||
"prepare": "husky"
|
||||
|
||||
@@ -4,6 +4,12 @@ modules:
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
except:
|
||||
- RPC_RESPONSE_STANDARD_NAME
|
||||
- RPC_REQUEST_STANDARD_NAME
|
||||
- RPC_REQUEST_RESPONSE_UNIQUE
|
||||
- PACKAGE_DIRECTORY_MATCH
|
||||
- DIRECTORY_SAME_PACKAGE
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
@@ -16,6 +16,13 @@ package next_edu_cloud.events.v1;
|
||||
// edu.insight.mastery.updated <- mastery.updated / warning.triggered (action field distinguishes)
|
||||
// edu.insight.ai.usage <- ai.usage.recorded
|
||||
|
||||
// EventMetadata 事件元数据(trace_id + 请求来源等).
|
||||
message EventMetadata {
|
||||
string trace_id = 1;
|
||||
string source = 2;
|
||||
string version = 3;
|
||||
}
|
||||
|
||||
message ClassEvent {
|
||||
string event_id = 1;
|
||||
string aggregate_id = 2;
|
||||
@@ -203,24 +210,7 @@ message MasteryEvent {
|
||||
map<string, string> metadata = 13;
|
||||
}
|
||||
|
||||
// AIUsageEvent AI 用量计费事件(ai 服务发布,data-ana 消费落 ai_usage_log).
|
||||
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)
|
||||
// AIUsageEvent AI 用量计费事件(ai 服务发布,data-ana 消费落 ClickHouse)
|
||||
// 派生数据,豁免 Outbox(004 §12.2);topic: edu.ai.usage(matrix.md §4 + ISSUE-02 裁决)
|
||||
message AIUsageEvent {
|
||||
string event_id = 1; // UUID,幂等去重
|
||||
@@ -239,5 +229,6 @@ message AIUsageEvent {
|
||||
uint32 latency_ms = 14;
|
||||
bool success = 15;
|
||||
bool degraded = 16; // 是否降级(LLM 不可用时)
|
||||
map<string, string> metadata = 17; // 额外上下文(subject/grade/difficulty 等)
|
||||
uint32 cost_cents = 17; // 计费(分)
|
||||
map<string, string> metadata = 18; // 额外上下文(subject/grade/difficulty 等)
|
||||
}
|
||||
|
||||
@@ -150,16 +150,12 @@ message ChildInfo {
|
||||
string relation = 3;
|
||||
}
|
||||
|
||||
message GetEffectiveDataScopeRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
// EffectiveDataScope 用户可见数据范围(6 级).
|
||||
// level: SELF / CLASS / GRADE / SCHOOL / DISTRICT / ALL
|
||||
// level: SELF / CLASS / GRADE / SCHOOL / SUBJECT / ALL
|
||||
// scope_ids: 具体可见的 class_id / grade_id 列表(SELF/ALL 时为空)
|
||||
message EffectiveDataScope {
|
||||
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 级)
|
||||
string school_id = 4; // SCHOOL 级时的学校 ID
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
* const client = new DownstreamClient(env);
|
||||
* const userInfo = await client.call('iam', 'GetUserInfo', { userId }, { metadata: { 'x-user-id': userId } });
|
||||
*/
|
||||
import { promises } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import * as protoLoader from '@grpc/proto-loader';
|
||||
import type { PackageDefinition, ServiceClientConstructor } from '@grpc/grpc-js';
|
||||
import { logger } from './logger.js';
|
||||
import { promises } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as grpc from "@grpc/grpc-js";
|
||||
import * as protoLoader from "@grpc/proto-loader";
|
||||
import type { PackageDefinition } from "@grpc/proto-loader";
|
||||
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);
|
||||
if (!svc) {
|
||||
throw new DownstreamError({
|
||||
code: 'BFF_DOWNSTREAM_UNKNOWN_SERVICE',
|
||||
code: "BFF_DOWNSTREAM_UNKNOWN_SERVICE",
|
||||
message: `Unknown downstream service: ${service}`,
|
||||
service,
|
||||
method,
|
||||
@@ -161,7 +162,7 @@ export class DownstreamClient {
|
||||
|
||||
if (!svc.enabled) {
|
||||
throw new DownstreamError({
|
||||
code: 'BFF_DOWNSTREAM_DISABLED',
|
||||
code: "BFF_DOWNSTREAM_DISABLED",
|
||||
message: `Downstream service ${service} is not enabled in current stage`,
|
||||
service,
|
||||
method,
|
||||
@@ -172,15 +173,12 @@ export class DownstreamClient {
|
||||
if (this.config.mockUpstream && this.mockProvider) {
|
||||
const mockData = this.mockProvider(service, method, request);
|
||||
if (mockData !== undefined) {
|
||||
logger.debug(
|
||||
{ service, method, mock: true },
|
||||
'Downstream call mocked',
|
||||
);
|
||||
logger.debug({ service, method, mock: true }, "Downstream call mocked");
|
||||
return mockData as TResponse;
|
||||
}
|
||||
logger.warn(
|
||||
{ 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);
|
||||
logger.warn(
|
||||
{ service, method, attempt: attempt + 1, retryCount, backoff, err },
|
||||
'Downstream call failed, retrying',
|
||||
"Downstream call failed, retrying",
|
||||
);
|
||||
await sleep(backoff);
|
||||
}
|
||||
@@ -214,7 +212,7 @@ export class DownstreamClient {
|
||||
}
|
||||
|
||||
throw new DownstreamError({
|
||||
code: 'BFF_DOWNSTREAM_BAD_GATEWAY',
|
||||
code: "BFF_DOWNSTREAM_BAD_GATEWAY",
|
||||
message: `Downstream ${service}.${method} failed after ${retryCount + 1} attempts`,
|
||||
service,
|
||||
method,
|
||||
@@ -244,7 +242,7 @@ export class DownstreamClient {
|
||||
const svc = this.serviceDefs.get(service);
|
||||
if (!svc) {
|
||||
throw new DownstreamError({
|
||||
code: 'BFF_DOWNSTREAM_UNKNOWN_SERVICE',
|
||||
code: "BFF_DOWNSTREAM_UNKNOWN_SERVICE",
|
||||
message: `Unknown downstream service: ${service}`,
|
||||
service,
|
||||
method,
|
||||
@@ -253,7 +251,7 @@ export class DownstreamClient {
|
||||
|
||||
if (!svc.enabled) {
|
||||
throw new DownstreamError({
|
||||
code: 'BFF_DOWNSTREAM_DISABLED',
|
||||
code: "BFF_DOWNSTREAM_DISABLED",
|
||||
message: `Downstream service ${service} is not enabled in current stage`,
|
||||
service,
|
||||
method,
|
||||
@@ -266,7 +264,7 @@ export class DownstreamClient {
|
||||
if (mockData !== undefined) {
|
||||
logger.debug(
|
||||
{ service, method, mock: true },
|
||||
'Downstream stream call mocked',
|
||||
"Downstream stream call mocked",
|
||||
);
|
||||
yield mockData as TResponse;
|
||||
return;
|
||||
@@ -283,10 +281,12 @@ export class DownstreamClient {
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const callFn = (client as unknown as Record<string, Function>)[method];
|
||||
if (typeof callFn !== 'function') {
|
||||
const callFn = (
|
||||
client as unknown as Record<string, (...args: unknown[]) => unknown>
|
||||
)[method];
|
||||
if (typeof callFn !== "function") {
|
||||
throw new DownstreamError({
|
||||
code: 'BFF_DOWNSTREAM_METHOD_NOT_FOUND',
|
||||
code: "BFF_DOWNSTREAM_METHOD_NOT_FOUND",
|
||||
message: `Method ${method} not found on service ${svc.name}`,
|
||||
service: svc.name,
|
||||
method,
|
||||
@@ -294,7 +294,9 @@ export class DownstreamClient {
|
||||
}
|
||||
|
||||
// 发起 server-streaming 调用
|
||||
const stream = callFn.call(client, request, meta, { deadline });
|
||||
const stream = callFn.call(client, request, meta, {
|
||||
deadline,
|
||||
}) as NodeJS.ReadableStream;
|
||||
|
||||
// 将 Node ReadableStream 转换为 AsyncIterable
|
||||
try {
|
||||
@@ -302,9 +304,11 @@ export class DownstreamClient {
|
||||
let streamError: Error | null = null;
|
||||
|
||||
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) {
|
||||
const r = resolveWait;
|
||||
resolveWait = null;
|
||||
@@ -313,7 +317,7 @@ export class DownstreamClient {
|
||||
chunkQueue.push(chunk);
|
||||
}
|
||||
});
|
||||
stream.on('end', () => {
|
||||
stream.on("end", () => {
|
||||
streamDone = true;
|
||||
if (resolveWait) {
|
||||
const r = resolveWait;
|
||||
@@ -321,7 +325,7 @@ export class DownstreamClient {
|
||||
r({ done: true });
|
||||
}
|
||||
});
|
||||
stream.on('error', (err: Error) => {
|
||||
stream.on("error", (err: Error) => {
|
||||
streamError = err;
|
||||
streamDone = true;
|
||||
if (resolveWait) {
|
||||
@@ -338,28 +342,29 @@ export class DownstreamClient {
|
||||
}
|
||||
if (streamDone) break;
|
||||
|
||||
const result = await new Promise<{ done: true } | { done: false; value: TResponse }>(
|
||||
(resolve) => {
|
||||
const result = await new Promise<
|
||||
{ done: true } | { done: false; value: TResponse }
|
||||
>((resolve) => {
|
||||
resolveWait = resolve;
|
||||
},
|
||||
);
|
||||
});
|
||||
if (result.done) break;
|
||||
yield result.value;
|
||||
}
|
||||
|
||||
if (streamError) {
|
||||
const err = streamError as Error;
|
||||
throw new DownstreamError({
|
||||
code: 'BFF_DOWNSTREAM_STREAM_ERROR',
|
||||
message: streamError.message,
|
||||
code: "BFF_DOWNSTREAM_STREAM_ERROR",
|
||||
message: err.message,
|
||||
service: svc.name,
|
||||
method,
|
||||
traceId: options?.traceId,
|
||||
cause: streamError,
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
// 确保流被销毁
|
||||
stream.destroy?.();
|
||||
(stream as { destroy?: () => void }).destroy?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,15 +386,23 @@ export class DownstreamClient {
|
||||
);
|
||||
|
||||
return results.map((r, idx) => {
|
||||
if (r.status === 'fulfilled') {
|
||||
if (r.status === "fulfilled") {
|
||||
return r.value;
|
||||
}
|
||||
const spec = calls[idx];
|
||||
if (!spec)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "BFF_DOWNSTREAM_UNKNOWN_ERROR",
|
||||
message: "Spec not found",
|
||||
},
|
||||
};
|
||||
const err =
|
||||
r.reason instanceof DownstreamError
|
||||
? r.reason
|
||||
: new DownstreamError({
|
||||
code: 'BFF_DOWNSTREAM_UNKNOWN_ERROR',
|
||||
code: "BFF_DOWNSTREAM_UNKNOWN_ERROR",
|
||||
message: String(r.reason),
|
||||
service: spec.service,
|
||||
method: spec.method,
|
||||
@@ -413,7 +426,7 @@ export class DownstreamClient {
|
||||
async close(): Promise<void> {
|
||||
for (const [name, client] of this.clients) {
|
||||
client.close();
|
||||
logger.debug({ service: name }, 'gRPC client closed');
|
||||
logger.debug({ service: name }, "gRPC client closed");
|
||||
}
|
||||
this.clients.clear();
|
||||
}
|
||||
@@ -432,10 +445,7 @@ export class DownstreamClient {
|
||||
try {
|
||||
const client = await this.getOrCreateClient(svc);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
client.waitForReady(
|
||||
Date.now() + 2000,
|
||||
(err) => resolve(!err),
|
||||
);
|
||||
client.waitForReady(Date.now() + 2000, (err) => resolve(!err));
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
@@ -480,11 +490,13 @@ export class DownstreamClient {
|
||||
|
||||
return new Promise<TResponse>((resolve, reject) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const call = (client as unknown as Record<string, Function>)[method];
|
||||
if (typeof call !== 'function') {
|
||||
const call = (
|
||||
client as unknown as Record<string, (...args: unknown[]) => unknown>
|
||||
)[method];
|
||||
if (typeof call !== "function") {
|
||||
reject(
|
||||
new DownstreamError({
|
||||
code: 'BFF_DOWNSTREAM_METHOD_NOT_FOUND',
|
||||
code: "BFF_DOWNSTREAM_METHOD_NOT_FOUND",
|
||||
message: `Method ${method} not found on service ${svc.name}`,
|
||||
service: svc.name,
|
||||
method,
|
||||
@@ -519,7 +531,9 @@ export class DownstreamClient {
|
||||
/**
|
||||
* 内部: 获取或创建 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);
|
||||
if (client) {
|
||||
return client;
|
||||
@@ -532,10 +546,7 @@ export class DownstreamClient {
|
||||
>;
|
||||
const packageObj = this.getNestedPackage(proto, svc.packageName);
|
||||
const ServiceCtor = this.findServiceCtor(packageObj, svc);
|
||||
client = new ServiceCtor(
|
||||
svc.grpcUrl,
|
||||
grpc.credentials.createInsecure(),
|
||||
);
|
||||
client = new ServiceCtor(svc.grpcUrl, grpc.credentials.createInsecure());
|
||||
this.clients.set(svc.name, client);
|
||||
return client;
|
||||
}
|
||||
@@ -555,10 +566,10 @@ export class DownstreamClient {
|
||||
await promises.access(fullPath);
|
||||
} catch {
|
||||
throw new DownstreamError({
|
||||
code: 'BFF_DOWNSTREAM_PROTO_NOT_FOUND',
|
||||
code: "BFF_DOWNSTREAM_PROTO_NOT_FOUND",
|
||||
message: `Proto file not found: ${fullPath}`,
|
||||
service: svc.name,
|
||||
method: '<init>',
|
||||
method: "<init>",
|
||||
});
|
||||
}
|
||||
pkgDef = protoLoader.loadSync(fullPath, {
|
||||
@@ -579,16 +590,16 @@ export class DownstreamClient {
|
||||
root: Record<string, unknown>,
|
||||
packageName: string,
|
||||
): Record<string, unknown> {
|
||||
const parts = packageName.split('.');
|
||||
const parts = packageName.split(".");
|
||||
let current: Record<string, unknown> = root;
|
||||
for (const part of parts) {
|
||||
const next = current[part];
|
||||
if (typeof next !== 'object' || next === null) {
|
||||
if (typeof next !== "object" || next === null) {
|
||||
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})`,
|
||||
service: '',
|
||||
method: '<init>',
|
||||
service: "",
|
||||
method: "<init>",
|
||||
});
|
||||
}
|
||||
current = next as Record<string, unknown>;
|
||||
@@ -604,19 +615,16 @@ export class DownstreamClient {
|
||||
packageObj: Record<string, unknown>,
|
||||
svc: DownstreamServiceConfig,
|
||||
): ServiceClientConstructor {
|
||||
for (const [key, value] of Object.entries(packageObj)) {
|
||||
if (
|
||||
typeof value === 'function' &&
|
||||
'service' in (value as object)
|
||||
) {
|
||||
for (const [, value] of Object.entries(packageObj)) {
|
||||
if (typeof value === "function" && "service" in (value as object)) {
|
||||
return value as ServiceClientConstructor;
|
||||
}
|
||||
}
|
||||
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}`,
|
||||
service: svc.name,
|
||||
method: '<init>',
|
||||
method: "<init>",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -640,7 +648,7 @@ export class DownstreamError extends Error {
|
||||
readonly method: string;
|
||||
readonly status?: number;
|
||||
readonly traceId?: string;
|
||||
readonly cause?: unknown;
|
||||
override readonly cause?: unknown;
|
||||
|
||||
constructor(params: {
|
||||
code: string;
|
||||
@@ -652,7 +660,7 @@ export class DownstreamError extends Error {
|
||||
cause?: unknown;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = 'DownstreamError';
|
||||
this.name = "DownstreamError";
|
||||
this.code = params.code;
|
||||
this.service = params.service;
|
||||
this.method = params.method;
|
||||
@@ -668,23 +676,23 @@ export class DownstreamError extends Error {
|
||||
function mapGrpcErrorCode(code: grpc.status | number): string {
|
||||
switch (code) {
|
||||
case grpc.status.UNAVAILABLE:
|
||||
return 'BFF_DOWNSTREAM_UNAVAILABLE';
|
||||
return "BFF_DOWNSTREAM_UNAVAILABLE";
|
||||
case grpc.status.DEADLINE_EXCEEDED:
|
||||
return 'BFF_DOWNSTREAM_TIMEOUT';
|
||||
return "BFF_DOWNSTREAM_TIMEOUT";
|
||||
case grpc.status.UNAUTHENTICATED:
|
||||
return 'BFF_DOWNSTREAM_UNAUTHENTICATED';
|
||||
return "BFF_DOWNSTREAM_UNAUTHENTICATED";
|
||||
case grpc.status.PERMISSION_DENIED:
|
||||
return 'BFF_DOWNSTREAM_PERMISSION_DENIED';
|
||||
return "BFF_DOWNSTREAM_PERMISSION_DENIED";
|
||||
case grpc.status.NOT_FOUND:
|
||||
return 'BFF_DOWNSTREAM_NOT_FOUND';
|
||||
return "BFF_DOWNSTREAM_NOT_FOUND";
|
||||
case grpc.status.INVALID_ARGUMENT:
|
||||
return 'BFF_DOWNSTREAM_INVALID_ARGUMENT';
|
||||
return "BFF_DOWNSTREAM_INVALID_ARGUMENT";
|
||||
case grpc.status.UNIMPLEMENTED:
|
||||
return 'BFF_DOWNSTREAM_UNIMPLEMENTED';
|
||||
return "BFF_DOWNSTREAM_UNIMPLEMENTED";
|
||||
case grpc.status.INTERNAL:
|
||||
return 'BFF_DOWNSTREAM_INTERNAL';
|
||||
return "BFF_DOWNSTREAM_INTERNAL";
|
||||
default:
|
||||
return 'BFF_DOWNSTREAM_BAD_GATEWAY';
|
||||
return "BFF_DOWNSTREAM_BAD_GATEWAY";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1732
pnpm-lock.yaml
generated
1732
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -52,8 +52,8 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
// B1 GraphQL Yoga endpoint 挂载
|
||||
const yoga = (await app.resolve(GRAPHQL_YOGA)) as YogaServerInstance<
|
||||
Record<string, unknown>,
|
||||
unknown
|
||||
{ req: Request; res: Response },
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const httpAdapter = app.getHttpAdapter().getInstance();
|
||||
httpAdapter.use(
|
||||
@@ -96,7 +96,9 @@ async function bootstrap(): Promise<void> {
|
||||
const downstream = app.get<DownstreamClient>(DOWNSTREAM_CLIENT);
|
||||
await downstream.close();
|
||||
|
||||
const circuitBreaker = app.get<CircuitBreakerService>(CircuitBreakerService);
|
||||
const circuitBreaker = app.get<CircuitBreakerService>(
|
||||
CircuitBreakerService,
|
||||
);
|
||||
await circuitBreaker.shutdown();
|
||||
|
||||
await shutdownTracer();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -96,7 +99,7 @@ export function fail(
|
||||
* - data.degradedReason=原因
|
||||
* - data.degradedFields=哪些字段降级了
|
||||
*/
|
||||
export function degraded<T extends Degradable>(
|
||||
export function degraded<T extends object>(
|
||||
data: T,
|
||||
reason: string,
|
||||
degradedFields: string[],
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* student:viewports:{userId} TTL 300s
|
||||
*/
|
||||
import { Global, Module, OnModuleDestroy } from "@nestjs/common";
|
||||
import Redis from "ioredis";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { recordCacheAccess } from "../observability/metrics.js";
|
||||
@@ -46,7 +46,10 @@ export const CacheTTL = {
|
||||
/**
|
||||
* 缓存 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(":");
|
||||
return `${env.REDIS_KEY_PREFIX}${pattern}:${suffix}`;
|
||||
}
|
||||
@@ -100,7 +103,10 @@ export class CacheService {
|
||||
* 读取缓存, 自动 JSON 反序列化.
|
||||
* 失败时返回 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);
|
||||
try {
|
||||
const raw = await this.redis.get(key);
|
||||
@@ -143,7 +149,10 @@ export class CacheService {
|
||||
* 失效缓存 (按 pattern 通配符删除).
|
||||
* 用于写操作后主动失效相关缓存.
|
||||
*/
|
||||
async invalidate(pattern: string, ...keyParts: (string | number)[]): Promise<void> {
|
||||
async invalidate(
|
||||
pattern: string,
|
||||
...keyParts: (string | number)[]
|
||||
): Promise<void> {
|
||||
const key = buildCacheKey(pattern, ...keyParts);
|
||||
try {
|
||||
// 如果 keyParts 含通配符, 用 SCAN 删除
|
||||
|
||||
@@ -21,12 +21,18 @@
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import CircuitBreaker from "opossum";
|
||||
import promClient from "prom-client";
|
||||
import type { DownstreamClient, CallOptions } from "@edu/shared-ts/bff";
|
||||
import { ServiceUnavailableError } from "../errors/application-error.js";
|
||||
import { metricsRegistry } from "../observability/metrics.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { env } from "../../config/env.js";
|
||||
|
||||
/**
|
||||
* 熔断器状态 (字符串字面量, 对齐 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) {
|
||||
case CircuitBreaker.CLOSED:
|
||||
case "closed":
|
||||
return 0;
|
||||
case CircuitBreaker.OPEN:
|
||||
case "open":
|
||||
return 1;
|
||||
case CircuitBreaker.HALF_OPEN:
|
||||
case "half_open":
|
||||
return 2;
|
||||
default:
|
||||
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) {
|
||||
case CircuitBreaker.CLOSED:
|
||||
case "closed":
|
||||
return "closed";
|
||||
case CircuitBreaker.OPEN:
|
||||
case "open":
|
||||
return "open";
|
||||
case CircuitBreaker.HALF_OPEN:
|
||||
case "half_open":
|
||||
return "half_open";
|
||||
default:
|
||||
return "unknown";
|
||||
@@ -108,7 +114,13 @@ export class CircuitBreakerService {
|
||||
request: TRequest,
|
||||
options?: CallOptions,
|
||||
): Promise<TResponse> {
|
||||
const breaker = this.getOrCreateBreaker(service, downstream, method, request, options);
|
||||
const breaker = this.getOrCreateBreaker(
|
||||
service,
|
||||
downstream,
|
||||
method,
|
||||
request,
|
||||
options,
|
||||
);
|
||||
|
||||
try {
|
||||
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);
|
||||
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", () => {
|
||||
logger.warn({ service }, "Circuit breaker OPENED");
|
||||
this.updateMetric(service, CircuitBreaker.OPEN);
|
||||
this.updateMetric(service, "open");
|
||||
});
|
||||
|
||||
breaker.on("close", () => {
|
||||
logger.info({ service }, "Circuit breaker CLOSED (recovered)");
|
||||
this.updateMetric(service, CircuitBreaker.CLOSED);
|
||||
this.updateMetric(service, "closed");
|
||||
});
|
||||
|
||||
breaker.on("halfOpen", () => {
|
||||
logger.info({ service }, "Circuit breaker HALF-OPEN");
|
||||
this.updateMetric(service, CircuitBreaker.HALF_OPEN);
|
||||
this.updateMetric(service, "half_open");
|
||||
});
|
||||
|
||||
// fallback: 熔断开启时返回 ServiceUnavailableError
|
||||
@@ -187,13 +202,14 @@ export class CircuitBreakerService {
|
||||
});
|
||||
|
||||
this.breakers.set(service, breaker);
|
||||
this.updateMetric(service, CircuitBreaker.CLOSED);
|
||||
this.updateMetric(service, "closed");
|
||||
} else {
|
||||
// 更新执行函数 (opossum 允许重新设置 action)
|
||||
// 由于 opossum 不支持直接替换 action, 我们使用 wrapper 方式
|
||||
// 实际上 opossum 的 fire() 会调用构造时传入的函数,
|
||||
// 所以我们用一个可变的 wrapper
|
||||
(breaker as unknown as { action: () => Promise<unknown> }).action = async () => {
|
||||
(breaker as unknown as { action: () => Promise<unknown> }).action =
|
||||
async () => {
|
||||
return downstream.call(service, method, request, options);
|
||||
};
|
||||
}
|
||||
@@ -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 name = stateName(state);
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_circuit_state")
|
||||
?.set({ service, state: name }, value);
|
||||
(
|
||||
metricsRegistry.getSingleMetric("student_bff_circuit_state") as
|
||||
promClient.Gauge<string> | undefined
|
||||
)?.set({ service, state: name }, value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,33 +133,78 @@ describe("ApplicationError", () => {
|
||||
const err = new ValidationError("Invalid input", { field: "name" });
|
||||
err.traceId = "trace-abc";
|
||||
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.error).toBeDefined();
|
||||
expect(json.error.code).toBe("BFF_STUDENT_VALIDATION_ERROR");
|
||||
expect(json.error.message).toBe("Invalid input");
|
||||
expect(json.error.i18nKey).toBe("error.bffStudent.validation_error");
|
||||
expect(json.error.details).toEqual({ field: "name" });
|
||||
expect(json.error.traceId).toBe("trace-abc");
|
||||
expect(error.code).toBe("BFF_STUDENT_VALIDATION_ERROR");
|
||||
expect(error.message).toBe("Invalid input");
|
||||
expect(error.i18nKey).toBe("error.bffStudent.validation_error");
|
||||
expect(error.details).toEqual({ field: "name" });
|
||||
expect(error.traceId).toBe("trace-abc");
|
||||
});
|
||||
|
||||
it("should generate correct i18n key for each error code", () => {
|
||||
const cases = [
|
||||
{ error: new ValidationError(), expectedKey: "error.bffStudent.validation_error" },
|
||||
{ error: new UnauthorizedError(), expectedKey: "error.bffStudent.unauthorized" },
|
||||
{ error: new ForbiddenResourceError("test"), expectedKey: "error.bffStudent.forbidden_resource" },
|
||||
{ error: new IdentityMismatchError("test"), expectedKey: "error.bffStudent.identity_mismatch" },
|
||||
{ error: new NotFoundError("X", "1"), expectedKey: "error.bffStudent.not_found" },
|
||||
{ error: new ConflictError("test"), expectedKey: "error.bffStudent.conflict" },
|
||||
{ error: new BusinessError("test"), expectedKey: "error.bffStudent.business_error" },
|
||||
{ error: new BadGatewayError("test"), expectedKey: "error.bffStudent.bad_gateway" },
|
||||
{ error: new GatewayTimeoutError("test"), expectedKey: "error.bffStudent.gateway_timeout" },
|
||||
{ error: new ServiceUnavailableError("test"), expectedKey: "error.bffStudent.service_unavailable" },
|
||||
{ error: new InternalError("test"), expectedKey: "error.bffStudent.internal_error" },
|
||||
{
|
||||
error: new ValidationError("test"),
|
||||
expectedKey: "error.bffStudent.validation_error",
|
||||
},
|
||||
{
|
||||
error: new UnauthorizedError(),
|
||||
expectedKey: "error.bffStudent.unauthorized",
|
||||
},
|
||||
{
|
||||
error: new ForbiddenResourceError("test"),
|
||||
expectedKey: "error.bffStudent.forbidden_resource",
|
||||
},
|
||||
{
|
||||
error: new IdentityMismatchError("test"),
|
||||
expectedKey: "error.bffStudent.identity_mismatch",
|
||||
},
|
||||
{
|
||||
error: new NotFoundError("X", "1"),
|
||||
expectedKey: "error.bffStudent.not_found",
|
||||
},
|
||||
{
|
||||
error: new ConflictError("test"),
|
||||
expectedKey: "error.bffStudent.conflict",
|
||||
},
|
||||
{
|
||||
error: new BusinessError("test"),
|
||||
expectedKey: "error.bffStudent.business_error",
|
||||
},
|
||||
{
|
||||
error: new BadGatewayError("test"),
|
||||
expectedKey: "error.bffStudent.bad_gateway",
|
||||
},
|
||||
{
|
||||
error: new GatewayTimeoutError("test"),
|
||||
expectedKey: "error.bffStudent.gateway_timeout",
|
||||
},
|
||||
{
|
||||
error: new ServiceUnavailableError("test"),
|
||||
expectedKey: "error.bffStudent.service_unavailable",
|
||||
},
|
||||
{
|
||||
error: new InternalError("test"),
|
||||
expectedKey: "error.bffStudent.internal_error",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { error, expectedKey } of cases) {
|
||||
const json = error.toJSON();
|
||||
expect(json.error.i18nKey).toBe(expectedKey);
|
||||
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", () => {
|
||||
expect(new ValidationError("x").name).toBe("ValidationError");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,15 +18,26 @@ import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||
import type { Request, Response } from "express";
|
||||
import type { DownstreamClient } from "@edu/shared-ts/bff";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
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 {
|
||||
extractUserIdFromRequest,
|
||||
extractTraceIdFromRequest,
|
||||
extractUserRolesFromRequest,
|
||||
} from "../../student/guards/authorization.guard.js";
|
||||
|
||||
/**
|
||||
* makeExecutableSchema 接受的 resolver 类型 (从函数签名推断, 避免额外依赖).
|
||||
*/
|
||||
type SchemaResolvers = NonNullable<
|
||||
Parameters<typeof makeExecutableSchema>[0]["resolvers"]
|
||||
>;
|
||||
|
||||
/**
|
||||
* GraphQL Context (每个请求一份).
|
||||
*/
|
||||
@@ -36,6 +47,7 @@ export interface StudentBffContext {
|
||||
userRoles: string[];
|
||||
downstream: DownstreamClient;
|
||||
redis: Redis;
|
||||
cache: CacheService;
|
||||
dataLoaders: StudentBffDataLoaders;
|
||||
requestId: string;
|
||||
}
|
||||
@@ -69,12 +81,16 @@ export async function loadSchemaSDL(): Promise<string> {
|
||||
* @param resolvers GraphQL Resolver 映射表 (由 StudentModule 装配)
|
||||
* @param downstream DownstreamClient 实例 (由 NestJS DI 注入)
|
||||
* @param redis Redis 客户端 (由 NestJS DI 注入)
|
||||
* @param cache CacheService 实例 (由 NestJS DI 注入)
|
||||
*/
|
||||
export async function createStudentBffYoga(
|
||||
resolvers: Record<string, unknown>,
|
||||
resolvers: SchemaResolvers,
|
||||
downstream: DownstreamClient,
|
||||
redis: Redis,
|
||||
): Promise<YogaServerInstance<Record<string, unknown>, StudentBffContext>> {
|
||||
cache: CacheService,
|
||||
): Promise<
|
||||
YogaServerInstance<{ req: Request; res: Response }, StudentBffContext>
|
||||
> {
|
||||
const typeDefs = await loadSchemaSDL();
|
||||
|
||||
const schema = makeExecutableSchema({
|
||||
@@ -82,10 +98,13 @@ export async function createStudentBffYoga(
|
||||
resolvers,
|
||||
});
|
||||
|
||||
const yoga = createYoga<{
|
||||
const yoga = createYoga<
|
||||
{
|
||||
req: Request;
|
||||
res: Response;
|
||||
}, StudentBffContext>({
|
||||
},
|
||||
StudentBffContext
|
||||
>({
|
||||
schema,
|
||||
graphqlEndpoint: "/graphql",
|
||||
context: ({ req }): StudentBffContext => {
|
||||
@@ -98,6 +117,7 @@ export async function createStudentBffYoga(
|
||||
userRoles,
|
||||
downstream,
|
||||
redis,
|
||||
cache,
|
||||
dataLoaders: createDataLoaders(downstream),
|
||||
requestId: traceId,
|
||||
};
|
||||
@@ -111,26 +131,6 @@ export async function createStudentBffYoga(
|
||||
maskedErrors: env.NODE_ENV === "production",
|
||||
// 开发环境启用 Playground
|
||||
graphiql: env.GRAPHQL_PLAYGROUND && env.NODE_ENV === "development",
|
||||
// 错误格式化 (G8): GraphQL errors 数组 + extensions.code + extensions.traceId
|
||||
formatError: (err) => {
|
||||
const originalError = err.originalError;
|
||||
const code =
|
||||
(originalError as { code?: string })?.code ??
|
||||
"BFF_STUDENT_INTERNAL_ERROR";
|
||||
const traceId = err.context?.requestId ?? "unknown";
|
||||
|
||||
return {
|
||||
message: err.message,
|
||||
extensions: {
|
||||
code,
|
||||
traceId,
|
||||
i18nKey: `error.bffStudent.${code.replace(/^BFF_STUDENT_/, "").toLowerCase()}`,
|
||||
severity: "error",
|
||||
},
|
||||
path: err.path,
|
||||
locations: err.locations,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -127,33 +127,35 @@ export function recordDownstreamCall(
|
||||
errorType?: string,
|
||||
): void {
|
||||
const labels = { service, method, status };
|
||||
registry
|
||||
.getSingleMetric("student_bff_downstream_calls_total")
|
||||
?.inc(labels);
|
||||
registry
|
||||
.getSingleMetric("student_bff_downstream_duration_seconds")
|
||||
?.observe({ service, method }, durationMs / 1000);
|
||||
(
|
||||
registry.getSingleMetric("student_bff_downstream_calls_total") as
|
||||
promClient.Counter<string> | undefined
|
||||
)?.inc(labels);
|
||||
(
|
||||
registry.getSingleMetric("student_bff_downstream_duration_seconds") as
|
||||
promClient.Histogram<string> | undefined
|
||||
)?.observe({ service, method }, durationMs / 1000);
|
||||
if (status === "error" && errorType) {
|
||||
registry
|
||||
.getSingleMetric("student_bff_downstream_errors_total")
|
||||
?.inc({ service, method, error_type: errorType });
|
||||
(
|
||||
registry.getSingleMetric("student_bff_downstream_errors_total") as
|
||||
promClient.Counter<string> | undefined
|
||||
)?.inc({ service, method, error_type: errorType });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存命中/未命中指标辅助器.
|
||||
*/
|
||||
export function recordCacheAccess(
|
||||
keyPattern: string,
|
||||
hit: boolean,
|
||||
): void {
|
||||
export function recordCacheAccess(keyPattern: string, hit: boolean): void {
|
||||
if (hit) {
|
||||
registry
|
||||
.getSingleMetric("student_bff_cache_hits_total")
|
||||
?.inc({ cache_key_pattern: keyPattern });
|
||||
(
|
||||
registry.getSingleMetric("student_bff_cache_hits_total") as
|
||||
promClient.Counter<string> | undefined
|
||||
)?.inc({ cache_key_pattern: keyPattern });
|
||||
} else {
|
||||
registry
|
||||
.getSingleMetric("student_bff_cache_misses_total")
|
||||
?.inc({ cache_key_pattern: keyPattern });
|
||||
(
|
||||
registry.getSingleMetric("student_bff_cache_misses_total") as
|
||||
promClient.Counter<string> | undefined
|
||||
)?.inc({ cache_key_pattern: keyPattern });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,14 @@
|
||||
*
|
||||
* 幂等性: 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 promClient from "prom-client";
|
||||
import { REDIS_CLIENT, CacheService } from "../../shared/cache/cache.module.js";
|
||||
import type { Redis } from "ioredis";
|
||||
import { env } from "../../config/env.js";
|
||||
@@ -126,7 +132,10 @@ export class EventSubscriberService implements OnModuleInit, OnModuleDestroy {
|
||||
const { topic, partition, message } = payload;
|
||||
const eventStr = message.value?.toString("utf-8");
|
||||
if (!eventStr) {
|
||||
logger.warn({ topic, partition, offset: message.offset }, "Empty Kafka message");
|
||||
logger.warn(
|
||||
{ topic, partition, offset: message.offset },
|
||||
"Empty Kafka message",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,15 +158,22 @@ export class EventSubscriberService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
// 指标记录
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_event_consumed_total")
|
||||
?.inc({ topic, event_type: event.event_type });
|
||||
(
|
||||
metricsRegistry.getSingleMetric("student_bff_event_consumed_total") as
|
||||
promClient.Counter<string> | undefined
|
||||
)?.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);
|
||||
await this.pushGateway.pushToStudent(
|
||||
studentId,
|
||||
topic,
|
||||
event.event_type,
|
||||
event.payload,
|
||||
event.timestamp,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
@@ -170,7 +186,10 @@ export class EventSubscriberService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* 按 topic 失效相关缓存.
|
||||
*/
|
||||
private async invalidateCache(topic: string, studentId: string): Promise<void> {
|
||||
private async invalidateCache(
|
||||
topic: string,
|
||||
studentId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (topic.startsWith("edu.teaching.homework")) {
|
||||
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("dashboard", studentId);
|
||||
} else if (topic === "edu.notification.sent") {
|
||||
await this.cacheService.invalidateByPrefix(`notifications:${studentId}`);
|
||||
await this.cacheService.invalidateByPrefix(
|
||||
`notifications:${studentId}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err, topic, studentId }, "Cache invalidation failed");
|
||||
|
||||
@@ -113,7 +113,9 @@ describe("PushGatewayService", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
"u-stu-001",
|
||||
@@ -139,10 +141,13 @@ describe("PushGatewayService", () => {
|
||||
);
|
||||
|
||||
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.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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* - 未来可直接由 resolver 调用 (如主动推送通知)
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import promClient from "prom-client";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../../shared/observability/logger.js";
|
||||
import { metricsRegistry } from "../../shared/observability/metrics.js";
|
||||
@@ -80,9 +81,10 @@ export class PushGatewayService {
|
||||
);
|
||||
|
||||
const pushStatus = response.ok ? "success" : `http_${response.status}`;
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_event_pushed_total")
|
||||
?.inc({ topic, push_status: pushStatus });
|
||||
(
|
||||
metricsRegistry.getSingleMetric("student_bff_event_pushed_total") as
|
||||
promClient.Counter<string> | undefined
|
||||
)?.inc({ topic, push_status: pushStatus });
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
@@ -97,9 +99,10 @@ export class PushGatewayService {
|
||||
{ err, studentId, topic },
|
||||
"Push-gateway push failed (soft failure)",
|
||||
);
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_event_pushed_total")
|
||||
?.inc({ topic, push_status: "error" });
|
||||
(
|
||||
metricsRegistry.getSingleMetric("student_bff_event_pushed_total") as
|
||||
promClient.Counter<string> | undefined
|
||||
)?.inc({ topic, push_status: "error" });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
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({
|
||||
messages: z
|
||||
@@ -45,7 +48,7 @@ const AIStreamChatInputSchema = z.object({
|
||||
/**
|
||||
* AI 流式响应 chunk 结构 (对齐 ai.StreamChat gRPC stream).
|
||||
*/
|
||||
interface AIStreamChunk {
|
||||
export interface AIStreamChunk {
|
||||
content: string;
|
||||
done: boolean;
|
||||
model?: string;
|
||||
@@ -85,28 +88,40 @@ export const aiStreamResolvers = {
|
||||
);
|
||||
}
|
||||
const input = parseResult.data;
|
||||
const userId = ctx.userId;
|
||||
|
||||
return (async function* (): AsyncGenerator<{ aiStreamChat: AIStreamChunk }> {
|
||||
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,
|
||||
const stream = ctx.downstream.callStream(
|
||||
"ai",
|
||||
"StreamChat",
|
||||
{
|
||||
userId,
|
||||
messages: input.messages,
|
||||
model: input.model,
|
||||
context: input.context,
|
||||
}, {
|
||||
},
|
||||
{
|
||||
traceId: ctx.traceId,
|
||||
metadata: { "x-user-id": ctx.userId },
|
||||
metadata: { "x-user-id": 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 };
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
yield {
|
||||
|
||||
@@ -32,20 +32,31 @@ export const analyticsResolvers = {
|
||||
|
||||
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) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
return ok(cached, {
|
||||
traceId: ctx.traceId,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("data-ana", "GetStudentWeakness", {
|
||||
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(
|
||||
await ctx.cache.set(
|
||||
"analytics:weakness",
|
||||
result,
|
||||
CacheTTL.ANALYTICS_WEAKNESS,
|
||||
@@ -79,21 +90,34 @@ export const analyticsResolvers = {
|
||||
|
||||
const range = args.range ?? "30d";
|
||||
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) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
return ok(cached, {
|
||||
traceId: ctx.traceId,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("data-ana", "GetLearningTrend", {
|
||||
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);
|
||||
await ctx.cache.set(
|
||||
"analytics:trend",
|
||||
result,
|
||||
CacheTTL.ANALYTICS_TREND,
|
||||
cacheKey,
|
||||
);
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
|
||||
@@ -23,7 +23,12 @@ export const contentResolvers = {
|
||||
*/
|
||||
async textbooks(
|
||||
_parent: unknown,
|
||||
args: { gradeId?: string; subjectId?: string; page?: number; pageSize?: number },
|
||||
args: {
|
||||
gradeId?: string;
|
||||
subjectId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
ctx: StudentBffContext,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.userId) {
|
||||
@@ -31,24 +36,32 @@ export const contentResolvers = {
|
||||
}
|
||||
|
||||
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) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
return ok(cached, {
|
||||
traceId: ctx.traceId,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("content", "ListTextbooks", {
|
||||
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);
|
||||
await ctx.cache.set("textbooks", data, CacheTTL.TEXTBOOKS, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
@@ -72,21 +85,34 @@ export const contentResolvers = {
|
||||
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) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
return ok(cached, {
|
||||
traceId: ctx.traceId,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("content", "ListChapters", {
|
||||
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);
|
||||
await ctx.cache.set(
|
||||
"chapters",
|
||||
data,
|
||||
CacheTTL.CHAPTERS,
|
||||
args.textbookId,
|
||||
);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
@@ -112,13 +138,18 @@ export const contentResolvers = {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("content", "GetLearningPath", {
|
||||
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) {
|
||||
|
||||
@@ -19,7 +19,12 @@
|
||||
*/
|
||||
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 {
|
||||
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";
|
||||
|
||||
@@ -40,10 +45,12 @@ export const dashboardResolvers = {
|
||||
}
|
||||
|
||||
// 缓存命中检查
|
||||
const cacheKey = ["dashboard", ctx.userId] as const;
|
||||
const cached = await ctx.redis.get<unknown>("dashboard", ctx.userId);
|
||||
const cached = await ctx.cache.get<unknown>("dashboard", ctx.userId);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
return ok(cached, {
|
||||
traceId: ctx.traceId,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 并行调用下游 (Promise.allSettled 容错)
|
||||
@@ -52,36 +59,56 @@ export const dashboardResolvers = {
|
||||
service: "iam",
|
||||
method: "GetUserInfo",
|
||||
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",
|
||||
method: "ListHomeworkByStudent",
|
||||
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",
|
||||
method: "ListExamsByClass",
|
||||
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",
|
||||
method: "ListGradesByStudent",
|
||||
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",
|
||||
method: "GetStudentDashboard",
|
||||
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);
|
||||
|
||||
const [userInfoResp, homeworkResp, examsResp, gradesResp, dashboardAnaResp] =
|
||||
results as [
|
||||
const [
|
||||
userInfoResp,
|
||||
homeworkResp,
|
||||
examsResp,
|
||||
gradesResp,
|
||||
dashboardAnaResp,
|
||||
] = results as [
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
DownstreamResponse<unknown>,
|
||||
@@ -113,13 +140,13 @@ export const dashboardResolvers = {
|
||||
// 容错聚合: 各字段独立降级
|
||||
const degradedFields: string[] = [];
|
||||
const pendingHomework = homeworkResp.success
|
||||
? (homeworkResp.data as { homework: unknown[] }).homework ?? []
|
||||
? ((homeworkResp.data as { homework: unknown[] }).homework ?? [])
|
||||
: (degradedFields.push("pendingHomework"), []);
|
||||
const upcomingExams = examsResp.success
|
||||
? (examsResp.data as { exams: unknown[] }).exams ?? []
|
||||
? ((examsResp.data as { exams: unknown[] }).exams ?? [])
|
||||
: (degradedFields.push("upcomingExams"), []);
|
||||
const lastGrade = gradesResp.success
|
||||
? ((gradesResp.data as { grades: unknown[] }).grades ?? [])[0] ?? null
|
||||
? (((gradesResp.data as { grades: unknown[] }).grades ?? [])[0] ?? null)
|
||||
: (degradedFields.push("lastGrade"), null);
|
||||
const analyticsSummary = dashboardAnaResp.success
|
||||
? 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) {
|
||||
return degraded(
|
||||
|
||||
@@ -28,23 +28,31 @@ export const examsResolvers = {
|
||||
}
|
||||
|
||||
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) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
return ok(cached, {
|
||||
traceId: ctx.traceId,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "ListExamsByClass", {
|
||||
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);
|
||||
await ctx.cache.set("exams", data, CacheTTL.EXAMS, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
|
||||
@@ -40,24 +40,32 @@ export const gradesResolvers = {
|
||||
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);
|
||||
const cached = await ctx.cache.get<unknown>("grades", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
return ok(cached, {
|
||||
traceId: ctx.traceId,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "ListGradesByStudent", {
|
||||
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);
|
||||
await ctx.cache.set("grades", data, CacheTTL.GRADES, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
|
||||
@@ -8,20 +8,27 @@
|
||||
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";
|
||||
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 {
|
||||
function mockContext(
|
||||
overrides: Partial<StudentBffContext> = {},
|
||||
): StudentBffContext {
|
||||
const downstream = {
|
||||
call: vi.fn(),
|
||||
callStream: vi.fn(),
|
||||
callAll: vi.fn(),
|
||||
};
|
||||
const redis = {
|
||||
const redis = {};
|
||||
const cache = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
invalidate: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -35,6 +42,7 @@ function mockContext(overrides: Partial<StudentBffContext> = {}): StudentBffCont
|
||||
userRoles: ["student"],
|
||||
downstream: downstream as never,
|
||||
redis: redis as never,
|
||||
cache: cache as never,
|
||||
dataLoaders,
|
||||
requestId: "trace-test-001",
|
||||
...overrides,
|
||||
@@ -58,11 +66,15 @@ describe("homeworkResolvers", () => {
|
||||
|
||||
it("should return cached data when cache hits", async () => {
|
||||
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 as { data: unknown }).data).toEqual(cachedData);
|
||||
expect(result.data).toEqual(cachedData);
|
||||
expect(ctx.downstream.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -70,12 +82,11 @@ describe("homeworkResolvers", () => {
|
||||
const downstreamData = { homework: [{ id: "hw-001", title: "Math HW" }] };
|
||||
ctx.downstream.call = vi.fn().mockResolvedValue(downstreamData);
|
||||
|
||||
const result = await homeworkResolvers.Query.myHomework(
|
||||
const result = (await homeworkResolvers.Query.myHomework(
|
||||
null,
|
||||
{ status: "ASSIGNED", classId: "c-001" },
|
||||
ctx,
|
||||
);
|
||||
|
||||
)) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
expect(ctx.downstream.call).toHaveBeenCalledWith(
|
||||
"core-edu",
|
||||
@@ -86,15 +97,21 @@ describe("homeworkResolvers", () => {
|
||||
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 () => {
|
||||
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 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 = {
|
||||
homeworkId: "hw-001",
|
||||
studentId: "u-stu-001",
|
||||
answers: [
|
||||
{ questionId: "q-001", content: "My answer" },
|
||||
],
|
||||
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),
|
||||
homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: validInput },
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(UnauthorizedError);
|
||||
});
|
||||
|
||||
@@ -130,7 +149,11 @@ describe("homeworkResolvers", () => {
|
||||
studentId: "u-stu-002",
|
||||
};
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(null, { input: maliciousInput }, ctx),
|
||||
homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: maliciousInput },
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(ForbiddenResourceError);
|
||||
});
|
||||
|
||||
@@ -143,11 +166,11 @@ describe("homeworkResolvers", () => {
|
||||
};
|
||||
ctx.downstream.call = vi.fn().mockResolvedValue(submitResult);
|
||||
|
||||
const result = await homeworkResolvers.Mutation.submitHomework(
|
||||
const result = (await homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: validInput },
|
||||
ctx,
|
||||
);
|
||||
)) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(ctx.downstream.call).toHaveBeenCalledWith(
|
||||
@@ -162,8 +185,14 @@ describe("homeworkResolvers", () => {
|
||||
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");
|
||||
expect(ctx.cache.invalidate).toHaveBeenCalledWith(
|
||||
"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 () => {
|
||||
@@ -178,26 +207,28 @@ describe("homeworkResolvers", () => {
|
||||
status: "SUBMITTED",
|
||||
});
|
||||
|
||||
const result = await homeworkResolvers.Mutation.submitHomework(
|
||||
const result = (await homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: inputWithoutStudentId },
|
||||
ctx,
|
||||
);
|
||||
)) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
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,
|
||||
{ input: validInput },
|
||||
ctx,
|
||||
);
|
||||
)) as { success: boolean; error: { code: string } };
|
||||
|
||||
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 () => {
|
||||
@@ -207,7 +238,11 @@ describe("homeworkResolvers", () => {
|
||||
answers: [{ questionId: "q-001", content: "x".repeat(10001) }],
|
||||
};
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(null, { input: longContentInput }, ctx),
|
||||
homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: longContentInput },
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
@@ -224,7 +259,11 @@ describe("homeworkResolvers", () => {
|
||||
],
|
||||
};
|
||||
await expect(
|
||||
homeworkResolvers.Mutation.submitHomework(null, { input: invalidAttachmentInput }, ctx),
|
||||
homeworkResolvers.Mutation.submitHomework(
|
||||
null,
|
||||
{ input: invalidAttachmentInput },
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,23 +53,31 @@ export const homeworkResolvers = {
|
||||
|
||||
// 缓存命中
|
||||
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) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
return ok(cached, {
|
||||
traceId: ctx.traceId,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "ListHomeworkByStudent", {
|
||||
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);
|
||||
await ctx.cache.set("homework", data, CacheTTL.HOMEWORK, cacheKey);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
@@ -110,18 +118,23 @@ export const homeworkResolvers = {
|
||||
assertOwnData(ctx.userId, input.studentId);
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("core-edu", "SubmitHomework", {
|
||||
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);
|
||||
await ctx.cache.invalidate("homework", ctx.userId);
|
||||
await ctx.cache.invalidate("dashboard", ctx.userId);
|
||||
|
||||
const data = result as {
|
||||
submissionId: string;
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
* 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";
|
||||
@@ -36,21 +35,29 @@ import { aiResolvers } from "./ai.resolver.js";
|
||||
import { aiStreamResolvers } from "./ai-stream.resolver.js";
|
||||
|
||||
/**
|
||||
* 全部 Resolver 合并.
|
||||
* 全部 Resolver 手动合并 (spread 运算符).
|
||||
*
|
||||
* 注意: 各 resolver 文件导出的对象结构为 { Query: {...}, Mutation: {...}, Subscription: {...} },
|
||||
* mergeResolvers 自动合并同名 Query/Mutation/Subscription 字段.
|
||||
* 这里按 Query/Mutation/Subscription 分别合并字段.
|
||||
*/
|
||||
export const studentBffResolvers = mergeResolvers([
|
||||
authResolvers,
|
||||
dashboardResolvers,
|
||||
homeworkResolvers,
|
||||
gradesResolvers,
|
||||
examsResolvers,
|
||||
classesResolvers,
|
||||
contentResolvers,
|
||||
analyticsResolvers,
|
||||
notificationsResolvers,
|
||||
aiResolvers,
|
||||
aiStreamResolvers,
|
||||
]);
|
||||
export const studentBffResolvers = {
|
||||
Query: {
|
||||
...(authResolvers.Query ?? {}),
|
||||
...(dashboardResolvers.Query ?? {}),
|
||||
...(homeworkResolvers.Query ?? {}),
|
||||
...(gradesResolvers.Query ?? {}),
|
||||
...(examsResolvers.Query ?? {}),
|
||||
...(classesResolvers.Query ?? {}),
|
||||
...(contentResolvers.Query ?? {}),
|
||||
...(analyticsResolvers.Query ?? {}),
|
||||
...(notificationsResolvers.Query ?? {}),
|
||||
...(aiResolvers.Query ?? {}),
|
||||
},
|
||||
Mutation: {
|
||||
...(homeworkResolvers.Mutation ?? {}),
|
||||
...(notificationsResolvers.Mutation ?? {}),
|
||||
},
|
||||
Subscription: {
|
||||
...(aiStreamResolvers.Subscription ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,28 +44,41 @@ export const notificationsResolvers = {
|
||||
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);
|
||||
const cached = await ctx.cache.get<unknown>("notifications", cacheKey);
|
||||
if (cached) {
|
||||
return ok(cached, { traceId: ctx.traceId, cachedAt: new Date().toISOString() });
|
||||
return ok(cached, {
|
||||
traceId: ctx.traceId,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("msg", "ListNotifications", {
|
||||
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);
|
||||
await ctx.cache.set(
|
||||
"notifications",
|
||||
data,
|
||||
CacheTTL.NOTIFICATIONS,
|
||||
cacheKey,
|
||||
);
|
||||
return ok(data, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
return fail(
|
||||
@@ -90,12 +103,17 @@ export const notificationsResolvers = {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("msg", "GetUnreadCount", {
|
||||
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 });
|
||||
@@ -137,16 +155,21 @@ export const notificationsResolvers = {
|
||||
assertOwnData(ctx.userId, input.studentId);
|
||||
|
||||
try {
|
||||
const result = await ctx.downstream.call("msg", "MarkNotificationAsRead", {
|
||||
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}`);
|
||||
await ctx.cache.invalidateByPrefix(`notifications:${ctx.userId}`);
|
||||
|
||||
return ok(result, { traceId: ctx.traceId });
|
||||
} catch (err) {
|
||||
|
||||
@@ -13,12 +13,16 @@
|
||||
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 { REDIS_CLIENT, CacheService } from "../shared/cache/cache.module.js";
|
||||
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 { logger } from "../shared/observability/logger.js";
|
||||
import type { YogaServerInstance } from "graphql-yoga";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
export const GRAPHQL_YOGA = Symbol("GRAPHQL_YOGA");
|
||||
|
||||
@@ -29,8 +33,16 @@ export const GRAPHQL_YOGA = Symbol("GRAPHQL_YOGA");
|
||||
useFactory: async (
|
||||
downstream: DownstreamClient,
|
||||
redis: Redis,
|
||||
): Promise<YogaServerInstance<Record<string, unknown>, StudentBffContext>> => {
|
||||
return createStudentBffYoga(studentBffResolvers, downstream, redis);
|
||||
): Promise<
|
||||
YogaServerInstance<{ req: Request; res: Response }, StudentBffContext>
|
||||
> => {
|
||||
const cache = new CacheService(redis);
|
||||
return createStudentBffYoga(
|
||||
studentBffResolvers,
|
||||
downstream,
|
||||
redis,
|
||||
cache,
|
||||
);
|
||||
},
|
||||
inject: [DownstreamClient, REDIS_CLIENT],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user