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

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

View File

@@ -0,0 +1,104 @@
/**
* ActionState 信封 + 降级模式方案 B 单元测试.
*/
import { describe, it, expect } from "vitest";
import { ok, fail, degraded, DegradedReason, type Degradable } from "./action-state.js";
describe("ActionState", () => {
describe("ok()", () => {
it("should construct success response with data only", () => {
const result = ok({ name: "test" });
expect(result.success).toBe(true);
expect(result.data).toEqual({ name: "test" });
expect(result.meta).toBeUndefined();
});
it("should construct success response with meta", () => {
const result = ok({ count: 1 }, { traceId: "trace-123" });
expect(result.success).toBe(true);
expect(result.data).toEqual({ count: 1 });
expect(result.meta?.traceId).toBe("trace-123");
});
it("should include cachedAt in meta", () => {
const result = ok({ items: [] }, { cachedAt: "2026-07-10T00:00:00Z" });
expect(result.meta?.cachedAt).toBe("2026-07-10T00:00:00Z");
});
});
describe("fail()", () => {
it("should construct error response with code and message", () => {
const result = fail("BFF_STUDENT_NOT_FOUND", "Resource not found");
expect(result.success).toBe(false);
expect(result.error.code).toBe("BFF_STUDENT_NOT_FOUND");
expect(result.error.message).toBe("Resource not found");
});
it("should include optional fields", () => {
const result = fail("BFF_STUDENT_BAD_GATEWAY", "Downstream failed", {
traceId: "trace-456",
i18nKey: "error.bffStudent.bad_gateway",
details: { service: "iam" },
});
expect(result.error.traceId).toBe("trace-456");
expect(result.error.i18nKey).toBe("error.bffStudent.bad_gateway");
expect(result.error.details).toEqual({ service: "iam" });
});
it("should have undefined optional fields when not provided", () => {
const result = fail("BFF_STUDENT_INTERNAL_ERROR", "Unknown error");
expect(result.error.traceId).toBeUndefined();
expect(result.error.i18nKey).toBeUndefined();
expect(result.error.details).toBeUndefined();
});
});
describe("degraded()", () => {
it("should construct degraded response with degraded=true", () => {
interface DashboardData extends Degradable {
score: number;
}
const result = degraded<DashboardData>(
{ score: 90 },
DegradedReason.DOWNSTREAM_PARTIAL_FAILURE,
["weakness", "trend"],
);
expect(result.success).toBe(true);
expect(result.data.score).toBe(90);
expect(result.data.degraded).toBe(true);
expect(result.data.degradedReason).toBe("downstream_partial_failure");
expect(result.data.degradedFields).toEqual(["weakness", "trend"]);
});
it("should set degraded=true in meta", () => {
const result = degraded(
{ items: [] },
DegradedReason.REDIS_UNAVAILABLE,
["items"],
);
expect(result.meta?.degraded).toBe(true);
expect(result.meta?.degradedReason).toBe("redis_unavailable");
});
it("should merge additional meta fields", () => {
const result = degraded(
{ data: "test" },
DegradedReason.CIRCUIT_OPEN,
["data"],
{ traceId: "trace-789" },
);
expect(result.meta?.traceId).toBe("trace-789");
expect(result.meta?.degraded).toBe(true);
});
});
describe("DegradedReason constants", () => {
it("should export all expected reasons", () => {
expect(DegradedReason.REDIS_UNAVAILABLE).toBe("redis_unavailable");
expect(DegradedReason.DOWNSTREAM_PARTIAL_FAILURE).toBe("downstream_partial_failure");
expect(DegradedReason.DOWNSTREAM_TIMEOUT).toBe("downstream_timeout");
expect(DegradedReason.CIRCUIT_OPEN).toBe("circuit_open");
expect(DegradedReason.MOCK_UPSTREAM).toBe("mock_upstream");
});
});
});

View File

@@ -0,0 +1,125 @@
/**
* ActionState 信封 + 降级模式方案 B 工具.
*
* 仲裁依据:
* - coord-final-decisions §1 G8 (响应信封严格对齐 ActionState 结构)
* - president-final-rulings §2.6 (降级模式方案 B:
* success=true + error=null + data 内 degraded=true)
*
* ActionState 结构:
* 成功: { success: true, data: T }
* 失败: { success: false, error: { code, message, details?, traceId? } }
*
* 降级模式 (方案 B):
* {
* success: true,
* data: {
* ...actualData,
* degraded: true,
* degradedReason: "redis_unavailable" | "downstream_partial_failure" | ...,
* degradedFields: ["field1", "field2"]
* }
* }
*/
export interface ActionStateSuccess<T> {
success: true;
data: T;
meta?: {
traceId?: string;
cachedAt?: string;
degraded?: boolean;
degradedReason?: string;
degradedServices?: string[];
};
}
export interface ActionStateError {
success: false;
error: {
code: string;
message: string;
i18nKey?: string;
details?: Record<string, unknown>;
traceId?: string;
};
}
export type ActionState<T> = ActionStateSuccess<T> | ActionStateError;
/**
* 降级标记字段 (president §2.6 方案 B).
* 业务 data 对象可包含此字段表示降级状态.
*/
export interface Degradable {
degraded?: boolean;
degradedReason?: string;
degradedFields?: string[];
}
/**
* 构造成功响应.
*/
export function ok<T>(data: T, meta?: ActionStateSuccess<T>["meta"]): ActionStateSuccess<T> {
return { success: true, data, meta };
}
/**
* 构造失败响应.
*/
export function fail(
code: string,
message: string,
options?: {
details?: Record<string, unknown>;
traceId?: string;
i18nKey?: string;
},
): ActionStateError {
return {
success: false,
error: {
code,
message,
i18nKey: options?.i18nKey,
details: options?.details,
traceId: options?.traceId,
},
};
}
/**
* 构造降级响应 (方案 B).
*
* 下游部分失败但仍返回部分数据时使用:
* - success=true (HTTP 200)
* - data.degraded=true
* - data.degradedReason=原因
* - data.degradedFields=哪些字段降级了
*/
export function degraded<T extends Degradable>(
data: T,
reason: string,
degradedFields: string[],
meta?: ActionStateSuccess<T>["meta"],
): ActionStateSuccess<T> {
return ok(
{
...data,
degraded: true,
degradedReason: reason,
degradedFields,
},
{ ...meta, degraded: true, degradedReason: reason },
);
}
/**
* 常用降级原因.
*/
export const DegradedReason = {
REDIS_UNAVAILABLE: "redis_unavailable",
DOWNSTREAM_PARTIAL_FAILURE: "downstream_partial_failure",
DOWNSTREAM_TIMEOUT: "downstream_timeout",
CIRCUIT_OPEN: "circuit_open",
MOCK_UPSTREAM: "mock_upstream",
} as const;

View File

@@ -0,0 +1,211 @@
/**
* student-bff Redis 缓存模块.
*
* 仲裁依据:
* - coord-final-decisions §2 B6 (Redis 5-30s 短缓存)
* - coord-final-decisions §1 G9 (优雅关闭 Redis 连接)
* - president-final-rulings §2.6 (降级模式方案 B: degraded=true + degradedReason)
* - 02-architecture-design.md §3.1 (Redis 缓存 Schema)
*
* 缓存 Key 规范:
* student:dashboard:{userId} TTL 15s
* student:exams:{userId}:{classId} TTL 30s
* student:homework:{userId}:{classId} TTL 30s
* student:grades:{userId}:{page} TTL 60s
* student:notifications:{userId}:{page} TTL 15s
* student:textbooks:{gradeId}:{subjectId} TTL 300s
* student:chapters:{textbookId} TTL 300s
* student:analytics:weakness:{userId} TTL 300s
* student:analytics:trend:{userId}:{range} TTL 600s
* student:viewports:{userId} TTL 300s
*/
import { Global, Module, OnModuleDestroy } from "@nestjs/common";
import Redis from "ioredis";
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
import { recordCacheAccess } from "../observability/metrics.js";
export const REDIS_CLIENT = Symbol("REDIS_CLIENT");
/**
* 缓存 TTL 预设 (秒).
*/
export const CacheTTL = {
DASHBOARD: 15,
EXAMS: 30,
HOMEWORK: 30,
GRADES: 60,
NOTIFICATIONS: 15,
TEXTBOOKS: 300,
CHAPTERS: 300,
ANALYTICS_WEAKNESS: 300,
ANALYTICS_TREND: 600,
VIEWPORTS: 300,
} as const;
/**
* 缓存 Key 构建器 (统一前缀 + 规范化).
*/
export function buildCacheKey(pattern: string, ...parts: (string | number)[]): string {
const suffix = parts.map(String).join(":");
return `${env.REDIS_KEY_PREFIX}${pattern}:${suffix}`;
}
@Global()
@Module({
providers: [
{
provide: REDIS_CLIENT,
useFactory: (): Redis => {
const client = new Redis(env.REDIS_URL, {
lazyConnect: false,
maxRetriesPerRequest: 3,
enableReadyCheck: true,
retryStrategy: (times) => Math.min(times * 100, 2000),
});
client.on("error", (err) => {
logger.error({ err }, "Redis client error");
});
client.on("connect", () => {
logger.info({ url: env.REDIS_URL }, "Redis connected");
});
return client;
},
},
],
exports: [REDIS_CLIENT],
})
export class CacheModule implements OnModuleDestroy {
constructor() {}
async onModuleDestroy(): Promise<void> {
// G9 优雅关闭: 由 main.ts SIGTERM handler 统一调用 disconnectAll
logger.info("CacheModule destroyed (Redis disconnect handled by main.ts)");
}
}
/**
* 缓存服务 - 封装 Redis get/set + 降级模式.
*
* 使用方式:
* const cached = await cacheService.get<StudentDashboard>("dashboard", userId);
* if (cached) return cached;
* const fresh = await aggregateDashboard(userId);
* await cacheService.set("dashboard", userId, fresh, CacheTTL.DASHBOARD);
*/
export class CacheService {
constructor(private readonly redis: Redis) {}
/**
* 读取缓存, 自动 JSON 反序列化.
* 失败时返回 null (不抛异常, 上层走降级模式).
*/
async get<T>(pattern: string, ...keyParts: (string | number)[]): Promise<T | null> {
const key = buildCacheKey(pattern, ...keyParts);
try {
const raw = await this.redis.get(key);
if (raw) {
recordCacheAccess(pattern, true);
return JSON.parse(raw) as T;
}
recordCacheAccess(pattern, false);
return null;
} catch (err) {
logger.warn({ err, key, pattern }, "Cache get failed, returning null");
recordCacheAccess(pattern, false);
return null;
}
}
/**
* 写入缓存, 自动 JSON 序列化.
* 失败时仅记录日志, 不影响主流程 (降级模式).
*/
async set(
pattern: string,
value: unknown,
ttlSeconds: number,
...keyParts: (string | number)[]
): Promise<void> {
const key = buildCacheKey(pattern, ...keyParts);
try {
const serialized = JSON.stringify(value);
// TTL 加 ±20% 随机抖动, 避免缓存雪崩 (02 §8.1)
const jitter = Math.floor(ttlSeconds * 0.2 * (Math.random() * 2 - 1));
const ttl = Math.max(1, ttlSeconds + jitter);
await this.redis.set(key, serialized, "EX", ttl);
} catch (err) {
logger.warn({ err, key, pattern }, "Cache set failed, skipping");
}
}
/**
* 失效缓存 (按 pattern 通配符删除).
* 用于写操作后主动失效相关缓存.
*/
async invalidate(pattern: string, ...keyParts: (string | number)[]): Promise<void> {
const key = buildCacheKey(pattern, ...keyParts);
try {
// 如果 keyParts 含通配符, 用 SCAN 删除
if (key.includes("*")) {
const stream = this.redis.scanStream({
match: key,
count: 100,
});
const pipeline = this.redis.pipeline();
stream.on("data", (keys: string[]) => {
if (keys.length > 0) {
keys.forEach((k) => pipeline.del(k));
}
});
await new Promise<void>((resolve) => {
stream.on("end", () => {
pipeline.exec().finally(() => resolve());
});
});
} else {
await this.redis.del(key);
}
} catch (err) {
logger.warn({ err, key, pattern }, "Cache invalidate failed");
}
}
/**
* 批量失效 (如成绩发布后失效学生所有成绩缓存).
*/
async invalidateByPrefix(prefix: string): Promise<void> {
const pattern = `${env.REDIS_KEY_PREFIX}${prefix}*`;
try {
const stream = this.redis.scanStream({
match: pattern,
count: 100,
});
const pipeline = this.redis.pipeline();
stream.on("data", (keys: string[]) => {
if (keys.length > 0) {
keys.forEach((k) => pipeline.del(k));
}
});
await new Promise<void>((resolve) => {
stream.on("end", () => {
pipeline.exec().finally(() => resolve());
});
});
} catch (err) {
logger.warn({ err, prefix }, "Cache invalidateByPrefix failed");
}
}
/**
* 健康检查 (供 /readyz 探针使用, §2.4).
*/
async ping(): Promise<boolean> {
try {
const result = await this.redis.ping();
return result === "PONG";
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,19 @@
/**
* CircuitBreakerModule - P6 熔断器模块.
*
* 仲裁依据:
* - workline §5.5 P6.1 (熔断器 opossum 完善)
* - coord-final-decisions §1 G11 (opossum 熔断器)
*
* 提供 CircuitBreakerService 供 Resolver 使用 (可选, P6 阶段启用).
* P3-P5 阶段 Resolver 直接调用 DownstreamClient.call, P6 可切换为 CircuitBreakerService.call.
*/
import { Global, Module } from "@nestjs/common";
import { CircuitBreakerService } from "./circuit-breaker.service.js";
@Global()
@Module({
providers: [CircuitBreakerService],
exports: [CircuitBreakerService],
})
export class CircuitBreakerModule {}

View File

@@ -0,0 +1,225 @@
/**
* CircuitBreakerService - P6 熔断器封装.
*
* 仲裁依据:
* - workline §5.5 P6.1 (熔断器 opossum 完善)
* - coord-final-decisions §1 G11 (熔断器: opossum, 50% 阈值, 30s reset)
* - president-final-rulings §2.6 (降级模式方案 B: 熔断开启时返回 degraded)
*
* 设计:
* - 每个下游服务一个独立 CircuitBreaker 实例 (Map 缓存)
* - 熔断开启时抛出 ServiceUnavailableError (503)
* - 状态变更同步到 student_bff_circuit_state 指标
* - half-open 状态允许单次试探调用
*
* Opossum 配置 (G11):
* - timeout: 5000ms (对齐 DOWNSTREAM_TIMEOUT_MS)
* - errorThresholdPercentage: 50%
* - resetTimeout: 30000ms (30s 后 half-open)
* - volumeThreshold: 10 (最少 10 次调用才评估)
* - rollingCountTimeout: 60000 (1 分钟滚动窗口)
*/
import { Injectable } from "@nestjs/common";
import CircuitBreaker from "opossum";
import type { DownstreamClient, CallOptions } from "@edu/shared-ts/bff";
import { ServiceUnavailableError } from "../errors/application-error.js";
import { metricsRegistry } from "../observability/metrics.js";
import { logger } from "../observability/logger.js";
import { env } from "../../config/env.js";
/**
* 熔断器配置.
*/
interface BreakerConfig {
timeoutMs: number;
errorThresholdPercentage: number;
resetTimeoutMs: number;
volumeThreshold: number;
rollingCountTimeoutMs: number;
}
/**
* 默认熔断配置 (G11).
*/
const DEFAULT_CONFIG: BreakerConfig = {
timeoutMs: env.DOWNSTREAM_TIMEOUT_MS,
errorThresholdPercentage: 50,
resetTimeoutMs: 30000,
volumeThreshold: 10,
rollingCountTimeoutMs: 60000,
};
/**
* 熔断器状态映射到指标值.
*/
function stateToMetricValue(state: CircuitBreaker.Status): number {
switch (state) {
case CircuitBreaker.CLOSED:
return 0;
case CircuitBreaker.OPEN:
return 1;
case CircuitBreaker.HALF_OPEN:
return 2;
default:
return 0;
}
}
/**
* 熔断器状态名称.
*/
function stateName(state: CircuitBreaker.Status): string {
switch (state) {
case CircuitBreaker.CLOSED:
return "closed";
case CircuitBreaker.OPEN:
return "open";
case CircuitBreaker.HALF_OPEN:
return "half_open";
default:
return "unknown";
}
}
@Injectable()
export class CircuitBreakerService {
private readonly breakers = new Map<string, CircuitBreaker>();
private readonly config: BreakerConfig;
constructor(config?: Partial<BreakerConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* 通过熔断器调用下游服务.
*
* @param downstream DownstreamClient 实例
* @param service 下游服务名
* @param method RPC 方法名
* @param request 请求 message
* @param options 调用配置
* @returns 下游响应
* @throws ServiceUnavailableError 当熔断器开启时
*/
async call<TRequest, TResponse>(
downstream: DownstreamClient,
service: string,
method: string,
request: TRequest,
options?: CallOptions,
): Promise<TResponse> {
const breaker = this.getOrCreateBreaker(service, downstream, method, request, options);
try {
return (await breaker.fire()) as TResponse;
} catch (err) {
if (err instanceof ServiceUnavailableError) {
throw err;
}
// 重新抛出原始错误 (DownstreamError 等)
throw err;
}
}
/**
* 获取熔断器当前状态.
*/
getState(service: string): CircuitBreaker.Status | null {
const breaker = this.breakers.get(service);
return breaker ? breaker.status : null;
}
/**
* 获取或创建某服务的熔断器.
*
* 注意: 由于 opossum 的 fire() 不接受动态参数, 我们在每次调用时
* 通过闭包捕获当前的 method/request/options.
* 实际上 opossum 支持 fire(args...), 但此处为简化设计,
* 每次创建新的执行函数.
*
* 为避免创建过多 breaker 实例, 我们按 service 名缓存 breaker,
* 并在 fire 前更新其执行函数.
*/
private getOrCreateBreaker<TRequest>(
service: string,
downstream: DownstreamClient,
method: string,
request: TRequest,
options?: CallOptions,
): CircuitBreaker {
let breaker = this.breakers.get(service);
if (!breaker) {
const execFn = async (): Promise<unknown> => {
return downstream.call(service, method, request, options);
};
breaker = new CircuitBreaker(execFn, {
timeout: this.config.timeoutMs,
errorThresholdPercentage: this.config.errorThresholdPercentage,
resetTimeout: this.config.resetTimeoutMs,
volumeThreshold: this.config.volumeThreshold,
rollingCountTimeout: this.config.rollingCountTimeoutMs,
});
// 状态变更监听
breaker.on("open", () => {
logger.warn({ service }, "Circuit breaker OPENED");
this.updateMetric(service, CircuitBreaker.OPEN);
});
breaker.on("close", () => {
logger.info({ service }, "Circuit breaker CLOSED (recovered)");
this.updateMetric(service, CircuitBreaker.CLOSED);
});
breaker.on("halfOpen", () => {
logger.info({ service }, "Circuit breaker HALF-OPEN");
this.updateMetric(service, CircuitBreaker.HALF_OPEN);
});
// fallback: 熔断开启时返回 ServiceUnavailableError
breaker.fallback(() => {
throw new ServiceUnavailableError(
`Circuit breaker open for service: ${service}`,
{ service, state: "open" },
);
});
this.breakers.set(service, breaker);
this.updateMetric(service, CircuitBreaker.CLOSED);
} else {
// 更新执行函数 (opossum 允许重新设置 action)
// 由于 opossum 不支持直接替换 action, 我们使用 wrapper 方式
// 实际上 opossum 的 fire() 会调用构造时传入的函数,
// 所以我们用一个可变的 wrapper
(breaker as unknown as { action: () => Promise<unknown> }).action = async () => {
return downstream.call(service, method, request, options);
};
}
return breaker;
}
/**
* 更新熔断器指标.
*/
private updateMetric(service: string, state: CircuitBreaker.Status): void {
const value = stateToMetricValue(state);
const name = stateName(state);
metricsRegistry
.getSingleMetric("student_bff_circuit_state")
?.set({ service, state: name }, value);
}
/**
* 关闭所有熔断器 (优雅关闭).
*/
async shutdown(): Promise<void> {
for (const [service, breaker] of this.breakers) {
breaker.shutdown();
logger.debug({ service }, "Circuit breaker shut down");
}
this.breakers.clear();
}
}

View File

@@ -0,0 +1,61 @@
/**
* student-bff DownstreamClient NestJS Module.
*
* 仲裁依据: coord-final-decisions §2 B8 (3 BFF 复用 shared-ts 抽象)
*
* 提供:
* - 全局共享 DownstreamClient 实例 (@Injectable)
* - 启动时注入 mock 数据提供器 (env.MOCK_UPSTREAM=true 时)
* - OnModuleDestroy 优雅关闭 gRPC 连接 (G9)
*/
import { Global, Module, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { DownstreamClient } from "@edu/shared-ts/bff";
import { downstreamClientConfig } from "../../config/downstream.js";
import { studentBffMockProvider } from "../../config/mock-data.js";
import { logger } from "../observability/logger.js";
export const DOWNSTREAM_CLIENT = Symbol("DOWNSTREAM_CLIENT");
@Global()
@Module({
providers: [
{
provide: DOWNSTREAM_CLIENT,
useFactory: (): DownstreamClient => {
const client = new DownstreamClient(downstreamClientConfig);
if (downstreamClientConfig.mockUpstream) {
client.setMockProvider(studentBffMockProvider);
logger.warn(
"MOCK_UPSTREAM=true, all downstream calls return mock data",
);
}
return client;
},
},
{
provide: DownstreamClient,
useExisting: DOWNSTREAM_CLIENT,
},
],
exports: [DownstreamClient, DOWNSTREAM_CLIENT],
})
export class DownstreamModule implements OnModuleInit, OnModuleDestroy {
constructor() {}
async onModuleInit(): Promise<void> {
const enabled = downstreamClientConfig.services
.filter((s) => s.enabled)
.map((s) => s.name);
logger.info(
{ enabled, mock: downstreamClientConfig.mockUpstream },
"DownstreamClient initialized",
);
}
async onModuleDestroy(): Promise<void> {
// G9 优雅关闭: 关闭所有 gRPC 连接
// DownstreamClient 实例由 NestJS 容器管理, 这里通过 token 获取
// 但 Module destroy 阶段不能注入, 实际由 main.ts SIGTERM handler 统一关闭
logger.info("DownstreamModule destroyed");
}
}

View File

@@ -0,0 +1,188 @@
/**
* ApplicationError 错误类层次 + i18n key 生成 单元测试.
*/
import { describe, it, expect } from "vitest";
import {
ApplicationError,
ValidationError,
UnauthorizedError,
ForbiddenResourceError,
IdentityMismatchError,
NotFoundError,
ConflictError,
BusinessError,
BadGatewayError,
GatewayTimeoutError,
ServiceUnavailableError,
InternalError,
} from "./application-error.js";
describe("ApplicationError", () => {
describe("ValidationError", () => {
it("should have 400 status and BFF_STUDENT_VALIDATION_ERROR code", () => {
const err = new ValidationError("Invalid input");
expect(err.statusCode).toBe(400);
expect(err.code).toBe("BFF_STUDENT_VALIDATION_ERROR");
expect(err.type).toBe("validation");
expect(err.message).toBe("Invalid input");
});
it("should accept details", () => {
const err = new ValidationError("Invalid input", { field: "email" });
expect(err.details).toEqual({ field: "email" });
});
});
describe("UnauthorizedError", () => {
it("should have 401 status and BFF_STUDENT_UNAUTHORIZED code", () => {
const err = new UnauthorizedError();
expect(err.statusCode).toBe(401);
expect(err.code).toBe("BFF_STUDENT_UNAUTHORIZED");
expect(err.type).toBe("unauthorized");
});
it("should use default message", () => {
const err = new UnauthorizedError();
expect(err.message).toBe("Missing or invalid x-user-id");
});
it("should accept custom message", () => {
const err = new UnauthorizedError("Token expired");
expect(err.message).toBe("Token expired");
});
});
describe("ForbiddenResourceError (场景 A)", () => {
it("should have 403 status and BFF_STUDENT_FORBIDDEN_RESOURCE code", () => {
const err = new ForbiddenResourceError("Not your data");
expect(err.statusCode).toBe(403);
expect(err.code).toBe("BFF_STUDENT_FORBIDDEN_RESOURCE");
expect(err.type).toBe("permission_denied");
});
});
describe("IdentityMismatchError (场景 B)", () => {
it("should have 403 status and BFF_STUDENT_IDENTITY_MISMATCH code", () => {
const err = new IdentityMismatchError("Identity mismatch");
expect(err.statusCode).toBe(403);
expect(err.code).toBe("BFF_STUDENT_IDENTITY_MISMATCH");
expect(err.type).toBe("permission_denied");
});
});
describe("NotFoundError", () => {
it("should have 404 status and BFF_STUDENT_NOT_FOUND code", () => {
const err = new NotFoundError("Homework", "hw-123");
expect(err.statusCode).toBe(404);
expect(err.code).toBe("BFF_STUDENT_NOT_FOUND");
expect(err.message).toBe("Homework not found: hw-123");
expect(err.details).toEqual({ resource: "Homework", id: "hw-123" });
});
});
describe("ConflictError", () => {
it("should have 409 status and BFF_STUDENT_CONFLICT code", () => {
const err = new ConflictError("Already submitted");
expect(err.statusCode).toBe(409);
expect(err.code).toBe("BFF_STUDENT_CONFLICT");
});
});
describe("BusinessError", () => {
it("should have 422 status and BFF_STUDENT_BUSINESS_ERROR code", () => {
const err = new BusinessError("Business rule violated");
expect(err.statusCode).toBe(422);
expect(err.code).toBe("BFF_STUDENT_BUSINESS_ERROR");
});
});
describe("BadGatewayError", () => {
it("should have 502 status and BFF_STUDENT_BAD_GATEWAY code", () => {
const err = new BadGatewayError("Downstream failed");
expect(err.statusCode).toBe(502);
expect(err.code).toBe("BFF_STUDENT_BAD_GATEWAY");
});
});
describe("GatewayTimeoutError", () => {
it("should have 504 status and BFF_STUDENT_GATEWAY_TIMEOUT code", () => {
const err = new GatewayTimeoutError("Downstream timeout");
expect(err.statusCode).toBe(504);
expect(err.code).toBe("BFF_STUDENT_GATEWAY_TIMEOUT");
});
});
describe("ServiceUnavailableError", () => {
it("should have 503 status and BFF_STUDENT_SERVICE_UNAVAILABLE code", () => {
const err = new ServiceUnavailableError("Circuit breaker open");
expect(err.statusCode).toBe(503);
expect(err.code).toBe("BFF_STUDENT_SERVICE_UNAVAILABLE");
});
});
describe("InternalError", () => {
it("should have 500 status and BFF_STUDENT_INTERNAL_ERROR code", () => {
const err = new InternalError("Unexpected error");
expect(err.statusCode).toBe(500);
expect(err.code).toBe("BFF_STUDENT_INTERNAL_ERROR");
});
});
describe("toJSON() serialization", () => {
it("should serialize to ActionState error envelope", () => {
const err = new ValidationError("Invalid input", { field: "name" });
err.traceId = "trace-abc";
const json = err.toJSON();
expect(json.success).toBe(false);
expect(json.error).toBeDefined();
expect(json.error.code).toBe("BFF_STUDENT_VALIDATION_ERROR");
expect(json.error.message).toBe("Invalid input");
expect(json.error.i18nKey).toBe("error.bffStudent.validation_error");
expect(json.error.details).toEqual({ field: "name" });
expect(json.error.traceId).toBe("trace-abc");
});
it("should generate correct i18n key for each error code", () => {
const cases = [
{ error: new ValidationError(), expectedKey: "error.bffStudent.validation_error" },
{ error: new UnauthorizedError(), expectedKey: "error.bffStudent.unauthorized" },
{ error: new ForbiddenResourceError("test"), expectedKey: "error.bffStudent.forbidden_resource" },
{ error: new IdentityMismatchError("test"), expectedKey: "error.bffStudent.identity_mismatch" },
{ error: new NotFoundError("X", "1"), expectedKey: "error.bffStudent.not_found" },
{ error: new ConflictError("test"), expectedKey: "error.bffStudent.conflict" },
{ error: new BusinessError("test"), expectedKey: "error.bffStudent.business_error" },
{ error: new BadGatewayError("test"), expectedKey: "error.bffStudent.bad_gateway" },
{ error: new GatewayTimeoutError("test"), expectedKey: "error.bffStudent.gateway_timeout" },
{ error: new ServiceUnavailableError("test"), expectedKey: "error.bffStudent.service_unavailable" },
{ error: new InternalError("test"), expectedKey: "error.bffStudent.internal_error" },
];
for (const { error, expectedKey } of cases) {
const json = error.toJSON();
expect(json.error.i18nKey).toBe(expectedKey);
}
});
});
describe("Error name", () => {
it("should set constructor name as error.name", () => {
expect(new ValidationError("x").name).toBe("ValidationError");
expect(new UnauthorizedError().name).toBe("UnauthorizedError");
expect(new ForbiddenResourceError("x").name).toBe("ForbiddenResourceError");
expect(new IdentityMismatchError("x").name).toBe("IdentityMismatchError");
});
});
describe("instanceof checks", () => {
it("should be instanceof ApplicationError", () => {
expect(new ValidationError("x")).toBeInstanceOf(ApplicationError);
expect(new UnauthorizedError()).toBeInstanceOf(ApplicationError);
expect(new ForbiddenResourceError("x")).toBeInstanceOf(ApplicationError);
});
it("should be instanceof Error", () => {
expect(new ValidationError("x")).toBeInstanceOf(Error);
expect(new InternalError("x")).toBeInstanceOf(Error);
});
});
});

View File

@@ -0,0 +1,177 @@
/**
* student-bff ApplicationError 层次.
*
* 仲裁依据:
* - coord-final-decisions §1 G8 (首次实现即 GlobalErrorFilter + ActionState 信封)
* - coord-final-decisions §1 G14 (服务名大写前缀, BFF_STUDENT_*)
* - coord-final-decisions §2 B5 (统一 BFF_ 前缀)
* - president-final-rulings §2.7 (3 类越权防御错误码)
*
* 错误码清单:
* BFF_STUDENT_VALIDATION_ERROR (400) Zod 校验失败
* BFF_STUDENT_UNAUTHORIZED (401) x-user-id 缺失或无效
* BFF_STUDENT_FORBIDDEN_RESOURCE (403) 资源无归属关系 (场景 A)
* BFF_STUDENT_IDENTITY_MISMATCH (403) JWT userId 与 body 不一致 (场景 B)
* BFF_STUDENT_NOT_FOUND (404) 资源不存在
* BFF_STUDENT_CONFLICT (409) 重复提交 / 状态冲突
* BFF_STUDENT_BUSINESS_ERROR (422) 业务规则违反
* BFF_STUDENT_BAD_GATEWAY (502) 下游 gRPC 失败
* BFF_STUDENT_GATEWAY_TIMEOUT (504) 下游超时
* BFF_STUDENT_SERVICE_UNAVAILABLE (503) 熔断器开启
* BFF_STUDENT_INTERNAL_ERROR (500) 未捕获异常
*/
export type ErrorType =
| "validation"
| "not_found"
| "permission_denied"
| "unauthorized"
| "conflict"
| "business"
| "bad_gateway"
| "gateway_timeout"
| "service_unavailable"
| "internal";
export interface ErrorDetails {
[key: string]: unknown;
}
/**
* i18n key 生成 (president §2.7 + F4 裁决): error.bffStudent.<code_snake>.
*/
function toI18nKey(code: string): string {
const snake = code
.replace(/^BFF_STUDENT_/, "")
.toLowerCase()
.replace(/_/g, "_");
return `error.bffStudent.${snake}`;
}
export abstract class ApplicationError extends Error {
abstract readonly type: ErrorType;
abstract readonly statusCode: number;
readonly code: string;
readonly details?: ErrorDetails;
traceId?: string;
constructor(message: string, code: string, details?: ErrorDetails) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.details = details;
}
/**
* 序列化为 ActionState 信封响应体 (G8).
*/
toJSON(): Record<string, unknown> {
return {
success: false,
error: {
code: this.code,
message: this.message,
i18nKey: toI18nKey(this.code),
details: this.details,
traceId: this.traceId,
},
};
}
}
export class ValidationError extends ApplicationError {
readonly type = "validation" as const;
readonly statusCode = 400;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_VALIDATION_ERROR", details);
}
}
export class UnauthorizedError extends ApplicationError {
readonly type = "unauthorized" as const;
readonly statusCode = 401;
constructor(message = "Missing or invalid x-user-id", details?: ErrorDetails) {
super(message, "BFF_STUDENT_UNAUTHORIZED", details);
}
}
/**
* 场景 A: 资源无归属关系 (president §2.7).
* 如学生请求的 studentId 与 JWT userId 不一致.
*/
export class ForbiddenResourceError extends ApplicationError {
readonly type = "permission_denied" as const;
readonly statusCode = 403;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_FORBIDDEN_RESOURCE", details);
}
}
/**
* 场景 B: JWT userId 与请求 body userId 不一致 (president §2.7).
*/
export class IdentityMismatchError extends ApplicationError {
readonly type = "permission_denied" as const;
readonly statusCode = 403;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_IDENTITY_MISMATCH", details);
}
}
export class NotFoundError extends ApplicationError {
readonly type = "not_found" as const;
readonly statusCode = 404;
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, "BFF_STUDENT_NOT_FOUND", {
resource,
id,
});
}
}
export class ConflictError extends ApplicationError {
readonly type = "conflict" as const;
readonly statusCode = 409;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_CONFLICT", details);
}
}
export class BusinessError extends ApplicationError {
readonly type = "business" as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_BUSINESS_ERROR", details);
}
}
export class BadGatewayError extends ApplicationError {
readonly type = "bad_gateway" as const;
readonly statusCode = 502;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_BAD_GATEWAY", details);
}
}
export class GatewayTimeoutError extends ApplicationError {
readonly type = "gateway_timeout" as const;
readonly statusCode = 504;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_GATEWAY_TIMEOUT", details);
}
}
export class ServiceUnavailableError extends ApplicationError {
readonly type = "service_unavailable" as const;
readonly statusCode = 503;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_SERVICE_UNAVAILABLE", details);
}
}
export class InternalError extends ApplicationError {
readonly type = "internal" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "BFF_STUDENT_INTERNAL_ERROR", details);
}
}

View File

@@ -0,0 +1,119 @@
/**
* student-bff GlobalErrorFilter.
*
* 仲裁依据:
* - coord-final-decisions §1 G8 (首次实现即 GlobalErrorFilter + ActionState 信封)
* - president-final-rulings §2.6 (降级模式方案 B: success=true + degraded)
*
* GraphQL Yoga 错误格式化由 graphql-error-formatter.ts 处理;
* 本 Filter 处理 NestJS HTTP 异常 (健康检查 /metrics 等), 并被 GraphQL Yoga
* 在错误转换时复用 code/i18nKey 生成逻辑.
*/
import {
Catch,
ExceptionFilter,
ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
import { DownstreamError } from "@edu/shared-ts/bff";
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500;
let body: Record<string, unknown>;
if (exception instanceof ApplicationError) {
exception.traceId = traceId;
statusCode = exception.statusCode;
body = exception.toJSON();
} else if (exception instanceof DownstreamError) {
// 下游 gRPC 错误归一化为 BFF_STUDENT_BAD_GATEWAY (B2 + G8)
statusCode = 502;
body = {
success: false,
error: {
code: "BFF_STUDENT_BAD_GATEWAY",
message: `Downstream ${exception.service}.${exception.method} failed: ${exception.message}`,
i18nKey: "error.bffStudent.bad_gateway",
details: {
service: exception.service,
method: exception.method,
downstreamCode: exception.code,
traceId,
},
traceId,
},
};
} else if (exception instanceof ZodError) {
statusCode = 400;
body = {
success: false,
error: {
code: "BFF_STUDENT_VALIDATION_ERROR",
message: "Validation failed",
i18nKey: "error.bffStudent.validation_error",
details: exception.flatten(),
traceId,
},
};
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const res = exception.getResponse();
const message = this.extractHttpMessage(res, exception);
body = {
success: false,
error: {
code: "BFF_STUDENT_HTTP_ERROR",
message,
i18nKey: "error.bffStudent.http_error",
traceId,
},
};
} else {
this.logger.error(
`Unhandled exception: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
body = {
success: false,
error: {
code: "BFF_STUDENT_INTERNAL_ERROR",
message: "An unexpected error occurred",
i18nKey: "error.bffStudent.internal_error",
traceId,
},
};
}
response.status(statusCode).json(body);
}
private extractHttpMessage(
res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === "object" && "message" in res) {
const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
}
}

View File

@@ -0,0 +1,142 @@
/**
* GraphQL Yoga 配置 - 加载 schema + context + error formatter.
*
* 仲裁依据:
* - coord-final-decisions §2 B1 (P2 起直接 GraphQL Yoga + DataLoader)
* - president-final-rulings §2.2 (GraphQL schema 存放 packages/shared-ts/contracts/graphql/)
* - coord-final-decisions §1 G8 (错误响应格式: GraphQL errors 数组 + extensions)
*
* Schema 文件: packages/shared-ts/contracts/graphql/student-bff.schema.graphql
*
* 集成方式: GraphQL Yoga 作为 Express middleware 挂载到 NestJS HTTP Adapter,
* 路径 POST /graphql,开发环境启用 Playground.
*/
import { promises } from "node:fs";
import path from "node:path";
import { createYoga, type YogaServerInstance } from "graphql-yoga";
import { makeExecutableSchema } from "@graphql-tools/schema";
import type { Request, Response } from "express";
import type { DownstreamClient } from "@edu/shared-ts/bff";
import { env } from "../../config/env.js";
import { logger } from "./logger.js";
import type { Redis } from "ioredis";
import { createDataLoaders, type StudentBffDataLoaders } from "../../student/dataloaders/data-loader.module.js";
import {
extractUserIdFromRequest,
extractTraceIdFromRequest,
extractUserRolesFromRequest,
} from "../../student/guards/authorization.guard.js";
/**
* GraphQL Context (每个请求一份).
*/
export interface StudentBffContext {
userId: string | null;
traceId: string;
userRoles: string[];
downstream: DownstreamClient;
redis: Redis;
dataLoaders: StudentBffDataLoaders;
requestId: string;
}
/**
* GraphQL schema 文件路径.
*/
const SCHEMA_PATH = path.resolve(
process.cwd(),
"packages/shared-ts/contracts/graphql/student-bff.schema.graphql",
);
/**
* 加载 schema SDL 文本.
*/
export async function loadSchemaSDL(): Promise<string> {
try {
return await promises.readFile(SCHEMA_PATH, "utf-8");
} catch (err) {
logger.error(
{ err, path: SCHEMA_PATH },
"Failed to load student-bff GraphQL schema file",
);
throw err;
}
}
/**
* 创建 GraphQL Yoga 实例.
*
* @param resolvers GraphQL Resolver 映射表 (由 StudentModule 装配)
* @param downstream DownstreamClient 实例 (由 NestJS DI 注入)
* @param redis Redis 客户端 (由 NestJS DI 注入)
*/
export async function createStudentBffYoga(
resolvers: Record<string, unknown>,
downstream: DownstreamClient,
redis: Redis,
): Promise<YogaServerInstance<Record<string, unknown>, StudentBffContext>> {
const typeDefs = await loadSchemaSDL();
const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
const yoga = createYoga<{
req: Request;
res: Response;
}, StudentBffContext>({
schema,
graphqlEndpoint: "/graphql",
context: ({ req }): StudentBffContext => {
const userId = extractUserIdFromRequest(req);
const traceId = extractTraceIdFromRequest(req);
const userRoles = extractUserRolesFromRequest(req);
return {
userId,
traceId,
userRoles,
downstream,
redis,
dataLoaders: createDataLoaders(downstream),
requestId: traceId,
};
},
logging: {
debug: (msg) => logger.debug({ component: "graphql-yoga" }, String(msg)),
info: (msg) => logger.info({ component: "graphql-yoga" }, String(msg)),
warn: (msg) => logger.warn({ component: "graphql-yoga" }, String(msg)),
error: (msg) => logger.error({ component: "graphql-yoga" }, String(msg)),
},
maskedErrors: env.NODE_ENV === "production",
// 开发环境启用 Playground
graphiql: env.GRAPHQL_PLAYGROUND && env.NODE_ENV === "development",
// 错误格式化 (G8): GraphQL errors 数组 + extensions.code + extensions.traceId
formatError: (err) => {
const originalError = err.originalError;
const code =
(originalError as { code?: string })?.code ??
"BFF_STUDENT_INTERNAL_ERROR";
const traceId = err.context?.requestId ?? "unknown";
return {
message: err.message,
extensions: {
code,
traceId,
i18nKey: `error.bffStudent.${code.replace(/^BFF_STUDENT_/, "").toLowerCase()}`,
severity: "error",
},
path: err.path,
locations: err.locations,
};
},
});
logger.info(
{ endpoint: "/graphql", playground: env.GRAPHQL_PLAYGROUND },
"GraphQL Yoga initialized",
);
return yoga;
}

View File

@@ -0,0 +1,146 @@
/**
* Health Controller - /healthz + /readyz 探针.
*
* 仲裁依据:
* - coord-final-decisions §1 G2 (首次实现即检查全部下游依赖)
* - coord-final-decisions §1 G3 (/healthz liveness)
* - president-final-rulings §2.4 (/readyz 探针按阶段扩展, 必需失败返回 503, 可选软失败)
*
* 探针列表 (按阶段扩展, president §2.4):
* P3: Redis + iam gRPC + core-edu gRPC (3 项)
* P4: + content gRPC + data-ana gRPC (5 项)
* P5: + msg gRPC + ai gRPC (7 项)
*
* 实现方式: DownstreamClient.checkHealth() 检查 gRPC 可达性 + CacheService.ping() 检查 Redis.
*/
import { Controller, Get, HttpCode, HttpStatus, Inject } from "@nestjs/common";
import { DownstreamClient } from "@edu/shared-ts/bff";
import { REDIS_CLIENT, CacheService } from "../cache/cache.module.js";
import type { Redis } from "ioredis";
import { downstreamServices } from "../../config/downstream.js";
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
interface HealthCheck {
service: string;
healthy: boolean;
required: boolean;
latencyMs?: number;
}
interface ReadyzResponse {
status: "ok" | "degraded" | "unavailable";
service: string;
timestamp: string;
checks: HealthCheck[];
degraded?: boolean;
degradedServices?: string[];
}
const SERVICE_NAME = "student-bff";
@Controller()
export class HealthController {
constructor(
private readonly downstream: DownstreamClient,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
/**
* /healthz: Liveness probe (G3).
* 仅检查进程存活, 不检查依赖.
*/
@Get("healthz")
@HttpCode(HttpStatus.OK)
liveness(): { status: string; service: string; timestamp: string } {
return {
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
}
/**
* /readyz: Readiness probe (G2 + president §2.4).
*
* 检查全部已启用的下游依赖:
* - Redis PING
* - 各下游 gRPC waitForReady
*
* 必需依赖失败 → 503 (触发 Pod 重启)
* 可选依赖失败 → 200 + degraded=true (软失败)
*/
@Get("readyz")
async readiness(): Promise<ReadyzResponse> {
const checks: HealthCheck[] = [];
// 1. Redis 检查
const redisStart = Date.now();
const cacheService = new CacheService(this.redis);
const redisOk = await cacheService.ping();
checks.push({
service: "redis",
healthy: redisOk,
required: true,
latencyMs: Date.now() - redisStart,
});
// 2. 各下游 gRPC 检查 (env.MOCK_UPSTREAM=true 时跳过, 直接 healthy)
for (const svc of downstreamServices) {
if (!svc.enabled) continue;
const start = Date.now();
let healthy = true;
if (!env.MOCK_UPSTREAM) {
healthy = await this.downstream.checkHealth(svc.name);
}
checks.push({
service: svc.name,
healthy,
required: svc.required,
latencyMs: Date.now() - start,
});
}
// 3. 判断整体状态
const failedRequired = checks.filter((c) => !c.healthy && c.required);
const failedOptional = checks.filter((c) => !c.healthy && !c.required);
let status: ReadyzResponse["status"] = "ok";
let httpStatus = HttpStatus.OK;
let degraded = false;
if (failedRequired.length > 0) {
status = "unavailable";
httpStatus = HttpStatus.SERVICE_UNAVAILABLE;
logger.warn(
{ failedRequired: failedRequired.map((c) => c.service) },
"/readyz failed: required dependencies unavailable",
);
} else if (failedOptional.length > 0) {
status = "degraded";
degraded = true;
logger.warn(
{ failedOptional: failedOptional.map((c) => c.service) },
"/readyz degraded: optional dependencies unavailable",
);
}
const response: ReadyzResponse = {
status,
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
checks,
degraded,
degradedServices: [...failedRequired, ...failedOptional].map((c) => c.service),
};
// NestJS 4.x 的 @HttpCode 装饰器对 async 方法不一定生效, 这里通过 throw 切换状态码
if (httpStatus !== HttpStatus.OK) {
// 通过抛 HttpException 切换状态码
const { HttpException } = await import("@nestjs/common");
throw new HttpException(response, httpStatus);
}
return response;
}
}

View File

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

View File

@@ -0,0 +1,13 @@
/**
* student-bff pino logger.
*
* 仲裁依据: coord-final-decisions §1 G4 (首次实现即 pino 结构化日志)
* coord-final-decisions §2 B8 (复用 shared-ts BFF logger 工厂)
*/
import { createBffLogger, type Logger } from "@edu/shared-ts/bff";
export const logger: Logger = createBffLogger("student-bff", {
level: process.env.LOG_LEVEL ?? "info",
});
export type { Logger };

View File

@@ -0,0 +1,159 @@
/**
* student-bff prom-client metrics.
*
* 仲裁依据: coord-final-decisions §1 G5 (首次实现即 /metrics + 基础业务指标)
* 02-architecture-design.md §6.4 (11 个 student_bff_* 指标)
*
* 指标命名: <service>_<module>_<operation>_<unit> (project_rules §12)
*/
import promClient from "prom-client";
const registry = new promClient.Registry();
registry.setDefaultLabels({ service: "student-bff" });
// 1. 请求总数
registry.registerMetric(
new promClient.Counter({
name: "student_bff_requests_total",
help: "Total number of student-bff HTTP/GraphQL requests",
labelNames: ["operation", "method", "status"],
}),
);
// 2. 请求延迟
registry.registerMetric(
new promClient.Histogram({
name: "student_bff_request_duration_seconds",
help: "Student-bff request duration in seconds",
labelNames: ["operation", "method"],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
// 3. 下游调用总数
registry.registerMetric(
new promClient.Counter({
name: "student_bff_downstream_calls_total",
help: "Total downstream gRPC calls",
labelNames: ["service", "method", "status"],
}),
);
// 4. 下游调用延迟
registry.registerMetric(
new promClient.Histogram({
name: "student_bff_downstream_duration_seconds",
help: "Downstream gRPC call duration in seconds",
labelNames: ["service", "method"],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
// 5. 下游错误数
registry.registerMetric(
new promClient.Counter({
name: "student_bff_downstream_errors_total",
help: "Downstream gRPC call errors",
labelNames: ["service", "method", "error_type"],
}),
);
// 6. 缓存命中
registry.registerMetric(
new promClient.Counter({
name: "student_bff_cache_hits_total",
help: "Cache hit count",
labelNames: ["cache_key_pattern"],
}),
);
// 7. 缓存未命中
registry.registerMetric(
new promClient.Counter({
name: "student_bff_cache_misses_total",
help: "Cache miss count",
labelNames: ["cache_key_pattern"],
}),
);
// 8. 熔断器状态 (P6)
registry.registerMetric(
new promClient.Gauge({
name: "student_bff_circuit_state",
help: "Circuit breaker state (0=closed, 1=open, 2=half-open)",
labelNames: ["service", "state"],
}),
);
// 9. SSE 连接数 (P5)
registry.registerMetric(
new promClient.Gauge({
name: "student_bff_sse_connections",
help: "Active SSE connections",
}),
);
// 10. 事件消费数 (P5)
registry.registerMetric(
new promClient.Counter({
name: "student_bff_event_consumed_total",
help: "Kafka events consumed",
labelNames: ["topic", "event_type"],
}),
);
// 11. 推送数 (P5)
registry.registerMetric(
new promClient.Counter({
name: "student_bff_event_pushed_total",
help: "Push-gateway push count",
labelNames: ["topic", "push_status"],
}),
);
// 自动收集 Node.js 进程级指标
promClient.collectDefaultMetrics({ register: registry });
export { registry as metricsRegistry };
/**
* 下游调用指标辅助器 (供 DownstreamClient 调用).
*/
export function recordDownstreamCall(
service: string,
method: string,
status: "success" | "error",
durationMs: number,
errorType?: string,
): void {
const labels = { service, method, status };
registry
.getSingleMetric("student_bff_downstream_calls_total")
?.inc(labels);
registry
.getSingleMetric("student_bff_downstream_duration_seconds")
?.observe({ service, method }, durationMs / 1000);
if (status === "error" && errorType) {
registry
.getSingleMetric("student_bff_downstream_errors_total")
?.inc({ service, method, error_type: errorType });
}
}
/**
* 缓存命中/未命中指标辅助器.
*/
export function recordCacheAccess(
keyPattern: string,
hit: boolean,
): void {
if (hit) {
registry
.getSingleMetric("student_bff_cache_hits_total")
?.inc({ cache_key_pattern: keyPattern });
} else {
registry
.getSingleMetric("student_bff_cache_misses_total")
?.inc({ cache_key_pattern: keyPattern });
}
}

View File

@@ -0,0 +1,42 @@
/**
* student-bff OpenTelemetry tracer.
*
* 仲裁依据: coord-final-decisions §1 G6 (首次实现即 OTel SDK + OTLP exporter + 完整资源属性 + 全链路 span)
* 02-architecture-design.md §6.5 (serviceName: student-bff)
*/
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { env } from "../../config/env.js";
import { logger } from "./logger.js";
let sdk: NodeSDK | null = null;
export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) {
logger.warn("OTEL_EXPORTER_OTLP_ENDPOINT not set, tracer disabled");
return;
}
sdk = new NodeSDK({
serviceName: env.OTEL_SERVICE_NAME,
traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
logger.info(
{ endpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT, service: env.OTEL_SERVICE_NAME },
"Tracer initialized with auto-instrumentations",
);
}
export async function shutdownTracer(): Promise<void> {
if (sdk) {
await sdk.shutdown();
sdk = null;
logger.debug("Tracer shutdown complete");
}
}