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,38 @@
import { drizzle } from "drizzle-orm/mysql2";
import type { MySql2Database } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { env } from "./env.js";
let pool: mysql.Pool | null = null;
export function getDb(): MySql2Database {
if (!pool) {
pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
}
return drizzle(pool);
}
/**
* 全局 db 实例(模块装配时初始化,供 OutboxModule 等需要 db 引用的模块使用)。
*/
let dbInstance: MySql2Database | null = null;
export function getDbInstance(): MySql2Database {
if (!dbInstance) {
dbInstance = getDb();
}
return dbInstance;
}
export async function closeDb(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
dbInstance = null;
}
}

View File

@@ -0,0 +1,53 @@
import { z } from "zod";
const envSchema = z.object({
// HTTPADR-026端口 3011
PORT: z.string().default("3011"),
// Database
DATABASE_URL: z.string().url(),
// Redis缓存plugin / user-layout
REDIS_URL: z.string().url(),
// KafkaOutbox 投递由 Debezium CDC 接管producer 仅用于健康检查)
KAFKA_BROKERS: z.string(),
KAFKA_CLIENT_ID: z.string().default("config-service"),
// gRPC serverADR-026端口 50059
GRPC_PORT: z.string().default("50059"),
// Router authApollo Router 信任凭证ADR-036
ROUTER_AUTH_SECRET: z.string().default("dev-router-secret"),
// 开发模式DEV_MODE=true 时 PermissionGuard 放行)
DEV_MODE: z
.string()
.default("false")
.transform((v) => v === "true"),
// 可观测性
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace"])
.default("info"),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error(
"❌ Invalid environment variables:",
result.error.flatten().fieldErrors,
);
throw new Error("Invalid environment configuration");
}
return result.data;
}
export const env = loadEnv();

View File

@@ -0,0 +1,48 @@
import { Kafka, type Producer } from "kafkajs";
import { env } from "./env.js";
let producer: Producer | null = null;
export function getKafkaProducer(): Producer {
if (!producer) {
const kafka = new Kafka({
clientId: env.KAFKA_CLIENT_ID,
brokers: env.KAFKA_BROKERS.split(","),
});
producer = kafka.producer({
idempotent: true,
transactionalId: "config-tx",
});
}
return producer;
}
export async function connectKafkaProducer(): Promise<void> {
const p = getKafkaProducer();
await p.connect();
}
export async function disconnectKafkaProducer(): Promise<void> {
if (producer) {
await producer.disconnect();
producer = null;
}
}
/**
* config-service Kafka topic 路由ADR-026 + ADR-032
*
* 事件命名规则:`<Aggregate>.<Action>`
* - PluginRegistryEvent: created/updated/activated/deactivated
* - RolePluginMappingEvent: updated
* - UserLayoutOverrideEvent: upserted/reset
*
* v2.1 ADR-032outbox 表由 Debezium 监听 binlog 投递到 Kafka
* 此处 TOPIC_MAP 仅用于文档化事件路由producer 实例用于健康检查探活。
*/
export const CONFIG_KAFKA_TOPICS = {
PLUGIN_REGISTRY_EVENTS: "edu.config.plugin.registry.events",
ROLE_PLUGIN_MAPPING_EVENTS: "edu.config.role.plugin.mapping.events",
USER_LAYOUT_OVERRIDE_EVENTS: "edu.config.user.layout.override.events",
ENTRY_CHANGED: "edu.config.entry.changed",
} as const;

View File

@@ -0,0 +1,24 @@
import { Redis } from "ioredis";
import { env } from "./env.js";
type RedisClient = InstanceType<typeof Redis>;
let client: RedisClient | null = null;
export function getRedis(): RedisClient {
if (!client) {
client = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
});
}
return client;
}
export async function closeRedis(): Promise<void> {
if (client) {
await client.quit();
client = null;
}
}