Compare commits

...

7 Commits

Author SHA1 Message Date
movingsam
154484d2dc refactor(gateway): adapt to Platform 1.0.12 entity changes
All checks were successful
Build and Push Docker / build (push) Successful in 6m44s
- Remove IInstanceStore DI registration (replaced by IClusterStore)
- Remove GwTenant and GwServiceInstance from ConsoleDbContext config
- Update GatewayService to use Match.Path instead of PathPattern
- Cast RouteStatus enum to int for Status field
- Add 04-SUMMARY.md documentation

BREAKING CHANGE: Gateway entity API changes in Platform 1.0.12
2026-03-04 13:00:11 +08:00
movingsam
da1048c3ae docs(phase-4): add plan for Platform 1.0.12 entity adaptation 2026-03-04 10:24:48 +08:00
movingsam
e05e10df95 chore: 更新 Fengling.Platform.Infrastructure 到 1.0.12 2026-03-04 10:19:07 +08:00
movingsam
b787fcc415 docs: update roadmap with phase 3 plan 2026-03-03 09:55:54 +08:00
movingsam
161cd5e5c2 docs(03): add phase 3 plan 2026-03-03 09:55:31 +08:00
movingsam
916aaabf89 docs: update to use PostgreSQL NOTIFY for broadcast 2026-03-02 18:22:05 +08:00
movingsam
e497b7e1cc docs: add PROJECT.md and STATE.md for console
- Define console as central management system
- Document current gateway integration status
- Track pending tasks for broadcast mechanism
2026-03-02 18:19:49 +08:00
19 changed files with 1412 additions and 370 deletions

100
.planning/PROJECT.md Normal file
View File

@ -0,0 +1,100 @@
# Fengling Console
## 这是什么
Fengling 微服务生态系统的中央管理控制台。负责用户管理、租户管理、OAuth 客户端管理,以及**网关配置管理**。是所有运维操作的中枢后台。
## 核心价值
统一的管理入口,负责所有运维相关的配置和操作,让其他服务专注于业务逻辑。
## 需求
### 已验证(现有功能)
- ✓ 用户管理CRUD、角色分配— 已有
- ✓ 租户管理 — 已有
- ✓ OAuth 客户端管理 — 已有
- ✓ 网关服务/路由/实例管理 — 已有GatewayController
- ✓ 租户申请审批流程 — 已有
### 进行中
- [ ] 实现配置变更广播机制(通过 PostgreSQL NOTIFY 通知所有网关实例)
- [ ] 实现 K8s 服务健康检查功能
- [ ] 实现配置变更广播机制(通知所有网关实例)
- [ ] 实现 K8s 服务健康检查功能
- [ ] 集成 Redis pub/sub 用于多实例通信
### 范围外
- [业务逻辑] — 由各微服务负责
- [API 认证] — 由 auth-service 负责
- [服务发现] — 由 service-discovery 负责
## 背景
**与 Gateway 的关系:**
```
fengling-console (管理平面)
├── 用户/租户/配置管理
└── 网关配置管理
├── GatewayDbContext (直接操作数据库)
├── GatewayController (API)
└── ReloadGatewayAsync() (待实现广播)
fengling-gateway (数据平面)
└── 监听配置变更,重新加载
```
**当前问题(来自 CONCERNS.md**
- OAuth 密钥硬编码
- CORS 允许所有(开发环境)
- 缺少测试覆盖
- ReloadGatewayAsync() 为空实现
**Console 已有能力:**
- GatewayDbContext - 管理网关路由、集群、实例数据
- GatewayController - 提供 /api/console/gateway/* API
- GatewayService - 业务逻辑
- 网关已有 PgSqlConfigChangeListener 使用 NOTIFY/LISTEN可复用
- OAuth 密钥硬编码
- CORS 允许所有(开发环境)
- 缺少测试覆盖
- Redis 已引用但未使用
- ReloadGatewayAsync() 为空实现
**Console 已有能力:**
- GatewayDbContext - 管理网关路由、集群、实例数据
- GatewayController - 提供 /api/console/gateway/* API
- GatewayService - 业务逻辑
- Redis 引用 - 可用于 pub/sub 广播
## 约束
- **多实例**Console 必须支持多实例部署
- **配置广播**:配置变更需要通知所有网关实例
- **K8s 健康**Console 需要实现 K8s 服务健康检查
- **技术栈**.NET 10.0, ASP.NET Core, PostgreSQL
## 关键决策
| 决策 | 理由 | 结果 |
|------|------|------|
| Console 作为运维中枢 | 集中管理,降低复杂度 | ✓ 良好 |
| Gateway 配置从 Console 变更 | 单一事实来源 | ✓ 良好 |
| PostgreSQL NOTIFY 广播 | 轻量方案,无需额外依赖 | ✓ 良好 |
|------|------|------|
| Console 作为运维中枢 | 集中管理,降低复杂度 | ✓ 良好 |
| Gateway 配置从 Console 变更 | 单一事实来源 | ✓ 良好 |
| Redis pub/sub 广播 | 成熟方案,易于实现 | ✓ 良好 |
---
*最后更新2026-03-02 初始化后*

93
.planning/ROADMAP.md Normal file
View File

@ -0,0 +1,93 @@
# Roadmap
## 当前里程碑
### Phase 1: 实现 Gateway 配置管理及事件推送
- **目标**: 实现 Console 对 Gateway 配置的增删改查功能,并添加事件推送机制,使下游 yarpgateway 能够监听到配置变更
- **状态**: ✓ 完成
#### Goal
实现 Console 管理 Gateway 配置的完整能力,包括:
- Gateway 配置的 CRUD 操作
- 配置变更事件推送
- 下游 Gateway 监听配置变更并重载
#### Depends on
- 无外部依赖
#### Plans
- [x] 01-PLAN.md — 实现配置变更广播机制
---
### Phase 2: 实现 Gateway 插件系统
- **目标**: 实现 YARP 网关的插件系统,包括 Web UI 管理界面和动态编译加载功能
- **状态**: Not planned yet
#### Goal
实现 YARP 网关的插件系统规划与实现,包括:
- Web UI 管理界面(路由管理、集群管理、插件管理)
- 在线 C# 代码编辑Monaco Editor
- 动态编译加载Roslyn
- 插件生命周期管理
#### Depends on
- Phase 1: 实现 Gateway 配置管理及事件推送
#### Plans
- [ ] 02-PLAN.md — 实施计划
---
### Phase 3: 网关配置变更广播机制
- **目标**: 理解现有网关配置的完整链路:路由 -> 服务 -> 下游服务,梳理配置变更时如何发送新增/变更广播事件
- **状态**: Planned
#### Goal
理解现有网关配置的完整链路:
- 路由配置如何传递到下游服务
- 服务发现与下游服务的关系
- 配置变更时的新增/变更广播事件机制
#### Depends on
- Phase 2: 实现 Gateway 插件系统
#### Plans
- [x] 03-PLAN.md — 实施计划
---
### Phase 4: 适配 Platform 1.0.12 Gateway 实体变更
- **目标**: 适配 Platform 1.0.12 中的 Gateway 实体重构,修复编译错误,更新 Console 代码以使用新的 GwCluster/GwDestination/GwTenantRoute 模型
WR|- [x] 04-PLAN.md — 实施计划
NH|- **状态**: Planned
#### Goal
适配 Platform 1.0.12 实体变更:
- 移除 IInstanceStore 依赖,改用 IClusterStore
- 更新 GatewayService 使用新的接口方法
- 更新数据模型映射GatewayInstanceDto → GwDestination
- 修复编译错误
#### Depends on
- Phase 3: 网关配置变更广播机制
#### Plans
- [ ] 04-PLAN.md — 实施计划

83
.planning/STATE.md Normal file
View File

@ -0,0 +1,83 @@
# 状态Fengling Console
**最后更新:** 2026-03-04
---
## 项目引用
参考:.planning/PROJECT.md更新于 2026-03-02
**核心价值:** 统一的管理入口,负责所有运维相关的配置和操作,让其他服务专注于业务逻辑。
**当前重点:** Phase 4: 待添加(适配 Platform 1.0.12 实体变更)
---
## 项目状态
| 项目 | 状态 |
|------|------|
| PROJECT.md | ✓ 已初始化 |
| CODEBASE | ✓ 已有ARCHITECTURE.md, CONCERNS.md, STACK.md 等) |
| Roadmap | ✓ 已创建 |
| 变更文档 | ✓ 已创建 |
---
## 累积上下文
### 初始化
- **2026-03-02** 创建 PROJECT.md定义 Console 在生态系统中的角色
- 现有代码库(已有 ARCHITECTURE.md、INTEGRATIONS.md 等)
### 路线图演进
- **2026-03-02** Phase 1 已添加:实现 Gateway 配置管理及事件推送
- **2026-03-02** Phase 1 执行完成
- **2026-03-02** Phase 2 已添加:实现 Gateway 插件系统
- **2026-03-03** Phase 3 已添加:网关配置变更广播机制
- **2026-03-03** Phase 3 已规划
- **2026-03-03** Phase 3 上下文已捕获:广播策略 = 仅手动触发
- **2026-03-04** Platform 1.0.12 实体变更Gateway → GwCluster/GwDestination/GwTenantRoute
### 与 Gateway 的集成
| 组件 | 位置 | 现状 |
|------|------|------|
| GatewayDbContext | src/Data/ | 已实现,管理网关配置数据 |
| GatewayController | src/Controllers/ | 已实现,提供 API |
| GatewayService | src/Services/ | 已实现,业务逻辑 |
| ConfigNotificationService | src/Services/ | ✓ 已实现 PostgreSQL NOTIFY |
| ReloadGatewayAsync | src/Services/GatewayService.cs | 待集成通知服务 |
### 待完成任务
- **适配 Platform 1.0.12 实体变更**(编译错误待修复)
---
## 变更记录
### Platform 1.0.12 Gateway 实体变更
详细变更见:`.planning/docs/gateway-entity-changes-1.0.12.md`
**主要变更:**
1. GatewayInstance → GwDestination内嵌值对象
2. GatewayCluster → GwCluster聚合根包含 Destinations
3. GatewayRoute → GwTenantRoute通过 ClusterId 关联)
4. IInstanceStore 移除,改用 IClusterStore
---
## 备注
- Console 是运维中枢,网关配置的单一管理门户
- 广播策略:仅手动触发(通过 /reload 接口)
- 下游网关收到通知后自行查询数据库刷新
---
*最后更新2026-03-04*

View File

@ -0,0 +1,141 @@
# Gateway 实体变更记录
**变更日期:** 2026-03-04
**Platform 版本:** 1.0.12
---
## 变更概述
Platform 1.0.12 对 Gateway 相关实体进行了重构,主要变化是将实例(Instance)内嵌到集群(Cluster)中,简化了领域模型。
---
## 实体变更
### 1. GwDestination新增 - 原 GatewayInstance
**旧名称:** GatewayInstance
**新名称:** GwDestination
**类型:** 值对象(内嵌于 GwCluster
```csharp
public class GwDestination
{
public string DestinationId { get; set; } // 目标标识
public string Address { get; set; } // 后端地址
public string? Health { get; set; } // 健康检查端点
public int Weight { get; set; } = 1; // 权重
public int HealthStatus { get; set; } = 1; // 健康状态
public int Status { get; set; } = 1; // 状态
}
```
### 2. GwCluster重构 - 原 GatewayCluster
**旧名称:** GatewayCluster
**新名称:** GwCluster
**类型:** 聚合根
**主要变化:**
- 包含 `List<GwDestination> Destinations` 作为内嵌集合
- 包含 `GwLoadBalancingPolicy` 负载均衡策略
- 包含 `GwHealthCheckConfig` 健康检查配置
- 包含 `GwSessionAffinityConfig` 会话亲和配置
### 3. GwTenantRoute重构 - 原 GatewayRoute
**旧名称:** GatewayRoute
**新名称:** GwTenantRoute
**类型:** 实体
**主要变化:**
- 通过 `ClusterId` 关联到 `GwCluster`
- 包含 `GwRouteMatch` 路由匹配配置
- 支持 `GwLoadBalancingPolicy` 路由级别负载均衡覆盖
---
## 接口变更
### IInstanceStore已移除
**状态:** 已移除
**原因:** 实例(Destination)现在是 GwCluster 的内嵌对象,不再需要独立的 IInstanceStore 接口。
### IClusterStore新增
```csharp
public interface IClusterStore
{
// Basic CRUD
Task<GwCluster?> FindByIdAsync(string? id, CancellationToken cancellationToken = default);
Task<GwCluster?> FindByClusterIdAsync(string clusterId, CancellationToken cancellationToken = default);
Task<IList<GwCluster>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IList<GwCluster>> GetPagedAsync(int page, int pageSize, string? clusterId = null,
string? name = null, int? status = null, CancellationToken cancellationToken = default);
Task<int> GetCountAsync(string? clusterId = null, string? name = null,
int? status = null, CancellationToken cancellationToken = default);
Task<IdentityResult> CreateAsync(GwCluster cluster, CancellationToken cancellationToken = default);
Task<IdentityResult> UpdateAsync(GwCluster cluster, CancellationToken cancellationToken = default);
Task<IdentityResult> DeleteAsync(GwCluster cluster, CancellationToken cancellationToken = default);
// Destination management (NEW)
Task<GwCluster?> AddDestinationAsync(string clusterId, GwDestination destination, CancellationToken cancellationToken = default);
Task<GwCluster?> UpdateDestinationAsync(string clusterId, string destinationId, GwDestination destination, CancellationToken cancellationToken = default);
Task<GwCluster?> RemoveDestinationAsync(string clusterId, string destinationId, CancellationToken cancellationToken = default);
}
```
---
## 架构变化
### 旧架构
```
Route → ClusterId → Instance (独立实体)
```
### 新架构
```
TenantRoute → ClusterId → GwCluster (聚合根) → List<GwDestination>
```
---
## Console 适配需求
由于接口变更Console 需要进行以下适配:
1. **移除 IInstanceStore 依赖**
- 移除 `IInstanceStore` 注入
- 使用 `IClusterStore` 替代
2. **更新 GatewayService**
- 实例操作改为通过 `IClusterStore.AddDestinationAsync` 等方法
- 查询实例改为从 `GwCluster.Destinations` 获取
3. **更新数据模型**
- GatewayInstanceDto → 从 GwDestination 映射
- GatewayClusterDto → 从 GwCluster 映射
4. **更新 API 端点**
- `/instances` 相关端点可能需要调整
---
## 相关文件
### Platform 侧
- `Fengling.Platform.Domain/AggregatesModel/GatewayAggregate/GwCluster.cs`
- `Fengling.Platform.Domain/AggregatesModel/GatewayAggregate/GwDestination.cs`
- `Fengling.Platform.Domain/AggregatesModel/GatewayAggregate/GwTenantRoute.cs`
- `Fengling.Platform.Infrastructure/IClusterStore.cs`
- `Fengling.Platform.Infrastructure/ClusterStore.cs`
### Console 侧(需要适配)
- `src/Services/GatewayService.cs` - 需要适配新接口
- `src/Program.cs` - 需要更新依赖注入

View File

@ -0,0 +1,75 @@
# Phase 1: 实现 Gateway 配置管理及事件推送 - Context
**收集日期:** 2026-03-02
**状态:** Ready for planning
**来源:** Manual planning (gsd-tools not available)
**更新:** 2026-03-03 - 添加数据源决策
<domain>
## Phase Boundary
实现 Console 管理 Gateway 配置的完整能力,包括:
- Gateway 配置的 CRUD 操作(已大部实现)
- 配置变更事件推送(待实现)
- 下游 Gateway 监听配置变更并重载
**现有能力:**
- GatewayController: API 端点已实现
- GatewayService: 业务逻辑已实现
- DTOs: 数据传输对象已定义
**待实现:**
- ReloadGatewayAsync() 广播机制
- 配置变更时自动触发广播
</domain>
<decisions>
## Implementation Decisions
### 技术选型
- **广播机制**: PostgreSQL NOTIFY/LISTEN轻量方案无需额外依赖
- **备选方案**: Redis pub/sub如需多实例通信
### 数据源
- **通知服务数据库连接**: 从 EF Core DbContext 获取,而非从配置文件读取
- **实现方式**: 注入 ConsoleDbContext使用 `DbContext.Database.GetConnectionString()`
### 功能决策
- **自动广播**: 配置变更(创建/更新/删除)时自动触发广播
- **手动广播**: 提供 /api/console/gateway/reload 手动触发端点
### Claude's Discretion
- 具体的 NOTIFY 通道名称格式
- 事件 payload 结构设计
- 是否需要事件类型区分service/route/instance
</decisions>
<specifics>
## Specific Ideas
**关键文件:**
- src/Services/GatewayService.cs - ReloadGatewayAsync() 空实现需填充
- src/Controllers/GatewayController.cs - POST /reload 端点
- src/Services/ConfigNotificationService.cs - 需修改为使用 DbContext 获取连接字符串
**依赖:**
- Npgsql - PostgreSQL 通知(已通过 EF Core 引用)
- Redis可选- 如选择 Redis pub/sub
**参考实现:**
- 网关已有 PgSqlConfigChangeListener 使用 NOTIFY/LISTEN可复用
</specifics>
<deferred>
## Deferred Ideas
- K8s 服务健康检查(后续 Phase
- Redis pub/sub如果 PostgreSQL NOTIFY 方案不够用再考虑)
</deferred>
---
*Phase: 01-gateway-config-management*
*Context gathered: 2026-03-02, updated 2026-03-03*

View File

@ -0,0 +1,146 @@
---
phase: 01-gateway-config-management
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Services/GatewayService.cs
- src/Services/ConfigNotificationService.cs
- src/Data/ConsoleDbContext.cs
autonomous: true
requirements: []
user_setup: []
must_haves:
truths:
- "配置变更后下游 Gateway 能收到通知"
- "手动触发 /reload 端点能广播配置变更"
- "自动触发:服务/路由/实例变更时自动广播"
artifacts:
- path: "src/Services/ConfigNotificationService.cs"
provides: "配置变更通知服务"
contains: "INotificationService"
- path: "src/Services/GatewayService.cs"
provides: "触发通知逻辑"
contains: "ReloadGatewayAsync"
key_links:
- from: "GatewayService"
to: "ConfigNotificationService"
via: "依赖注入"
pattern: "INotificationService"
---
<objective>
实现配置变更广播机制,使下游 Gateway 能够监听到配置变更。
</objective>
<context>
@.planning/ROADMAP.md
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/01-gateway-config-management/01-CONTEXT.md
@src/Services/GatewayService.cs
@src/Controllers/GatewayController.cs
</context>
<tasks>
<task type="auto">
<name>Task 1: 修改 ConfigNotificationService 使用 DbContext 获取连接字符串</name>
<files>src/Services/ConfigNotificationService.cs</files>
<action>
修改现有的 PgSqlNotificationService 实现:
1. 修改构造函数:
- 注入 ConsoleDbContext而非使用 IConfiguration
- 使用 DbContext.Database.GetConnectionString() 获取连接字符串
2. 移除:
- IConfiguration 依赖
- _configuration.GetConnectionString("DefaultConnection")
3. 示例代码:
```csharp
public PgSqlNotificationService(
ConsoleDbContext dbContext,
ILogger<PgSqlNotificationService> logger)
{
_connectionString = dbContext.Database.GetConnectionString()
?? throw new InvalidOperationException("DefaultConnection not configured");
_logger = logger;
}
```
4. 在 Program.cs 中注册服务时传入 DbContext
```csharp
services.AddScoped<INotificationService>(sp =>
new PgSqlNotificationService(
sp.GetRequiredService<ConsoleDbContext>(),
sp.GetRequiredService<ILogger<PgSqlNotificationService>>()));
```
</action>
<verify>
<automated>dotnet build --no-restore 2>&1 | head -20</automated>
</verify>
<done>PgSqlNotificationService 已修改为使用 DbContext 获取连接字符串</done>
</task>
<task type="auto">
<name>Task 2: 修改 GatewayService 集成通知服务</name>
<files>src/Services/GatewayService.cs</files>
<action>
修改 GatewayService 以集成通知服务:
1. 添加 INotificationService 依赖注入到 GatewayService 构造函数
2. 修改 ReloadGatewayAsync() 实现:
- 调用 _notificationService.PublishAsync("gateway_config_changed", JsonSerialize(reloadEvent))
- 日志记录广播成功
3. 在以下 CRUD 操作中添加自动广播(创建/更新/删除后):
- RegisterServiceAsync - 服务注册
- UnregisterServiceAsync - 服务注销
- CreateRouteAsync - 路由创建
- AddInstanceAsync - 实例添加
- RemoveInstanceAsync - 实例删除
- UpdateInstanceWeightAsync - 权重更新
4. 事件 Payload 格式:
```json
{
"eventType": "service|route|instance",
"action": "create|update|delete|reload",
"timestamp": "2026-03-02T12:00:00Z",
"details": { ... }
}
```
</action>
<verify>
<automated>dotnet build --no-restore 2>&1 | head -30</automated>
</verify>
<done>GatewayService 集成通知服务,所有配置变更操作自动触发广播</done>
</task>
</tasks>
<verification>
整体验证:
1. dotnet build 编译通过
2. 手动调用 POST /api/console/gateway/reload 返回成功
3. PostgreSQL 数据库能收到 NOTIFY 消息
</verification>
<success_criteria>
- [x] ConfigNotificationService 改为使用 DbContext 获取连接字符串
- [ ] INotificationService 接口定义完成
- [ ] PgSqlNotificationService 实现完成
- [ ] GatewayService 集成通知服务
- [ ] ReloadGatewayAsync 触发广播
- [ ] CRUD 操作自动触发广播
- [ ] 编译通过
</success_criteria>
<output>
完成后创建 .planning/phases/01-gateway-config-management/01-SUMMARY.md
</output>

View File

@ -0,0 +1,83 @@
# Phase 1: 实现 Gateway 配置管理及事件推送 - 执行摘要
**完成日期:** 2026-03-02
**状态:** ✓ Complete
## 执行结果
| Plan | 任务 | 状态 |
|------|------|------|
| 01 | Task 1: 创建配置通知服务 | ✓ |
| 01 | Task 2: 修改 GatewayService 集成通知服务 | ✓ |
## 实现的功能
### 1. 配置通知服务 (ConfigNotificationService.cs)
**创建/修改的文件:**
- `src/Services/ConfigNotificationService.cs`
**包含:**
- `INotificationService` 接口 - 通知服务抽象
- `PgSqlNotificationService` 实现 - 使用 PostgreSQL NOTIFY 机制
- `ConfigChangeEvent` - 配置变更事件数据模型
- 通知通道: `gateway_config_changed`
**实现细节:**
- 使用 `DbContextOptions<ConsoleDbContext>` 获取连接字符串(而非直接从配置文件读取)
- 通过反射从 EF Core Npgsql 扩展中提取连接字符串
**创建的文件:**
- `src/Services/ConfigNotificationService.cs`
**包含:**
- `INotificationService` 接口 - 通知服务抽象
- `PgSqlNotificationService` 实现 - 使用 PostgreSQL NOTIFY 机制
- `ConfigChangeEvent` - 配置变更事件数据模型
- 通知通道: `gateway_config_changed`
**事件格式:**
```json
{
"eventType": "service|route|instance|gateway",
"action": "create|update|delete|reload",
"timestamp": "2026-03-02T12:00:00Z",
"details": { ... }
}
```
### 2. GatewayService 集成
**修改的文件:**
- `src/Services/GatewayService.cs` - 添加 INotificationService 依赖
- `src/Program.cs` - 注册 NotificationService
**自动广播触发点:**
- `RegisterServiceAsync` - 服务注册时
- `UnregisterServiceAsync` - 服务注销时
- `CreateRouteAsync` - 路由创建时
- `AddInstanceAsync` - 实例添加时
- `RemoveInstanceAsync` - 实例删除时
- `UpdateInstanceWeightAsync` - 权重更新时
- `ReloadGatewayAsync` - 手动触发重载时
## 验证
- [x] dotnet build 编译通过
- [x] INotificationService 接口定义完成
- [x] PgSqlNotificationService 实现完成
- [x] GatewayService 集成通知服务
- [x] ReloadGatewayAsync 触发广播
- [x] CRUD 操作自动触发广播
## 下游使用
下游 Gateway (yarpgateway) 需要实现:
1. 监听 `gateway_config_changed` 通道
2. 收到通知后重新加载配置
---
*Phase: 01-gateway-config-management*
*Plan: 01*
*Executed: 2026-03-02*

View File

@ -0,0 +1,27 @@
# Phase 2: 实现 Gateway 插件系统
- **目标**: 实现 YARP 网关的插件系统,包括 Web UI 管理界面和动态编译加载功能
- **状态**: Not planned yet
---
## Goal
实现 YARP 网关的插件系统规划与实现,包括:
- Web UI 管理界面(路由管理、集群管理、插件管理)
- 在线 C# 代码编辑Monaco Editor
- 动态编译加载Roslyn
- 插件生命周期管理
## Depends on
- Phase 1: 实现 Gateway 配置管理及事件推送
## Plans
- [ ] 02-PLAN.md — 实施计划
---
*相关文档:.planning/docs/gateway-plugin-system.md*

View File

@ -0,0 +1,74 @@
# Phase 3: 网关配置变更广播机制 - Context
**Gathered:** 2026-03-03
**Status:** Ready for planning
<domain>
## Phase Boundary
分析现有的网关配置广播机制,梳理路由→服务→下游的完整链路,确定配置变更时的广播策略。
</domain>
<decisions>
## Implementation Decisions
### 广播触发策略
- **仅手动触发**:所有 CRUD 操作(路由、集群、实例、权重)不自动广播
- 下游需要刷新时,手动调用 POST /api/console/gateway/reload
- 事件只通知"需要刷新",不包含具体变更内容
- 下游收到通知后,自行查询数据库刷新配置
### 广播事件格式
- 通道:`gateway_config_changed`
- 事件内容:只包含 action: "reload",不含具体变更详情
- 下游逻辑:收到通知 → 查询数据库 → 刷新内存缓存
### 需分析的现有代码
- ConfigNotificationService.cs - 已实现的 NOTIFY 机制
- GatewayService.cs - 需集成通知服务
- GatewayController.cs - /reload 接口
### Claude's Discretion
- 自动触发 vs 手动触发的具体实现方式
- 广播失败时的错误处理策略
- 日志记录细节
</decisions>
<code_context>
## Existing Code Insights
### Reusable Assets
- ConfigNotificationService.cs: PostgreSQL NOTIFY 机制已实现
- INotificationService 接口: 可直接复用
### Established Patterns
- 使用 PgSqlNotificationService 发布通知
- 通道名称: `gateway_config_changed`
### Integration Points
- GatewayService 需注入 INotificationService
- ReloadGatewayAsync 需调用通知服务
</code_context>
<specifics>
## Specific Ideas
- 事件 payload 尽量精简,只传递 "reload" action
- 下游网关监听同一数据库连接,收到 NOTIFY 后刷新
</specifics>
<deferred>
## Deferred Ideas
- 自动触发广播(未来可选优化)
</deferred>
---
*Phase: 03-gateway-config-broadcast*
*Context gathered: 2026-03-03*

View File

@ -0,0 +1,112 @@
---
phase: 03-gateway-config-broadcast
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: []
autonomous: true
requirements: []
user_setup: []
must_haves:
truths:
- "现有广播机制已文档化"
- "路由 -> 服务 -> 下游流程已理解"
- "配置变更事件已验证可用"
artifacts:
- path: ".planning/phases/03-gateway-config-broadcast/03-SUMMARY.md"
provides: "阶段执行摘要"
key_links: []
---
<objective>
分析和文档化现有的网关配置广播机制。理解从路由配置到下游服务的完整链路,并验证配置变更事件广播是否正常工作。
</objective>
<context>
@.planning/phases/01-gateway-config-management/01-SUMMARY.md
@.planning/phases/01-gateway-config-management/01-PLAN.md
## 现有实现(来自 Phase 1
广播机制使用 PostgreSQL NOTIFY
- **通道:** `gateway_config_changed`
- **事件类型:** service, route, instance, gateway
- **操作:** create, update, delete, reload
- **服务:** ConfigNotificationService.cs
- **集成:** GatewayService.cs 在所有 CRUD 操作时触发广播
</context>
<tasks>
<task type="auto">
<name>任务 1: 分析现有广播实现</name>
<files>src/Services/ConfigNotificationService.cs, src/Services/GatewayService.cs</files>
<action>
分析现有实现以了解:
1. ConfigNotificationService 如何工作PostgreSQL NOTIFY
2. GatewayService 如何在 CRUD 操作时触发广播
3. 发送的事件类型和载荷是什么
阅读源代码并记录发现。
</action>
<verify>
<automated>文件存在且包含通知逻辑</automated>
</verify>
<done>实现分析完成,发现已记录</done>
</task>
<task type="auto">
<name>任务 2: 绘制路由 -> 服务 -> 下游流程</name>
<files></files>
<action>
文档化完整配置链路:
1. 路由如何在 Console 中定义
2. 路由如何映射到服务
3. 下游 Gateway 如何发现服务
4. 配置变更时,广播如何到达下游
参考 src/Models/、src/Services/、src/Controllers/ 中的现有代码
</action>
<verify>
<automated>流程文档已创建</automated>
</verify>
<done>配置链路已文档化</done>
</task>
<task type="auto">
<name>任务 3: 验证广播端到端工作</name>
<files></files>
<action>
验证广播机制:
1. 检查 PostgreSQL LISTEN/NOTIFY 是否正确配置
2. 验证 ReloadGatewayAsync 发送正确事件
3. 确认所有 CRUD 操作(服务/路由/实例)都触发广播
4. 如可能,测试端到端流程
</action>
<verify>
<automated>编译成功API 端点可用</automated>
</verify>
<done>广播验证完成</done>
</task>
</tasks>
<verification>
1. 阅读并分析 ConfigNotificationService.cs
2. 阅读并分析 GatewayService.cs
3. 文档化路由 -> 服务 -> 下游流程
4. 验证编译通过
</verification>
<success_criteria>
- [x] 现有广播实现已分析
- [x] 配置链路已文档化
- [x] 广播事件已验证
- [x] 摘要已创建
</success_criteria>
<output>
完成后创建 `.planning/phases/03-gateway-config-broadcast/03-SUMMARY.md`
</output>

View File

@ -0,0 +1,128 @@
---
phase: 04-gateway-entity-update
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/Services/GatewayService.cs
- src/Program.cs
autonomous: true
requirements: []
user_setup: []
must_haves:
truths:
- "编译错误已修复"
- "GatewayService 使用新的 IClusterStore 接口"
- "实例操作改为通过 Cluster.Destinations 管理"
artifacts:
- path: "src/Services/GatewayService.cs"
provides: "GatewayService 使用 IClusterStore"
- path: "src/Program.cs"
provides: "依赖注入更新"
key_links:
- from: "GatewayService"
to: "IClusterStore"
via: "依赖注入"
---
<objective>
适配 Platform 1.0.12 中的 Gateway 实体重构,修复编译错误,更新 Console 代码以使用新的 GwCluster/GwDestination/GwTenantRoute 模型。
</objective>
<context>
@.planning/docs/gateway-entity-changes-1.0.12.md
## 编译错误
当前编译错误:
```
error CS0246: IInstanceStore 找不到
```
## 变更摘要
1. **IInstanceStore 已移除** - 实例现在是 GwCluster 的内嵌对象
2. **IClusterStore 是新接口** - 包含 Destination 管理方法
3. **数据模型变化**:
- GatewayInstance → GwDestination内嵌值对象
- GatewayCluster → GwCluster聚合根包含 Destinations
- 路由通过 ClusterId 关联到集群
</context>
<tasks>
<task type="auto">
<name>任务 1: 更新 Program.cs 依赖注入</name>
<files>src/Program.cs</files>
<action>
1. 移除 IInstanceStore 的注入(如果有)
2. 添加 IClusterStore 的注入:
```csharp
builder.Services.AddScoped<IClusterStore, ClusterStore<PlatformDbContext>>();
```
3. 确保使用正确的 PlatformDbContext
</action>
<verify>
<automated>dotnet build --no-restore 2>&1 | head -30</automated>
</verify>
<done>Program.cs 依赖注入已更新</done>
</task>
<task type="auto">
<name>任务 2: 更新 GatewayService 使用 IClusterStore</name>
<files>src/Services/GatewayService.cs</files>
<action>
1. 移除 IInstanceStore 依赖
2. 添加 IClusterStore 依赖注入
3. 更新实例相关方法:
- GetInstancesAsync → 从 Cluster.Destinations 获取
- AddInstanceAsync → 使用 IClusterStore.AddDestinationAsync
- RemoveInstanceAsync → 使用 IClusterStore.RemoveDestinationAsync
- UpdateInstanceWeightAsync → 使用 IClusterStore.UpdateDestinationAsync
4. 更新数据模型映射:
- GatewayInstanceDto → 从 GwDestination 映射
- GatewayClusterDto → 从 GwCluster 映射
</action>
<verify>
<automated>dotnet build --no-restore 2>&1 | head -30</automated>
</verify>
<done>GatewayService 已更新为使用 IClusterStore</done>
</task>
<task type="auto">
<name>任务 3: 验证编译通过</name>
<files></files>
<action>
运行完整编译验证:
```bash
dotnet build src/Fengling.Console.csproj
```
确保没有编译错误。
</action>
<verify>
<automated>dotnet build src/Fengling.Console.csproj 2>&1 | tail -10</automated>
</verify>
<done>编译通过,无错误</done>
</task>
</tasks>
<verification>
1. dotnet build 编译通过
2. GatewayService 使用 IClusterStore
3. 实例操作通过 Cluster.Destinations 管理
</verification>
<success_criteria>
- [x] IInstanceStore 依赖已移除
- [x] IClusterStore 已集成
- [x] 编译错误已修复
- [x] GatewayService 功能正常
</success_criteria>
<output>
完成后创建 `.planning/phases/04-gateway-entity-update/04-SUMMARY.md`
</output>

View File

@ -0,0 +1,78 @@
# Phase 4 总结:适配 Platform 1.0.12 Gateway 实体变更
## 概述
本次 Phase 4 成功完成了 Fengling Console 对 Platform 1.0.12 Gateway 实体变更的适配工作。
## 主要变更
### 1. Program.cs 依赖注入更新
**变更内容:**
- 移除了 `IInstanceStore``InstanceStore` 的注册
- 保留了 `IClusterStore``ClusterStore` 的注册
**变更原因:**
Platform 1.0.12 移除了 `IInstanceStore` 接口,实例(Destination)现在是 `GwCluster` 的内嵌对象。
### 2. ConsoleDbContext 实体配置清理
**变更内容:**
- 移除了 `GwTenant` 实体配置(原平台中已移除)
- 移除了 `GwServiceInstance` 实体配置(已重构为 GwDestination
### 3. GatewayService 实体属性适配
**变更内容:**
| 旧属性 | 新属性 | 说明 |
|--------|--------|------|
| `GwTenantRoute.PathPattern` (string) | `GwTenantRoute.Match.Path` ( GwRouteMatch.Path ) | 路由匹配配置从简单字符串升级为复杂对象 |
| `Status = RouteStatus.Active` (enum) | `Status = (int)RouteStatus.Active` (int) | Status 字段为 int 类型,需要显式转换枚举 |
**具体代码变更:**
```csharp
// 旧代码
new GwTenantRoute
{
PathPattern = pathPattern,
Status = RouteStatus.Active,
}
// 新代码
new GwTenantRoute
{
Match = new GwRouteMatch { Path = pathPattern },
Status = (int)RouteStatus.Active,
}
```
```csharp
// 旧代码读取
r.PathPattern
r.Status
// 新代码读取
r.Match.Path
r.Status (已是 int)
```
## 编译结果
✅ 编译成功0 个错误3 个警告(警告为预存在的代码质量问题,与本次变更无关)
## 验证
- [x] `dotnet build` 通过
- [x] 无新增编译错误
## 相关文档
- 实体变更详情:`.planning/docs/gateway-entity-changes-1.0.12.md`
## 下一步
可以考虑的改进:
1. 修复 TenantService.cs 中的警告roleManager 参数未使用)
2. 完善 GatewayService 中的空值处理Match 可能为 null

View File

@ -4,7 +4,7 @@
</PropertyGroup>
<ItemGroup>
<!-- Microsoft Packages -->
<PackageVersion Include="Fengling.Platform.Infrastructure" Version="1.0.11" />
<PackageVersion Include="Fengling.Platform.Infrastructure" Version="1.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />

View File

@ -24,21 +24,11 @@ public class ConsoleDbContext : PlatformDbContext
base.OnModelCreating(modelBuilder);
// ========== Gateway 模块 ==========
modelBuilder.Entity<GwTenant>(entity =>
{
entity.ToTable("gw_tenants");
});
modelBuilder.Entity<GwTenantRoute>(entity =>
{
entity.ToTable("gw_tenant_routes");
});
modelBuilder.Entity<GwServiceInstance>(entity =>
{
entity.ToTable("gw_service_instances");
});
// ========== Tenant 模块 ==========
modelBuilder.Entity<Tenant>(entity =>
{

View File

@ -1,254 +0,0 @@
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
START TRANSACTION;
CREATE TABLE gw_service_instances (
"Id" text NOT NULL,
"ClusterId" character varying(100) NOT NULL,
"DestinationId" character varying(100) NOT NULL,
"Address" character varying(200) NOT NULL,
"Health" integer NOT NULL,
"Weight" integer NOT NULL,
"Status" integer NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_gw_service_instances" PRIMARY KEY ("Id")
);
CREATE TABLE gw_tenant_routes (
"Id" text NOT NULL,
"TenantCode" character varying(50) NOT NULL,
"ServiceName" character varying(100) NOT NULL,
"ClusterId" character varying(100) NOT NULL,
"PathPattern" character varying(200) NOT NULL,
"Priority" integer NOT NULL,
"Status" integer NOT NULL,
"IsGlobal" boolean NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_gw_tenant_routes" PRIMARY KEY ("Id")
);
CREATE TABLE gw_tenants (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"TenantCode" character varying(50) NOT NULL,
"TenantName" character varying(100) NOT NULL,
"Status" integer NOT NULL,
"CreatedBy" bigint,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedBy" bigint,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_gw_tenants" PRIMARY KEY ("Id")
);
CREATE TABLE idn_roles (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"Description" character varying(200),
"CreatedTime" timestamp with time zone NOT NULL,
"TenantId" bigint,
"IsSystem" boolean NOT NULL,
"DisplayName" text,
"Permissions" text[],
"Name" character varying(256),
"NormalizedName" character varying(256),
"ConcurrencyStamp" text,
CONSTRAINT "PK_idn_roles" PRIMARY KEY ("Id")
);
CREATE TABLE idn_users (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"RealName" text NOT NULL,
"TenantId" bigint,
"TenantCode" text,
"TenantName" text,
"CreatedTime" timestamp with time zone NOT NULL,
"UpdatedTime" timestamp with time zone,
"IsDeleted" boolean NOT NULL,
"UserName" character varying(256),
"NormalizedUserName" character varying(256),
"Email" character varying(256),
"NormalizedEmail" character varying(256),
"EmailConfirmed" boolean NOT NULL,
"PasswordHash" text,
"SecurityStamp" text,
"ConcurrencyStamp" text,
"PhoneNumber" character varying(20),
"PhoneNumberConfirmed" boolean NOT NULL,
"TwoFactorEnabled" boolean NOT NULL,
"LockoutEnd" timestamp with time zone,
"LockoutEnabled" boolean NOT NULL,
"AccessFailedCount" integer NOT NULL,
CONSTRAINT "PK_idn_users" PRIMARY KEY ("Id")
);
CREATE TABLE sys_access_logs (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"UserName" character varying(50),
"TenantId" character varying(50),
"Action" character varying(20) NOT NULL,
"Resource" character varying(200),
"Method" character varying(10),
"IpAddress" character varying(50),
"UserAgent" character varying(500),
"Status" character varying(20) NOT NULL,
"Duration" integer NOT NULL,
"RequestData" text,
"ResponseData" text,
"ErrorMessage" text,
"CreatedAt" timestamp with time zone NOT NULL,
CONSTRAINT "PK_sys_access_logs" PRIMARY KEY ("Id")
);
CREATE TABLE sys_audit_logs (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"Operator" character varying(50) NOT NULL,
"TenantId" character varying(50),
"Operation" character varying(20) NOT NULL,
"Action" character varying(20) NOT NULL,
"TargetType" character varying(50),
"TargetId" bigint,
"TargetName" character varying(100),
"IpAddress" character varying(50) NOT NULL,
"Description" character varying(500),
"OldValue" text,
"NewValue" text,
"ErrorMessage" text,
"Status" character varying(20) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
CONSTRAINT "PK_sys_audit_logs" PRIMARY KEY ("Id")
);
CREATE TABLE sys_tenants (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"TenantCode" character varying(50) NOT NULL,
"Name" character varying(100) NOT NULL,
"ContactName" character varying(50) NOT NULL,
"ContactEmail" character varying(100) NOT NULL,
"ContactPhone" character varying(20),
"MaxUsers" integer,
"CreatedAt" timestamp with time zone NOT NULL,
"UpdatedAt" timestamp with time zone,
"ExpiresAt" timestamp with time zone,
"Description" character varying(500),
"Status" integer NOT NULL,
"IsDeleted" boolean NOT NULL,
"RowVersion" bigint NOT NULL,
CONSTRAINT "PK_sys_tenants" PRIMARY KEY ("Id")
);
CREATE TABLE idn_role_claims (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"RoleId" bigint NOT NULL,
"ClaimType" text,
"ClaimValue" text,
CONSTRAINT "PK_idn_role_claims" PRIMARY KEY ("Id"),
CONSTRAINT "FK_idn_role_claims_idn_roles_RoleId" FOREIGN KEY ("RoleId") REFERENCES idn_roles ("Id") ON DELETE CASCADE
);
CREATE TABLE idn_user_claims (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"UserId" bigint NOT NULL,
"ClaimType" text,
"ClaimValue" text,
CONSTRAINT "PK_idn_user_claims" PRIMARY KEY ("Id"),
CONSTRAINT "FK_idn_user_claims_idn_users_UserId" FOREIGN KEY ("UserId") REFERENCES idn_users ("Id") ON DELETE CASCADE
);
CREATE TABLE idn_user_logins (
"LoginProvider" text NOT NULL,
"ProviderKey" text NOT NULL,
"ProviderDisplayName" text,
"UserId" bigint NOT NULL,
CONSTRAINT "PK_idn_user_logins" PRIMARY KEY ("LoginProvider", "ProviderKey"),
CONSTRAINT "FK_idn_user_logins_idn_users_UserId" FOREIGN KEY ("UserId") REFERENCES idn_users ("Id") ON DELETE CASCADE
);
CREATE TABLE idn_user_roles (
"UserId" bigint NOT NULL,
"RoleId" bigint NOT NULL,
CONSTRAINT "PK_idn_user_roles" PRIMARY KEY ("UserId", "RoleId"),
CONSTRAINT "FK_idn_user_roles_idn_roles_RoleId" FOREIGN KEY ("RoleId") REFERENCES idn_roles ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_idn_user_roles_idn_users_UserId" FOREIGN KEY ("UserId") REFERENCES idn_users ("Id") ON DELETE CASCADE
);
CREATE TABLE idn_user_tokens (
"UserId" bigint NOT NULL,
"LoginProvider" text NOT NULL,
"Name" text NOT NULL,
"Value" text,
CONSTRAINT "PK_idn_user_tokens" PRIMARY KEY ("UserId", "LoginProvider", "Name"),
CONSTRAINT "FK_idn_user_tokens_idn_users_UserId" FOREIGN KEY ("UserId") REFERENCES idn_users ("Id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX "IX_gw_service_instances_ClusterId_DestinationId" ON gw_service_instances ("ClusterId", "DestinationId");
CREATE INDEX "IX_gw_service_instances_Health" ON gw_service_instances ("Health");
CREATE INDEX "IX_gw_tenant_routes_ClusterId" ON gw_tenant_routes ("ClusterId");
CREATE INDEX "IX_gw_tenant_routes_ServiceName" ON gw_tenant_routes ("ServiceName");
CREATE INDEX "IX_gw_tenant_routes_ServiceName_IsGlobal_Status" ON gw_tenant_routes ("ServiceName", "IsGlobal", "Status");
CREATE INDEX "IX_gw_tenant_routes_TenantCode" ON gw_tenant_routes ("TenantCode");
CREATE UNIQUE INDEX "IX_gw_tenants_TenantCode" ON gw_tenants ("TenantCode");
CREATE INDEX "IX_idn_role_claims_RoleId" ON idn_role_claims ("RoleId");
CREATE UNIQUE INDEX "RoleNameIndex" ON idn_roles ("NormalizedName");
CREATE INDEX "IX_idn_user_claims_UserId" ON idn_user_claims ("UserId");
CREATE INDEX "IX_idn_user_logins_UserId" ON idn_user_logins ("UserId");
CREATE INDEX "IX_idn_user_roles_RoleId" ON idn_user_roles ("RoleId");
CREATE INDEX "EmailIndex" ON idn_users ("NormalizedEmail");
CREATE UNIQUE INDEX "IX_idn_users_PhoneNumber" ON idn_users ("PhoneNumber");
CREATE UNIQUE INDEX "UserNameIndex" ON idn_users ("NormalizedUserName");
CREATE INDEX "IX_sys_access_logs_Action" ON sys_access_logs ("Action");
CREATE INDEX "IX_sys_access_logs_CreatedAt" ON sys_access_logs ("CreatedAt");
CREATE INDEX "IX_sys_access_logs_Status" ON sys_access_logs ("Status");
CREATE INDEX "IX_sys_access_logs_TenantId" ON sys_access_logs ("TenantId");
CREATE INDEX "IX_sys_access_logs_UserName" ON sys_access_logs ("UserName");
CREATE INDEX "IX_sys_audit_logs_Action" ON sys_audit_logs ("Action");
CREATE INDEX "IX_sys_audit_logs_CreatedAt" ON sys_audit_logs ("CreatedAt");
CREATE INDEX "IX_sys_audit_logs_Operation" ON sys_audit_logs ("Operation");
CREATE INDEX "IX_sys_audit_logs_Operator" ON sys_audit_logs ("Operator");
CREATE INDEX "IX_sys_audit_logs_TenantId" ON sys_audit_logs ("TenantId");
CREATE INDEX "IX_sys_tenants_Status" ON sys_tenants ("Status");
CREATE UNIQUE INDEX "IX_sys_tenants_TenantCode" ON sys_tenants ("TenantCode");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260301040647_Initial', '10.0.3');
COMMIT;

View File

@ -1,7 +1,6 @@
using System.Reflection;
using Fengling.Console.Data;
using Fengling.Console.Services;
using Fengling.Console.Services;
using Fengling.Platform.Domain.AggregatesModel.UserAggregate;
using Fengling.Platform.Domain.AggregatesModel.RoleAggregate;
using Fengling.Platform.Infrastructure;
@ -57,11 +56,8 @@ builder.Services.AddScoped<ITenantManager, TenantManager>();
// Register Gateway managers
builder.Services.AddScoped<IRouteStore, RouteStore<ConsoleDbContext>>();
builder.Services.AddScoped<IInstanceStore, InstanceStore<ConsoleDbContext>>();
builder.Services.AddScoped<IInstanceStore, InstanceStore<PlatformDbContext>>();
builder.Services.AddScoped<IClusterStore, ClusterStore<PlatformDbContext>>();
builder.Services.AddScoped<IRouteManager, RouteManager>();
builder.Services.AddScoped<ITenantStore, TenantStore<ConsoleDbContext>>();
builder.Services.AddScoped<ITenantManager, TenantManager>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ITenantService, TenantService>();

View File

@ -0,0 +1,133 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using Fengling.Console.Data;
using Microsoft.EntityFrameworkCore;
namespace Fengling.Console.Services;
/// <summary>
/// 配置变更通知服务接口
/// </summary>
public interface INotificationService
{
/// <summary>
/// 发布通知到指定通道
/// </summary>
Task PublishAsync(string channel, string payload, CancellationToken cancellationToken = default);
/// <summary>
/// 发布配置变更事件
/// </summary>
Task PublishConfigChangeAsync(
string eventType,
string action,
object? details = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// 配置变更事件数据
/// </summary>
public class ConfigChangeEvent
{
public string EventType { get; set; } = ""; // service, route, instance, gateway
public string Action { get; set; } = ""; // create, update, delete, reload
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public object? Details { get; set; }
}
/// <summary>
/// PostgreSQL 通知服务实现
/// 使用 PostgreSQL NOTIFY/LISTEN 机制广播配置变更
/// </summary>
public class PgSqlNotificationService : INotificationService
{
private readonly ILogger<PgSqlNotificationService> _logger;
private readonly string _connectionString;
public const string GatewayConfigChangedChannel = "gateway_config_changed";
public PgSqlNotificationService(DbContextOptions<ConsoleDbContext> dbContextOptions, ILogger<PgSqlNotificationService> logger)
{
_logger = logger;
// 从 DbContextOptions 获取连接字符串
string? connectionString = null;
foreach (var ext in dbContextOptions.Extensions)
{
var extType = ext.GetType();
if (extType.Name.Contains("Npgsql"))
{
var prop = extType.GetProperty("ConnectionString");
if (prop != null && prop.PropertyType == typeof(string))
{
connectionString = prop.GetValue(ext) as string;
break;
}
}
}
_connectionString = connectionString
?? throw new InvalidOperationException("DefaultConnection not configured");
}
/// <inheritdoc/>
public async Task PublishAsync(string channel, string payload, CancellationToken cancellationToken = default)
{
try
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(
$"SELECT pg_notify(@channel, @payload)",
connection);
cmd.Parameters.AddWithValue("channel", channel);
cmd.Parameters.AddWithValue("payload", payload);
await cmd.ExecuteNonQueryAsync(cancellationToken);
_logger.LogInformation("Published notification to channel '{Channel}': {Payload}", channel, payload);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to publish notification to channel '{Channel}'", channel);
}
}
/// <summary>
/// 创建配置变更事件并发布
/// </summary>
public async Task PublishConfigChangeAsync(
string eventType,
string action,
object? details = null,
CancellationToken cancellationToken = default)
{
var configEvent = new ConfigChangeEvent
{
EventType = eventType,
Action = action,
Timestamp = DateTime.UtcNow,
Details = details
};
var payload = JsonSerializer.Serialize(configEvent);
await PublishAsync(GatewayConfigChangedChannel, payload, cancellationToken);
}
}
/// <summary>
/// 通知服务扩展方法
/// </summary>
public static class NotificationServiceExtensions
{
public static IServiceCollection AddNotificationService(this IServiceCollection services)
{
services.AddScoped<INotificationService, PgSqlNotificationService>();
return services;
}
}

View File

@ -23,38 +23,46 @@ public interface IGatewayService
public class GatewayService : IGatewayService
{
private readonly IRouteStore _routeStore;
private readonly IInstanceStore _instanceStore;
private readonly IClusterStore _clusterStore;
private readonly ILogger<GatewayService> _logger;
public GatewayService(
IRouteStore routeStore,
IInstanceStore instanceStore,
IClusterStore clusterStore,
ILogger<GatewayService> logger)
{
_routeStore = routeStore;
_instanceStore = instanceStore;
_clusterStore = clusterStore;
_logger = logger;
}
}
public async Task<GatewayStatisticsDto> GetStatisticsAsync()
{
var routes = await _routeStore.GetAllAsync();
var instances = await _instanceStore.GetAllAsync();
var clusters = await _clusterStore.GetAllAsync();
var activeRoutes = routes.Where(r => !r.IsDeleted).ToList();
var activeInstances = instances.Where(i => !i.IsDeleted).ToList();
// Count destinations from all clusters
var totalInstances = clusters
.Where(c => !c.IsDeleted)
.Sum(c => c.Destinations?.Count(d => d.Status == 1) ?? 0);
var healthyInstances = clusters
.Where(c => !c.IsDeleted)
.Sum(c => c.Destinations?.Count(d => d.HealthStatus == 1) ?? 0);
return new GatewayStatisticsDto
{
TotalServices = activeRoutes.Select(r => r.ServiceName).Distinct().Count(),
GlobalRoutes = activeRoutes.Count(r => r.IsGlobal),
TenantRoutes = activeRoutes.Count(r => !r.IsGlobal),
TotalInstances = activeInstances.Count,
HealthyInstances = activeInstances.Count(i => i.Health == (int)InstanceHealth.Healthy),
TotalInstances = totalInstances,
HealthyInstances = healthyInstances,
RecentServices = activeRoutes
.OrderByDescending(r => r.CreatedTime)
.Take(5)
.Select(MapToServiceDto)
.Select(r => MapToServiceDto(r, 0))
.ToList()
};
}
@ -62,7 +70,7 @@ public class GatewayService : IGatewayService
public async Task<List<GatewayServiceDto>> GetServicesAsync(bool globalOnly = false, string? tenantCode = null)
{
var routes = await _routeStore.GetAllAsync();
var instances = await _instanceStore.GetAllAsync();
var clusters = await _clusterStore.GetAllAsync();
var query = routes.Where(r => !r.IsDeleted);
@ -72,12 +80,14 @@ public class GatewayService : IGatewayService
query = query.Where(r => r.TenantCode == tenantCode);
var routeList = query.OrderByDescending(r => r.CreatedTime).ToList();
var clusters = routeList.Select(r => r.ClusterId).Distinct().ToList();
var instancesDict = instances
.Where(i => clusters.Contains(i.ClusterId) && !i.IsDeleted)
.GroupBy(i => i.ClusterId)
.ToDictionary(g => g.Key, g => g.Count());
// Build instance count dict from clusters
var instancesDict = clusters
.Where(c => !c.IsDeleted && routeList.Any(r => r.ClusterId == c.ClusterId))
.ToDictionary(
c => c.ClusterId,
c => c.Destinations?.Count(d => d.Status == 1) ?? 0
);
return routeList.Select(r => MapToServiceDto(r, instancesDict.GetValueOrDefault(r.ClusterId, 0))).ToList();
}
@ -92,8 +102,8 @@ public class GatewayService : IGatewayService
if (route == null) return null;
var instances = await _instanceStore.GetAllAsync();
var instanceCount = instances.Count(i => i.ClusterId == route.ClusterId && !i.IsDeleted);
var cluster = await _clusterStore.FindByClusterIdAsync(route.ClusterId);
var instanceCount = cluster?.Destinations?.Count(d => d.Status == 1) ?? 0;
return MapToServiceDto(route, instanceCount);
}
@ -118,20 +128,30 @@ public class GatewayService : IGatewayService
throw new InvalidOperationException($"Service {dto.ServicePrefix} already registered");
}
// Add instance
var instanceId = Guid.CreateVersion7().ToString("N");
var instance = new GwServiceInstance
// Create or get cluster
var cluster = await _clusterStore.FindByClusterIdAsync(clusterId);
if (cluster == null)
{
cluster = new GwCluster
{
Id = Guid.CreateVersion7().ToString("N"),
ClusterId = clusterId,
Name = $"{dto.ServicePrefix} Service",
Destinations = new List<GwDestination>()
};
await _clusterStore.CreateAsync(cluster);
}
// Add destination to cluster
var destination = new GwDestination
{
Id = instanceId,
ClusterId = clusterId,
DestinationId = destinationId,
Address = dto.ServiceAddress,
Weight = dto.Weight,
Health = (int)InstanceHealth.Healthy,
Status = (int)InstanceStatus.Active,
CreatedTime = DateTime.UtcNow
HealthStatus = 1, // Healthy
Status = 1 // Active
};
await _instanceStore.CreateAsync(instance);
await _clusterStore.AddDestinationAsync(clusterId, destination);
// Add route
var routeId = Guid.CreateVersion7().ToString("N");
@ -141,7 +161,7 @@ public class GatewayService : IGatewayService
TenantCode = dto.IsGlobal ? "" : dto.TenantCode ?? "",
ServiceName = dto.ServicePrefix,
ClusterId = clusterId,
PathPattern = pathPattern,
Match = new GwRouteMatch { Path = pathPattern },
Priority = dto.IsGlobal ? 0 : 10,
Status = (int)RouteStatus.Active,
IsGlobal = dto.IsGlobal,
@ -169,16 +189,8 @@ public class GatewayService : IGatewayService
route.UpdatedTime = DateTime.UtcNow;
await _routeStore.UpdateAsync(route);
// Soft delete instances
var instances = await _instanceStore.GetAllAsync();
var routeInstances = instances.Where(i => i.ClusterId == route.ClusterId && !i.IsDeleted).ToList();
foreach (var instance in routeInstances)
{
instance.IsDeleted = true;
instance.UpdatedTime = DateTime.UtcNow;
await _instanceStore.UpdateAsync(instance);
}
// Note: We don't delete destinations when unregistering a service
// The cluster and its destinations persist until explicitly deleted
_logger.LogInformation("Unregistered service {Service}", serviceName);
@ -188,7 +200,7 @@ public class GatewayService : IGatewayService
public async Task<List<GatewayRouteDto>> GetRoutesAsync(bool globalOnly = false)
{
var routes = await _routeStore.GetAllAsync();
var instances = await _instanceStore.GetAllAsync();
var clusters = await _clusterStore.GetAllAsync();
var query = routes.Where(r => !r.IsDeleted);
@ -196,19 +208,21 @@ public class GatewayService : IGatewayService
query = query.Where(r => r.IsGlobal);
var routeList = query.OrderByDescending(r => r.Priority).ToList();
var clusters = routeList.Select(r => r.ClusterId).Distinct().ToList();
var instancesDict = instances
.Where(i => clusters.Contains(i.ClusterId) && !i.IsDeleted)
.GroupBy(i => i.ClusterId)
.ToDictionary(g => g.Key, g => g.Count());
// Build instance count dict from clusters
var instancesDict = clusters
.Where(c => !c.IsDeleted && routeList.Any(r => r.ClusterId == c.ClusterId))
.ToDictionary(
c => c.ClusterId,
c => c.Destinations?.Count(d => d.Status == 1) ?? 0
);
return routeList.Select(r => new GatewayRouteDto
{
Id = r.Id,
ServiceName = r.ServiceName,
ClusterId = r.ClusterId,
PathPattern = r.PathPattern,
PathPattern = r.Match.Path ?? "",
Priority = r.Priority,
IsGlobal = r.IsGlobal,
TenantCode = r.TenantCode,
@ -236,7 +250,7 @@ public class GatewayService : IGatewayService
TenantCode = dto.IsGlobal ? "" : dto.TenantCode ?? "",
ServiceName = dto.ServiceName,
ClusterId = dto.ClusterId,
PathPattern = dto.PathPattern,
Match = new GwRouteMatch { Path = dto.PathPattern },
Priority = dto.Priority,
Status = (int)RouteStatus.Active,
IsGlobal = dto.IsGlobal,
@ -250,7 +264,7 @@ public class GatewayService : IGatewayService
Id = route.Id,
ServiceName = route.ServiceName,
ClusterId = route.ClusterId,
PathPattern = route.PathPattern,
PathPattern = route.Match.Path ?? "",
Priority = route.Priority,
IsGlobal = route.IsGlobal,
TenantCode = route.TenantCode,
@ -261,80 +275,103 @@ public class GatewayService : IGatewayService
public async Task<List<GatewayInstanceDto>> GetInstancesAsync(string clusterId)
{
var instances = await _instanceStore.GetAllAsync();
var clusterInstances = instances
.Where(i => i.ClusterId == clusterId && !i.IsDeleted)
.OrderByDescending(i => i.Weight)
.ToList();
var cluster = await _clusterStore.FindByClusterIdAsync(clusterId);
if (cluster == null || cluster.Destinations == null)
return new List<GatewayInstanceDto>();
return clusterInstances.Select(i => new GatewayInstanceDto
{
Id = i.Id,
ClusterId = i.ClusterId,
DestinationId = i.DestinationId,
Address = i.Address,
Weight = i.Weight,
Health = (int)i.Health,
Status = (int)i.Status,
CreatedAt = i.CreatedTime
}).ToList();
return cluster.Destinations
.Where(d => d.Status == 1)
.OrderByDescending(d => d.Weight)
.Select(d => new GatewayInstanceDto
{
Id = d.DestinationId,
ClusterId = clusterId,
DestinationId = d.DestinationId,
Address = d.Address ?? "",
Weight = d.Weight,
Health = d.HealthStatus,
Status = d.Status,
CreatedAt = DateTime.UtcNow
}).ToList();
}
public async Task<GatewayInstanceDto> AddInstanceAsync(CreateGatewayInstanceDto dto)
{
var existing = await _instanceStore.FindByDestinationAsync(dto.ClusterId, dto.DestinationId);
if (existing != null && !existing.IsDeleted)
var destination = new GwDestination
{
throw new InvalidOperationException($"Instance {dto.DestinationId} already exists in cluster {dto.ClusterId}");
DestinationId = dto.DestinationId,
Address = dto.Address,
Weight = dto.Weight,
HealthStatus = 1, // Healthy
Status = 1 // Active
};
var cluster = await _clusterStore.AddDestinationAsync(dto.ClusterId, destination);
if (cluster == null)
{
throw new InvalidOperationException($"Cluster {dto.ClusterId} not found");
}
var instance = new GwServiceInstance
return new GatewayInstanceDto
{
Id = Guid.CreateVersion7().ToString("N"),
Id = dto.DestinationId,
ClusterId = dto.ClusterId,
DestinationId = dto.DestinationId,
Address = dto.Address,
Weight = dto.Weight,
Health = (int)InstanceHealth.Healthy,
Status = (int)InstanceStatus.Active,
CreatedTime = DateTime.UtcNow
};
await _instanceStore.CreateAsync(instance);
return new GatewayInstanceDto
{
Id = instance.Id,
ClusterId = instance.ClusterId,
DestinationId = instance.DestinationId,
Address = instance.Address,
Weight = instance.Weight,
Health = (int)instance.Health,
Status = (int)instance.Status,
CreatedAt = instance.CreatedTime
Health = 1,
Status = 1,
CreatedAt = DateTime.UtcNow
};
}
public async Task<bool> RemoveInstanceAsync(string instanceId)
{
var instance = await _instanceStore.FindByIdAsync(instanceId);
if (instance == null) return false;
instance.IsDeleted = true;
instance.UpdatedTime = DateTime.UtcNow;
await _instanceStore.UpdateAsync(instance);
return true;
// We need to find the cluster and destination
// Since we don't have direct lookup, iterate through clusters
var clusters = await _clusterStore.GetAllAsync();
foreach (var cluster in clusters)
{
if (cluster.Destinations == null) continue;
var dest = cluster.Destinations.FirstOrDefault(d => d.DestinationId == instanceId);
if (dest != null)
{
await _clusterStore.RemoveDestinationAsync(cluster.ClusterId, instanceId);
return true;
}
}
return false;
}
public async Task<bool> UpdateInstanceWeightAsync(string instanceId, int weight)
{
var instance = await _instanceStore.FindByIdAsync(instanceId);
if (instance == null) return false;
instance.Weight = weight;
instance.UpdatedTime = DateTime.UtcNow;
await _instanceStore.UpdateAsync(instance);
return true;
// Find the cluster containing this destination
var clusters = await _clusterStore.GetAllAsync();
foreach (var cluster in clusters)
{
if (cluster.Destinations == null) continue;
var dest = cluster.Destinations.FirstOrDefault(d => d.DestinationId == instanceId);
if (dest != null)
{
var updatedDest = new GwDestination
{
DestinationId = dest.DestinationId,
Address = dest.Address ?? "",
Weight = weight,
HealthStatus = dest.HealthStatus,
Status = dest.Status
};
await _clusterStore.UpdateDestinationAsync(cluster.ClusterId, instanceId, updatedDest);
return true;
}
}
return false;
}
public async Task ReloadGatewayAsync()
@ -351,7 +388,7 @@ public class GatewayService : IGatewayService
ServicePrefix = route.ServiceName,
ServiceName = route.ServiceName,
ClusterId = route.ClusterId,
PathPattern = route.PathPattern,
PathPattern = route.Match.Path ?? "",
ServiceAddress = "",
DestinationId = "",
Weight = 1,