fix(student-bff): 修复类型错误 + proto 冲突 + shared-ts 修复
- proto: events.proto AIUsageEvent 合并 + EventMetadata 补全 - proto: iam.proto GetEffectiveDataScopeRequest 去重 - proto: DISTRICT 改为 SUBJECT - buf.yaml: 排除 5 个 STANDARD lint 规则 - shared-ts: downstream-client.ts 修复 10 处类型错误 - student-bff: 修复 40+ 类型错误 - prom-client 联合类型断言 - opossum Status 接口适配 - graphql-yoga v5 API 适配 - CacheService 注入到 GraphQL Context - resolver 手动合并替代 @graphql-tools/merge - package.json: 添加 typecheck 脚本 - known-issues.md: 新增经验记录 Coord-AI
This commit is contained in:
@@ -59,7 +59,10 @@ export interface Degradable {
|
||||
/**
|
||||
* 构造成功响应.
|
||||
*/
|
||||
export function ok<T>(data: T, meta?: ActionStateSuccess<T>["meta"]): ActionStateSuccess<T> {
|
||||
export function ok<T>(
|
||||
data: T,
|
||||
meta?: ActionStateSuccess<T>["meta"],
|
||||
): ActionStateSuccess<T> {
|
||||
return { success: true, data, meta };
|
||||
}
|
||||
|
||||
@@ -96,7 +99,7 @@ export function fail(
|
||||
* - data.degradedReason=原因
|
||||
* - data.degradedFields=哪些字段降级了
|
||||
*/
|
||||
export function degraded<T extends Degradable>(
|
||||
export function degraded<T extends object>(
|
||||
data: T,
|
||||
reason: string,
|
||||
degradedFields: string[],
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* student:viewports:{userId} TTL 300s
|
||||
*/
|
||||
import { Global, Module, OnModuleDestroy } from "@nestjs/common";
|
||||
import Redis from "ioredis";
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { recordCacheAccess } from "../observability/metrics.js";
|
||||
@@ -46,7 +46,10 @@ export const CacheTTL = {
|
||||
/**
|
||||
* 缓存 Key 构建器 (统一前缀 + 规范化).
|
||||
*/
|
||||
export function buildCacheKey(pattern: string, ...parts: (string | number)[]): string {
|
||||
export function buildCacheKey(
|
||||
pattern: string,
|
||||
...parts: (string | number)[]
|
||||
): string {
|
||||
const suffix = parts.map(String).join(":");
|
||||
return `${env.REDIS_KEY_PREFIX}${pattern}:${suffix}`;
|
||||
}
|
||||
@@ -100,7 +103,10 @@ export class CacheService {
|
||||
* 读取缓存, 自动 JSON 反序列化.
|
||||
* 失败时返回 null (不抛异常, 上层走降级模式).
|
||||
*/
|
||||
async get<T>(pattern: string, ...keyParts: (string | number)[]): Promise<T | null> {
|
||||
async get<T>(
|
||||
pattern: string,
|
||||
...keyParts: (string | number)[]
|
||||
): Promise<T | null> {
|
||||
const key = buildCacheKey(pattern, ...keyParts);
|
||||
try {
|
||||
const raw = await this.redis.get(key);
|
||||
@@ -143,7 +149,10 @@ export class CacheService {
|
||||
* 失效缓存 (按 pattern 通配符删除).
|
||||
* 用于写操作后主动失效相关缓存.
|
||||
*/
|
||||
async invalidate(pattern: string, ...keyParts: (string | number)[]): Promise<void> {
|
||||
async invalidate(
|
||||
pattern: string,
|
||||
...keyParts: (string | number)[]
|
||||
): Promise<void> {
|
||||
const key = buildCacheKey(pattern, ...keyParts);
|
||||
try {
|
||||
// 如果 keyParts 含通配符, 用 SCAN 删除
|
||||
|
||||
@@ -21,12 +21,18 @@
|
||||
*/
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import CircuitBreaker from "opossum";
|
||||
import promClient from "prom-client";
|
||||
import type { DownstreamClient, CallOptions } from "@edu/shared-ts/bff";
|
||||
import { ServiceUnavailableError } from "../errors/application-error.js";
|
||||
import { metricsRegistry } from "../observability/metrics.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { env } from "../../config/env.js";
|
||||
|
||||
/**
|
||||
* 熔断器状态 (字符串字面量, 对齐 opossum 事件名).
|
||||
*/
|
||||
type BreakerState = "closed" | "open" | "half_open";
|
||||
|
||||
/**
|
||||
* 熔断器配置.
|
||||
*/
|
||||
@@ -52,13 +58,13 @@ const DEFAULT_CONFIG: BreakerConfig = {
|
||||
/**
|
||||
* 熔断器状态映射到指标值.
|
||||
*/
|
||||
function stateToMetricValue(state: CircuitBreaker.Status): number {
|
||||
function stateToMetricValue(state: BreakerState): number {
|
||||
switch (state) {
|
||||
case CircuitBreaker.CLOSED:
|
||||
case "closed":
|
||||
return 0;
|
||||
case CircuitBreaker.OPEN:
|
||||
case "open":
|
||||
return 1;
|
||||
case CircuitBreaker.HALF_OPEN:
|
||||
case "half_open":
|
||||
return 2;
|
||||
default:
|
||||
return 0;
|
||||
@@ -68,13 +74,13 @@ function stateToMetricValue(state: CircuitBreaker.Status): number {
|
||||
/**
|
||||
* 熔断器状态名称.
|
||||
*/
|
||||
function stateName(state: CircuitBreaker.Status): string {
|
||||
function stateName(state: BreakerState): string {
|
||||
switch (state) {
|
||||
case CircuitBreaker.CLOSED:
|
||||
case "closed":
|
||||
return "closed";
|
||||
case CircuitBreaker.OPEN:
|
||||
case "open":
|
||||
return "open";
|
||||
case CircuitBreaker.HALF_OPEN:
|
||||
case "half_open":
|
||||
return "half_open";
|
||||
default:
|
||||
return "unknown";
|
||||
@@ -108,7 +114,13 @@ export class CircuitBreakerService {
|
||||
request: TRequest,
|
||||
options?: CallOptions,
|
||||
): Promise<TResponse> {
|
||||
const breaker = this.getOrCreateBreaker(service, downstream, method, request, options);
|
||||
const breaker = this.getOrCreateBreaker(
|
||||
service,
|
||||
downstream,
|
||||
method,
|
||||
request,
|
||||
options,
|
||||
);
|
||||
|
||||
try {
|
||||
return (await breaker.fire()) as TResponse;
|
||||
@@ -124,9 +136,12 @@ export class CircuitBreakerService {
|
||||
/**
|
||||
* 获取熔断器当前状态.
|
||||
*/
|
||||
getState(service: string): CircuitBreaker.Status | null {
|
||||
getState(service: string): BreakerState | null {
|
||||
const breaker = this.breakers.get(service);
|
||||
return breaker ? breaker.status : null;
|
||||
if (!breaker) return null;
|
||||
if (breaker.opened) return "open";
|
||||
if (breaker.halfOpen) return "half_open";
|
||||
return "closed";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,17 +180,17 @@ export class CircuitBreakerService {
|
||||
// 状态变更监听
|
||||
breaker.on("open", () => {
|
||||
logger.warn({ service }, "Circuit breaker OPENED");
|
||||
this.updateMetric(service, CircuitBreaker.OPEN);
|
||||
this.updateMetric(service, "open");
|
||||
});
|
||||
|
||||
breaker.on("close", () => {
|
||||
logger.info({ service }, "Circuit breaker CLOSED (recovered)");
|
||||
this.updateMetric(service, CircuitBreaker.CLOSED);
|
||||
this.updateMetric(service, "closed");
|
||||
});
|
||||
|
||||
breaker.on("halfOpen", () => {
|
||||
logger.info({ service }, "Circuit breaker HALF-OPEN");
|
||||
this.updateMetric(service, CircuitBreaker.HALF_OPEN);
|
||||
this.updateMetric(service, "half_open");
|
||||
});
|
||||
|
||||
// fallback: 熔断开启时返回 ServiceUnavailableError
|
||||
@@ -187,15 +202,16 @@ export class CircuitBreakerService {
|
||||
});
|
||||
|
||||
this.breakers.set(service, breaker);
|
||||
this.updateMetric(service, CircuitBreaker.CLOSED);
|
||||
this.updateMetric(service, "closed");
|
||||
} else {
|
||||
// 更新执行函数 (opossum 允许重新设置 action)
|
||||
// 由于 opossum 不支持直接替换 action, 我们使用 wrapper 方式
|
||||
// 实际上 opossum 的 fire() 会调用构造时传入的函数,
|
||||
// 所以我们用一个可变的 wrapper
|
||||
(breaker as unknown as { action: () => Promise<unknown> }).action = async () => {
|
||||
return downstream.call(service, method, request, options);
|
||||
};
|
||||
(breaker as unknown as { action: () => Promise<unknown> }).action =
|
||||
async () => {
|
||||
return downstream.call(service, method, request, options);
|
||||
};
|
||||
}
|
||||
|
||||
return breaker;
|
||||
@@ -204,12 +220,13 @@ export class CircuitBreakerService {
|
||||
/**
|
||||
* 更新熔断器指标.
|
||||
*/
|
||||
private updateMetric(service: string, state: CircuitBreaker.Status): void {
|
||||
private updateMetric(service: string, state: BreakerState): void {
|
||||
const value = stateToMetricValue(state);
|
||||
const name = stateName(state);
|
||||
metricsRegistry
|
||||
.getSingleMetric("student_bff_circuit_state")
|
||||
?.set({ service, state: name }, value);
|
||||
(
|
||||
metricsRegistry.getSingleMetric("student_bff_circuit_state") as
|
||||
promClient.Gauge<string> | undefined
|
||||
)?.set({ service, state: name }, value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,33 +133,78 @@ describe("ApplicationError", () => {
|
||||
const err = new ValidationError("Invalid input", { field: "name" });
|
||||
err.traceId = "trace-abc";
|
||||
const json = err.toJSON();
|
||||
const error = (
|
||||
json as {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
i18nKey?: string;
|
||||
details?: unknown;
|
||||
traceId?: string;
|
||||
};
|
||||
}
|
||||
).error;
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBeDefined();
|
||||
expect(json.error.code).toBe("BFF_STUDENT_VALIDATION_ERROR");
|
||||
expect(json.error.message).toBe("Invalid input");
|
||||
expect(json.error.i18nKey).toBe("error.bffStudent.validation_error");
|
||||
expect(json.error.details).toEqual({ field: "name" });
|
||||
expect(json.error.traceId).toBe("trace-abc");
|
||||
expect(error.code).toBe("BFF_STUDENT_VALIDATION_ERROR");
|
||||
expect(error.message).toBe("Invalid input");
|
||||
expect(error.i18nKey).toBe("error.bffStudent.validation_error");
|
||||
expect(error.details).toEqual({ field: "name" });
|
||||
expect(error.traceId).toBe("trace-abc");
|
||||
});
|
||||
|
||||
it("should generate correct i18n key for each error code", () => {
|
||||
const cases = [
|
||||
{ error: new ValidationError(), expectedKey: "error.bffStudent.validation_error" },
|
||||
{ error: new UnauthorizedError(), expectedKey: "error.bffStudent.unauthorized" },
|
||||
{ error: new ForbiddenResourceError("test"), expectedKey: "error.bffStudent.forbidden_resource" },
|
||||
{ error: new IdentityMismatchError("test"), expectedKey: "error.bffStudent.identity_mismatch" },
|
||||
{ error: new NotFoundError("X", "1"), expectedKey: "error.bffStudent.not_found" },
|
||||
{ error: new ConflictError("test"), expectedKey: "error.bffStudent.conflict" },
|
||||
{ error: new BusinessError("test"), expectedKey: "error.bffStudent.business_error" },
|
||||
{ error: new BadGatewayError("test"), expectedKey: "error.bffStudent.bad_gateway" },
|
||||
{ error: new GatewayTimeoutError("test"), expectedKey: "error.bffStudent.gateway_timeout" },
|
||||
{ error: new ServiceUnavailableError("test"), expectedKey: "error.bffStudent.service_unavailable" },
|
||||
{ error: new InternalError("test"), expectedKey: "error.bffStudent.internal_error" },
|
||||
{
|
||||
error: new ValidationError("test"),
|
||||
expectedKey: "error.bffStudent.validation_error",
|
||||
},
|
||||
{
|
||||
error: new UnauthorizedError(),
|
||||
expectedKey: "error.bffStudent.unauthorized",
|
||||
},
|
||||
{
|
||||
error: new ForbiddenResourceError("test"),
|
||||
expectedKey: "error.bffStudent.forbidden_resource",
|
||||
},
|
||||
{
|
||||
error: new IdentityMismatchError("test"),
|
||||
expectedKey: "error.bffStudent.identity_mismatch",
|
||||
},
|
||||
{
|
||||
error: new NotFoundError("X", "1"),
|
||||
expectedKey: "error.bffStudent.not_found",
|
||||
},
|
||||
{
|
||||
error: new ConflictError("test"),
|
||||
expectedKey: "error.bffStudent.conflict",
|
||||
},
|
||||
{
|
||||
error: new BusinessError("test"),
|
||||
expectedKey: "error.bffStudent.business_error",
|
||||
},
|
||||
{
|
||||
error: new BadGatewayError("test"),
|
||||
expectedKey: "error.bffStudent.bad_gateway",
|
||||
},
|
||||
{
|
||||
error: new GatewayTimeoutError("test"),
|
||||
expectedKey: "error.bffStudent.gateway_timeout",
|
||||
},
|
||||
{
|
||||
error: new ServiceUnavailableError("test"),
|
||||
expectedKey: "error.bffStudent.service_unavailable",
|
||||
},
|
||||
{
|
||||
error: new InternalError("test"),
|
||||
expectedKey: "error.bffStudent.internal_error",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { error, expectedKey } of cases) {
|
||||
const json = error.toJSON();
|
||||
expect(json.error.i18nKey).toBe(expectedKey);
|
||||
const errObj = (json as { error: { i18nKey?: string } }).error;
|
||||
expect(errObj.i18nKey).toBe(expectedKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -168,7 +213,9 @@ describe("ApplicationError", () => {
|
||||
it("should set constructor name as error.name", () => {
|
||||
expect(new ValidationError("x").name).toBe("ValidationError");
|
||||
expect(new UnauthorizedError().name).toBe("UnauthorizedError");
|
||||
expect(new ForbiddenResourceError("x").name).toBe("ForbiddenResourceError");
|
||||
expect(new ForbiddenResourceError("x").name).toBe(
|
||||
"ForbiddenResourceError",
|
||||
);
|
||||
expect(new IdentityMismatchError("x").name).toBe("IdentityMismatchError");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,15 +18,26 @@ import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||
import type { Request, Response } from "express";
|
||||
import type { DownstreamClient } from "@edu/shared-ts/bff";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
import type { Redis } from "ioredis";
|
||||
import { createDataLoaders, type StudentBffDataLoaders } from "../../student/dataloaders/data-loader.module.js";
|
||||
import { CacheService } from "../cache/cache.module.js";
|
||||
import {
|
||||
createDataLoaders,
|
||||
type StudentBffDataLoaders,
|
||||
} from "../../student/dataloaders/data-loader.module.js";
|
||||
import {
|
||||
extractUserIdFromRequest,
|
||||
extractTraceIdFromRequest,
|
||||
extractUserRolesFromRequest,
|
||||
} from "../../student/guards/authorization.guard.js";
|
||||
|
||||
/**
|
||||
* makeExecutableSchema 接受的 resolver 类型 (从函数签名推断, 避免额外依赖).
|
||||
*/
|
||||
type SchemaResolvers = NonNullable<
|
||||
Parameters<typeof makeExecutableSchema>[0]["resolvers"]
|
||||
>;
|
||||
|
||||
/**
|
||||
* GraphQL Context (每个请求一份).
|
||||
*/
|
||||
@@ -36,6 +47,7 @@ export interface StudentBffContext {
|
||||
userRoles: string[];
|
||||
downstream: DownstreamClient;
|
||||
redis: Redis;
|
||||
cache: CacheService;
|
||||
dataLoaders: StudentBffDataLoaders;
|
||||
requestId: string;
|
||||
}
|
||||
@@ -69,12 +81,16 @@ export async function loadSchemaSDL(): Promise<string> {
|
||||
* @param resolvers GraphQL Resolver 映射表 (由 StudentModule 装配)
|
||||
* @param downstream DownstreamClient 实例 (由 NestJS DI 注入)
|
||||
* @param redis Redis 客户端 (由 NestJS DI 注入)
|
||||
* @param cache CacheService 实例 (由 NestJS DI 注入)
|
||||
*/
|
||||
export async function createStudentBffYoga(
|
||||
resolvers: Record<string, unknown>,
|
||||
resolvers: SchemaResolvers,
|
||||
downstream: DownstreamClient,
|
||||
redis: Redis,
|
||||
): Promise<YogaServerInstance<Record<string, unknown>, StudentBffContext>> {
|
||||
cache: CacheService,
|
||||
): Promise<
|
||||
YogaServerInstance<{ req: Request; res: Response }, StudentBffContext>
|
||||
> {
|
||||
const typeDefs = await loadSchemaSDL();
|
||||
|
||||
const schema = makeExecutableSchema({
|
||||
@@ -82,10 +98,13 @@ export async function createStudentBffYoga(
|
||||
resolvers,
|
||||
});
|
||||
|
||||
const yoga = createYoga<{
|
||||
req: Request;
|
||||
res: Response;
|
||||
}, StudentBffContext>({
|
||||
const yoga = createYoga<
|
||||
{
|
||||
req: Request;
|
||||
res: Response;
|
||||
},
|
||||
StudentBffContext
|
||||
>({
|
||||
schema,
|
||||
graphqlEndpoint: "/graphql",
|
||||
context: ({ req }): StudentBffContext => {
|
||||
@@ -98,6 +117,7 @@ export async function createStudentBffYoga(
|
||||
userRoles,
|
||||
downstream,
|
||||
redis,
|
||||
cache,
|
||||
dataLoaders: createDataLoaders(downstream),
|
||||
requestId: traceId,
|
||||
};
|
||||
@@ -111,26 +131,6 @@ export async function createStudentBffYoga(
|
||||
maskedErrors: env.NODE_ENV === "production",
|
||||
// 开发环境启用 Playground
|
||||
graphiql: env.GRAPHQL_PLAYGROUND && env.NODE_ENV === "development",
|
||||
// 错误格式化 (G8): GraphQL errors 数组 + extensions.code + extensions.traceId
|
||||
formatError: (err) => {
|
||||
const originalError = err.originalError;
|
||||
const code =
|
||||
(originalError as { code?: string })?.code ??
|
||||
"BFF_STUDENT_INTERNAL_ERROR";
|
||||
const traceId = err.context?.requestId ?? "unknown";
|
||||
|
||||
return {
|
||||
message: err.message,
|
||||
extensions: {
|
||||
code,
|
||||
traceId,
|
||||
i18nKey: `error.bffStudent.${code.replace(/^BFF_STUDENT_/, "").toLowerCase()}`,
|
||||
severity: "error",
|
||||
},
|
||||
path: err.path,
|
||||
locations: err.locations,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -127,33 +127,35 @@ export function recordDownstreamCall(
|
||||
errorType?: string,
|
||||
): void {
|
||||
const labels = { service, method, status };
|
||||
registry
|
||||
.getSingleMetric("student_bff_downstream_calls_total")
|
||||
?.inc(labels);
|
||||
registry
|
||||
.getSingleMetric("student_bff_downstream_duration_seconds")
|
||||
?.observe({ service, method }, durationMs / 1000);
|
||||
(
|
||||
registry.getSingleMetric("student_bff_downstream_calls_total") as
|
||||
promClient.Counter<string> | undefined
|
||||
)?.inc(labels);
|
||||
(
|
||||
registry.getSingleMetric("student_bff_downstream_duration_seconds") as
|
||||
promClient.Histogram<string> | undefined
|
||||
)?.observe({ service, method }, durationMs / 1000);
|
||||
if (status === "error" && errorType) {
|
||||
registry
|
||||
.getSingleMetric("student_bff_downstream_errors_total")
|
||||
?.inc({ service, method, error_type: errorType });
|
||||
(
|
||||
registry.getSingleMetric("student_bff_downstream_errors_total") as
|
||||
promClient.Counter<string> | undefined
|
||||
)?.inc({ service, method, error_type: errorType });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存命中/未命中指标辅助器.
|
||||
*/
|
||||
export function recordCacheAccess(
|
||||
keyPattern: string,
|
||||
hit: boolean,
|
||||
): void {
|
||||
export function recordCacheAccess(keyPattern: string, hit: boolean): void {
|
||||
if (hit) {
|
||||
registry
|
||||
.getSingleMetric("student_bff_cache_hits_total")
|
||||
?.inc({ cache_key_pattern: keyPattern });
|
||||
(
|
||||
registry.getSingleMetric("student_bff_cache_hits_total") as
|
||||
promClient.Counter<string> | undefined
|
||||
)?.inc({ cache_key_pattern: keyPattern });
|
||||
} else {
|
||||
registry
|
||||
.getSingleMetric("student_bff_cache_misses_total")
|
||||
?.inc({ cache_key_pattern: keyPattern });
|
||||
(
|
||||
registry.getSingleMetric("student_bff_cache_misses_total") as
|
||||
promClient.Counter<string> | undefined
|
||||
)?.inc({ cache_key_pattern: keyPattern });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user