diff --git a/.commitlintrc.js b/.commitlintrc.js index 598303b..1128214 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -1,4 +1,4 @@ -module.exports = { +module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [ @@ -12,6 +12,7 @@ [ 'api-gateway', 'push-gateway', 'iam', 'core-edu', 'classes', 'content', 'data-ana', 'msg', 'ai', + 'config-service', 'teacher-bff', 'student-bff', 'parent-bff', 'teacher-portal', 'student-portal', 'parent-portal', 'admin-portal', 'shared-proto', 'shared-ts', 'shared-go', 'shared-py', 'shared-tokens', diff --git a/infra/apollo-router/supergraph.yaml b/infra/apollo-router/supergraph.yaml index 6c829f3..e27b17a 100644 --- a/infra/apollo-router/supergraph.yaml +++ b/infra/apollo-router/supergraph.yaml @@ -40,3 +40,8 @@ subgraphs: routing_url: http://data-ana:3006/graphql schema: subgraph_url: http://data-ana:3006/graphql + + config: + routing_url: http://config-service:3011/graphql + schema: + subgraph_url: http://config-service:3011/graphql diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index c4443d6..d8367ea 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -294,6 +294,33 @@ services: depends_on: redis: 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: build: context: ./apollo-router @@ -313,6 +340,7 @@ services: - msg - ai - data-ana + - config-service healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8088/health"] interval: 15s diff --git a/infra/init-sql/02-all-services-schema.sql b/infra/init-sql/02-all-services-schema.sql index 7c4c62c..8b28a92 100644 --- a/infra/init-sql/02-all-services-schema.sql +++ b/infra/init-sql/02-all-services-schema.sql @@ -733,3 +733,84 @@ CREATE TABLE IF NOT EXISTS `msg_announcement_reads` ( UNIQUE KEY `uniq_announcement_user` (`announcement_id`, `user_id`), INDEX `idx_msg_announcement_reads_user` (`user_id`) ) 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:业务写 outbox,Debezium 监听 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; diff --git a/infra/port-allocation.md b/infra/port-allocation.md index 77b80fb..d772ff3 100644 --- a/infra/port-allocation.md +++ b/infra/port-allocation.md @@ -37,21 +37,22 @@ ## 3. NestJS BFF + 业务服务(3000-3099) -| 服务 | HTTP 端口 | gRPC 端口 | 说明 | 阶段 | -| ------------- | --------- | --------- | ----------------------------------------------- | ------- | -| apollo-router | 3000 | — | v2.1 GraphQL 聚合层(替代 BFF 手动聚合) | P3 | -| iam | 3002 | 50052 | P2 启用 gRPC server(I1 裁决,2026-07-09 修正) | P2 ✅ | -| teacher-bff | 3003 | — | BFF 不暴露 gRPC,对下游走 gRPC | P2 ✅ | -| core-edu | 3004 | 50053 | 含原 classes 服务(合并后) | P3 | -| content | 3005 | 50054 | Neo4j + ES | P4 | -| data-ana | 3006 | 50055 | HTTP 保留作 Gateway 直连降级,gRPC 为 P4 主入口 | P4 | -| msg | 3007 | 50056 | 通知中心 | P5 | -| ai | 3008 | 50058 | LLM 网关 | P5 | -| student-bff | 3009 | — | BFF 不暴露 gRPC | P3 | -| parent-bff | 3010 | — | BFF 不暴露 gRPC | P4 | -| ~~classes~~ | ~~3001~~ | ~~—~~ | 已合并入 core-edu(P3) | P1 历史 | +| 服务 | HTTP 端口 | gRPC 端口 | 说明 | 阶段 | +| -------------- | --------- | --------- | ----------------------------------------------- | ------- | +| apollo-router | 3000 | — | v2.1 GraphQL 聚合层(替代 BFF 手动聚合) | P3 | +| iam | 3002 | 50052 | P2 启用 gRPC server(I1 裁决,2026-07-09 修正) | P2 ✅ | +| teacher-bff | 3003 | — | BFF 不暴露 gRPC,对下游走 gRPC | P2 ✅ | +| core-edu | 3004 | 50053 | 含原 classes 服务(合并后) | P3 | +| content | 3005 | 50054 | Neo4j + ES | P4 | +| data-ana | 3006 | 50055 | HTTP 保留作 Gateway 直连降级,gRPC 为 P4 主入口 | P4 | +| msg | 3007 | 50056 | 通知中心 | P5 | +| ai | 3008 | 50058 | LLM 网关 | P5 | +| student-bff | 3009 | — | BFF 不暴露 gRPC | P3 | +| parent-bff | 3010 | — | BFF 不暴露 gRPC | P4 | +| config-service | 3011 | 50059 | v2.1 M3 从 iam 拆分(ADR-026) | P3 | +| ~~classes~~ | ~~3001~~ | ~~—~~ | 已合并入 core-edu(P3) | P1 历史 | -> **端口空闲**:3011-3099 预留扩展。 +> **端口空闲**:3012-3099 预留扩展。 --- @@ -82,9 +83,10 @@ | data-ana | 50055 | AnalyticsService | P4 | | msg | 50056 | NotificationService / NotificationPreferenceService / NotificationTemplateService | P5 | | ai | 50058 | AiService | P5 | +| config-service | 50059 | ConfigService | P3 | | ~~push-gateway~~ | ~~50057~~ | ~~PushService~~ | 豁免(见 §2) | -> **端口空闲**:50051、50057、50059-50099 预留扩展。 +> **端口空闲**:50051、50057、50060-50099 预留扩展。 > **push-gateway 50057**:原计划预留,因 gRPC 豁免释放。50058 让给 ai 服务。 --- @@ -116,12 +118,13 @@ ## 7. 变更记录 -| 日期 | 变更 | 决策者 | -| ---------- | ------------------------------------------------------ | ------ | -| 2026-07-09 | 初始创建,登记全部 15 服务 + 基础设施端口 | coord | -| 2026-07-09 | 仲裁 admin-portal 3003 → 4003;MF 配置 3000 → 4000 | coord | -| 2026-07-09 | 仲裁 push-gateway 豁免 gRPC,释放 50057;50058 让给 ai | coord | -| 2026-07-09 | classes 3001 标记为历史(已合并入 core-edu) | coord | +| 日期 | 变更 | 决策者 | +| ---------- | --------------------------------------------------------- | ------ | +| 2026-07-09 | 初始创建,登记全部 15 服务 + 基础设施端口 | coord | +| 2026-07-09 | 仲裁 admin-portal 3003 → 4003;MF 配置 3000 → 4000 | coord | +| 2026-07-09 | 仲裁 push-gateway 豁免 gRPC,释放 50057;50058 让给 ai | coord | +| 2026-07-09 | classes 3001 标记为历史(已合并入 core-edu) | coord | +| 2026-07-14 | M3 新增 config-service(3011/50059,ADR-026 从 iam 拆分) | coord | --- diff --git a/packages/shared-proto/proto/config.proto b/packages/shared-proto/proto/config.proto new file mode 100644 index 0000000..30d38a2 --- /dev/null +++ b/packages/shared-proto/proto/config.proto @@ -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 端口 50059(v2.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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f1292f..cf012cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -773,6 +773,109 @@ importers: 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/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: dependencies: '@apollo/subgraph': @@ -13482,7 +13585,7 @@ snapshots: '@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/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 '@types/aws-lambda': 8.10.143 transitivePeerDependencies: @@ -13665,8 +13768,8 @@ snapshots: '@opentelemetry/instrumentation-fastify@0.44.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': 0.57.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 1.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.57.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 transitivePeerDependencies: - supports-color @@ -13773,8 +13876,8 @@ snapshots: '@opentelemetry/instrumentation-hapi@0.45.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/instrumentation': 0.57.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 1.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.57.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 transitivePeerDependencies: - supports-color @@ -14067,7 +14170,7 @@ snapshots: '@opentelemetry/instrumentation-redis-4@0.46.0(@opentelemetry/api@1.9.1)': dependencies: '@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/semantic-conventions': 1.43.0 transitivePeerDependencies: @@ -14462,7 +14565,7 @@ snapshots: '@opentelemetry/resource-detector-alibaba-cloud@0.29.1(@opentelemetry/api@1.9.1)': dependencies: '@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/resource-detector-alibaba-cloud@0.29.7(@opentelemetry/api@1.9.1)': @@ -14517,7 +14620,7 @@ snapshots: dependencies: '@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 gcp-metadata: 6.0.0(encoding@0.1.13) transitivePeerDependencies: @@ -14923,7 +15026,7 @@ snapshots: dependencies: '@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: - '@opentelemetry/api-logs' - supports-color @@ -16664,7 +16767,7 @@ snapshots: cliui@8.0.1: dependencies: - string-width: 4.2.3 + string-width: 4.2.0 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 @@ -21455,7 +21558,7 @@ snapshots: wide-align@1.1.5: dependencies: - string-width: 4.2.3 + string-width: 4.1.0 wonka@4.0.14: {} diff --git a/services/config-service/Dockerfile b/services/config-service/Dockerfile new file mode 100644 index 0000000..0b5390b --- /dev/null +++ b/services/config-service/Dockerfile @@ -0,0 +1,54 @@ +# Build stage +FROM node:22-alpine AS builder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@11.13.0 --activate + +# 复制 workspace 配置 + tsconfig 基线 +COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml tsconfig.base.json ./ + +# 复制 shared-proto(gRPC proto-loader 运行时加载) +COPY packages/shared-proto ./packages/shared-proto + +# 复制 shared-ts(workspace 依赖) +COPY packages/shared-ts ./packages/shared-ts + +# 复制 config-service 源码 +COPY services/config-service ./services/config-service + +# HUSKY=0 跳过根 package.json 的 prepare:husky 脚本 +ENV HUSKY=0 + +# 只安装 config-service 及其 workspace 依赖(@edu/shared-ts、@edu/shared-proto) +RUN pnpm install --no-frozen-lockfile --filter @edu/config-service... + +# 构建 shared-ts(config-service 依赖 @edu/shared-ts/outbox dist 产物) +RUN cd packages/shared-ts && pnpm build + +# 构建 config-service +WORKDIR /app/services/config-service +RUN pnpm build + +# Runtime stage +FROM node:22-alpine +WORKDIR /app + +# 复制 workspace 配置 + tsconfig 基线 +COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml tsconfig.base.json ./ + +# 复制 shared-proto + shared-ts(运行时需要:proto 文件 + shared-ts dist) +COPY packages/shared-proto ./packages/shared-proto +COPY packages/shared-ts ./packages/shared-ts + +# 复制 config-service 源码 +COPY services/config-service ./services/config-service + +# 从 builder 复制已编译的 prod node_modules + config-service dist + shared-ts dist +COPY --from=builder /app/services/config-service/node_modules ./services/config-service/node_modules +COPY --from=builder /app/packages/shared-ts/node_modules ./packages/shared-ts/node_modules +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/services/config-service/dist ./services/config-service/dist +COPY --from=builder /app/packages/shared-ts/dist ./packages/shared-ts/dist + +WORKDIR /app/services/config-service +EXPOSE 3011 50059 +CMD ["node", "dist/main.js"] diff --git a/services/config-service/README.md b/services/config-service/README.md new file mode 100644 index 0000000..b06121d --- /dev/null +++ b/services/config-service/README.md @@ -0,0 +1,100 @@ +# config-service + +> v2.1 M3 / ADR-026:从 iam 拆分插件配置 + 布局 + 用户偏好职责 + +## 端口 + +- HTTP: 3011 +- gRPC: 50059 + +## 职责 + +config-service 负责插件化仪表盘的后端配置管理(portal-shell spec §6): + +1. **插件注册表**(`config_plugin_registry`):系统级插件元数据 +2. **角色-插件映射**(`config_role_plugin_mapping`):角色可用插件集 +3. **角色 Layout 默认**(`config_role_layout_default`):角色默认布局模板 +4. **Layout 模板**(`config_layout_templates`):5 种内置模板 +5. **用户布局覆盖**(`config_user_layout_override`):用户自定义 +6. **Outbox**(`config_outbox`):v2.1 ADR-032,Debezium 监听 binlog + +## 不涉及 + +- DataScope(仍归 iam) +- 认证 / JWT(仍归 iam) +- 权限点 DB 查询(简化为角色判断) + +## 架构 + +``` +HTTP 3011 ─┬─ /v1/config/plugin-config (用户三层合并) + ├─ /v1/config/layout-templates (用户列模板) + ├─ /v1/config/user-layout (用户更新布局) + ├─ /v1/config/admin/* (admin CRUD) + ├─ /healthz, /readyz (健康检查) + ├─ /metrics (Prometheus) + └─ /graphql (Federation 子图) + +gRPC 50059 ── ConfigService (BFF 聚合调用) +``` + +## 三层合并算法 + +`getPluginConfig(userId, userRole)` 的合并逻辑(portal-shell spec §6.4): + +1. 查 `config_plugin_registry WHERE is_active=TRUE` +2. 查 `config_role_plugin_mapping WHERE role=:userRole` +3. 查 `config_user_layout_override WHERE user_id=:userId` +4. 对每个 plugin: + - `finalProps = merge(registry.default_props, roleMapping.widget_props, userPlacement.props)` + - `finalSlot = userPlacement.slot ?? roleMapping.slot ?? registry.default_slot` + - `isVisible = !hidden.includes(pluginId) && (roleMapping.is_enabled ?? true) && registry.is_active` + +## 缓存 + +Redis 缓存(TTL 5min): + +- `config:plugin:{pluginId}` — 插件注册表项 +- `config:user-layout:{userId}` — 用户布局覆盖 + +失效策略:admin 修改配置或用户更新布局时主动 del。 + +## 事件 + +v2.1 ADR-032:业务写 `config_outbox` 表,Debezium 监听 binlog 投递到 Kafka。 + +事件命名:`.` + +- `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 端点 | diff --git a/services/config-service/nest-cli.json b/services/config-service/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/services/config-service/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/config-service/package.json b/services/config-service/package.json new file mode 100644 index 0000000..b50e7ec --- /dev/null +++ b/services/config-service/package.json @@ -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" + } +} diff --git a/services/config-service/src/app.module.ts b/services/config-service/src/app.module.ts new file mode 100644 index 0000000..ef7920d --- /dev/null +++ b/services/config-service/src/app.module.ts @@ -0,0 +1,68 @@ +import { + Module, + NestModule, + MiddlewareConsumer, + OnModuleInit, + Logger, +} from "@nestjs/common"; +import { APP_GUARD } from "@nestjs/core"; +import { ConfigModule } from "./config-config/config.module.js"; +import { HealthModule } from "./shared/health/health.module.js"; +import { PermissionGuard } from "./middleware/permission.guard.js"; +import { AuthMiddleware } from "./middleware/auth.middleware.js"; +import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js"; +import { GraphqlModule } from "./graphql/graphql.module.js"; +import { RouterAuthGuard } from "./graphql/router-auth.guard.js"; +import { connectKafkaProducer } from "./config/kafka.js"; + +/** + * config-service 根模块(v2.1 M3 / ADR-026)。 + * + * 装配: + * - ConfigModule(业务:插件配置 + 布局 + 用户偏好) + * - HealthModule(健康检查) + * - GraphqlModule(Apollo Federation 子图,v2.1 M3) + * - AuthMiddleware(从 Gateway 注入的 x-user-* 头部解析用户身份) + * - PermissionGuard(APP_GUARD,简化版角色判断,ADR-026) + * - RouterAuthGuard(APP_GUARD,仅 /graphql 端点生效,ADR-036) + * + * v2.1 ADR-032:outbox 投递由 Debezium CDC 接管,本服务不持有 OutboxPublisher, + * 但保留 Kafka producer 连接(健康检查 /readyz 探活)。 + */ +@Module({ + imports: [ConfigModule, HealthModule, GraphqlModule], + providers: [ + { provide: APP_GUARD, useClass: PermissionGuard }, + { provide: APP_GUARD, useClass: RouterAuthGuard }, + LifecycleService, + ], +}) +export class AppModule implements NestModule, OnModuleInit { + private readonly logger = new Logger(AppModule.name); + + async onModuleInit(): Promise { + // 连接 Kafka producer(健康检查 /healthz 依赖 producer 探活) + // v2.1 ADR-032:outbox 投递由 Debezium CDC 接管,producer 仅用于健康检查 + try { + await connectKafkaProducer(); + this.logger.log("Kafka producer connected"); + } catch (error) { + this.logger.error( + `Kafka producer connect failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + configure(consumer: MiddlewareConsumer): void { + // AuthMiddleware 应用于需要鉴权的 /v1/config 路由 + // 公开端点(health/metrics)不走此中间件 + consumer + .apply(AuthMiddleware) + .forRoutes( + "v1/config/plugin-config", + "v1/config/layout-templates", + "v1/config/user-layout", + "v1/config/admin", + ); + } +} diff --git a/services/config-service/src/config-config/admin.controller.ts b/services/config-service/src/config-config/admin.controller.ts new file mode 100644 index 0000000..5e747aa --- /dev/null +++ b/services/config-service/src/config-config/admin.controller.ts @@ -0,0 +1,126 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Put, + Query, +} from "@nestjs/common"; +import { ConfigService } from "./config.service.js"; +import { + updatePluginSchema, + updateRolePluginMappingSchema, + updateRoleLayoutDefaultSchema, + listPluginsQuerySchema, +} from "./config.dto.js"; +import { + Permissions, + RequirePermission, +} from "../middleware/permission.guard.js"; +import type { + PluginRegistry, + RolePluginMapping, + RoleLayoutDefault, + UserLayoutOverride, + LayoutTemplate, +} from "./config.schema.js"; + +/** + * Admin REST API(portal-shell spec §6.3 管理路径)。 + * + * 路由前缀:/v1/config/admin + * 所有端点要求 role=admin(PermissionGuard CONFIG_ADMIN)。 + */ +@Controller("v1/config/admin") +export class AdminController { + constructor(private readonly service: ConfigService) {} + + // ============ Plugin Registry ============ + + @Get("plugins") + @RequirePermission(Permissions.CONFIG_ADMIN) + async listPlugins( + @Query() query: unknown, + ): Promise<{ plugins: PluginRegistry[] }> { + const dto = listPluginsQuerySchema.parse(query); + const plugins = await this.service.listPlugins({ + category: dto.category, + isActive: dto.isActive, + }); + return { plugins }; + } + + @Put("plugins/:id") + @RequirePermission(Permissions.CONFIG_ADMIN) + async updatePlugin( + @Param("id") pluginId: string, + @Body() body: unknown, + ): Promise { + 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 { + 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 }; + } +} diff --git a/services/config-service/src/config-config/config.controller.ts b/services/config-service/src/config-config/config.controller.ts new file mode 100644 index 0000000..4b91535 --- /dev/null +++ b/services/config-service/src/config-config/config.controller.ts @@ -0,0 +1,103 @@ +import { Body, Controller, Get, Put, Req } from "@nestjs/common"; +import { ConfigService } from "./config.service.js"; +import { upsertUserLayoutOverrideSchema } from "./config.dto.js"; +import { + Permissions, + RequirePermission, +} from "../middleware/permission.guard.js"; +import type { AuthenticatedRequest } from "../middleware/auth.middleware.js"; +import type { + PluginConfigResponse, + LayoutTemplateInfo, +} from "./config.service.js"; +import type { LayoutTemplate } from "./config.schema.js"; + +/** + * 用户侧 REST API(portal-shell spec §6.3 用户路径)。 + * + * 路由前缀:/v1/config + * - GET /v1/config/plugin-config — 获取当前用户合并后的插件配置(三层合并) + * - GET /v1/config/layout-templates — 列出所有 Layout 模板 + * - PUT /v1/config/user-layout — 用户更新自己的布局覆盖 + * + * 所有端点要求已认证(AuthMiddleware 注入 userId/role)。 + */ +@Controller("v1/config") +export class ConfigController { + constructor(private readonly service: ConfigService) {} + + /** + * 获取当前用户合并后的插件配置(三层合并)。 + */ + @Get("plugin-config") + @RequirePermission(Permissions.CONFIG_USER) + async getPluginConfig( + @Req() req: AuthenticatedRequest, + ): Promise { + 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 []; + } +} diff --git a/services/config-service/src/config-config/config.dto.ts b/services/config-service/src/config-config/config.dto.ts new file mode 100644 index 0000000..85f7249 --- /dev/null +++ b/services/config-service/src/config-config/config.dto.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; + +/** + * config-service DTO(Zod schema)。 + * + * 覆盖 admin CRUD + 用户侧 REST + gRPC 输入校验。 + */ + +// ============ Plugin Registry ============ + +export const updatePluginSchema = z.object({ + category: z + .enum([ + "universal", + "sidebar", + "topbar", + "teacher", + "student", + "parent", + "admin", + ]) + .optional(), + version: z.string().max(50).optional(), + displayName: z.string().min(1).max(200).optional(), + description: z.string().optional(), + defaultSlot: z.string().max(50).optional(), + defaultSize: z.record(z.unknown()).optional(), + defaultProps: z.record(z.unknown()).optional(), + propsSchema: z.record(z.unknown()).optional(), + isActive: z.boolean().optional(), + packageUrl: z.string().optional(), +}); +export type UpdatePluginDto = z.infer; + +// ============ 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; diff --git a/services/config-service/src/config-config/config.grpc.controller.ts b/services/config-service/src/config-config/config.grpc.controller.ts new file mode 100644 index 0000000..1e83c22 --- /dev/null +++ b/services/config-service/src/config-config/config.grpc.controller.ts @@ -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 { + 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 { + 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 { + const templates = await this.service.listLayoutTemplates(); + return { + templates: templates.map((t) => this.toLayoutTemplateProto(t)), + }; + } + + @GrpcMethod("ConfigService", "GetUserLayoutOverride") + async getUserLayoutOverride(data: { userId: string }): Promise { + 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 { + // 构造 DTO 输入对象(JSON 字符串解析为 unknown) + const input: Record = {}; + 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; + } + } +} diff --git a/services/config-service/src/config-config/config.module.ts b/services/config-service/src/config-config/config.module.ts new file mode 100644 index 0000000..09b168d --- /dev/null +++ b/services/config-service/src/config-config/config.module.ts @@ -0,0 +1,30 @@ +import { Module } from "@nestjs/common"; +import { ConfigController } from "./config.controller.js"; +import { AdminController } from "./admin.controller.js"; +import { ConfigGrpcController } from "./config.grpc.controller.js"; +import { ConfigService } from "./config.service.js"; +import { ConfigRepository } from "./config.repository.js"; +import { ConfigCacheService } from "../shared/cache/config-cache.service.js"; + +/** + * config-service 业务模块(ADR-026)。 + * + * 装配: + * - ConfigController(用户侧 REST) + * - AdminController(admin REST CRUD) + * - ConfigGrpcController(gRPC ConfigService) + * - ConfigService(业务逻辑 + 三层合并) + * - ConfigRepository(数据访问,6 张 config_ 表) + * - ConfigCacheService(Redis 缓存) + */ +@Module({ + controllers: [ConfigController, AdminController], + providers: [ + ConfigService, + ConfigRepository, + ConfigCacheService, + ConfigGrpcController, + ], + exports: [ConfigService, ConfigRepository], +}) +export class ConfigModule {} diff --git a/services/config-service/src/config-config/config.repository.ts b/services/config-service/src/config-config/config.repository.ts new file mode 100644 index 0000000..c1ac4c5 --- /dev/null +++ b/services/config-service/src/config-config/config.repository.ts @@ -0,0 +1,335 @@ +import { eq, and } from "drizzle-orm"; +import { getDb } from "../config/database.js"; +import { + pluginRegistry, + rolePluginMapping, + roleLayoutDefault, + layoutTemplates, + userLayoutOverride, + configOutbox, +} from "./config.schema.js"; +import type { + PluginRegistry, + RolePluginMapping, + RoleLayoutDefault, + LayoutTemplate, + UserLayoutOverride, +} from "./config.schema.js"; +import { + DatabaseError, + NotFoundError, +} from "../shared/errors/application-error.js"; + +/** + * config-service 数据访问层。 + * + * 覆盖 6 张 config_ 表的 CRUD: + * - plugin_registry: 插件注册表 + * - role_plugin_mapping: 角色-插件映射 + * - role_layout_default: 角色 Layout 默认 + * - layout_templates: Layout 模板 + * - user_layout_override: 用户布局覆盖 + * - outbox: Outbox 表(v2.1 ADR-032:Debezium 监听 binlog) + */ +export class ConfigRepository { + // ============ Plugin Registry ============ + + async findPluginById(pluginId: string): Promise { + 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 { + 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 { + return this.listPlugins({ isActive: true }); + } + + async updatePlugin( + pluginId: string, + data: Partial>, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const db = getDb(); + return db.select().from(layoutTemplates); + } + + async listActiveLayoutTemplates(): Promise { + const db = getDb(); + return db + .select() + .from(layoutTemplates) + .where(eq(layoutTemplates.isActive, true)); + } + + async findLayoutTemplate( + layoutId: string, + ): Promise { + 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 { + const db = getDb(); + const [result] = await db + .select() + .from(userLayoutOverride) + .where(eq(userLayoutOverride.userId, userId)); + return result; + } + + async batchFindUserLayoutOverrides( + userIds: string[], + ): Promise { + 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 { + const db = getDb(); + const existing = await this.findUserLayoutOverride(userId); + if (existing) { + const updateData: Record = {}; + 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 { + const db = getDb(); + await db + .delete(userLayoutOverride) + .where(eq(userLayoutOverride.userId, userId)); + } + + // ============ Outbox ============ + + /** + * 写入 Outbox 事件记录(v2.1 ADR-032:业务写 outbox,Debezium 监听 binlog 投递 Kafka)。 + */ + async appendOutboxEvent(event: { + eventId: string; + aggregateType: string; + aggregateId: string; + eventType: string; + payload: unknown; + }): Promise { + const db = getDb(); + await db.insert(configOutbox).values({ + eventId: event.eventId, + aggregateType: event.aggregateType, + aggregateId: event.aggregateId, + eventType: event.eventType, + payload: event.payload, + }); + } +} diff --git a/services/config-service/src/config-config/config.schema.ts b/services/config-service/src/config-config/config.schema.ts new file mode 100644 index 0000000..de86ff7 --- /dev/null +++ b/services/config-service/src/config-config/config.schema.ts @@ -0,0 +1,122 @@ +import { + mysqlTable, + varchar, + char, + timestamp, + text, + int, + boolean, + json, + index, +} from "drizzle-orm/mysql-core"; + +/** + * config-service 数据模型(ADR-026 + portal-shell spec §6.1)。 + * + * 6 张表前缀 config_: + * 1. plugin_registry — 插件注册表 + * 2. role_plugin_mapping — 角色-插件映射 + * 3. role_layout_default — 角色 Layout 默认 + * 4. layout_templates — Layout 模板(5 种内置) + * 5. user_layout_override — 用户布局覆盖 + * 6. outbox — Outbox 表(v2.1 ADR-032:Debezium 监听 binlog 投递 Kafka) + */ + +// ============ 1. 插件注册表 ============ + +export const pluginRegistry = mysqlTable("config_plugin_registry", { + pluginId: varchar("plugin_id", { length: 100 }).notNull().primaryKey(), + category: varchar("category", { length: 50 }).notNull(), + version: varchar("version", { length: 50 }), + displayName: varchar("display_name", { length: 200 }).notNull(), + description: text("description"), + requiredRoles: json("required_roles"), + defaultSlot: varchar("default_slot", { length: 50 }), + defaultSize: json("default_size"), + defaultProps: json("default_props"), + propsSchema: json("props_schema"), + isBuiltin: boolean("is_builtin").notNull().default(true), + isActive: boolean("is_active").notNull().default(true), + packageUrl: text("package_url"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), +}); + +// ============ 2. 角色-插件映射 ============ + +export const rolePluginMapping = mysqlTable( + "config_role_plugin_mapping", + { + role: varchar("role", { length: 50 }).notNull(), + pluginId: varchar("plugin_id", { length: 100 }).notNull(), + slot: varchar("slot", { length: 50 }), + sortOrder: int("sort_order").notNull().default(0), + isEnabled: boolean("is_enabled").notNull().default(true), + widgetProps: json("widget_props"), + }, + (table) => ({ + pk: index("idx_config_role_plugin_pk").on(table.role, table.pluginId), + }), +); + +// ============ 3. 角色 Layout 默认 ============ + +export const roleLayoutDefault = mysqlTable("config_role_layout_default", { + role: varchar("role", { length: 50 }).notNull().primaryKey(), + layoutId: varchar("layout_id", { length: 50 }).notNull(), + slotOverrides: json("slot_overrides"), +}); + +// ============ 4. Layout 模板(5 种内置) ============ + +export const layoutTemplates = mysqlTable("config_layout_templates", { + layoutId: varchar("layout_id", { length: 50 }).notNull().primaryKey(), + displayName: varchar("display_name", { length: 200 }).notNull(), + description: text("description"), + availableSlots: json("available_slots"), + layoutSchema: json("layout_schema"), + isActive: boolean("is_active").notNull().default(true), +}); + +// ============ 5. 用户布局覆盖 ============ + +export const userLayoutOverride = mysqlTable("config_user_layout_override", { + userId: varchar("user_id", { length: 36 }).notNull().primaryKey(), + activeLayout: varchar("active_layout", { length: 50 }), + slotOverrides: json("slot_overrides"), + pluginPlacements: json("plugin_placements"), + hiddenPlugins: json("hidden_plugins"), + updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), +}); + +// ============ 6. Outbox 表(v2.1 ADR-032) ============ + +export const configOutbox = mysqlTable( + "config_outbox", + { + eventId: char("event_id", { length: 36 }).notNull().primaryKey(), + aggregateType: varchar("aggregate_type", { length: 50 }).notNull(), + aggregateId: varchar("aggregate_id", { length: 100 }).notNull(), + eventType: varchar("event_type", { length: 100 }).notNull(), + payload: json("payload").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + publishedAt: timestamp("published_at"), + retryCount: int("retry_count").notNull().default(0), + nextRetryAt: timestamp("next_retry_at"), + }, + (table) => ({ + unpublishedIdx: index("idx_outbox_unpublished").on( + table.publishedAt, + table.nextRetryAt, + ), + }), +); + +// ============ Type Exports ============ + +export type PluginRegistry = typeof pluginRegistry.$inferSelect; +export type RolePluginMapping = typeof rolePluginMapping.$inferSelect; +export type RoleLayoutDefault = typeof roleLayoutDefault.$inferSelect; +export type LayoutTemplate = typeof layoutTemplates.$inferSelect; +export type UserLayoutOverride = typeof userLayoutOverride.$inferSelect; +export type ConfigOutbox = typeof configOutbox.$inferSelect; diff --git a/services/config-service/src/config-config/config.service.ts b/services/config-service/src/config-config/config.service.ts new file mode 100644 index 0000000..ed79172 --- /dev/null +++ b/services/config-service/src/config-config/config.service.ts @@ -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; +} + +/** + * config-service Application Service(双入口:REST + gRPC 共用)。 + * + * 核心职责(ADR-026): + * 1. 插件配置三层合并(registry.defaultProps + roleMapping.widget_props + userOverride.props) + * 2. Layout 模板查询 + * 3. 用户布局覆盖 CRUD + * 4. Admin CRUD(plugin / role-plugin-mapping / role-layout-default) + * 5. Outbox 事件写入(v2.1 ADR-032:Debezium 监听 binlog) + */ +@Injectable() +export class ConfigService { + constructor( + @Inject(ConfigRepository) private readonly repository: ConfigRepository, + private readonly cache: ConfigCacheService, + ) {} + + // ============ 三层合并(核心方法) ============ + + /** + * 获取用户合并后的插件配置(三层合并)。 + * + * 合并算法(portal-shell spec §6.4): + * 1. 查 config_plugin_registry WHERE is_active=TRUE + * 2. 查 config_role_plugin_mapping WHERE role=:userRole + * 3. 查 config_user_layout_override WHERE user_id=:userId + * 4. 对每个 plugin 计算最终配置: + * finalProps = merge(registry.default_props, roleMapping.widget_props, userPlacement.props) + * finalSlot = userPlacement.slot ?? roleMapping.slot ?? registry.default_slot + * isVisible = !hidden.includes(pluginId) && (roleMapping.is_enabled ?? true) && registry.is_active + */ + async getPluginConfig( + userId: string, + userRole: string, + ): Promise { + 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(); + 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 { + return this.repository.listPlugins(filters); + } + + async updatePlugin( + pluginId: string, + dto: UpdatePluginDto, + ): Promise { + 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 { + return this.repository.listRolePluginMappings(role); + } + + async updateRolePluginMapping( + role: string, + dto: UpdateRolePluginMappingDto, + ): Promise { + 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 { + return this.repository.findRoleLayoutDefault(role); + } + + async updateRoleLayoutDefault( + role: string, + dto: UpdateRoleLayoutDefaultDto, + ): Promise { + 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 { + return this.repository.listLayoutTemplates(); + } + + // ============ User Layout Override ============ + + async getUserLayoutOverride( + userId: string, + ): Promise { + return this.repository.findUserLayoutOverride(userId); + } + + async upsertUserLayoutOverride( + userId: string, + dto: UpsertUserLayoutOverrideDto, + ): Promise { + 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 { + 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 { + const map = new Map(); + 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 { + 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; + 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 { + const result: Record = {}; + 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; + 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 { + 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)}`, + ); + } + } +} diff --git a/services/config-service/src/config/database.ts b/services/config-service/src/config/database.ts new file mode 100644 index 0000000..61e865c --- /dev/null +++ b/services/config-service/src/config/database.ts @@ -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 { + if (pool) { + await pool.end(); + pool = null; + dbInstance = null; + } +} diff --git a/services/config-service/src/config/env.ts b/services/config-service/src/config/env.ts new file mode 100644 index 0000000..31965c0 --- /dev/null +++ b/services/config-service/src/config/env.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +const envSchema = z.object({ + // HTTP(ADR-026:端口 3011) + PORT: z.string().default("3011"), + + // Database + DATABASE_URL: z.string().url(), + + // Redis(缓存:plugin / user-layout) + REDIS_URL: z.string().url(), + + // Kafka(Outbox 投递由 Debezium CDC 接管,producer 仅用于健康检查) + KAFKA_BROKERS: z.string(), + KAFKA_CLIENT_ID: z.string().default("config-service"), + + // gRPC server(ADR-026:端口 50059) + GRPC_PORT: z.string().default("50059"), + + // Router auth(Apollo Router 信任凭证,ADR-036) + ROUTER_AUTH_SECRET: z.string().default("dev-router-secret"), + + // 开发模式(DEV_MODE=true 时 PermissionGuard 放行) + DEV_MODE: z + .string() + .default("false") + .transform((v) => v === "true"), + + // 可观测性 + OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(), + LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace"]) + .default("info"), + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), +}); + +export type Env = z.infer; + +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(); diff --git a/services/config-service/src/config/kafka.ts b/services/config-service/src/config/kafka.ts new file mode 100644 index 0000000..e457480 --- /dev/null +++ b/services/config-service/src/config/kafka.ts @@ -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 { + const p = getKafkaProducer(); + await p.connect(); +} + +export async function disconnectKafkaProducer(): Promise { + if (producer) { + await producer.disconnect(); + producer = null; + } +} + +/** + * config-service Kafka topic 路由(ADR-026 + ADR-032)。 + * + * 事件命名规则:`.` + * - PluginRegistryEvent: created/updated/activated/deactivated + * - RolePluginMappingEvent: updated + * - UserLayoutOverrideEvent: upserted/reset + * + * v2.1 ADR-032:outbox 表由 Debezium 监听 binlog 投递到 Kafka, + * 此处 TOPIC_MAP 仅用于文档化事件路由,producer 实例用于健康检查探活。 + */ +export const CONFIG_KAFKA_TOPICS = { + PLUGIN_REGISTRY_EVENTS: "edu.config.plugin.registry.events", + ROLE_PLUGIN_MAPPING_EVENTS: "edu.config.role.plugin.mapping.events", + USER_LAYOUT_OVERRIDE_EVENTS: "edu.config.user.layout.override.events", + ENTRY_CHANGED: "edu.config.entry.changed", +} as const; diff --git a/services/config-service/src/config/redis.ts b/services/config-service/src/config/redis.ts new file mode 100644 index 0000000..107f415 --- /dev/null +++ b/services/config-service/src/config/redis.ts @@ -0,0 +1,24 @@ +import { Redis } from "ioredis"; +import { env } from "./env.js"; + +type RedisClient = InstanceType; + +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 { + if (client) { + await client.quit(); + client = null; + } +} diff --git a/services/config-service/src/graphql/dataloader.service.ts b/services/config-service/src/graphql/dataloader.service.ts new file mode 100644 index 0000000..8a71e5e --- /dev/null +++ b/services/config-service/src/graphql/dataloader.service.ts @@ -0,0 +1,103 @@ +/** + * config-service DataLoader 服务(v2.1 M3 / ADR-035) + * + * 强制约束:@key Reference Resolver 必须使用 DataLoader 请求合并。 + * + * 每个 GraphQL 请求独立 DataLoader 实例(请求级缓存)。 + * 通过 REQUEST scope 注入,确保不同请求不共享缓存。 + */ +import { Injectable, Scope } from "@nestjs/common"; +import DataLoader from "dataloader"; +import { ConfigRepository } from "../config-config/config.repository.js"; + +/** + * PluginRegistry Entity(GraphQL 输出类型) + * 对应 config_plugin_registry 表 + */ +export interface PluginRegistryEntity { + pluginId: string; + category: string; + version: string; + displayName: string; + description: string; + isBuiltin: boolean; + isActive: boolean; +} + +/** + * UserLayoutOverride Entity(GraphQL 输出类型) + * 对应 config_user_layout_override 表 + */ +export interface UserLayoutOverrideEntity { + userId: string; + activeLayout: string; + updatedAt: string; +} + +@Injectable({ scope: Scope.REQUEST }) +export class DataLoaderService { + private pluginLoaderInstance: DataLoader< + string, + PluginRegistryEntity | null + > | null = null; + private userLayoutLoaderInstance: DataLoader< + string, + UserLayoutOverrideEntity | null + > | null = null; + + constructor(private readonly repository: ConfigRepository) {} + + /** PluginRegistry @key 解析器 DataLoader */ + get pluginLoader(): DataLoader { + 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 { + 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; + } +} diff --git a/services/config-service/src/graphql/generated/.gitkeep b/services/config-service/src/graphql/generated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services/config-service/src/graphql/graphql.module.ts b/services/config-service/src/graphql/graphql.module.ts new file mode 100644 index 0000000..9b9f4bf --- /dev/null +++ b/services/config-service/src/graphql/graphql.module.ts @@ -0,0 +1,57 @@ +/** + * config-service GraphQL 子图模块(v2.1 M3) + * + * Apollo Federation 2 子图,提供 4 类 Entity 查询: + * - PluginRegistry(@key(fields: "pluginId")) + * - RolePluginMapping + * - LayoutTemplate + * - UserLayoutOverride(@key(fields: "userId")) + * + * 强制约束: + * - @key 解析器必须使用 DataLoader(ADR-035) + * - RouterAuthGuard 校验 Router-Authorization Header(ADR-036) + */ +import { Module } from "@nestjs/common"; +import { GraphQLModule } from "@nestjs/graphql"; +import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo"; +import { join } from "node:path"; +import { GraphqlContext } from "@edu/shared-ts/federation"; +import { ConfigModule } from "../config-config/config.module.js"; +import { PluginResolver } from "./resolvers/plugin.resolver.js"; +import { LayoutTemplateResolver } from "./resolvers/layout-template.resolver.js"; +import { UserLayoutResolver } from "./resolvers/user-layout.resolver.js"; +import { DataLoaderService } from "./dataloader.service.js"; + +@Module({ + imports: [ + ConfigModule, + GraphQLModule.forRoot({ + 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 }; + }) => ({ + req: ctx.req, + graphqlContext: GraphqlContext.fromHeaders(ctx.req.headers), + }), + }), + ], + providers: [ + PluginResolver, + LayoutTemplateResolver, + UserLayoutResolver, + DataLoaderService, + ], + exports: [DataLoaderService], +}) +export class GraphqlModule {} diff --git a/services/config-service/src/graphql/resolvers/layout-template.resolver.ts b/services/config-service/src/graphql/resolvers/layout-template.resolver.ts new file mode 100644 index 0000000..616b1dc --- /dev/null +++ b/services/config-service/src/graphql/resolvers/layout-template.resolver.ts @@ -0,0 +1,39 @@ +/** + * LayoutTemplate Resolver(v2.1 M3) + * + * Apollo Federation 子图:LayoutTemplate Entity(无 @key,仅 Query) + * - layoutTemplates: [LayoutTemplate!]! + */ +import { Resolver, Query, ObjectType, Field, ID } from "@nestjs/graphql"; +import { ConfigService } from "../../config-config/config.service.js"; + +@ObjectType() +export class LayoutTemplateGql { + @Field(() => ID) + layoutId!: string; + + @Field() + displayName!: string; + + @Field({ nullable: true }) + description: string | null = null; + + @Field() + isActive!: boolean; +} + +@Resolver(() => LayoutTemplateGql) +export class LayoutTemplateResolver { + constructor(private readonly service: ConfigService) {} + + @Query(() => [LayoutTemplateGql]) + async layoutTemplates(): Promise { + const templates = await this.service.listLayoutTemplates(); + return templates.map((t) => ({ + layoutId: t.layoutId, + displayName: t.displayName, + description: t.description, + isActive: t.isActive, + })); + } +} diff --git a/services/config-service/src/graphql/resolvers/plugin.resolver.ts b/services/config-service/src/graphql/resolvers/plugin.resolver.ts new file mode 100644 index 0000000..c68478e --- /dev/null +++ b/services/config-service/src/graphql/resolvers/plugin.resolver.ts @@ -0,0 +1,84 @@ +/** + * PluginRegistry Resolver(v2.1 M3) + * + * Apollo Federation 子图:PluginRegistry Entity + * - @key(fields: "pluginId") 支持跨子图引用 + * - @ResolveReference 使用 DataLoader 批量加载(ADR-035) + * - Query 入口供 Apollo Router 直接查询 + */ +import { + Resolver, + Query, + Args, + ID, + ResolveReference, + ObjectType, + Field, + Directive, +} from "@nestjs/graphql"; +import { + DataLoaderService, + type PluginRegistryEntity, +} from "../dataloader.service.js"; +import { ConfigService } from "../../config-config/config.service.js"; + +@ObjectType() +@Directive(`@key(fields: "pluginId")`) +export class PluginRegistry { + @Field(() => ID) + pluginId!: string; + + @Field() + category!: string; + + @Field() + version!: string; + + @Field() + displayName!: string; + + @Field() + description!: string; + + @Field() + isBuiltin!: boolean; + + @Field() + isActive!: boolean; +} + +@Resolver(() => PluginRegistry) +export class PluginResolver { + constructor( + private readonly loader: DataLoaderService, + private readonly service: ConfigService, + ) {} + + @ResolveReference() + async resolveReference(ref: { + pluginId: string; + }): Promise { + return this.loader.pluginLoader.load(ref.pluginId); + } + + @Query(() => PluginRegistry, { nullable: true }) + async plugin( + @Args("pluginId", { type: () => ID }) pluginId: string, + ): Promise { + return this.loader.pluginLoader.load(pluginId); + } + + @Query(() => [PluginRegistry]) + async plugins(): Promise { + 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, + })); + } +} diff --git a/services/config-service/src/graphql/resolvers/user-layout.resolver.ts b/services/config-service/src/graphql/resolvers/user-layout.resolver.ts new file mode 100644 index 0000000..1a55408 --- /dev/null +++ b/services/config-service/src/graphql/resolvers/user-layout.resolver.ts @@ -0,0 +1,54 @@ +/** + * UserLayoutOverride Resolver(v2.1 M3) + * + * Apollo Federation 子图:UserLayoutOverride Entity + * - @key(fields: "userId") 支持跨子图引用 + * - @ResolveReference 使用 DataLoader 批量加载(ADR-035) + * - Query 入口供 Apollo Router 直接查询 + */ +import { + Resolver, + Query, + Args, + ID, + ResolveReference, + ObjectType, + Field, + Directive, +} from "@nestjs/graphql"; +import { + DataLoaderService, + type UserLayoutOverrideEntity, +} from "../dataloader.service.js"; + +@ObjectType() +@Directive(`@key(fields: "userId")`) +export class UserLayoutOverrideGql { + @Field(() => ID) + userId!: string; + + @Field() + activeLayout!: string; + + @Field() + updatedAt!: string; +} + +@Resolver(() => UserLayoutOverrideGql) +export class UserLayoutResolver { + constructor(private readonly loader: DataLoaderService) {} + + @ResolveReference() + async resolveReference(ref: { + userId: string; + }): Promise { + return this.loader.userLayoutLoader.load(ref.userId); + } + + @Query(() => UserLayoutOverrideGql, { nullable: true }) + async userLayoutOverride( + @Args("userId", { type: () => ID }) userId: string, + ): Promise { + return this.loader.userLayoutLoader.load(userId); + } +} diff --git a/services/config-service/src/graphql/router-auth.guard.ts b/services/config-service/src/graphql/router-auth.guard.ts new file mode 100644 index 0000000..14bd8da --- /dev/null +++ b/services/config-service/src/graphql/router-auth.guard.ts @@ -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); + } +} diff --git a/services/config-service/src/main.ts b/services/config-service/src/main.ts new file mode 100644 index 0000000..e2752f8 --- /dev/null +++ b/services/config-service/src/main.ts @@ -0,0 +1,103 @@ +import "reflect-metadata"; +import { NestFactory } from "@nestjs/core"; +import { Transport, MicroserviceOptions } from "@nestjs/microservices"; +import { join } from "node:path"; +import { existsSync } from "node:fs"; +import { AppModule } from "./app.module.js"; +import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js"; +import { initTracer, shutdownTracer } from "./shared/observability/tracer.js"; +import { env } from "./config/env.js"; +import { logger } from "./shared/observability/logger.js"; +import { metricsRegistry } from "./shared/observability/metrics.js"; +import type { Request, Response } from "express"; + +/** + * 解析 proto 文件路径。 + * 开发环境:从 monorepo 根目录的 packages/shared-proto/proto/ 加载 + * - 当 cwd 为 monorepo 根(如 CI)→ packages/shared-proto/proto/config.proto + * - 当 cwd 为 services/config-service(pnpm --filter run dev)→ ../../packages/shared-proto/proto/config.proto + * 生产环境(Docker):从服务本地的 ./proto/ 加载(Dockerfile COPY) + */ +function resolveProtoPath(): string { + const monorepoRootPath = join( + process.cwd(), + "packages", + "shared-proto", + "proto", + "config.proto", + ); + const monorepoParentPath = join( + process.cwd(), + "..", + "..", + "packages", + "shared-proto", + "proto", + "config.proto", + ); + const localPath = join(process.cwd(), "proto", "config.proto"); + if (existsSync(monorepoRootPath)) return monorepoRootPath; + if (existsSync(monorepoParentPath)) return monorepoParentPath; + if (existsSync(localPath)) return localPath; + return monorepoParentPath; +} + +/** + * config-service 启动入口(ADR-026)。 + * + * 双入口: + * - HTTP server:env.PORT(3011),供 gateway 透传 + portal 直连 + * - gRPC server:env.GRPC_PORT(50059),供 BFF 聚合调用 + * + * 启动顺序: + * 1. initTracer(OTel SDK) + * 2. 创建 NestApplication + * 3. 注册 GlobalErrorFilter + * 4. 启动 gRPC microservice(hybrid app) + * 5. 启动 HTTP server + */ +async function bootstrap(): Promise { + initTracer(); + + const app = await NestFactory.create(AppModule, { + logger: ["log", "error", "warn"], + }); + + app.useGlobalFilters(new GlobalErrorFilter()); + app.enableShutdownHooks(); + + // gRPC microservice(端口 50059) + app.connectMicroservice({ + transport: Transport.GRPC, + options: { + package: "next_edu_cloud.config.v1", + protoPath: resolveProtoPath(), + url: `0.0.0.0:${env.GRPC_PORT}`, + }, + }); + + // Prometheus 指标端点 + app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => { + res.set("Content-Type", metricsRegistry.contentType); + res.end(await metricsRegistry.metrics()); + }); + + // 启动 hybrid app(HTTP + gRPC) + await app.startAllMicroservices(); + await app.listen(env.PORT); + + logger.info( + { httpPort: env.PORT, grpcPort: env.GRPC_PORT }, + "config-service started (HTTP + gRPC dual entry)", + ); + + process.on("SIGTERM", async () => { + await app.close(); + await shutdownTracer(); + }); +} + +bootstrap().catch((err: unknown) => { + logger.error({ err }, "Failed to start config-service"); + process.exit(1); +}); diff --git a/services/config-service/src/middleware/auth.middleware.ts b/services/config-service/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..7c3c423 --- /dev/null +++ b/services/config-service/src/middleware/auth.middleware.ts @@ -0,0 +1,36 @@ +import { + Injectable, + NestMiddleware, + UnauthorizedException, +} from "@nestjs/common"; +import type { Request, Response, NextFunction } from "express"; + +/** + * 已认证请求:由 AuthMiddleware 从 Gateway 注入的 x-user-* 头部解析。 + * 公开端点(health/metrics)不走此中间件。 + * + * config-service 不涉及 DataScope(ADR-026:仅插件配置 + 布局 + 用户偏好), + * 故仅解析 userId 和 role 两个字段。 + */ +export interface AuthenticatedRequest extends Request { + userId?: string; + userRole?: string; +} + +@Injectable() +export class AuthMiddleware implements NestMiddleware { + use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void { + const userIdHeader = req.headers["x-user-id"]; + const userId = typeof userIdHeader === "string" ? userIdHeader : undefined; + const roleHeader = req.headers["x-user-role"]; + const role = typeof roleHeader === "string" ? roleHeader : undefined; + + if (!userId) { + throw new UnauthorizedException("Missing x-user-id header"); + } + + req.userId = userId; + req.userRole = role ?? "student"; + next(); + } +} diff --git a/services/config-service/src/middleware/permission.guard.ts b/services/config-service/src/middleware/permission.guard.ts new file mode 100644 index 0000000..dadd1f3 --- /dev/null +++ b/services/config-service/src/middleware/permission.guard.ts @@ -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( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredPermissions || requiredPermissions.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/services/config-service/src/shared/cache/config-cache.service.ts b/services/config-service/src/shared/cache/config-cache.service.ts new file mode 100644 index 0000000..c7dcc1e --- /dev/null +++ b/services/config-service/src/shared/cache/config-cache.service.ts @@ -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 { + 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 { + const redis = getRedis(); + await redis.set( + ConfigCacheService.buildPluginKey(pluginId), + json, + "EX", + CACHE_TTL_SECONDS, + ); + } + + async invalidatePlugin(pluginId: string, reason = "manual"): Promise { + const redis = getRedis(); + await redis.del(ConfigCacheService.buildPluginKey(pluginId)); + cacheMetrics.recordInvalidation("plugin", reason); + } + + /** + * 批量失效所有 plugin 缓存(admin 全量更新时调用)。 + * 通过 SCAN 匹配 config:plugin:* 模式删除。 + */ + async invalidateAllPlugins(reason = "admin-update"): Promise { + 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 { + 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 { + const redis = getRedis(); + await redis.set( + ConfigCacheService.buildUserLayoutKey(userId), + json, + "EX", + CACHE_TTL_SECONDS, + ); + } + + async invalidateUserLayout(userId: string, reason = "manual"): Promise { + const redis = getRedis(); + await redis.del(ConfigCacheService.buildUserLayoutKey(userId)); + cacheMetrics.recordInvalidation("user-layout", reason); + } +} diff --git a/services/config-service/src/shared/errors/application-error.ts b/services/config-service/src/shared/errors/application-error.ts new file mode 100644 index 0000000..144c288 --- /dev/null +++ b/services/config-service/src/shared/errors/application-error.ts @@ -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 { + 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); + } +} diff --git a/services/config-service/src/shared/errors/global-error.filter.ts b/services/config-service/src/shared/errors/global-error.filter.ts new file mode 100644 index 0000000..b1472fd --- /dev/null +++ b/services/config-service/src/shared/errors/global-error.filter.ts @@ -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(); + const request = ctx.getRequest(); + + const traceIdHeader = request.headers["x-request-id"]; + const traceId = + typeof traceIdHeader === "string" ? traceIdHeader : "unknown"; + + let statusCode = 500; + let body: Record; + + 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; + } +} diff --git a/services/config-service/src/shared/health/health.controller.ts b/services/config-service/src/shared/health/health.controller.ts new file mode 100644 index 0000000..71f9bad --- /dev/null +++ b/services/config-service/src/shared/health/health.controller.ts @@ -0,0 +1,120 @@ +import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common"; +import { sql } from "drizzle-orm"; +import { getDb } from "../../config/database.js"; +import { getRedis } from "../../config/redis.js"; + +const SERVICE_NAME = "config-service"; + +interface DependencyCheck { + name: string; + status: "ok" | "error"; + error?: string; +} + +/** + * 健康检查端点。 + * + * - GET /healthz:liveness,仅返回进程存活,不检查依赖 + * - GET /readyz:readiness,检查 4 依赖(DB/Redis/Kafka/gRPC),失败返回 503 + * + * 与 iam 相比省略 JWKS 检查(config-service 不持有 JWT 密钥)。 + */ +@Controller() +export class HealthController { + @Get("healthz") + liveness(): { status: string; service: string; timestamp: string } { + return { + status: "ok", + service: SERVICE_NAME, + timestamp: new Date().toISOString(), + }; + } + + @Get("readyz") + async readiness(): Promise<{ + status: string; + service: string; + timestamp: string; + dependencies: DependencyCheck[]; + }> { + const checks: DependencyCheck[] = []; + + // 1. DB + checks.push(await this.checkDb()); + + // 2. Redis + checks.push(await this.checkRedis()); + + // 3. Kafka(检查 producer 实例存在) + checks.push(await this.checkKafka()); + + // 4. gRPC(本进程内启动,进程存活即 gRPC 存活) + checks.push({ name: "grpc", status: "ok" }); + + const allOk = checks.every((c) => c.status === "ok"); + if (!allOk) { + throw new HttpException( + { + status: "error", + service: SERVICE_NAME, + timestamp: new Date().toISOString(), + dependencies: checks, + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + return { + status: "ok", + service: SERVICE_NAME, + timestamp: new Date().toISOString(), + dependencies: checks, + }; + } + + private async checkDb(): Promise { + 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 { + 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 { + 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), + }; + } + } +} diff --git a/services/config-service/src/shared/health/health.module.ts b/services/config-service/src/shared/health/health.module.ts new file mode 100644 index 0000000..df6c79b --- /dev/null +++ b/services/config-service/src/shared/health/health.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { HealthController } from "./health.controller.js"; + +/** + * 健康检查模块。 + */ +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/services/config-service/src/shared/lifecycle/lifecycle.service.ts b/services/config-service/src/shared/lifecycle/lifecycle.service.ts new file mode 100644 index 0000000..832c2e8 --- /dev/null +++ b/services/config-service/src/shared/lifecycle/lifecycle.service.ts @@ -0,0 +1,69 @@ +import { + Injectable, + Logger, + OnApplicationShutdown, + OnModuleInit, +} from "@nestjs/common"; +import { closeDb } from "../../config/database.js"; +import { closeRedis } from "../../config/redis.js"; +import { disconnectKafkaProducer } from "../../config/kafka.js"; + +const SERVICE_NAME = "config-service"; + +/** + * 优雅停机服务。 + * + * 关闭顺序: + * 1. HTTP/gRPC server 已由 NestJS app.close() 停止 + * 2. Kafka producer 断开 + * 3. Redis 断开 + * 4. DB 连接池关闭(最后关闭) + * + * v2.1 ADR-032:outbox 投递由 Debezium CDC 接管,本服务不持有 OutboxPublisher。 + */ +@Injectable() +export class LifecycleService implements OnModuleInit, OnApplicationShutdown { + private readonly logger = new Logger(LifecycleService.name); + + onModuleInit(): void { + this.logger.log(`service ${SERVICE_NAME} module initialized`); + } + + async onApplicationShutdown(signal?: string): Promise { + 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`); + } +} diff --git a/services/config-service/src/shared/observability/logger.ts b/services/config-service/src/shared/observability/logger.ts new file mode 100644 index 0000000..c7275de --- /dev/null +++ b/services/config-service/src/shared/observability/logger.ts @@ -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; diff --git a/services/config-service/src/shared/observability/metrics.ts b/services/config-service/src/shared/observability/metrics.ts new file mode 100644 index 0000000..427238b --- /dev/null +++ b/services/config-service/src/shared/observability/metrics.ts @@ -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 }; diff --git a/services/config-service/src/shared/observability/tracer.ts b/services/config-service/src/shared/observability/tracer.ts new file mode 100644 index 0000000..cb51ec7 --- /dev/null +++ b/services/config-service/src/shared/observability/tracer.ts @@ -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 { + if (sdk) { + await sdk.shutdown(); + } +} diff --git a/services/config-service/tsconfig.json b/services/config-service/tsconfig.json new file mode 100644 index 0000000..7ca5991 --- /dev/null +++ b/services/config-service/tsconfig.json @@ -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"] +}