feat(student-bff): 完整实现 student-bff 聚合层
包含 src 全部实现、Dockerfile、shared-ts/bff 包等
This commit is contained in:
104
services/student-bff/src/shared/action-state.test.ts
Normal file
104
services/student-bff/src/shared/action-state.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
125
services/student-bff/src/shared/action-state.ts
Normal file
125
services/student-bff/src/shared/action-state.ts
Normal 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;
|
||||
211
services/student-bff/src/shared/cache/cache.module.ts
vendored
Normal file
211
services/student-bff/src/shared/cache/cache.module.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
188
services/student-bff/src/shared/errors/application-error.test.ts
Normal file
188
services/student-bff/src/shared/errors/application-error.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
177
services/student-bff/src/shared/errors/application-error.ts
Normal file
177
services/student-bff/src/shared/errors/application-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
119
services/student-bff/src/shared/errors/global-error.filter.ts
Normal file
119
services/student-bff/src/shared/errors/global-error.filter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
142
services/student-bff/src/shared/graphql/yoga.ts
Normal file
142
services/student-bff/src/shared/graphql/yoga.ts
Normal 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;
|
||||
}
|
||||
146
services/student-bff/src/shared/health/health.controller.ts
Normal file
146
services/student-bff/src/shared/health/health.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
services/student-bff/src/shared/health/health.module.ts
Normal file
10
services/student-bff/src/shared/health/health.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* HealthModule - 健康检查模块.
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
13
services/student-bff/src/shared/observability/logger.ts
Normal file
13
services/student-bff/src/shared/observability/logger.ts
Normal 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 };
|
||||
159
services/student-bff/src/shared/observability/metrics.ts
Normal file
159
services/student-bff/src/shared/observability/metrics.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
42
services/student-bff/src/shared/observability/tracer.ts
Normal file
42
services/student-bff/src/shared/observability/tracer.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user