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

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

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

View File

@@ -1,4 +1,4 @@
module.exports = { module.exports = {
extends: ['@commitlint/config-conventional'], extends: ['@commitlint/config-conventional'],
rules: { rules: {
'type-enum': [ 'type-enum': [
@@ -12,6 +12,7 @@
[ [
'api-gateway', 'push-gateway', 'api-gateway', 'push-gateway',
'iam', 'core-edu', 'classes', 'content', 'data-ana', 'msg', 'ai', 'iam', 'core-edu', 'classes', 'content', 'data-ana', 'msg', 'ai',
'config-service',
'teacher-bff', 'student-bff', 'parent-bff', 'teacher-bff', 'student-bff', 'parent-bff',
'teacher-portal', 'student-portal', 'parent-portal', 'admin-portal', 'teacher-portal', 'student-portal', 'parent-portal', 'admin-portal',
'shared-proto', 'shared-ts', 'shared-go', 'shared-py', 'shared-tokens', 'shared-proto', 'shared-ts', 'shared-go', 'shared-py', 'shared-tokens',

View File

@@ -40,3 +40,8 @@ subgraphs:
routing_url: http://data-ana:3006/graphql routing_url: http://data-ana:3006/graphql
schema: schema:
subgraph_url: http://data-ana:3006/graphql subgraph_url: http://data-ana:3006/graphql
config:
routing_url: http://config-service:3011/graphql
schema:
subgraph_url: http://config-service:3011/graphql

View File

@@ -294,6 +294,33 @@ services:
depends_on: depends_on:
redis: redis:
condition: service_started condition: service_started
config-service:
build:
context: ../services/config-service
container_name: edu-config-service
profiles: ["p3", "p4", "p5", "p6"]
restart: unless-stopped
environment:
DATABASE_URL: mysql://edu:${MYSQL_PASSWORD:-changeme}@edu-mysql:3306/next_edu_cloud
REDIS_URL: redis://edu-redis:6379
KAFKA_BROKERS: kafka:29092
ROUTER_AUTH_SECRET: ${ROUTER_AUTH_SECRET:-dev-router-secret}
DEV_MODE: ${DEV_MODE:-false}
PORT: "3011"
GRPC_PORT: "50059"
ports:
- "3011:3011"
- "50059:50059"
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3011/healthz"]
interval: 15s
timeout: 5s
retries: 5
apollo-router: apollo-router:
build: build:
context: ./apollo-router context: ./apollo-router
@@ -313,6 +340,7 @@ services:
- msg - msg
- ai - ai
- data-ana - data-ana
- config-service
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8088/health"] test: ["CMD", "curl", "-f", "http://localhost:8088/health"]
interval: 15s interval: 15s

View File

@@ -733,3 +733,84 @@ CREATE TABLE IF NOT EXISTS `msg_announcement_reads` (
UNIQUE KEY `uniq_announcement_user` (`announcement_id`, `user_id`), UNIQUE KEY `uniq_announcement_user` (`announcement_id`, `user_id`),
INDEX `idx_msg_announcement_reads_user` (`user_id`) INDEX `idx_msg_announcement_reads_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 6. Config 服务services/config-service/src/config-config/config.schema.ts
-- v2.1 M3 / ADR-026从 iam 拆分出插件配置 + 布局 + 用户偏好职责
-- ============================================================
-- 6.1 插件注册表系统级admin 维护;内置插件由构建脚本同步)
CREATE TABLE IF NOT EXISTS `config_plugin_registry` (
`plugin_id` VARCHAR(100) NOT NULL,
`category` VARCHAR(50) NOT NULL,
`version` VARCHAR(50) NULL,
`display_name` VARCHAR(200) NOT NULL,
`description` TEXT NULL,
`required_roles` JSON NULL,
`default_slot` VARCHAR(50) NULL,
`default_size` JSON NULL,
`default_props` JSON NULL,
`props_schema` JSON NULL,
`is_builtin` BOOLEAN NOT NULL DEFAULT TRUE,
`is_active` BOOLEAN NOT NULL DEFAULT TRUE,
`package_url` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`plugin_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6.2 角色-插件映射admin 配置角色可用插件集 + 角色 Layout 默认)
CREATE TABLE IF NOT EXISTS `config_role_plugin_mapping` (
`role` VARCHAR(50) NOT NULL,
`plugin_id` VARCHAR(100) NOT NULL,
`slot` VARCHAR(50) NULL,
`sort_order` INT NOT NULL DEFAULT 0,
`is_enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`widget_props` JSON NULL,
INDEX `idx_config_role_plugin_pk` (`role`, `plugin_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6.3 角色 Layout 默认admin 配置角色默认模板)
CREATE TABLE IF NOT EXISTS `config_role_layout_default` (
`role` VARCHAR(50) NOT NULL,
`layout_id` VARCHAR(50) NOT NULL,
`slot_overrides` JSON NULL,
PRIMARY KEY (`role`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6.4 Layout 模板(系统预置 5 种不可改classic/focus/split/triple/canvas
CREATE TABLE IF NOT EXISTS `config_layout_templates` (
`layout_id` VARCHAR(50) NOT NULL,
`display_name` VARCHAR(200) NOT NULL,
`description` TEXT NULL,
`available_slots` JSON NULL,
`layout_schema` JSON NULL,
`is_active` BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (`layout_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6.5 用户布局覆盖(用户自定义)
CREATE TABLE IF NOT EXISTS `config_user_layout_override` (
`user_id` VARCHAR(36) NOT NULL,
`active_layout` VARCHAR(50) NULL,
`slot_overrides` JSON NULL,
`plugin_placements` JSON NULL,
`hidden_plugins` JSON NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6.6 Config Outbox 表v2.1 ADR-032业务写 outboxDebezium 监听 binlog 投递 Kafka
CREATE TABLE IF NOT EXISTS `config_outbox` (
`event_id` CHAR(36) NOT NULL,
`aggregate_type` VARCHAR(50) NOT NULL,
`aggregate_id` VARCHAR(100) NOT NULL,
`event_type` VARCHAR(100) NOT NULL,
`payload` JSON NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`published_at` TIMESTAMP NULL,
`retry_count` INT NOT NULL DEFAULT 0,
`next_retry_at` TIMESTAMP NULL,
PRIMARY KEY (`event_id`),
INDEX `idx_outbox_unpublished` (`published_at`, `next_retry_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -37,21 +37,22 @@
## 3. NestJS BFF + 业务服务3000-3099 ## 3. NestJS BFF + 业务服务3000-3099
| 服务 | HTTP 端口 | gRPC 端口 | 说明 | 阶段 | | 服务 | HTTP 端口 | gRPC 端口 | 说明 | 阶段 |
| ------------- | --------- | --------- | ----------------------------------------------- | ------- | | -------------- | --------- | --------- | ----------------------------------------------- | ------- |
| apollo-router | 3000 | — | v2.1 GraphQL 聚合层(替代 BFF 手动聚合) | P3 | | apollo-router | 3000 | — | v2.1 GraphQL 聚合层(替代 BFF 手动聚合) | P3 |
| iam | 3002 | 50052 | P2 启用 gRPC serverI1 裁决2026-07-09 修正) | P2 ✅ | | iam | 3002 | 50052 | P2 启用 gRPC serverI1 裁决2026-07-09 修正) | P2 ✅ |
| teacher-bff | 3003 | — | BFF 不暴露 gRPC对下游走 gRPC | P2 ✅ | | teacher-bff | 3003 | — | BFF 不暴露 gRPC对下游走 gRPC | P2 ✅ |
| core-edu | 3004 | 50053 | 含原 classes 服务(合并后) | P3 | | core-edu | 3004 | 50053 | 含原 classes 服务(合并后) | P3 |
| content | 3005 | 50054 | Neo4j + ES | P4 | | content | 3005 | 50054 | Neo4j + ES | P4 |
| data-ana | 3006 | 50055 | HTTP 保留作 Gateway 直连降级gRPC 为 P4 主入口 | P4 | | data-ana | 3006 | 50055 | HTTP 保留作 Gateway 直连降级gRPC 为 P4 主入口 | P4 |
| msg | 3007 | 50056 | 通知中心 | P5 | | msg | 3007 | 50056 | 通知中心 | P5 |
| ai | 3008 | 50058 | LLM 网关 | P5 | | ai | 3008 | 50058 | LLM 网关 | P5 |
| student-bff | 3009 | — | BFF 不暴露 gRPC | P3 | | student-bff | 3009 | — | BFF 不暴露 gRPC | P3 |
| parent-bff | 3010 | — | BFF 不暴露 gRPC | P4 | | parent-bff | 3010 | — | BFF 不暴露 gRPC | P4 |
| ~~classes~~ | ~~3001~~ | ~~—~~ | 已合并入 core-eduP3 | P1 历史 | | config-service | 3011 | 50059 | v2.1 M3 从 iam 拆分ADR-026 | P3 |
| ~~classes~~ | ~~3001~~ | ~~—~~ | 已合并入 core-eduP3 | P1 历史 |
> **端口空闲**3011-3099 预留扩展。 > **端口空闲**3012-3099 预留扩展。
--- ---
@@ -82,9 +83,10 @@
| data-ana | 50055 | AnalyticsService | P4 | | data-ana | 50055 | AnalyticsService | P4 |
| msg | 50056 | NotificationService / NotificationPreferenceService / NotificationTemplateService | P5 | | msg | 50056 | NotificationService / NotificationPreferenceService / NotificationTemplateService | P5 |
| ai | 50058 | AiService | P5 | | ai | 50058 | AiService | P5 |
| config-service | 50059 | ConfigService | P3 |
| ~~push-gateway~~ | ~~50057~~ | ~~PushService~~ | 豁免(见 §2 | | ~~push-gateway~~ | ~~50057~~ | ~~PushService~~ | 豁免(见 §2 |
> **端口空闲**50051、50057、50059-50099 预留扩展。 > **端口空闲**50051、50057、50060-50099 预留扩展。
> **push-gateway 50057**:原计划预留,因 gRPC 豁免释放。50058 让给 ai 服务。 > **push-gateway 50057**:原计划预留,因 gRPC 豁免释放。50058 让给 ai 服务。
--- ---
@@ -116,12 +118,13 @@
## 7. 变更记录 ## 7. 变更记录
| 日期 | 变更 | 决策者 | | 日期 | 变更 | 决策者 |
| ---------- | ------------------------------------------------------ | ------ | | ---------- | --------------------------------------------------------- | ------ |
| 2026-07-09 | 初始创建,登记全部 15 服务 + 基础设施端口 | coord | | 2026-07-09 | 初始创建,登记全部 15 服务 + 基础设施端口 | coord |
| 2026-07-09 | 仲裁 admin-portal 3003 → 4003MF 配置 3000 → 4000 | coord | | 2026-07-09 | 仲裁 admin-portal 3003 → 4003MF 配置 3000 → 4000 | coord |
| 2026-07-09 | 仲裁 push-gateway 豁免 gRPC释放 5005750058 让给 ai | coord | | 2026-07-09 | 仲裁 push-gateway 豁免 gRPC释放 5005750058 让给 ai | coord |
| 2026-07-09 | classes 3001 标记为历史(已合并入 core-edu | coord | | 2026-07-09 | classes 3001 标记为历史(已合并入 core-edu | coord |
| 2026-07-14 | M3 新增 config-service3011/50059ADR-026 从 iam 拆分) | coord |
--- ---

View File

@@ -0,0 +1,119 @@
syntax = "proto3";
package next_edu_cloud.config.v1;
import "google/protobuf/empty.proto";
// ConfigService 定义插件配置 + 布局 + 用户偏好契约ADR-026
// 从 iam 服务拆分而来,避免 iam 成为"上帝服务"。
//
// 双入口策略president §2.16
// - REST 供 gateway 透传 + portal 直连
// - gRPC 供 BFF / portal-shell 聚合调用
//
// gRPC 端口 50059v2.1 M3
service ConfigService {
// 获取用户合并后的插件配置(三层合并)
rpc GetPluginConfig(GetPluginConfigRequest) returns (PluginConfigResponse);
// 列出插件注册表
rpc ListPlugins(ListPluginsRequest) returns (ListPluginsResponse);
// 获取所有 Layout 模板5 种内置)
rpc GetLayoutTemplates(google.protobuf.Empty) returns (LayoutTemplatesResponse);
// 获取用户布局覆盖
rpc GetUserLayoutOverride(GetUserLayoutOverrideRequest) returns (UserLayoutOverride);
// 更新或创建用户布局覆盖
rpc UpsertUserLayoutOverride(UpsertUserLayoutOverrideRequest) returns (UserLayoutOverride);
// 重置用户布局覆盖(恢复角色默认)
rpc ResetUserLayoutOverride(ResetUserLayoutOverrideRequest) returns (ResetResponse);
}
// ========== 请求 / 响应 ==========
message GetPluginConfigRequest {
string user_id = 1;
string user_role = 2;
}
message ListPluginsRequest {
string category = 1;
bool is_active = 2;
}
message ListPluginsResponse {
repeated PluginRegistryItem plugins = 1;
}
message GetUserLayoutOverrideRequest {
string user_id = 1;
}
message UpsertUserLayoutOverrideRequest {
string user_id = 1;
string active_layout = 2;
string slot_overrides_json = 3;
string plugin_placements_json = 4;
string hidden_plugins_json = 5;
}
message ResetUserLayoutOverrideRequest {
string user_id = 1;
}
message ResetResponse {
bool success = 1;
}
message LayoutTemplatesResponse {
repeated LayoutTemplate templates = 1;
}
// ========== 业务实体 ==========
message PluginConfigResponse {
LayoutTemplate active_layout = 1;
repeated SlotConfig slots = 2;
repeated PluginPlacement plugins = 3;
repeated PluginRegistryItem registry = 4;
}
message LayoutTemplate {
string layout_id = 1;
string display_name = 2;
string description = 3;
repeated string available_slots = 4;
string layout_schema_json = 5;
}
message SlotConfig {
string slot_name = 1;
repeated string nav_items = 2;
}
message PluginPlacement {
string plugin_id = 1;
string slot = 2;
int32 sort_order = 3;
string size_json = 4;
string props_json = 5;
bool is_visible = 6;
}
message PluginRegistryItem {
string plugin_id = 1;
string category = 2;
string version = 3;
string display_name = 4;
string description = 5;
repeated string required_roles = 6;
bool is_builtin = 7;
bool is_active = 8;
}
message UserLayoutOverride {
string user_id = 1;
string active_layout = 2;
string slot_overrides_json = 3;
string plugin_placements_json = 4;
repeated string hidden_plugins = 5;
string updated_at = 6;
}

125
pnpm-lock.yaml generated
View File

@@ -773,6 +773,109 @@ importers:
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.0(@types/node@22.20.1)(jsdom@25.0.0)(msw@2.15.0(@types/node@22.20.1)(typescript@5.6.2))(terser@5.49.0) version: 2.1.0(@types/node@22.20.1)(jsdom@25.0.0)(msw@2.15.0(@types/node@22.20.1)(typescript@5.6.2))(terser@5.49.0)
services/config-service:
dependencies:
'@apollo/subgraph':
specifier: ^2.2.3
version: 2.14.2(graphql@16.14.2)
'@edu/shared-ts':
specifier: workspace:*
version: link:../../packages/shared-ts
'@grpc/grpc-js':
specifier: ^1.12.0
version: 1.14.4
'@grpc/proto-loader':
specifier: ^0.7.13
version: 0.7.13
'@nestjs/apollo':
specifier: ^12.2.0
version: 12.2.0(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.14.2))(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.0(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(graphql@16.14.2)(reflect-metadata@0.2.2))(graphql@16.14.2)
'@nestjs/common':
specifier: ^10.4.0
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/graphql':
specifier: ^12.2.0
version: 12.2.0(@apollo/subgraph@2.14.2(graphql@16.14.2))(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(graphql@16.14.2)(reflect-metadata@0.2.2)
'@nestjs/microservices':
specifier: ^10.4.0
version: 10.4.22(@grpc/grpc-js@1.14.4)(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(ioredis@5.11.1)(kafkajs@2.2.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
'@opentelemetry/api':
specifier: ^1.9.0
version: 1.9.1
'@opentelemetry/auto-instrumentations-node':
specifier: ^0.50.0
version: 0.50.0(@opentelemetry/api@1.9.1)(encoding@0.1.13)
'@opentelemetry/exporter-trace-otlp-http':
specifier: ^0.53.0
version: 0.53.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-node':
specifier: ^0.53.0
version: 0.53.0(@opentelemetry/api@1.9.1)
dataloader:
specifier: ^2.2.2
version: 2.2.3
drizzle-orm:
specifier: ^0.31.0
version: 0.31.0(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@types/react@18.3.31)(better-sqlite3@11.3.0)(mysql2@3.22.6(@types/node@22.20.1))(react@18.3.0)
graphql:
specifier: ^16.9.0
version: 16.14.2
ioredis:
specifier: ^5.4.0
version: 5.11.1
kafkajs:
specifier: ^2.2.4
version: 2.2.4
mysql2:
specifier: ^3.11.0
version: 3.22.6(@types/node@22.20.1)
pino:
specifier: ^9.4.0
version: 9.4.0
prom-client:
specifier: ^15.1.0
version: 15.1.3
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
rxjs:
specifier: ^7.8.0
version: 7.8.2
uuid:
specifier: ^10.0.0
version: 10.0.0
zod:
specifier: ^3.23.0
version: 3.23.0
devDependencies:
'@nestjs/cli':
specifier: ^10.4.0
version: 10.4.0
'@types/express':
specifier: ^4.17.0
version: 4.17.25
'@types/node':
specifier: ^22.0.0
version: 22.20.1
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
eslint:
specifier: ^9.10.0
version: 9.39.5(jiti@2.7.0)
typescript:
specifier: ^5.6.0
version: 5.6.2
vitest:
specifier: ^2.1.0
version: 2.1.0(@types/node@22.20.1)(jsdom@25.0.0)(msw@2.15.0(@types/node@22.20.1)(typescript@5.6.2))(terser@5.49.0)
services/content: services/content:
dependencies: dependencies:
'@apollo/subgraph': '@apollo/subgraph':
@@ -13482,7 +13585,7 @@ snapshots:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/instrumentation': 0.53.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation': 0.53.0(@opentelemetry/api@1.9.1)
'@opentelemetry/propagator-aws-xray': 1.3.1(@opentelemetry/api@1.9.1) '@opentelemetry/propagator-aws-xray': 1.3.1(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.24.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 1.9.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/semantic-conventions': 1.43.0
'@types/aws-lambda': 8.10.143 '@types/aws-lambda': 8.10.143
transitivePeerDependencies: transitivePeerDependencies:
@@ -13665,8 +13768,8 @@ snapshots:
'@opentelemetry/instrumentation-fastify@0.44.1(@opentelemetry/api@1.9.1)': '@opentelemetry/instrumentation-fastify@0.44.1(@opentelemetry/api@1.9.1)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) '@opentelemetry/core': 1.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.57.1(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation': 0.57.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/semantic-conventions': 1.43.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -13773,8 +13876,8 @@ snapshots:
'@opentelemetry/instrumentation-hapi@0.45.1(@opentelemetry/api@1.9.1)': '@opentelemetry/instrumentation-hapi@0.45.1(@opentelemetry/api@1.9.1)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) '@opentelemetry/core': 1.8.0(@opentelemetry/api@1.9.1)
'@opentelemetry/instrumentation': 0.57.1(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation': 0.57.0(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/semantic-conventions': 1.43.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -14067,7 +14170,7 @@ snapshots:
'@opentelemetry/instrumentation-redis-4@0.46.0(@opentelemetry/api@1.9.1)': '@opentelemetry/instrumentation-redis-4@0.46.0(@opentelemetry/api@1.9.1)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/instrumentation': 0.57.1(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation': 0.57.0(@opentelemetry/api@1.9.1)
'@opentelemetry/redis-common': 0.36.2 '@opentelemetry/redis-common': 0.36.2
'@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/semantic-conventions': 1.43.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -14462,7 +14565,7 @@ snapshots:
'@opentelemetry/resource-detector-alibaba-cloud@0.29.1(@opentelemetry/api@1.9.1)': '@opentelemetry/resource-detector-alibaba-cloud@0.29.1(@opentelemetry/api@1.9.1)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/resources': 1.24.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 1.9.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/semantic-conventions': 1.43.0
'@opentelemetry/resource-detector-alibaba-cloud@0.29.7(@opentelemetry/api@1.9.1)': '@opentelemetry/resource-detector-alibaba-cloud@0.29.7(@opentelemetry/api@1.9.1)':
@@ -14517,7 +14620,7 @@ snapshots:
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.0.0(@opentelemetry/api@1.9.1) '@opentelemetry/core': 1.0.0(@opentelemetry/api@1.9.1)
'@opentelemetry/resources': 1.24.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 1.9.1(@opentelemetry/api@1.9.1)
'@opentelemetry/semantic-conventions': 1.43.0 '@opentelemetry/semantic-conventions': 1.43.0
gcp-metadata: 6.0.0(encoding@0.1.13) gcp-metadata: 6.0.0(encoding@0.1.13)
transitivePeerDependencies: transitivePeerDependencies:
@@ -14923,7 +15026,7 @@ snapshots:
dependencies: dependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/instrumentation': 0.49.0(@opentelemetry/api-logs@0.46.0)(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation': 0.49.0(@opentelemetry/api-logs@0.46.0)(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 1.25.0(@opentelemetry/api@1.9.1)
transitivePeerDependencies: transitivePeerDependencies:
- '@opentelemetry/api-logs' - '@opentelemetry/api-logs'
- supports-color - supports-color
@@ -16664,7 +16767,7 @@ snapshots:
cliui@8.0.1: cliui@8.0.1:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.0
strip-ansi: 6.0.1 strip-ansi: 6.0.1
wrap-ansi: 7.0.0 wrap-ansi: 7.0.0
@@ -21455,7 +21558,7 @@ snapshots:
wide-align@1.1.5: wide-align@1.1.5:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.1.0
wonka@4.0.14: {} wonka@4.0.14: {}

View 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-protogRPC proto-loader 运行时加载)
COPY packages/shared-proto ./packages/shared-proto
# 复制 shared-tsworkspace 依赖)
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-tsconfig-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"]

View 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-032Debezium 监听 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 端点 |

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View 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"
}
}

View 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健康检查
* - GraphqlModuleApollo Federation 子图v2.1 M3
* - AuthMiddleware从 Gateway 注入的 x-user-* 头部解析用户身份)
* - PermissionGuardAPP_GUARD简化版角色判断ADR-026
* - RouterAuthGuardAPP_GUARD仅 /graphql 端点生效ADR-036
*
* v2.1 ADR-032outbox 投递由 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-032outbox 投递由 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",
);
}
}

View 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 APIportal-shell spec §6.3 管理路径)。
*
* 路由前缀:/v1/config/admin
* 所有端点要求 role=adminPermissionGuard 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 };
}
}

View 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 APIportal-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 [];
}
}

View File

@@ -0,0 +1,101 @@
import { z } from "zod";
/**
* config-service DTOZod 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>;

View File

@@ -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;
}
}
}

View 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
* - AdminControlleradmin REST CRUD
* - ConfigGrpcControllergRPC ConfigService
* - ConfigService业务逻辑 + 三层合并)
* - ConfigRepository数据访问6 张 config_ 表)
* - ConfigCacheServiceRedis 缓存)
*/
@Module({
controllers: [ConfigController, AdminController],
providers: [
ConfigService,
ConfigRepository,
ConfigCacheService,
ConfigGrpcController,
],
exports: [ConfigService, ConfigRepository],
})
export class ConfigModule {}

View 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-032Debezium 监听 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业务写 outboxDebezium 监听 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,
});
}
}

View 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-032Debezium 监听 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;

View 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 CRUDplugin / role-plugin-mapping / role-layout-default
* 5. Outbox 事件写入v2.1 ADR-032Debezium 监听 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)}`,
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View 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 EntityGraphQL 输出类型)
* 对应 config_plugin_registry 表
*/
export interface PluginRegistryEntity {
pluginId: string;
category: string;
version: string;
displayName: string;
description: string;
isBuiltin: boolean;
isActive: boolean;
}
/**
* UserLayoutOverride EntityGraphQL 输出类型)
* 对应 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;
}
}

View 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 解析器必须使用 DataLoaderADR-035
* - RouterAuthGuard 校验 Router-Authorization HeaderADR-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 {}

View File

@@ -0,0 +1,39 @@
/**
* LayoutTemplate Resolverv2.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,
}));
}
}

View File

@@ -0,0 +1,84 @@
/**
* PluginRegistry Resolverv2.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,
}));
}
}

View File

@@ -0,0 +1,54 @@
/**
* UserLayoutOverride Resolverv2.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);
}
}

View 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);
}
}

View 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-servicepnpm --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 serverenv.PORT3011供 gateway 透传 + portal 直连
* - gRPC serverenv.GRPC_PORT50059供 BFF 聚合调用
*
* 启动顺序:
* 1. initTracerOTel SDK
* 2. 创建 NestApplication
* 3. 注册 GlobalErrorFilter
* 4. 启动 gRPC microservicehybrid 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 appHTTP + 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);
});

View 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 不涉及 DataScopeADR-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();
}
}

View 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;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
import {
Catch,
ExceptionFilter,
ArgumentsHost,
HttpException,
Logger,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { ZodError } from "zod";
import { ApplicationError } from "./application-error.js";
@Catch()
export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const traceIdHeader = request.headers["x-request-id"];
const traceId =
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
let statusCode = 500;
let body: Record<string, unknown>;
if (exception instanceof ApplicationError) {
exception.traceId = traceId;
statusCode = exception.statusCode;
body = exception.toJSON();
} else if (exception instanceof ZodError) {
statusCode = 400;
body = {
success: false,
error: {
code: "CONFIG_VALIDATION_ERROR",
message: "Validation failed",
details: exception.flatten(),
traceId,
},
};
} else if (exception instanceof HttpException) {
statusCode = exception.getStatus();
const res = exception.getResponse();
const message = this.extractHttpMessage(res, exception);
body = {
success: false,
error: {
code: "HTTP_ERROR",
message,
traceId,
},
};
} else {
this.logger.error(
`Unhandled exception: ${exception}`,
exception instanceof Error ? exception.stack : undefined,
);
body = {
success: false,
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId,
},
};
}
response.status(statusCode).json(body);
}
private extractHttpMessage(
res: string | object,
exception: HttpException,
): string {
if (typeof res === "string") {
return res;
}
if (res && typeof res === "object" && "message" in res) {
const msg = (res as { message: unknown }).message;
return typeof msg === "string" ? msg : exception.message;
}
return exception.message;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"]
}