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:
54
services/config-service/Dockerfile
Normal file
54
services/config-service/Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@11.13.0 --activate
|
||||
|
||||
# 复制 workspace 配置 + tsconfig 基线
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml tsconfig.base.json ./
|
||||
|
||||
# 复制 shared-proto(gRPC proto-loader 运行时加载)
|
||||
COPY packages/shared-proto ./packages/shared-proto
|
||||
|
||||
# 复制 shared-ts(workspace 依赖)
|
||||
COPY packages/shared-ts ./packages/shared-ts
|
||||
|
||||
# 复制 config-service 源码
|
||||
COPY services/config-service ./services/config-service
|
||||
|
||||
# HUSKY=0 跳过根 package.json 的 prepare:husky 脚本
|
||||
ENV HUSKY=0
|
||||
|
||||
# 只安装 config-service 及其 workspace 依赖(@edu/shared-ts、@edu/shared-proto)
|
||||
RUN pnpm install --no-frozen-lockfile --filter @edu/config-service...
|
||||
|
||||
# 构建 shared-ts(config-service 依赖 @edu/shared-ts/outbox dist 产物)
|
||||
RUN cd packages/shared-ts && pnpm build
|
||||
|
||||
# 构建 config-service
|
||||
WORKDIR /app/services/config-service
|
||||
RUN pnpm build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 workspace 配置 + tsconfig 基线
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml tsconfig.base.json ./
|
||||
|
||||
# 复制 shared-proto + shared-ts(运行时需要:proto 文件 + shared-ts dist)
|
||||
COPY packages/shared-proto ./packages/shared-proto
|
||||
COPY packages/shared-ts ./packages/shared-ts
|
||||
|
||||
# 复制 config-service 源码
|
||||
COPY services/config-service ./services/config-service
|
||||
|
||||
# 从 builder 复制已编译的 prod node_modules + config-service dist + shared-ts dist
|
||||
COPY --from=builder /app/services/config-service/node_modules ./services/config-service/node_modules
|
||||
COPY --from=builder /app/packages/shared-ts/node_modules ./packages/shared-ts/node_modules
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/services/config-service/dist ./services/config-service/dist
|
||||
COPY --from=builder /app/packages/shared-ts/dist ./packages/shared-ts/dist
|
||||
|
||||
WORKDIR /app/services/config-service
|
||||
EXPOSE 3011 50059
|
||||
CMD ["node", "dist/main.js"]
|
||||
100
services/config-service/README.md
Normal file
100
services/config-service/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# config-service
|
||||
|
||||
> v2.1 M3 / ADR-026:从 iam 拆分插件配置 + 布局 + 用户偏好职责
|
||||
|
||||
## 端口
|
||||
|
||||
- HTTP: 3011
|
||||
- gRPC: 50059
|
||||
|
||||
## 职责
|
||||
|
||||
config-service 负责插件化仪表盘的后端配置管理(portal-shell spec §6):
|
||||
|
||||
1. **插件注册表**(`config_plugin_registry`):系统级插件元数据
|
||||
2. **角色-插件映射**(`config_role_plugin_mapping`):角色可用插件集
|
||||
3. **角色 Layout 默认**(`config_role_layout_default`):角色默认布局模板
|
||||
4. **Layout 模板**(`config_layout_templates`):5 种内置模板
|
||||
5. **用户布局覆盖**(`config_user_layout_override`):用户自定义
|
||||
6. **Outbox**(`config_outbox`):v2.1 ADR-032,Debezium 监听 binlog
|
||||
|
||||
## 不涉及
|
||||
|
||||
- DataScope(仍归 iam)
|
||||
- 认证 / JWT(仍归 iam)
|
||||
- 权限点 DB 查询(简化为角色判断)
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
HTTP 3011 ─┬─ /v1/config/plugin-config (用户三层合并)
|
||||
├─ /v1/config/layout-templates (用户列模板)
|
||||
├─ /v1/config/user-layout (用户更新布局)
|
||||
├─ /v1/config/admin/* (admin CRUD)
|
||||
├─ /healthz, /readyz (健康检查)
|
||||
├─ /metrics (Prometheus)
|
||||
└─ /graphql (Federation 子图)
|
||||
|
||||
gRPC 50059 ── ConfigService (BFF 聚合调用)
|
||||
```
|
||||
|
||||
## 三层合并算法
|
||||
|
||||
`getPluginConfig(userId, userRole)` 的合并逻辑(portal-shell spec §6.4):
|
||||
|
||||
1. 查 `config_plugin_registry WHERE is_active=TRUE`
|
||||
2. 查 `config_role_plugin_mapping WHERE role=:userRole`
|
||||
3. 查 `config_user_layout_override WHERE user_id=:userId`
|
||||
4. 对每个 plugin:
|
||||
- `finalProps = merge(registry.default_props, roleMapping.widget_props, userPlacement.props)`
|
||||
- `finalSlot = userPlacement.slot ?? roleMapping.slot ?? registry.default_slot`
|
||||
- `isVisible = !hidden.includes(pluginId) && (roleMapping.is_enabled ?? true) && registry.is_active`
|
||||
|
||||
## 缓存
|
||||
|
||||
Redis 缓存(TTL 5min):
|
||||
|
||||
- `config:plugin:{pluginId}` — 插件注册表项
|
||||
- `config:user-layout:{userId}` — 用户布局覆盖
|
||||
|
||||
失效策略:admin 修改配置或用户更新布局时主动 del。
|
||||
|
||||
## 事件
|
||||
|
||||
v2.1 ADR-032:业务写 `config_outbox` 表,Debezium 监听 binlog 投递到 Kafka。
|
||||
|
||||
事件命名:`<Aggregate>.<Action>`
|
||||
|
||||
- `PluginRegistry.updated`
|
||||
- `RolePluginMapping.updated`
|
||||
- `RoleLayoutDefault.updated`
|
||||
- `UserLayoutOverride.upserted` / `UserLayoutOverride.reset`
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 类型检查
|
||||
pnpm --filter @edu/config-service run typecheck
|
||||
|
||||
# Lint
|
||||
pnpm --filter @edu/config-service run lint
|
||||
|
||||
# 开发模式
|
||||
pnpm --filter @edu/config-service run dev
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --------------------------- | ----------------- | ------------------------ |
|
||||
| PORT | 3011 | HTTP 端口 |
|
||||
| GRPC_PORT | 50059 | gRPC 端口 |
|
||||
| DATABASE_URL | — | MySQL 连接串 |
|
||||
| REDIS_URL | — | Redis 连接串 |
|
||||
| KAFKA_BROKERS | — | Kafka broker 列表 |
|
||||
| ROUTER_AUTH_SECRET | dev-router-secret | Apollo Router 信任凭证 |
|
||||
| DEV_MODE | false | 开发模式(放行权限校验) |
|
||||
| OTEL_EXPORTER_OTLP_ENDPOINT | — | OTLP exporter 端点 |
|
||||
8
services/config-service/nest-cli.json
Normal file
8
services/config-service/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
51
services/config-service/package.json
Normal file
51
services/config-service/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@edu/config-service",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/subgraph": "^2.2.3",
|
||||
"@edu/shared-ts": "workspace:*",
|
||||
"@grpc/grpc-js": "^1.12.0",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@nestjs/apollo": "^12.2.0",
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/graphql": "^12.2.0",
|
||||
"@nestjs/microservices": "^10.4.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
|
||||
"@opentelemetry/sdk-node": "^0.53.0",
|
||||
"dataloader": "^2.2.2",
|
||||
"drizzle-orm": "^0.31.0",
|
||||
"graphql": "^16.9.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"kafkajs": "^2.2.4",
|
||||
"mysql2": "^3.11.0",
|
||||
"pino": "^9.4.0",
|
||||
"prom-client": "^15.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
68
services/config-service/src/app.module.ts
Normal file
68
services/config-service/src/app.module.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Module,
|
||||
NestModule,
|
||||
MiddlewareConsumer,
|
||||
OnModuleInit,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { ConfigModule } from "./config-config/config.module.js";
|
||||
import { HealthModule } from "./shared/health/health.module.js";
|
||||
import { PermissionGuard } from "./middleware/permission.guard.js";
|
||||
import { AuthMiddleware } from "./middleware/auth.middleware.js";
|
||||
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||
import { GraphqlModule } from "./graphql/graphql.module.js";
|
||||
import { RouterAuthGuard } from "./graphql/router-auth.guard.js";
|
||||
import { connectKafkaProducer } from "./config/kafka.js";
|
||||
|
||||
/**
|
||||
* config-service 根模块(v2.1 M3 / ADR-026)。
|
||||
*
|
||||
* 装配:
|
||||
* - ConfigModule(业务:插件配置 + 布局 + 用户偏好)
|
||||
* - HealthModule(健康检查)
|
||||
* - GraphqlModule(Apollo Federation 子图,v2.1 M3)
|
||||
* - AuthMiddleware(从 Gateway 注入的 x-user-* 头部解析用户身份)
|
||||
* - PermissionGuard(APP_GUARD,简化版角色判断,ADR-026)
|
||||
* - RouterAuthGuard(APP_GUARD,仅 /graphql 端点生效,ADR-036)
|
||||
*
|
||||
* v2.1 ADR-032:outbox 投递由 Debezium CDC 接管,本服务不持有 OutboxPublisher,
|
||||
* 但保留 Kafka producer 连接(健康检查 /readyz 探活)。
|
||||
*/
|
||||
@Module({
|
||||
imports: [ConfigModule, HealthModule, GraphqlModule],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
{ provide: APP_GUARD, useClass: RouterAuthGuard },
|
||||
LifecycleService,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule, OnModuleInit {
|
||||
private readonly logger = new Logger(AppModule.name);
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 连接 Kafka producer(健康检查 /healthz 依赖 producer 探活)
|
||||
// v2.1 ADR-032:outbox 投递由 Debezium CDC 接管,producer 仅用于健康检查
|
||||
try {
|
||||
await connectKafkaProducer();
|
||||
this.logger.log("Kafka producer connected");
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Kafka producer connect failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
// AuthMiddleware 应用于需要鉴权的 /v1/config 路由
|
||||
// 公开端点(health/metrics)不走此中间件
|
||||
consumer
|
||||
.apply(AuthMiddleware)
|
||||
.forRoutes(
|
||||
"v1/config/plugin-config",
|
||||
"v1/config/layout-templates",
|
||||
"v1/config/user-layout",
|
||||
"v1/config/admin",
|
||||
);
|
||||
}
|
||||
}
|
||||
126
services/config-service/src/config-config/admin.controller.ts
Normal file
126
services/config-service/src/config-config/admin.controller.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "./config.service.js";
|
||||
import {
|
||||
updatePluginSchema,
|
||||
updateRolePluginMappingSchema,
|
||||
updateRoleLayoutDefaultSchema,
|
||||
listPluginsQuerySchema,
|
||||
} from "./config.dto.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import type {
|
||||
PluginRegistry,
|
||||
RolePluginMapping,
|
||||
RoleLayoutDefault,
|
||||
UserLayoutOverride,
|
||||
LayoutTemplate,
|
||||
} from "./config.schema.js";
|
||||
|
||||
/**
|
||||
* Admin REST API(portal-shell spec §6.3 管理路径)。
|
||||
*
|
||||
* 路由前缀:/v1/config/admin
|
||||
* 所有端点要求 role=admin(PermissionGuard CONFIG_ADMIN)。
|
||||
*/
|
||||
@Controller("v1/config/admin")
|
||||
export class AdminController {
|
||||
constructor(private readonly service: ConfigService) {}
|
||||
|
||||
// ============ Plugin Registry ============
|
||||
|
||||
@Get("plugins")
|
||||
@RequirePermission(Permissions.CONFIG_ADMIN)
|
||||
async listPlugins(
|
||||
@Query() query: unknown,
|
||||
): Promise<{ plugins: PluginRegistry[] }> {
|
||||
const dto = listPluginsQuerySchema.parse(query);
|
||||
const plugins = await this.service.listPlugins({
|
||||
category: dto.category,
|
||||
isActive: dto.isActive,
|
||||
});
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
@Put("plugins/:id")
|
||||
@RequirePermission(Permissions.CONFIG_ADMIN)
|
||||
async updatePlugin(
|
||||
@Param("id") pluginId: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<PluginRegistry> {
|
||||
const dto = updatePluginSchema.parse(body);
|
||||
return this.service.updatePlugin(pluginId, dto);
|
||||
}
|
||||
|
||||
// ============ Role Plugin Mapping ============
|
||||
|
||||
@Get("role-plugin-mapping")
|
||||
@RequirePermission(Permissions.CONFIG_ADMIN)
|
||||
async listRolePluginMapping(
|
||||
@Query("role") role?: string,
|
||||
): Promise<{ mappings: RolePluginMapping[] }> {
|
||||
const mappings = await this.service.listRolePluginMappings(role);
|
||||
return { mappings };
|
||||
}
|
||||
|
||||
@Put("role-plugin-mapping/:role")
|
||||
@RequirePermission(Permissions.CONFIG_ADMIN)
|
||||
async updateRolePluginMapping(
|
||||
@Param("role") role: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ mappings: RolePluginMapping[] }> {
|
||||
const dto = updateRolePluginMappingSchema.parse(body);
|
||||
const mappings = await this.service.updateRolePluginMapping(role, dto);
|
||||
return { mappings };
|
||||
}
|
||||
|
||||
// ============ Layout Templates ============
|
||||
|
||||
@Get("layout-templates")
|
||||
@RequirePermission(Permissions.CONFIG_ADMIN)
|
||||
async listLayoutTemplates(): Promise<{ templates: LayoutTemplate[] }> {
|
||||
const templates = await this.service.listLayoutTemplates();
|
||||
return { templates };
|
||||
}
|
||||
|
||||
// ============ Role Layout Default ============
|
||||
|
||||
@Put("role-layout-default/:role")
|
||||
@RequirePermission(Permissions.CONFIG_ADMIN)
|
||||
async updateRoleLayoutDefault(
|
||||
@Param("role") role: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<RoleLayoutDefault> {
|
||||
const dto = updateRoleLayoutDefaultSchema.parse(body);
|
||||
return this.service.updateRoleLayoutDefault(role, dto);
|
||||
}
|
||||
|
||||
// ============ User Layout Override ============
|
||||
|
||||
@Get("user-layout-override/:userId")
|
||||
@RequirePermission(Permissions.CONFIG_ADMIN)
|
||||
async getUserLayoutOverride(
|
||||
@Param("userId") userId: string,
|
||||
): Promise<{ override: UserLayoutOverride | null }> {
|
||||
const override = await this.service.getUserLayoutOverride(userId);
|
||||
return { override: override ?? null };
|
||||
}
|
||||
|
||||
@Delete("user-layout-override/:userId")
|
||||
@RequirePermission(Permissions.CONFIG_ADMIN)
|
||||
async resetUserLayoutOverride(
|
||||
@Param("userId") userId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.service.resetUserLayoutOverride(userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
103
services/config-service/src/config-config/config.controller.ts
Normal file
103
services/config-service/src/config-config/config.controller.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Body, Controller, Get, Put, Req } from "@nestjs/common";
|
||||
import { ConfigService } from "./config.service.js";
|
||||
import { upsertUserLayoutOverrideSchema } from "./config.dto.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
|
||||
import type {
|
||||
PluginConfigResponse,
|
||||
LayoutTemplateInfo,
|
||||
} from "./config.service.js";
|
||||
import type { LayoutTemplate } from "./config.schema.js";
|
||||
|
||||
/**
|
||||
* 用户侧 REST API(portal-shell spec §6.3 用户路径)。
|
||||
*
|
||||
* 路由前缀:/v1/config
|
||||
* - GET /v1/config/plugin-config — 获取当前用户合并后的插件配置(三层合并)
|
||||
* - GET /v1/config/layout-templates — 列出所有 Layout 模板
|
||||
* - PUT /v1/config/user-layout — 用户更新自己的布局覆盖
|
||||
*
|
||||
* 所有端点要求已认证(AuthMiddleware 注入 userId/role)。
|
||||
*/
|
||||
@Controller("v1/config")
|
||||
export class ConfigController {
|
||||
constructor(private readonly service: ConfigService) {}
|
||||
|
||||
/**
|
||||
* 获取当前用户合并后的插件配置(三层合并)。
|
||||
*/
|
||||
@Get("plugin-config")
|
||||
@RequirePermission(Permissions.CONFIG_USER)
|
||||
async getPluginConfig(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<PluginConfigResponse> {
|
||||
const userId = this.requireUserId(req);
|
||||
return this.service.getPluginConfig(userId, req.userRole ?? "student");
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有 Layout 模板(5 种内置)。
|
||||
*/
|
||||
@Get("layout-templates")
|
||||
@RequirePermission(Permissions.CONFIG_USER)
|
||||
async listLayoutTemplates(): Promise<{
|
||||
templates: LayoutTemplateInfo[];
|
||||
}> {
|
||||
const templates = await this.service.listLayoutTemplates();
|
||||
return {
|
||||
templates: templates.map((t) => this.toInfo(t)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户更新自己的布局覆盖。
|
||||
*/
|
||||
@Put("user-layout")
|
||||
@RequirePermission(Permissions.CONFIG_USER)
|
||||
async updateUserLayout(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ userId: string; updatedAt: Date }> {
|
||||
const userId = this.requireUserId(req);
|
||||
const dto = upsertUserLayoutOverrideSchema.parse(body);
|
||||
const result = await this.service.upsertUserLayoutOverride(userId, dto);
|
||||
return { userId: result.userId, updatedAt: result.updatedAt };
|
||||
}
|
||||
|
||||
private requireUserId(req: AuthenticatedRequest): string {
|
||||
if (!req.userId) {
|
||||
throw new Error("Missing userId (AuthMiddleware should have set it)");
|
||||
}
|
||||
return req.userId;
|
||||
}
|
||||
|
||||
private toInfo(t: LayoutTemplate): LayoutTemplateInfo {
|
||||
return {
|
||||
layoutId: t.layoutId,
|
||||
displayName: t.displayName,
|
||||
description: t.description ?? "",
|
||||
availableSlots: this.parseStringArray(t.availableSlots),
|
||||
layoutSchemaJson: JSON.stringify(t.layoutSchema ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
private parseStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((x): x is string => typeof x === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
101
services/config-service/src/config-config/config.dto.ts
Normal file
101
services/config-service/src/config-config/config.dto.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* config-service DTO(Zod schema)。
|
||||
*
|
||||
* 覆盖 admin CRUD + 用户侧 REST + gRPC 输入校验。
|
||||
*/
|
||||
|
||||
// ============ Plugin Registry ============
|
||||
|
||||
export const updatePluginSchema = z.object({
|
||||
category: z
|
||||
.enum([
|
||||
"universal",
|
||||
"sidebar",
|
||||
"topbar",
|
||||
"teacher",
|
||||
"student",
|
||||
"parent",
|
||||
"admin",
|
||||
])
|
||||
.optional(),
|
||||
version: z.string().max(50).optional(),
|
||||
displayName: z.string().min(1).max(200).optional(),
|
||||
description: z.string().optional(),
|
||||
defaultSlot: z.string().max(50).optional(),
|
||||
defaultSize: z.record(z.unknown()).optional(),
|
||||
defaultProps: z.record(z.unknown()).optional(),
|
||||
propsSchema: z.record(z.unknown()).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
packageUrl: z.string().optional(),
|
||||
});
|
||||
export type UpdatePluginDto = z.infer<typeof updatePluginSchema>;
|
||||
|
||||
// ============ Role Plugin Mapping ============
|
||||
|
||||
export const rolePluginMappingItemSchema = z.object({
|
||||
pluginId: z.string().min(1).max(100),
|
||||
slot: z.string().max(50).optional(),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
isEnabled: z.boolean().default(true),
|
||||
widgetProps: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const updateRolePluginMappingSchema = z.object({
|
||||
mappings: z.array(rolePluginMappingItemSchema),
|
||||
});
|
||||
export type UpdateRolePluginMappingDto = z.infer<
|
||||
typeof updateRolePluginMappingSchema
|
||||
>;
|
||||
|
||||
// ============ Role Layout Default ============
|
||||
|
||||
export const updateRoleLayoutDefaultSchema = z.object({
|
||||
layoutId: z.string().min(1).max(50),
|
||||
slotOverrides: z.record(z.unknown()).optional(),
|
||||
});
|
||||
export type UpdateRoleLayoutDefaultDto = z.infer<
|
||||
typeof updateRoleLayoutDefaultSchema
|
||||
>;
|
||||
|
||||
// ============ User Layout Override ============
|
||||
|
||||
export const pluginPlacementSchema = z.object({
|
||||
pluginId: z.string().min(1).max(100),
|
||||
slot: z.string().max(50).optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
size: z.record(z.unknown()).optional(),
|
||||
props: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const upsertUserLayoutOverrideSchema = z.object({
|
||||
activeLayout: z.string().max(50).optional(),
|
||||
slotOverrides: z.record(z.unknown()).optional(),
|
||||
pluginPlacements: z.array(pluginPlacementSchema).optional(),
|
||||
hiddenPlugins: z.array(z.string()).optional(),
|
||||
});
|
||||
export type UpsertUserLayoutOverrideDto = z.infer<
|
||||
typeof upsertUserLayoutOverrideSchema
|
||||
>;
|
||||
|
||||
// ============ List Plugins Query ============
|
||||
|
||||
export const listPluginsQuerySchema = z.object({
|
||||
category: z
|
||||
.enum([
|
||||
"universal",
|
||||
"sidebar",
|
||||
"topbar",
|
||||
"teacher",
|
||||
"student",
|
||||
"parent",
|
||||
"admin",
|
||||
])
|
||||
.optional(),
|
||||
isActive: z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.transform((v) => (v === undefined ? undefined : v === "true")),
|
||||
});
|
||||
export type ListPluginsQueryDto = z.infer<typeof listPluginsQuerySchema>;
|
||||
@@ -0,0 +1,236 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { ConfigService } from "./config.service.js";
|
||||
import {
|
||||
upsertUserLayoutOverrideSchema,
|
||||
type UpsertUserLayoutOverrideDto,
|
||||
} from "./config.dto.js";
|
||||
import type {
|
||||
PluginConfigResponse,
|
||||
PluginPlacement,
|
||||
PluginRegistryItem,
|
||||
LayoutTemplateInfo,
|
||||
SlotConfig,
|
||||
} from "./config.service.js";
|
||||
import type { LayoutTemplate, UserLayoutOverride } from "./config.schema.js";
|
||||
|
||||
/**
|
||||
* config-service gRPC Controller(双入口之 gRPC 侧,ADR-026)。
|
||||
*
|
||||
* 端口 50059,供 BFF / portal-shell 聚合调用。
|
||||
* proto package: next_edu_cloud.config.v1
|
||||
* service name: ConfigService
|
||||
*
|
||||
* 同一 ConfigService 实例同时被 REST Controller 和本 gRPC Controller 调用,
|
||||
* 业务逻辑不重复(president §2.16 双入口策略)。
|
||||
*/
|
||||
@Controller()
|
||||
export class ConfigGrpcController {
|
||||
constructor(private readonly service: ConfigService) {}
|
||||
|
||||
@GrpcMethod("ConfigService", "GetPluginConfig")
|
||||
async getPluginConfig(data: {
|
||||
userId: string;
|
||||
userRole?: string;
|
||||
}): Promise<unknown> {
|
||||
const result = await this.service.getPluginConfig(
|
||||
data.userId,
|
||||
data.userRole ?? "student",
|
||||
);
|
||||
return this.toPluginConfigProto(result);
|
||||
}
|
||||
|
||||
@GrpcMethod("ConfigService", "ListPlugins")
|
||||
async listPlugins(data: {
|
||||
category?: string;
|
||||
isActive?: boolean;
|
||||
}): Promise<unknown> {
|
||||
const plugins = await this.service.listPlugins({
|
||||
category: data.category,
|
||||
isActive: data.isActive,
|
||||
});
|
||||
return {
|
||||
plugins: plugins.map((p) => this.toPluginRegistryItemProto(p)),
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod("ConfigService", "GetLayoutTemplates")
|
||||
async getLayoutTemplates(): Promise<unknown> {
|
||||
const templates = await this.service.listLayoutTemplates();
|
||||
return {
|
||||
templates: templates.map((t) => this.toLayoutTemplateProto(t)),
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod("ConfigService", "GetUserLayoutOverride")
|
||||
async getUserLayoutOverride(data: { userId: string }): Promise<unknown> {
|
||||
const override = await this.service.getUserLayoutOverride(data.userId);
|
||||
return {
|
||||
override: override ? this.toUserLayoutOverrideProto(override) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod("ConfigService", "UpsertUserLayoutOverride")
|
||||
async upsertUserLayoutOverride(data: {
|
||||
userId: string;
|
||||
activeLayout?: string;
|
||||
slotOverridesJson?: string;
|
||||
pluginPlacementsJson?: string;
|
||||
hiddenPluginsJson?: string;
|
||||
}): Promise<unknown> {
|
||||
// 构造 DTO 输入对象(JSON 字符串解析为 unknown)
|
||||
const input: Record<string, unknown> = {};
|
||||
if (data.activeLayout !== undefined) {
|
||||
input.activeLayout = data.activeLayout;
|
||||
}
|
||||
if (data.slotOverridesJson) {
|
||||
input.slotOverrides = this.safeParse(data.slotOverridesJson);
|
||||
}
|
||||
if (data.pluginPlacementsJson) {
|
||||
input.pluginPlacements = this.safeParse(data.pluginPlacementsJson);
|
||||
}
|
||||
if (data.hiddenPluginsJson) {
|
||||
input.hiddenPlugins = this.safeParse(data.hiddenPluginsJson);
|
||||
}
|
||||
// 用 Zod 校验并得到类型化 DTO(与 REST 路径一致)
|
||||
const dto: UpsertUserLayoutOverrideDto =
|
||||
upsertUserLayoutOverrideSchema.parse(input);
|
||||
const override = await this.service.upsertUserLayoutOverride(
|
||||
data.userId,
|
||||
dto,
|
||||
);
|
||||
return { override: this.toUserLayoutOverrideProto(override) };
|
||||
}
|
||||
|
||||
@GrpcMethod("ConfigService", "ResetUserLayoutOverride")
|
||||
async resetUserLayoutOverride(data: {
|
||||
userId: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
await this.service.resetUserLayoutOverride(data.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============ 私有转换方法 ============
|
||||
|
||||
private toPluginConfigProto(resp: PluginConfigResponse): unknown {
|
||||
return {
|
||||
activeLayout: resp.activeLayout
|
||||
? this.toLayoutTemplateProtoFromInfo(resp.activeLayout)
|
||||
: null,
|
||||
slots: resp.slots.map((s) => this.toSlotConfigProto(s)),
|
||||
plugins: resp.plugins.map((p) => this.toPluginPlacementProto(p)),
|
||||
registry: resp.registry.map((r) =>
|
||||
this.toPluginRegistryItemProtoFromInfo(r),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private toLayoutTemplateProto(t: LayoutTemplate): unknown {
|
||||
return {
|
||||
layoutId: t.layoutId,
|
||||
displayName: t.displayName,
|
||||
description: t.description ?? "",
|
||||
availableSlots: this.parseStringArray(t.availableSlots),
|
||||
layoutSchemaJson: JSON.stringify(t.layoutSchema ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
private toLayoutTemplateProtoFromInfo(t: LayoutTemplateInfo): unknown {
|
||||
return {
|
||||
layoutId: t.layoutId,
|
||||
displayName: t.displayName,
|
||||
description: t.description,
|
||||
availableSlots: t.availableSlots,
|
||||
layoutSchemaJson: t.layoutSchemaJson,
|
||||
};
|
||||
}
|
||||
|
||||
private toSlotConfigProto(s: SlotConfig): unknown {
|
||||
return {
|
||||
slotName: s.slotName,
|
||||
navItems: s.navItems,
|
||||
};
|
||||
}
|
||||
|
||||
private toPluginPlacementProto(p: PluginPlacement): unknown {
|
||||
return {
|
||||
pluginId: p.pluginId,
|
||||
slot: p.slot,
|
||||
sortOrder: p.sortOrder,
|
||||
sizeJson: p.sizeJson,
|
||||
propsJson: p.propsJson,
|
||||
isVisible: p.isVisible,
|
||||
};
|
||||
}
|
||||
|
||||
private toPluginRegistryItemProto(p: {
|
||||
pluginId: string;
|
||||
category: string;
|
||||
version: string | null;
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
requiredRoles: unknown;
|
||||
isBuiltin: boolean;
|
||||
isActive: boolean;
|
||||
}): unknown {
|
||||
return {
|
||||
pluginId: p.pluginId,
|
||||
category: p.category,
|
||||
version: p.version ?? "",
|
||||
displayName: p.displayName,
|
||||
description: p.description ?? "",
|
||||
requiredRoles: this.parseStringArray(p.requiredRoles),
|
||||
isBuiltin: p.isBuiltin,
|
||||
isActive: p.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
private toPluginRegistryItemProtoFromInfo(r: PluginRegistryItem): unknown {
|
||||
return {
|
||||
pluginId: r.pluginId,
|
||||
category: r.category,
|
||||
version: r.version,
|
||||
displayName: r.displayName,
|
||||
description: r.description,
|
||||
requiredRoles: r.requiredRoles,
|
||||
isBuiltin: r.isBuiltin,
|
||||
isActive: r.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
private toUserLayoutOverrideProto(o: UserLayoutOverride): unknown {
|
||||
return {
|
||||
userId: o.userId,
|
||||
activeLayout: o.activeLayout ?? "",
|
||||
slotOverridesJson: JSON.stringify(o.slotOverrides ?? {}),
|
||||
pluginPlacementsJson: JSON.stringify(o.pluginPlacements ?? []),
|
||||
hiddenPlugins: this.parseStringArray(o.hiddenPlugins),
|
||||
updatedAt: o.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private parseStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((x): x is string => typeof x === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private safeParse(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
services/config-service/src/config-config/config.module.ts
Normal file
30
services/config-service/src/config-config/config.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigController } from "./config.controller.js";
|
||||
import { AdminController } from "./admin.controller.js";
|
||||
import { ConfigGrpcController } from "./config.grpc.controller.js";
|
||||
import { ConfigService } from "./config.service.js";
|
||||
import { ConfigRepository } from "./config.repository.js";
|
||||
import { ConfigCacheService } from "../shared/cache/config-cache.service.js";
|
||||
|
||||
/**
|
||||
* config-service 业务模块(ADR-026)。
|
||||
*
|
||||
* 装配:
|
||||
* - ConfigController(用户侧 REST)
|
||||
* - AdminController(admin REST CRUD)
|
||||
* - ConfigGrpcController(gRPC ConfigService)
|
||||
* - ConfigService(业务逻辑 + 三层合并)
|
||||
* - ConfigRepository(数据访问,6 张 config_ 表)
|
||||
* - ConfigCacheService(Redis 缓存)
|
||||
*/
|
||||
@Module({
|
||||
controllers: [ConfigController, AdminController],
|
||||
providers: [
|
||||
ConfigService,
|
||||
ConfigRepository,
|
||||
ConfigCacheService,
|
||||
ConfigGrpcController,
|
||||
],
|
||||
exports: [ConfigService, ConfigRepository],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
335
services/config-service/src/config-config/config.repository.ts
Normal file
335
services/config-service/src/config-config/config.repository.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
pluginRegistry,
|
||||
rolePluginMapping,
|
||||
roleLayoutDefault,
|
||||
layoutTemplates,
|
||||
userLayoutOverride,
|
||||
configOutbox,
|
||||
} from "./config.schema.js";
|
||||
import type {
|
||||
PluginRegistry,
|
||||
RolePluginMapping,
|
||||
RoleLayoutDefault,
|
||||
LayoutTemplate,
|
||||
UserLayoutOverride,
|
||||
} from "./config.schema.js";
|
||||
import {
|
||||
DatabaseError,
|
||||
NotFoundError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
|
||||
/**
|
||||
* config-service 数据访问层。
|
||||
*
|
||||
* 覆盖 6 张 config_ 表的 CRUD:
|
||||
* - plugin_registry: 插件注册表
|
||||
* - role_plugin_mapping: 角色-插件映射
|
||||
* - role_layout_default: 角色 Layout 默认
|
||||
* - layout_templates: Layout 模板
|
||||
* - user_layout_override: 用户布局覆盖
|
||||
* - outbox: Outbox 表(v2.1 ADR-032:Debezium 监听 binlog)
|
||||
*/
|
||||
export class ConfigRepository {
|
||||
// ============ Plugin Registry ============
|
||||
|
||||
async findPluginById(pluginId: string): Promise<PluginRegistry | undefined> {
|
||||
const db = getDb();
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(pluginRegistry)
|
||||
.where(eq(pluginRegistry.pluginId, pluginId));
|
||||
return result;
|
||||
}
|
||||
|
||||
async listPlugins(filters?: {
|
||||
category?: string;
|
||||
isActive?: boolean;
|
||||
}): Promise<PluginRegistry[]> {
|
||||
const db = getDb();
|
||||
const conditions = [];
|
||||
if (filters?.category) {
|
||||
conditions.push(eq(pluginRegistry.category, filters.category));
|
||||
}
|
||||
if (filters?.isActive !== undefined) {
|
||||
conditions.push(eq(pluginRegistry.isActive, filters.isActive));
|
||||
}
|
||||
if (conditions.length === 0) {
|
||||
return db.select().from(pluginRegistry);
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(pluginRegistry)
|
||||
.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
||||
}
|
||||
|
||||
async listActivePlugins(): Promise<PluginRegistry[]> {
|
||||
return this.listPlugins({ isActive: true });
|
||||
}
|
||||
|
||||
async updatePlugin(
|
||||
pluginId: string,
|
||||
data: Partial<Omit<PluginRegistry, "pluginId" | "createdAt">>,
|
||||
): Promise<PluginRegistry> {
|
||||
const db = getDb();
|
||||
const existing = await this.findPluginById(pluginId);
|
||||
if (!existing) {
|
||||
throw new NotFoundError("Plugin", pluginId);
|
||||
}
|
||||
await db
|
||||
.update(pluginRegistry)
|
||||
.set(data)
|
||||
.where(eq(pluginRegistry.pluginId, pluginId));
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(pluginRegistry)
|
||||
.where(eq(pluginRegistry.pluginId, pluginId));
|
||||
if (!updated) {
|
||||
throw new DatabaseError("Failed to reload updated plugin");
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async batchFindPlugins(pluginIds: string[]): Promise<PluginRegistry[]> {
|
||||
if (pluginIds.length === 0) return [];
|
||||
const db = getDb();
|
||||
const { inArray } = await import("drizzle-orm");
|
||||
return db
|
||||
.select()
|
||||
.from(pluginRegistry)
|
||||
.where(inArray(pluginRegistry.pluginId, pluginIds));
|
||||
}
|
||||
|
||||
// ============ Role Plugin Mapping ============
|
||||
|
||||
async listRolePluginMappings(role?: string): Promise<RolePluginMapping[]> {
|
||||
const db = getDb();
|
||||
if (role) {
|
||||
return db
|
||||
.select()
|
||||
.from(rolePluginMapping)
|
||||
.where(eq(rolePluginMapping.role, role));
|
||||
}
|
||||
return db.select().from(rolePluginMapping);
|
||||
}
|
||||
|
||||
async findRolePluginMapping(
|
||||
role: string,
|
||||
pluginId: string,
|
||||
): Promise<RolePluginMapping | undefined> {
|
||||
const db = getDb();
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(rolePluginMapping)
|
||||
.where(
|
||||
and(
|
||||
eq(rolePluginMapping.role, role),
|
||||
eq(rolePluginMapping.pluginId, pluginId),
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量替换角色的插件映射(先删后插)。
|
||||
*/
|
||||
async replaceRolePluginMappings(
|
||||
role: string,
|
||||
mappings: Array<{
|
||||
pluginId: string;
|
||||
slot: string | null;
|
||||
sortOrder: number;
|
||||
isEnabled: boolean;
|
||||
widgetProps: unknown;
|
||||
}>,
|
||||
): Promise<RolePluginMapping[]> {
|
||||
const db = getDb();
|
||||
await db.delete(rolePluginMapping).where(eq(rolePluginMapping.role, role));
|
||||
if (mappings.length > 0) {
|
||||
await db.insert(rolePluginMapping).values(
|
||||
mappings.map((m) => ({
|
||||
role,
|
||||
pluginId: m.pluginId,
|
||||
slot: m.slot,
|
||||
sortOrder: m.sortOrder,
|
||||
isEnabled: m.isEnabled,
|
||||
widgetProps: m.widgetProps,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return this.listRolePluginMappings(role);
|
||||
}
|
||||
|
||||
// ============ Role Layout Default ============
|
||||
|
||||
async findRoleLayoutDefault(
|
||||
role: string,
|
||||
): Promise<RoleLayoutDefault | undefined> {
|
||||
const db = getDb();
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(roleLayoutDefault)
|
||||
.where(eq(roleLayoutDefault.role, role));
|
||||
return result;
|
||||
}
|
||||
|
||||
async upsertRoleLayoutDefault(
|
||||
role: string,
|
||||
layoutId: string,
|
||||
slotOverrides: unknown,
|
||||
): Promise<RoleLayoutDefault> {
|
||||
const db = getDb();
|
||||
const existing = await this.findRoleLayoutDefault(role);
|
||||
if (existing) {
|
||||
await db
|
||||
.update(roleLayoutDefault)
|
||||
.set({ layoutId, slotOverrides })
|
||||
.where(eq(roleLayoutDefault.role, role));
|
||||
} else {
|
||||
await db.insert(roleLayoutDefault).values({
|
||||
role,
|
||||
layoutId,
|
||||
slotOverrides,
|
||||
});
|
||||
}
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(roleLayoutDefault)
|
||||
.where(eq(roleLayoutDefault.role, role));
|
||||
if (!result) {
|
||||
throw new DatabaseError("Failed to upsert role layout default");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ Layout Templates ============
|
||||
|
||||
async listLayoutTemplates(): Promise<LayoutTemplate[]> {
|
||||
const db = getDb();
|
||||
return db.select().from(layoutTemplates);
|
||||
}
|
||||
|
||||
async listActiveLayoutTemplates(): Promise<LayoutTemplate[]> {
|
||||
const db = getDb();
|
||||
return db
|
||||
.select()
|
||||
.from(layoutTemplates)
|
||||
.where(eq(layoutTemplates.isActive, true));
|
||||
}
|
||||
|
||||
async findLayoutTemplate(
|
||||
layoutId: string,
|
||||
): Promise<LayoutTemplate | undefined> {
|
||||
const db = getDb();
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(layoutTemplates)
|
||||
.where(eq(layoutTemplates.layoutId, layoutId));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ User Layout Override ============
|
||||
|
||||
async findUserLayoutOverride(
|
||||
userId: string,
|
||||
): Promise<UserLayoutOverride | undefined> {
|
||||
const db = getDb();
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(userLayoutOverride)
|
||||
.where(eq(userLayoutOverride.userId, userId));
|
||||
return result;
|
||||
}
|
||||
|
||||
async batchFindUserLayoutOverrides(
|
||||
userIds: string[],
|
||||
): Promise<UserLayoutOverride[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
const db = getDb();
|
||||
const { inArray } = await import("drizzle-orm");
|
||||
return db
|
||||
.select()
|
||||
.from(userLayoutOverride)
|
||||
.where(inArray(userLayoutOverride.userId, userIds));
|
||||
}
|
||||
|
||||
async upsertUserLayoutOverride(
|
||||
userId: string,
|
||||
data: {
|
||||
activeLayout?: string | null;
|
||||
slotOverrides?: unknown;
|
||||
pluginPlacements?: unknown;
|
||||
hiddenPlugins?: unknown;
|
||||
},
|
||||
): Promise<UserLayoutOverride> {
|
||||
const db = getDb();
|
||||
const existing = await this.findUserLayoutOverride(userId);
|
||||
if (existing) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.activeLayout !== undefined) {
|
||||
updateData.activeLayout = data.activeLayout;
|
||||
}
|
||||
if (data.slotOverrides !== undefined) {
|
||||
updateData.slotOverrides = data.slotOverrides;
|
||||
}
|
||||
if (data.pluginPlacements !== undefined) {
|
||||
updateData.pluginPlacements = data.pluginPlacements;
|
||||
}
|
||||
if (data.hiddenPlugins !== undefined) {
|
||||
updateData.hiddenPlugins = data.hiddenPlugins;
|
||||
}
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db
|
||||
.update(userLayoutOverride)
|
||||
.set(updateData)
|
||||
.where(eq(userLayoutOverride.userId, userId));
|
||||
}
|
||||
} else {
|
||||
await db.insert(userLayoutOverride).values({
|
||||
userId,
|
||||
activeLayout: data.activeLayout ?? null,
|
||||
slotOverrides: data.slotOverrides ?? null,
|
||||
pluginPlacements: data.pluginPlacements ?? null,
|
||||
hiddenPlugins: data.hiddenPlugins ?? null,
|
||||
});
|
||||
}
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(userLayoutOverride)
|
||||
.where(eq(userLayoutOverride.userId, userId));
|
||||
if (!result) {
|
||||
throw new DatabaseError("Failed to upsert user layout override");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteUserLayoutOverride(userId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.delete(userLayoutOverride)
|
||||
.where(eq(userLayoutOverride.userId, userId));
|
||||
}
|
||||
|
||||
// ============ Outbox ============
|
||||
|
||||
/**
|
||||
* 写入 Outbox 事件记录(v2.1 ADR-032:业务写 outbox,Debezium 监听 binlog 投递 Kafka)。
|
||||
*/
|
||||
async appendOutboxEvent(event: {
|
||||
eventId: string;
|
||||
aggregateType: string;
|
||||
aggregateId: string;
|
||||
eventType: string;
|
||||
payload: unknown;
|
||||
}): Promise<void> {
|
||||
const db = getDb();
|
||||
await db.insert(configOutbox).values({
|
||||
eventId: event.eventId,
|
||||
aggregateType: event.aggregateType,
|
||||
aggregateId: event.aggregateId,
|
||||
eventType: event.eventType,
|
||||
payload: event.payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
122
services/config-service/src/config-config/config.schema.ts
Normal file
122
services/config-service/src/config-config/config.schema.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
char,
|
||||
timestamp,
|
||||
text,
|
||||
int,
|
||||
boolean,
|
||||
json,
|
||||
index,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
/**
|
||||
* config-service 数据模型(ADR-026 + portal-shell spec §6.1)。
|
||||
*
|
||||
* 6 张表前缀 config_:
|
||||
* 1. plugin_registry — 插件注册表
|
||||
* 2. role_plugin_mapping — 角色-插件映射
|
||||
* 3. role_layout_default — 角色 Layout 默认
|
||||
* 4. layout_templates — Layout 模板(5 种内置)
|
||||
* 5. user_layout_override — 用户布局覆盖
|
||||
* 6. outbox — Outbox 表(v2.1 ADR-032:Debezium 监听 binlog 投递 Kafka)
|
||||
*/
|
||||
|
||||
// ============ 1. 插件注册表 ============
|
||||
|
||||
export const pluginRegistry = mysqlTable("config_plugin_registry", {
|
||||
pluginId: varchar("plugin_id", { length: 100 }).notNull().primaryKey(),
|
||||
category: varchar("category", { length: 50 }).notNull(),
|
||||
version: varchar("version", { length: 50 }),
|
||||
displayName: varchar("display_name", { length: 200 }).notNull(),
|
||||
description: text("description"),
|
||||
requiredRoles: json("required_roles"),
|
||||
defaultSlot: varchar("default_slot", { length: 50 }),
|
||||
defaultSize: json("default_size"),
|
||||
defaultProps: json("default_props"),
|
||||
propsSchema: json("props_schema"),
|
||||
isBuiltin: boolean("is_builtin").notNull().default(true),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
packageUrl: text("package_url"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
// ============ 2. 角色-插件映射 ============
|
||||
|
||||
export const rolePluginMapping = mysqlTable(
|
||||
"config_role_plugin_mapping",
|
||||
{
|
||||
role: varchar("role", { length: 50 }).notNull(),
|
||||
pluginId: varchar("plugin_id", { length: 100 }).notNull(),
|
||||
slot: varchar("slot", { length: 50 }),
|
||||
sortOrder: int("sort_order").notNull().default(0),
|
||||
isEnabled: boolean("is_enabled").notNull().default(true),
|
||||
widgetProps: json("widget_props"),
|
||||
},
|
||||
(table) => ({
|
||||
pk: index("idx_config_role_plugin_pk").on(table.role, table.pluginId),
|
||||
}),
|
||||
);
|
||||
|
||||
// ============ 3. 角色 Layout 默认 ============
|
||||
|
||||
export const roleLayoutDefault = mysqlTable("config_role_layout_default", {
|
||||
role: varchar("role", { length: 50 }).notNull().primaryKey(),
|
||||
layoutId: varchar("layout_id", { length: 50 }).notNull(),
|
||||
slotOverrides: json("slot_overrides"),
|
||||
});
|
||||
|
||||
// ============ 4. Layout 模板(5 种内置) ============
|
||||
|
||||
export const layoutTemplates = mysqlTable("config_layout_templates", {
|
||||
layoutId: varchar("layout_id", { length: 50 }).notNull().primaryKey(),
|
||||
displayName: varchar("display_name", { length: 200 }).notNull(),
|
||||
description: text("description"),
|
||||
availableSlots: json("available_slots"),
|
||||
layoutSchema: json("layout_schema"),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
});
|
||||
|
||||
// ============ 5. 用户布局覆盖 ============
|
||||
|
||||
export const userLayoutOverride = mysqlTable("config_user_layout_override", {
|
||||
userId: varchar("user_id", { length: 36 }).notNull().primaryKey(),
|
||||
activeLayout: varchar("active_layout", { length: 50 }),
|
||||
slotOverrides: json("slot_overrides"),
|
||||
pluginPlacements: json("plugin_placements"),
|
||||
hiddenPlugins: json("hidden_plugins"),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
// ============ 6. Outbox 表(v2.1 ADR-032) ============
|
||||
|
||||
export const configOutbox = mysqlTable(
|
||||
"config_outbox",
|
||||
{
|
||||
eventId: char("event_id", { length: 36 }).notNull().primaryKey(),
|
||||
aggregateType: varchar("aggregate_type", { length: 50 }).notNull(),
|
||||
aggregateId: varchar("aggregate_id", { length: 100 }).notNull(),
|
||||
eventType: varchar("event_type", { length: 100 }).notNull(),
|
||||
payload: json("payload").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
publishedAt: timestamp("published_at"),
|
||||
retryCount: int("retry_count").notNull().default(0),
|
||||
nextRetryAt: timestamp("next_retry_at"),
|
||||
},
|
||||
(table) => ({
|
||||
unpublishedIdx: index("idx_outbox_unpublished").on(
|
||||
table.publishedAt,
|
||||
table.nextRetryAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// ============ Type Exports ============
|
||||
|
||||
export type PluginRegistry = typeof pluginRegistry.$inferSelect;
|
||||
export type RolePluginMapping = typeof rolePluginMapping.$inferSelect;
|
||||
export type RoleLayoutDefault = typeof roleLayoutDefault.$inferSelect;
|
||||
export type LayoutTemplate = typeof layoutTemplates.$inferSelect;
|
||||
export type UserLayoutOverride = typeof userLayoutOverride.$inferSelect;
|
||||
export type ConfigOutbox = typeof configOutbox.$inferSelect;
|
||||
440
services/config-service/src/config-config/config.service.ts
Normal file
440
services/config-service/src/config-config/config.service.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ConfigRepository } from "./config.repository.js";
|
||||
import { ConfigCacheService } from "../shared/cache/config-cache.service.js";
|
||||
import type {
|
||||
PluginRegistry,
|
||||
RolePluginMapping,
|
||||
RoleLayoutDefault,
|
||||
LayoutTemplate,
|
||||
UserLayoutOverride,
|
||||
} from "./config.schema.js";
|
||||
import type {
|
||||
UpdatePluginDto,
|
||||
UpdateRolePluginMappingDto,
|
||||
UpdateRoleLayoutDefaultDto,
|
||||
UpsertUserLayoutOverrideDto,
|
||||
} from "./config.dto.js";
|
||||
|
||||
/**
|
||||
* 三层合并后的插件配置响应(对应 proto PluginConfigResponse)。
|
||||
*/
|
||||
export interface PluginConfigResponse {
|
||||
activeLayout: LayoutTemplateInfo | null;
|
||||
slots: SlotConfig[];
|
||||
plugins: PluginPlacement[];
|
||||
registry: PluginRegistryItem[];
|
||||
}
|
||||
|
||||
export interface LayoutTemplateInfo {
|
||||
layoutId: string;
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
availableSlots: string[];
|
||||
layoutSchemaJson: string;
|
||||
}
|
||||
|
||||
export interface SlotConfig {
|
||||
slotName: string;
|
||||
navItems: string[];
|
||||
}
|
||||
|
||||
export interface PluginPlacement {
|
||||
pluginId: string;
|
||||
slot: string;
|
||||
sortOrder: number;
|
||||
sizeJson: string;
|
||||
propsJson: string;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export interface PluginRegistryItem {
|
||||
pluginId: string;
|
||||
category: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
requiredRoles: string[];
|
||||
isBuiltin: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface UserPluginPlacement {
|
||||
pluginId: string;
|
||||
slot?: string;
|
||||
sortOrder?: number;
|
||||
size?: unknown;
|
||||
props?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* config-service Application Service(双入口:REST + gRPC 共用)。
|
||||
*
|
||||
* 核心职责(ADR-026):
|
||||
* 1. 插件配置三层合并(registry.defaultProps + roleMapping.widget_props + userOverride.props)
|
||||
* 2. Layout 模板查询
|
||||
* 3. 用户布局覆盖 CRUD
|
||||
* 4. Admin CRUD(plugin / role-plugin-mapping / role-layout-default)
|
||||
* 5. Outbox 事件写入(v2.1 ADR-032:Debezium 监听 binlog)
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConfigService {
|
||||
constructor(
|
||||
@Inject(ConfigRepository) private readonly repository: ConfigRepository,
|
||||
private readonly cache: ConfigCacheService,
|
||||
) {}
|
||||
|
||||
// ============ 三层合并(核心方法) ============
|
||||
|
||||
/**
|
||||
* 获取用户合并后的插件配置(三层合并)。
|
||||
*
|
||||
* 合并算法(portal-shell spec §6.4):
|
||||
* 1. 查 config_plugin_registry WHERE is_active=TRUE
|
||||
* 2. 查 config_role_plugin_mapping WHERE role=:userRole
|
||||
* 3. 查 config_user_layout_override WHERE user_id=:userId
|
||||
* 4. 对每个 plugin 计算最终配置:
|
||||
* finalProps = merge(registry.default_props, roleMapping.widget_props, userPlacement.props)
|
||||
* finalSlot = userPlacement.slot ?? roleMapping.slot ?? registry.default_slot
|
||||
* isVisible = !hidden.includes(pluginId) && (roleMapping.is_enabled ?? true) && registry.is_active
|
||||
*/
|
||||
async getPluginConfig(
|
||||
userId: string,
|
||||
userRole: string,
|
||||
): Promise<PluginConfigResponse> {
|
||||
const [registry, roleMappings, userOverride, roleLayoutDefault] =
|
||||
await Promise.all([
|
||||
this.repository.listActivePlugins(),
|
||||
this.repository.listRolePluginMappings(userRole),
|
||||
this.repository.findUserLayoutOverride(userId),
|
||||
this.repository.findRoleLayoutDefault(userRole),
|
||||
]);
|
||||
|
||||
// 解析用户覆盖
|
||||
const userPlacements = this.parseUserPlacements(userOverride);
|
||||
const hiddenPlugins = this.parseHiddenPlugins(userOverride);
|
||||
|
||||
// 计算活跃 layout
|
||||
const activeLayoutId =
|
||||
userOverride?.activeLayout ?? roleLayoutDefault?.layoutId ?? "classic";
|
||||
const layoutTemplate =
|
||||
await this.repository.findLayoutTemplate(activeLayoutId);
|
||||
const activeLayout = layoutTemplate
|
||||
? this.toLayoutTemplateInfo(layoutTemplate)
|
||||
: null;
|
||||
|
||||
// 角色映射索引
|
||||
const roleMappingMap = new Map<string, RolePluginMapping>();
|
||||
for (const m of roleMappings) {
|
||||
roleMappingMap.set(m.pluginId, m);
|
||||
}
|
||||
|
||||
// 三层合并计算每个插件
|
||||
const plugins: PluginPlacement[] = registry.map((p) => {
|
||||
const roleMapping = roleMappingMap.get(p.pluginId);
|
||||
const userPlacement = userPlacements.get(p.pluginId);
|
||||
|
||||
const finalProps = this.mergeProps(
|
||||
p.defaultProps,
|
||||
roleMapping?.widgetProps,
|
||||
userPlacement?.props,
|
||||
);
|
||||
const finalSlot =
|
||||
userPlacement?.slot ?? roleMapping?.slot ?? p.defaultSlot ?? "main";
|
||||
const finalSortOrder =
|
||||
userPlacement?.sortOrder ?? roleMapping?.sortOrder ?? 0;
|
||||
const finalSize = userPlacement?.size ?? p.defaultSize;
|
||||
const isVisible =
|
||||
!hiddenPlugins.has(p.pluginId) &&
|
||||
(roleMapping?.isEnabled ?? true) &&
|
||||
p.isActive;
|
||||
|
||||
return {
|
||||
pluginId: p.pluginId,
|
||||
slot: finalSlot,
|
||||
sortOrder: finalSortOrder,
|
||||
sizeJson: JSON.stringify(finalSize ?? {}),
|
||||
propsJson: JSON.stringify(finalProps),
|
||||
isVisible,
|
||||
};
|
||||
});
|
||||
|
||||
// 构造 registry 输出
|
||||
const registryItems: PluginRegistryItem[] = registry.map((p) => ({
|
||||
pluginId: p.pluginId,
|
||||
category: p.category,
|
||||
version: p.version ?? "",
|
||||
displayName: p.displayName,
|
||||
description: p.description ?? "",
|
||||
requiredRoles: this.parseStringArray(p.requiredRoles),
|
||||
isBuiltin: p.isBuiltin,
|
||||
isActive: p.isActive,
|
||||
}));
|
||||
|
||||
// 构造 slot 配置
|
||||
const slots = this.buildSlotConfigs(plugins, activeLayout);
|
||||
|
||||
return {
|
||||
activeLayout,
|
||||
slots,
|
||||
plugins,
|
||||
registry: registryItems,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Plugin Registry ============
|
||||
|
||||
async listPlugins(filters?: {
|
||||
category?: string;
|
||||
isActive?: boolean;
|
||||
}): Promise<PluginRegistry[]> {
|
||||
return this.repository.listPlugins(filters);
|
||||
}
|
||||
|
||||
async updatePlugin(
|
||||
pluginId: string,
|
||||
dto: UpdatePluginDto,
|
||||
): Promise<PluginRegistry> {
|
||||
const updated = await this.repository.updatePlugin(pluginId, dto);
|
||||
await this.cache.invalidatePlugin(pluginId, "admin-update");
|
||||
await this.appendEvent(
|
||||
"PluginRegistry",
|
||||
pluginId,
|
||||
"PluginRegistry.updated",
|
||||
{
|
||||
pluginId,
|
||||
changes: dto,
|
||||
},
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ============ Role Plugin Mapping ============
|
||||
|
||||
async listRolePluginMappings(role?: string): Promise<RolePluginMapping[]> {
|
||||
return this.repository.listRolePluginMappings(role);
|
||||
}
|
||||
|
||||
async updateRolePluginMapping(
|
||||
role: string,
|
||||
dto: UpdateRolePluginMappingDto,
|
||||
): Promise<RolePluginMapping[]> {
|
||||
const mappings = dto.mappings.map((m) => ({
|
||||
pluginId: m.pluginId,
|
||||
slot: m.slot ?? null,
|
||||
sortOrder: m.sortOrder,
|
||||
isEnabled: m.isEnabled,
|
||||
widgetProps: m.widgetProps ?? null,
|
||||
}));
|
||||
const result = await this.repository.replaceRolePluginMappings(
|
||||
role,
|
||||
mappings,
|
||||
);
|
||||
await this.cache.invalidateAllPlugins("role-mapping-update");
|
||||
await this.appendEvent(
|
||||
"RolePluginMapping",
|
||||
role,
|
||||
"RolePluginMapping.updated",
|
||||
{ role, mappings: dto.mappings },
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ Role Layout Default ============
|
||||
|
||||
async getRoleLayoutDefault(
|
||||
role: string,
|
||||
): Promise<RoleLayoutDefault | undefined> {
|
||||
return this.repository.findRoleLayoutDefault(role);
|
||||
}
|
||||
|
||||
async updateRoleLayoutDefault(
|
||||
role: string,
|
||||
dto: UpdateRoleLayoutDefaultDto,
|
||||
): Promise<RoleLayoutDefault> {
|
||||
const result = await this.repository.upsertRoleLayoutDefault(
|
||||
role,
|
||||
dto.layoutId,
|
||||
dto.slotOverrides ?? null,
|
||||
);
|
||||
await this.appendEvent(
|
||||
"RoleLayoutDefault",
|
||||
role,
|
||||
"RoleLayoutDefault.updated",
|
||||
{ role, ...dto },
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ Layout Templates ============
|
||||
|
||||
async listLayoutTemplates(): Promise<LayoutTemplate[]> {
|
||||
return this.repository.listLayoutTemplates();
|
||||
}
|
||||
|
||||
// ============ User Layout Override ============
|
||||
|
||||
async getUserLayoutOverride(
|
||||
userId: string,
|
||||
): Promise<UserLayoutOverride | undefined> {
|
||||
return this.repository.findUserLayoutOverride(userId);
|
||||
}
|
||||
|
||||
async upsertUserLayoutOverride(
|
||||
userId: string,
|
||||
dto: UpsertUserLayoutOverrideDto,
|
||||
): Promise<UserLayoutOverride> {
|
||||
const result = await this.repository.upsertUserLayoutOverride(userId, {
|
||||
activeLayout: dto.activeLayout ?? undefined,
|
||||
slotOverrides: dto.slotOverrides ?? undefined,
|
||||
pluginPlacements: dto.pluginPlacements ?? undefined,
|
||||
hiddenPlugins: dto.hiddenPlugins ?? undefined,
|
||||
});
|
||||
await this.cache.invalidateUserLayout(userId, "user-update");
|
||||
await this.appendEvent(
|
||||
"UserLayoutOverride",
|
||||
userId,
|
||||
"UserLayoutOverride.upserted",
|
||||
{ userId, ...dto },
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async resetUserLayoutOverride(userId: string): Promise<void> {
|
||||
await this.repository.deleteUserLayoutOverride(userId);
|
||||
await this.cache.invalidateUserLayout(userId, "user-reset");
|
||||
await this.appendEvent(
|
||||
"UserLayoutOverride",
|
||||
userId,
|
||||
"UserLayoutOverride.reset",
|
||||
{ userId },
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 私有工具方法 ============
|
||||
|
||||
private parseUserPlacements(
|
||||
override: UserLayoutOverride | undefined,
|
||||
): Map<string, UserPluginPlacement> {
|
||||
const map = new Map<string, UserPluginPlacement>();
|
||||
if (!override?.pluginPlacements) return map;
|
||||
const arr = this.safeParseArray(override.pluginPlacements);
|
||||
for (const item of arr) {
|
||||
if (this.isPluginPlacement(item)) {
|
||||
map.set(item.pluginId, item);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private parseHiddenPlugins(
|
||||
override: UserLayoutOverride | undefined,
|
||||
): Set<string> {
|
||||
if (!override?.hiddenPlugins) return new Set();
|
||||
const arr = this.safeParseArray(override.hiddenPlugins);
|
||||
return new Set(arr.filter((x): x is string => typeof x === "string"));
|
||||
}
|
||||
|
||||
private safeParseArray(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private isPluginPlacement(value: unknown): value is UserPluginPlacement {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return typeof obj.pluginId === "string";
|
||||
}
|
||||
|
||||
private parseStringArray(value: unknown): string[] {
|
||||
const arr = this.safeParseArray(value);
|
||||
return arr.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度合并三个 props 层级(后者覆盖前者)。
|
||||
*/
|
||||
private mergeProps(
|
||||
...layers: (unknown | undefined | null)[]
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const layer of layers) {
|
||||
if (!layer) continue;
|
||||
const obj = typeof layer === "string" ? this.safeParse(layer) : layer;
|
||||
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
||||
const record = obj as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
result[key] = record[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private safeParse(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private toLayoutTemplateInfo(t: LayoutTemplate): LayoutTemplateInfo {
|
||||
return {
|
||||
layoutId: t.layoutId,
|
||||
displayName: t.displayName,
|
||||
description: t.description ?? "",
|
||||
availableSlots: this.parseStringArray(t.availableSlots),
|
||||
layoutSchemaJson: JSON.stringify(t.layoutSchema ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSlotConfigs(
|
||||
plugins: PluginPlacement[],
|
||||
layout: LayoutTemplateInfo | null,
|
||||
): SlotConfig[] {
|
||||
const slotNames = layout?.availableSlots ?? [
|
||||
"top",
|
||||
"side",
|
||||
"main",
|
||||
"right",
|
||||
];
|
||||
return slotNames.map((slotName) => ({
|
||||
slotName,
|
||||
navItems: plugins
|
||||
.filter((p) => p.slot === slotName)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((p) => p.pluginId),
|
||||
}));
|
||||
}
|
||||
|
||||
private async appendEvent(
|
||||
aggregateType: string,
|
||||
aggregateId: string,
|
||||
eventType: string,
|
||||
payload: unknown,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.repository.appendOutboxEvent({
|
||||
eventId: randomUUID(),
|
||||
aggregateType,
|
||||
aggregateId,
|
||||
eventType,
|
||||
payload,
|
||||
});
|
||||
} catch (error) {
|
||||
// Outbox 写入失败不阻断主流程,仅记录(v2.1 ADR-032 容错策略)
|
||||
console.error(
|
||||
`[config-service] appendOutboxEvent failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
103
services/config-service/src/graphql/dataloader.service.ts
Normal file
103
services/config-service/src/graphql/dataloader.service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* config-service DataLoader 服务(v2.1 M3 / ADR-035)
|
||||
*
|
||||
* 强制约束:@key Reference Resolver 必须使用 DataLoader 请求合并。
|
||||
*
|
||||
* 每个 GraphQL 请求独立 DataLoader 实例(请求级缓存)。
|
||||
* 通过 REQUEST scope 注入,确保不同请求不共享缓存。
|
||||
*/
|
||||
import { Injectable, Scope } from "@nestjs/common";
|
||||
import DataLoader from "dataloader";
|
||||
import { ConfigRepository } from "../config-config/config.repository.js";
|
||||
|
||||
/**
|
||||
* PluginRegistry Entity(GraphQL 输出类型)
|
||||
* 对应 config_plugin_registry 表
|
||||
*/
|
||||
export interface PluginRegistryEntity {
|
||||
pluginId: string;
|
||||
category: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
isBuiltin: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserLayoutOverride Entity(GraphQL 输出类型)
|
||||
* 对应 config_user_layout_override 表
|
||||
*/
|
||||
export interface UserLayoutOverrideEntity {
|
||||
userId: string;
|
||||
activeLayout: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class DataLoaderService {
|
||||
private pluginLoaderInstance: DataLoader<
|
||||
string,
|
||||
PluginRegistryEntity | null
|
||||
> | null = null;
|
||||
private userLayoutLoaderInstance: DataLoader<
|
||||
string,
|
||||
UserLayoutOverrideEntity | null
|
||||
> | null = null;
|
||||
|
||||
constructor(private readonly repository: ConfigRepository) {}
|
||||
|
||||
/** PluginRegistry @key 解析器 DataLoader */
|
||||
get pluginLoader(): DataLoader<string, PluginRegistryEntity | null> {
|
||||
if (!this.pluginLoaderInstance) {
|
||||
this.pluginLoaderInstance = new DataLoader<
|
||||
string,
|
||||
PluginRegistryEntity | null
|
||||
>(async (pluginIds) => {
|
||||
const plugins = await this.repository.batchFindPlugins([...pluginIds]);
|
||||
const map = new Map(
|
||||
plugins.map((p) => [
|
||||
p.pluginId,
|
||||
{
|
||||
pluginId: p.pluginId,
|
||||
category: p.category,
|
||||
version: p.version ?? "",
|
||||
displayName: p.displayName,
|
||||
description: p.description ?? "",
|
||||
isBuiltin: p.isBuiltin,
|
||||
isActive: p.isActive,
|
||||
} satisfies PluginRegistryEntity,
|
||||
]),
|
||||
);
|
||||
return pluginIds.map((id) => map.get(id) ?? null);
|
||||
});
|
||||
}
|
||||
return this.pluginLoaderInstance;
|
||||
}
|
||||
|
||||
/** UserLayoutOverride @key 解析器 DataLoader */
|
||||
get userLayoutLoader(): DataLoader<string, UserLayoutOverrideEntity | null> {
|
||||
if (!this.userLayoutLoaderInstance) {
|
||||
this.userLayoutLoaderInstance = new DataLoader<
|
||||
string,
|
||||
UserLayoutOverrideEntity | null
|
||||
>(async (userIds) => {
|
||||
const overrides = await this.repository.batchFindUserLayoutOverrides([
|
||||
...userIds,
|
||||
]);
|
||||
const map = new Map(
|
||||
overrides.map((o) => [
|
||||
o.userId,
|
||||
{
|
||||
userId: o.userId,
|
||||
activeLayout: o.activeLayout ?? "",
|
||||
updatedAt: o.updatedAt.toISOString(),
|
||||
} satisfies UserLayoutOverrideEntity,
|
||||
]),
|
||||
);
|
||||
return userIds.map((id) => map.get(id) ?? null);
|
||||
});
|
||||
}
|
||||
return this.userLayoutLoaderInstance;
|
||||
}
|
||||
}
|
||||
57
services/config-service/src/graphql/graphql.module.ts
Normal file
57
services/config-service/src/graphql/graphql.module.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* config-service GraphQL 子图模块(v2.1 M3)
|
||||
*
|
||||
* Apollo Federation 2 子图,提供 4 类 Entity 查询:
|
||||
* - PluginRegistry(@key(fields: "pluginId"))
|
||||
* - RolePluginMapping
|
||||
* - LayoutTemplate
|
||||
* - UserLayoutOverride(@key(fields: "userId"))
|
||||
*
|
||||
* 强制约束:
|
||||
* - @key 解析器必须使用 DataLoader(ADR-035)
|
||||
* - RouterAuthGuard 校验 Router-Authorization Header(ADR-036)
|
||||
*/
|
||||
import { Module } from "@nestjs/common";
|
||||
import { GraphQLModule } from "@nestjs/graphql";
|
||||
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
|
||||
import { join } from "node:path";
|
||||
import { GraphqlContext } from "@edu/shared-ts/federation";
|
||||
import { ConfigModule } from "../config-config/config.module.js";
|
||||
import { PluginResolver } from "./resolvers/plugin.resolver.js";
|
||||
import { LayoutTemplateResolver } from "./resolvers/layout-template.resolver.js";
|
||||
import { UserLayoutResolver } from "./resolvers/user-layout.resolver.js";
|
||||
import { DataLoaderService } from "./dataloader.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
// Federation 2 子图
|
||||
autoSchemaFile: {
|
||||
path: join(process.cwd(), "src/graphql/generated/schema.graphql"),
|
||||
federation: 2,
|
||||
},
|
||||
// /graphql 端点(Apollo Router 访问入口)
|
||||
path: "/graphql",
|
||||
// 禁用 playground(生产环境通过 Router 访问)
|
||||
playground: process.env.NODE_ENV === "development",
|
||||
introspection: process.env.NODE_ENV === "development",
|
||||
// Context 从 HTTP headers 构造
|
||||
context: (ctx: {
|
||||
req: { headers: Record<string, string | undefined> };
|
||||
}) => ({
|
||||
req: ctx.req,
|
||||
graphqlContext: GraphqlContext.fromHeaders(ctx.req.headers),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
PluginResolver,
|
||||
LayoutTemplateResolver,
|
||||
UserLayoutResolver,
|
||||
DataLoaderService,
|
||||
],
|
||||
exports: [DataLoaderService],
|
||||
})
|
||||
export class GraphqlModule {}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* LayoutTemplate Resolver(v2.1 M3)
|
||||
*
|
||||
* Apollo Federation 子图:LayoutTemplate Entity(无 @key,仅 Query)
|
||||
* - layoutTemplates: [LayoutTemplate!]!
|
||||
*/
|
||||
import { Resolver, Query, ObjectType, Field, ID } from "@nestjs/graphql";
|
||||
import { ConfigService } from "../../config-config/config.service.js";
|
||||
|
||||
@ObjectType()
|
||||
export class LayoutTemplateGql {
|
||||
@Field(() => ID)
|
||||
layoutId!: string;
|
||||
|
||||
@Field()
|
||||
displayName!: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
description: string | null = null;
|
||||
|
||||
@Field()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
@Resolver(() => LayoutTemplateGql)
|
||||
export class LayoutTemplateResolver {
|
||||
constructor(private readonly service: ConfigService) {}
|
||||
|
||||
@Query(() => [LayoutTemplateGql])
|
||||
async layoutTemplates(): Promise<LayoutTemplateGql[]> {
|
||||
const templates = await this.service.listLayoutTemplates();
|
||||
return templates.map((t) => ({
|
||||
layoutId: t.layoutId,
|
||||
displayName: t.displayName,
|
||||
description: t.description,
|
||||
isActive: t.isActive,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* PluginRegistry Resolver(v2.1 M3)
|
||||
*
|
||||
* Apollo Federation 子图:PluginRegistry Entity
|
||||
* - @key(fields: "pluginId") 支持跨子图引用
|
||||
* - @ResolveReference 使用 DataLoader 批量加载(ADR-035)
|
||||
* - Query 入口供 Apollo Router 直接查询
|
||||
*/
|
||||
import {
|
||||
Resolver,
|
||||
Query,
|
||||
Args,
|
||||
ID,
|
||||
ResolveReference,
|
||||
ObjectType,
|
||||
Field,
|
||||
Directive,
|
||||
} from "@nestjs/graphql";
|
||||
import {
|
||||
DataLoaderService,
|
||||
type PluginRegistryEntity,
|
||||
} from "../dataloader.service.js";
|
||||
import { ConfigService } from "../../config-config/config.service.js";
|
||||
|
||||
@ObjectType()
|
||||
@Directive(`@key(fields: "pluginId")`)
|
||||
export class PluginRegistry {
|
||||
@Field(() => ID)
|
||||
pluginId!: string;
|
||||
|
||||
@Field()
|
||||
category!: string;
|
||||
|
||||
@Field()
|
||||
version!: string;
|
||||
|
||||
@Field()
|
||||
displayName!: string;
|
||||
|
||||
@Field()
|
||||
description!: string;
|
||||
|
||||
@Field()
|
||||
isBuiltin!: boolean;
|
||||
|
||||
@Field()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
@Resolver(() => PluginRegistry)
|
||||
export class PluginResolver {
|
||||
constructor(
|
||||
private readonly loader: DataLoaderService,
|
||||
private readonly service: ConfigService,
|
||||
) {}
|
||||
|
||||
@ResolveReference()
|
||||
async resolveReference(ref: {
|
||||
pluginId: string;
|
||||
}): Promise<PluginRegistryEntity | null> {
|
||||
return this.loader.pluginLoader.load(ref.pluginId);
|
||||
}
|
||||
|
||||
@Query(() => PluginRegistry, { nullable: true })
|
||||
async plugin(
|
||||
@Args("pluginId", { type: () => ID }) pluginId: string,
|
||||
): Promise<PluginRegistryEntity | null> {
|
||||
return this.loader.pluginLoader.load(pluginId);
|
||||
}
|
||||
|
||||
@Query(() => [PluginRegistry])
|
||||
async plugins(): Promise<PluginRegistryEntity[]> {
|
||||
const list = await this.service.listPlugins({ isActive: true });
|
||||
return list.map((p) => ({
|
||||
pluginId: p.pluginId,
|
||||
category: p.category,
|
||||
version: p.version ?? "",
|
||||
displayName: p.displayName,
|
||||
description: p.description ?? "",
|
||||
isBuiltin: p.isBuiltin,
|
||||
isActive: p.isActive,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* UserLayoutOverride Resolver(v2.1 M3)
|
||||
*
|
||||
* Apollo Federation 子图:UserLayoutOverride Entity
|
||||
* - @key(fields: "userId") 支持跨子图引用
|
||||
* - @ResolveReference 使用 DataLoader 批量加载(ADR-035)
|
||||
* - Query 入口供 Apollo Router 直接查询
|
||||
*/
|
||||
import {
|
||||
Resolver,
|
||||
Query,
|
||||
Args,
|
||||
ID,
|
||||
ResolveReference,
|
||||
ObjectType,
|
||||
Field,
|
||||
Directive,
|
||||
} from "@nestjs/graphql";
|
||||
import {
|
||||
DataLoaderService,
|
||||
type UserLayoutOverrideEntity,
|
||||
} from "../dataloader.service.js";
|
||||
|
||||
@ObjectType()
|
||||
@Directive(`@key(fields: "userId")`)
|
||||
export class UserLayoutOverrideGql {
|
||||
@Field(() => ID)
|
||||
userId!: string;
|
||||
|
||||
@Field()
|
||||
activeLayout!: string;
|
||||
|
||||
@Field()
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
@Resolver(() => UserLayoutOverrideGql)
|
||||
export class UserLayoutResolver {
|
||||
constructor(private readonly loader: DataLoaderService) {}
|
||||
|
||||
@ResolveReference()
|
||||
async resolveReference(ref: {
|
||||
userId: string;
|
||||
}): Promise<UserLayoutOverrideEntity | null> {
|
||||
return this.loader.userLayoutLoader.load(ref.userId);
|
||||
}
|
||||
|
||||
@Query(() => UserLayoutOverrideGql, { nullable: true })
|
||||
async userLayoutOverride(
|
||||
@Args("userId", { type: () => ID }) userId: string,
|
||||
): Promise<UserLayoutOverrideEntity | null> {
|
||||
return this.loader.userLayoutLoader.load(userId);
|
||||
}
|
||||
}
|
||||
34
services/config-service/src/graphql/router-auth.guard.ts
Normal file
34
services/config-service/src/graphql/router-auth.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* config-service RouterAuthGuard 包装(v2.1 ADR-036)
|
||||
*
|
||||
* 仅作用于 /graphql 端点,REST 路由放行(已有 PermissionGuard)。
|
||||
*/
|
||||
import { Injectable, ExecutionContext } from "@nestjs/common";
|
||||
import {
|
||||
RouterAuthGuard as BaseRouterAuthGuard,
|
||||
type RouterAuthConfig,
|
||||
} from "@edu/shared-ts/federation";
|
||||
|
||||
@Injectable()
|
||||
export class RouterAuthGuard extends BaseRouterAuthGuard {
|
||||
constructor() {
|
||||
const config: RouterAuthConfig = {
|
||||
secret: process.env.ROUTER_AUTH_SECRET ?? "",
|
||||
devMode: process.env.DEV_MODE === "true",
|
||||
};
|
||||
super(config);
|
||||
}
|
||||
|
||||
override canActivate(ctx: ExecutionContext): boolean {
|
||||
const req = ctx.switchToHttp().getRequest<{
|
||||
url: string;
|
||||
}>();
|
||||
|
||||
// 仅 GraphQL 端点需要校验,REST 路由放行
|
||||
if (!req.url?.startsWith("/graphql")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(ctx);
|
||||
}
|
||||
}
|
||||
103
services/config-service/src/main.ts
Normal file
103
services/config-service/src/main.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { Transport, MicroserviceOptions } from "@nestjs/microservices";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { AppModule } from "./app.module.js";
|
||||
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||
import { env } from "./config/env.js";
|
||||
import { logger } from "./shared/observability/logger.js";
|
||||
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
/**
|
||||
* 解析 proto 文件路径。
|
||||
* 开发环境:从 monorepo 根目录的 packages/shared-proto/proto/ 加载
|
||||
* - 当 cwd 为 monorepo 根(如 CI)→ packages/shared-proto/proto/config.proto
|
||||
* - 当 cwd 为 services/config-service(pnpm --filter run dev)→ ../../packages/shared-proto/proto/config.proto
|
||||
* 生产环境(Docker):从服务本地的 ./proto/ 加载(Dockerfile COPY)
|
||||
*/
|
||||
function resolveProtoPath(): string {
|
||||
const monorepoRootPath = join(
|
||||
process.cwd(),
|
||||
"packages",
|
||||
"shared-proto",
|
||||
"proto",
|
||||
"config.proto",
|
||||
);
|
||||
const monorepoParentPath = join(
|
||||
process.cwd(),
|
||||
"..",
|
||||
"..",
|
||||
"packages",
|
||||
"shared-proto",
|
||||
"proto",
|
||||
"config.proto",
|
||||
);
|
||||
const localPath = join(process.cwd(), "proto", "config.proto");
|
||||
if (existsSync(monorepoRootPath)) return monorepoRootPath;
|
||||
if (existsSync(monorepoParentPath)) return monorepoParentPath;
|
||||
if (existsSync(localPath)) return localPath;
|
||||
return monorepoParentPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* config-service 启动入口(ADR-026)。
|
||||
*
|
||||
* 双入口:
|
||||
* - HTTP server:env.PORT(3011),供 gateway 透传 + portal 直连
|
||||
* - gRPC server:env.GRPC_PORT(50059),供 BFF 聚合调用
|
||||
*
|
||||
* 启动顺序:
|
||||
* 1. initTracer(OTel SDK)
|
||||
* 2. 创建 NestApplication
|
||||
* 3. 注册 GlobalErrorFilter
|
||||
* 4. 启动 gRPC microservice(hybrid app)
|
||||
* 5. 启动 HTTP server
|
||||
*/
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ["log", "error", "warn"],
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// gRPC microservice(端口 50059)
|
||||
app.connectMicroservice<MicroserviceOptions>({
|
||||
transport: Transport.GRPC,
|
||||
options: {
|
||||
package: "next_edu_cloud.config.v1",
|
||||
protoPath: resolveProtoPath(),
|
||||
url: `0.0.0.0:${env.GRPC_PORT}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Prometheus 指标端点
|
||||
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
|
||||
res.set("Content-Type", metricsRegistry.contentType);
|
||||
res.end(await metricsRegistry.metrics());
|
||||
});
|
||||
|
||||
// 启动 hybrid app(HTTP + gRPC)
|
||||
await app.startAllMicroservices();
|
||||
await app.listen(env.PORT);
|
||||
|
||||
logger.info(
|
||||
{ httpPort: env.PORT, grpcPort: env.GRPC_PORT },
|
||||
"config-service started (HTTP + gRPC dual entry)",
|
||||
);
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await app.close();
|
||||
await shutdownTracer();
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap().catch((err: unknown) => {
|
||||
logger.error({ err }, "Failed to start config-service");
|
||||
process.exit(1);
|
||||
});
|
||||
36
services/config-service/src/middleware/auth.middleware.ts
Normal file
36
services/config-service/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestMiddleware,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
/**
|
||||
* 已认证请求:由 AuthMiddleware 从 Gateway 注入的 x-user-* 头部解析。
|
||||
* 公开端点(health/metrics)不走此中间件。
|
||||
*
|
||||
* config-service 不涉及 DataScope(ADR-026:仅插件配置 + 布局 + 用户偏好),
|
||||
* 故仅解析 userId 和 role 两个字段。
|
||||
*/
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthMiddleware implements NestMiddleware {
|
||||
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
|
||||
const userIdHeader = req.headers["x-user-id"];
|
||||
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
|
||||
const roleHeader = req.headers["x-user-role"];
|
||||
const role = typeof roleHeader === "string" ? roleHeader : undefined;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Missing x-user-id header");
|
||||
}
|
||||
|
||||
req.userId = userId;
|
||||
req.userRole = role ?? "student";
|
||||
next();
|
||||
}
|
||||
}
|
||||
77
services/config-service/src/middleware/permission.guard.ts
Normal file
77
services/config-service/src/middleware/permission.guard.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
SetMetadata,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { PermissionDeniedError } from "../shared/errors/application-error.js";
|
||||
import type { AuthenticatedRequest } from "./auth.middleware.js";
|
||||
|
||||
/**
|
||||
* 权限点常量(config-service 简化版)。
|
||||
*
|
||||
* config-service 不依赖 iam 的 role_permissions 表(ADR-026:避免上帝服务)。
|
||||
* 权限模型简化为基于角色的粗粒度控制:
|
||||
* - admin 端点要求 role=admin
|
||||
* - 用户侧端点仅要求已认证(userId 存在)
|
||||
*/
|
||||
export const Permissions = {
|
||||
CONFIG_ADMIN: "config:admin",
|
||||
CONFIG_USER: "config:user",
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof Permissions)[keyof typeof Permissions];
|
||||
|
||||
export const PERMISSIONS_KEY = "permissions";
|
||||
export const RequirePermission = (...permissions: Permission[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
/**
|
||||
* 权限守卫(config-service 简化版,ADR-026)。
|
||||
*
|
||||
* 校验流程:
|
||||
* 1. DEV_MODE=true 时直接放行
|
||||
* 2. 无 @RequirePermission 装饰器的方法直接放行
|
||||
* 3. CONFIG_ADMIN 要求 req.userRole === "admin"
|
||||
* 4. CONFIG_USER 仅要求 req.userId 存在
|
||||
*
|
||||
* 与 iam 的 DB 驱动权限模型不同,config-service 采用简化角色判断,
|
||||
* 因为配置管理本身是低敏感操作(不涉及 DataScope 与认证)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (process.env.DEV_MODE === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
|
||||
PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const userId = request.userId;
|
||||
const role = request.userRole ?? "student";
|
||||
|
||||
if (!userId) {
|
||||
throw new PermissionDeniedError("missing user identity");
|
||||
}
|
||||
|
||||
if (requiredPermissions.includes(Permissions.CONFIG_ADMIN)) {
|
||||
if (role !== "admin") {
|
||||
throw new PermissionDeniedError("admin role required");
|
||||
}
|
||||
}
|
||||
|
||||
// CONFIG_USER 仅需已认证(userId 存在已在上面校验)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
108
services/config-service/src/shared/cache/config-cache.service.ts
vendored
Normal file
108
services/config-service/src/shared/cache/config-cache.service.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
107
services/config-service/src/shared/errors/application-error.ts
Normal file
107
services/config-service/src/shared/errors/application-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
120
services/config-service/src/shared/health/health.controller.ts
Normal file
120
services/config-service/src/shared/health/health.controller.ts
Normal 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 /healthz:liveness,仅返回进程存活,不检查依赖
|
||||
* - GET /readyz:readiness,检查 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
10
services/config-service/src/shared/health/health.module.ts
Normal file
10
services/config-service/src/shared/health/health.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
|
||||
/**
|
||||
* 健康检查模块。
|
||||
*/
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -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-032:outbox 投递由 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`);
|
||||
}
|
||||
}
|
||||
19
services/config-service/src/shared/observability/logger.ts
Normal file
19
services/config-service/src/shared/observability/logger.ts
Normal 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;
|
||||
79
services/config-service/src/shared/observability/metrics.ts
Normal file
79
services/config-service/src/shared/observability/metrics.ts
Normal 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 };
|
||||
27
services/config-service/src/shared/observability/tracer.ts
Normal file
27
services/config-service/src/shared/observability/tracer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
services/config-service/tsconfig.json
Normal file
16
services/config-service/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"incremental": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user