feat(config-service): split config-service from iam for plugin/layout config

- new NestJS service on port 3011/gRPC 50059 (ADR-026)
- owns 6 config_ tables (plugin/role-mapping/role-layout/layout-tpl/user-override/outbox)
- GraphQL Federation 2 subgraph with DataLoader + RouterAuthGuard
- gRPC ConfigService + admin REST CRUD + user REST API
- three-layer merge: registry.defaultProps + roleMapping.widget_props + userOverride.props
- Redis cache with 5min TTL
- registered in apollo-router supergraph + docker-compose + port-allocation

Implements M3 of v2.1 migration plan.
This commit is contained in:
SpecialX
2026-07-15 02:13:03 +08:00
parent 163bff6666
commit 1a5fa78fa6
44 changed files with 3538 additions and 33 deletions

View File

@@ -0,0 +1,108 @@
import { Injectable } from "@nestjs/common";
import { getRedis } from "../../config/redis.js";
import { cacheMetrics } from "../observability/metrics.js";
const CACHE_TTL_SECONDS = 300; // 5 分钟
/**
* config-service Redis 缓存服务。
*
* 缓存策略:
* - Plugin registry: `config:plugin:{pluginId}` → JSON PluginRegistryItem
* - User layout override: `config:user-layout:{userId}` → JSON UserLayoutOverride
* - TTL: 5 分钟,超时自动失效重新从 DB 加载
* - 失效admin 修改配置或用户更新布局时主动 del
*
* 使用 ioredis 单例config/redis.ts 管理),不重复创建连接。
*/
@Injectable()
export class ConfigCacheService {
private static buildPluginKey(pluginId: string): string {
return `config:plugin:${pluginId}`;
}
private static buildUserLayoutKey(userId: string): string {
return `config:user-layout:${userId}`;
}
// ============ Plugin Registry ============
async getPlugin(pluginId: string): Promise<string | null> {
const redis = getRedis();
const raw = await redis.get(ConfigCacheService.buildPluginKey(pluginId));
if (!raw) {
cacheMetrics.recordMiss("plugin");
return null;
}
cacheMetrics.recordHit("plugin");
return raw;
}
async setPlugin(pluginId: string, json: string): Promise<void> {
const redis = getRedis();
await redis.set(
ConfigCacheService.buildPluginKey(pluginId),
json,
"EX",
CACHE_TTL_SECONDS,
);
}
async invalidatePlugin(pluginId: string, reason = "manual"): Promise<void> {
const redis = getRedis();
await redis.del(ConfigCacheService.buildPluginKey(pluginId));
cacheMetrics.recordInvalidation("plugin", reason);
}
/**
* 批量失效所有 plugin 缓存admin 全量更新时调用)。
* 通过 SCAN 匹配 config:plugin:* 模式删除。
*/
async invalidateAllPlugins(reason = "admin-update"): Promise<void> {
const redis = getRedis();
let cursor = "0";
do {
const [next, keys] = await redis.scan(
cursor,
"MATCH",
"config:plugin:*",
"COUNT",
100,
);
cursor = next;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== "0");
cacheMetrics.recordInvalidation("plugin", reason);
}
// ============ User Layout Override ============
async getUserLayout(userId: string): Promise<string | null> {
const redis = getRedis();
const raw = await redis.get(ConfigCacheService.buildUserLayoutKey(userId));
if (!raw) {
cacheMetrics.recordMiss("user-layout");
return null;
}
cacheMetrics.recordHit("user-layout");
return raw;
}
async setUserLayout(userId: string, json: string): Promise<void> {
const redis = getRedis();
await redis.set(
ConfigCacheService.buildUserLayoutKey(userId),
json,
"EX",
CACHE_TTL_SECONDS,
);
}
async invalidateUserLayout(userId: string, reason = "manual"): Promise<void> {
const redis = getRedis();
await redis.del(ConfigCacheService.buildUserLayoutKey(userId));
cacheMetrics.recordInvalidation("user-layout", reason);
}
}

View File

@@ -0,0 +1,107 @@
export type ErrorType =
| "validation"
| "not_found"
| "permission_denied"
| "unauthorized"
| "conflict"
| "business"
| "database"
| "internal";
export interface ErrorDetails {
[key: string]: unknown;
}
export abstract class ApplicationError extends Error {
abstract readonly type: ErrorType;
abstract readonly statusCode: number;
readonly code: string;
readonly details?: ErrorDetails;
// traceId 改为可写,以便 GlobalErrorFilter 注入请求级 traceId
traceId?: string;
constructor(message: string, code: string, details?: ErrorDetails) {
super(message);
this.name = this.constructor.name;
this.code = code;
this.details = details;
}
toJSON(): Record<string, unknown> {
return {
success: false,
error: {
code: this.code,
message: this.message,
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, "CONFIG_VALIDATION_ERROR", 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}`, "CONFIG_NOT_FOUND", { resource, id });
}
}
export class PermissionDeniedError extends ApplicationError {
readonly type = "permission_denied" as const;
readonly statusCode = 403;
constructor(permission: string) {
super(`Permission denied: ${permission}`, "CONFIG_PERMISSION_DENIED", {
permission,
});
}
}
export class UnauthorizedError extends ApplicationError {
readonly type = "unauthorized" as const;
readonly statusCode = 401;
constructor(message: string, details?: ErrorDetails) {
super(message, "CONFIG_UNAUTHORIZED", details);
}
}
export class ConflictError extends ApplicationError {
readonly type = "conflict" as const;
readonly statusCode = 409;
constructor(message: string, details?: ErrorDetails) {
super(message, "CONFIG_CONFLICT", details);
}
}
export class BusinessError extends ApplicationError {
readonly type = "business" as const;
readonly statusCode = 422;
constructor(message: string, details?: ErrorDetails) {
super(message, "CONFIG_BUSINESS_ERROR", details);
}
}
export class DatabaseError extends ApplicationError {
readonly type = "database" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "CONFIG_DATABASE_ERROR", details);
}
}
export class InternalError extends ApplicationError {
readonly type = "internal" as const;
readonly statusCode = 500;
constructor(message: string, details?: ErrorDetails) {
super(message, "CONFIG_INTERNAL_ERROR", details);
}
}

View File

@@ -0,0 +1,86 @@
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";
@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 ZodError) {
statusCode = 400;
body = {
success: false,
error: {
code: "CONFIG_VALIDATION_ERROR",
message: "Validation failed",
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: "HTTP_ERROR",
message,
traceId,
},
};
} else {
this.logger.error(
`Unhandled exception: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
body = {
success: false,
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
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,120 @@
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { getDb } from "../../config/database.js";
import { getRedis } from "../../config/redis.js";
const SERVICE_NAME = "config-service";
interface DependencyCheck {
name: string;
status: "ok" | "error";
error?: string;
}
/**
* 健康检查端点。
*
* - GET /healthzliveness仅返回进程存活不检查依赖
* - GET /readyzreadiness检查 4 依赖DB/Redis/Kafka/gRPC失败返回 503
*
* 与 iam 相比省略 JWKS 检查config-service 不持有 JWT 密钥)。
*/
@Controller()
export class HealthController {
@Get("healthz")
liveness(): { status: string; service: string; timestamp: string } {
return {
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
}
@Get("readyz")
async readiness(): Promise<{
status: string;
service: string;
timestamp: string;
dependencies: DependencyCheck[];
}> {
const checks: DependencyCheck[] = [];
// 1. DB
checks.push(await this.checkDb());
// 2. Redis
checks.push(await this.checkRedis());
// 3. Kafka检查 producer 实例存在)
checks.push(await this.checkKafka());
// 4. gRPC本进程内启动进程存活即 gRPC 存活)
checks.push({ name: "grpc", status: "ok" });
const allOk = checks.every((c) => c.status === "ok");
if (!allOk) {
throw new HttpException(
{
status: "error",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
dependencies: checks,
},
HttpStatus.SERVICE_UNAVAILABLE,
);
}
return {
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
dependencies: checks,
};
}
private async checkDb(): Promise<DependencyCheck> {
try {
const db = getDb();
await db.execute(sql`SELECT 1`);
return { name: "database", status: "ok" };
} catch (error) {
return {
name: "database",
status: "error",
error: error instanceof Error ? error.message : String(error),
};
}
}
private async checkRedis(): Promise<DependencyCheck> {
try {
const redis = getRedis();
const pong = await redis.ping();
if (pong !== "PONG") {
return { name: "redis", status: "error", error: `Unexpected: ${pong}` };
}
return { name: "redis", status: "ok" };
} catch (error) {
return {
name: "redis",
status: "error",
error: error instanceof Error ? error.message : String(error),
};
}
}
private async checkKafka(): Promise<DependencyCheck> {
try {
const { getKafkaProducer } = await import("../../config/kafka.js");
const producer = getKafkaProducer();
void producer;
return { name: "kafka", status: "ok" };
} catch (error) {
return {
name: "kafka",
status: "error",
error: error instanceof Error ? error.message : String(error),
};
}
}
}

View File

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

View File

@@ -0,0 +1,69 @@
import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js";
import { closeRedis } from "../../config/redis.js";
import { disconnectKafkaProducer } from "../../config/kafka.js";
const SERVICE_NAME = "config-service";
/**
* 优雅停机服务。
*
* 关闭顺序:
* 1. HTTP/gRPC server 已由 NestJS app.close() 停止
* 2. Kafka producer 断开
* 3. Redis 断开
* 4. DB 连接池关闭(最后关闭)
*
* v2.1 ADR-032outbox 投递由 Debezium CDC 接管,本服务不持有 OutboxPublisher。
*/
@Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name);
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
);
// 1. Kafka producer
try {
await disconnectKafkaProducer();
this.logger.log("Kafka producer disconnected");
} catch (error) {
this.logger.error(
`Kafka producer disconnect failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
// 2. Redis
try {
await closeRedis();
this.logger.log("Redis connection closed");
} catch (error) {
this.logger.error(
`Redis close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
// 3. DB最后关闭
try {
await closeDb();
this.logger.log("database connection closed");
} catch (error) {
this.logger.error(
`database close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
}
}

View File

@@ -0,0 +1,19 @@
import { pino } from "pino";
import { env } from "../../config/env.js";
export const logger = pino({
level: env.LOG_LEVEL,
base: {
service: "config-service",
version: "0.1.0",
},
transport:
env.NODE_ENV === "development"
? {
target: "pino-pretty",
options: { colorize: true },
}
: undefined,
});
export type Logger = typeof logger;

View File

@@ -0,0 +1,79 @@
import promClient from "prom-client";
const registry = new promClient.Registry();
registry.setDefaultLabels({ service: "config-service" });
registry.registerMetric(
new promClient.Counter({
name: "config_service_requests_total",
help: "Total number of config-service requests",
labelNames: ["method", "endpoint", "status"],
}),
);
registry.registerMetric(
new promClient.Histogram({
name: "config_service_request_duration_seconds",
help: "Config-service request duration in seconds",
labelNames: ["method", "endpoint"],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
}),
);
// 自动收集 Node.js 进程级指标
promClient.collectDefaultMetrics({ register: registry });
// Redis 缓存指标plugin / user-layout 缓存可观测性)
registry.registerMetric(
new promClient.Counter({
name: "config_service_cache_hits_total",
help: "Total number of config cache hits (Redis)",
labelNames: ["kind"],
}),
);
registry.registerMetric(
new promClient.Counter({
name: "config_service_cache_misses_total",
help: "Total number of config cache misses (Redis)",
labelNames: ["kind"],
}),
);
registry.registerMetric(
new promClient.Counter({
name: "config_service_cache_invalidations_total",
help: "Total number of config cache invalidations (Redis)",
labelNames: ["kind", "reason"],
}),
);
/**
* 缓存指标访问器:供 ConfigCacheService 使用.
*/
export const cacheMetrics = {
recordHit(kind: string): void {
const metric = registry.getSingleMetric("config_service_cache_hits_total");
if (metric && "inc" in metric) {
(metric as promClient.Counter).inc({ kind });
}
},
recordMiss(kind: string): void {
const metric = registry.getSingleMetric(
"config_service_cache_misses_total",
);
if (metric && "inc" in metric) {
(metric as promClient.Counter).inc({ kind });
}
},
recordInvalidation(kind: string, reason: string): void {
const metric = registry.getSingleMetric(
"config_service_cache_invalidations_total",
);
if (metric && "inc" in metric) {
(metric as promClient.Counter).inc({ kind, reason });
}
},
};
export { registry as metricsRegistry };

View File

@@ -0,0 +1,27 @@
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";
let sdk: NodeSDK | null = null;
export function initTracer(): void {
if (!env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
sdk = new NodeSDK({
serviceName: "config-service",
traceExporter: new OTLPTraceExporter({
url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
console.log("Tracer initialized with auto-instrumentations");
}
export async function shutdownTracer(): Promise<void> {
if (sdk) {
await sdk.shutdown();
}
}