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:
38
services/config-service/src/config/database.ts
Normal file
38
services/config-service/src/config/database.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
53
services/config-service/src/config/env.ts
Normal file
53
services/config-service/src/config/env.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
// HTTP(ADR-026:端口 3011)
|
||||
PORT: z.string().default("3011"),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
// Redis(缓存:plugin / user-layout)
|
||||
REDIS_URL: z.string().url(),
|
||||
|
||||
// Kafka(Outbox 投递由 Debezium CDC 接管,producer 仅用于健康检查)
|
||||
KAFKA_BROKERS: z.string(),
|
||||
KAFKA_CLIENT_ID: z.string().default("config-service"),
|
||||
|
||||
// gRPC server(ADR-026:端口 50059)
|
||||
GRPC_PORT: z.string().default("50059"),
|
||||
|
||||
// Router auth(Apollo 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();
|
||||
48
services/config-service/src/config/kafka.ts
Normal file
48
services/config-service/src/config/kafka.ts
Normal 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-032:outbox 表由 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;
|
||||
24
services/config-service/src/config/redis.ts
Normal file
24
services/config-service/src/config/redis.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user