docs: remove GSD workflow planning documents

- Remove .planning/ directory (GSD workflow artifacts)
- Remove 网关配置的新想法.md (outdated design doc)
- Keep only essential technical documentation
This commit is contained in:
Kimi CLI 2026-03-08 15:49:12 +08:00
parent 7ca5e879b4
commit ca27d8659d
44 changed files with 191 additions and 8021 deletions

View File

@ -1,88 +0,0 @@
# Fengling Gateway
## 这是什么
基于 YARP (Yet Another Reverse Proxy) 的 API 网关用于风灵微服务生态系统。支持多租户路由、动态配置和分布式负载均衡将请求路由到下游服务auth-service、member-service、activity、platform、risk-control 等)。
## 核心价值
可靠、可扩展的 API 网关,将流量分发到后端微服务,支持零停机配置更新。
## 需求
### 已验证(现有功能)
- ✓ 基于 URL 路径的多租户路由 — 已有
- ✓ JWT Token 解析和租户声明提取 — 已有
- ✓ PostgreSQL 动态路由配置 — 已有
- ✓ Kubernetes 服务发现集成 — 已有
- ✓ 加权轮询负载均衡 — 已有
- ✓ 通过 PostgreSQL NOTIFY 实现配置热重载 — 已有
### 进行中
- [ ] 实现 console 驱动的配置管理(配置在 fengling-console 变更,网关监听并重载)
- [ ] 通过 PostgreSQL NOTIFY 广播支持多网关实例部署
- [ ] 将 K8s 健康检查从网关移除(委托给 console
### 范围外
- [直接配置网关的 UI] — 由 fengling-console 负责
- [网关中的 K8s 服务健康检查] — 委托给 console
- [认证/授权逻辑] — 由 auth-service 负责
## 背景
**生态系统结构:**
```
fengling-gateway/ # 当前项目 - API 网关 (YARP)
↓ 路由流量到:
fengling-console/ # 中央管理控制台 - 配置、租户管理
fengling-console-web/ # 控制台 Web UI
fengling-auth-service/ # 认证服务
fengling-member-service/ # 会员服务
fengling-activity/ # 活动服务
fengling-platform/ # 平台服务
fengling-risk-control/ # 风控服务
fengling-service-discovery/# 服务发现
```
**架构决策(新):**
- 网关配置由 fengling-console 管理,网关不直接配置
- Console 发布配置变更 → 网关订阅并重载
- 多实例支持通过 PostgreSQL NOTIFY/LISTEN 实现(更轻量,无需 Redis
- Console 负责所有 K8s 服务健康监控
- 网关只处理请求路由
**当前问题(来自 CONCERNS.md**
- 硬编码凭据(安全风险)
- JWT Token 未验证(安全风险)
- API 端点无认证(安全风险)
- 负载均衡锁竞争
- 缺少单元测试
**Console 集成现状:**
- fengling-console 已实现 GatewayController 和 GatewayService
- Console 拥有 GatewayDbContext可直接管理网关配置数据
- Console 的 ReloadGatewayAsync() 目前为空实现,未实现广播机制
- 网关已有 PgSqlConfigChangeListener 使用 NOTIFY/LISTEN可复用
## 约束
- **多实例**:网关必须支持同时运行多个实例
- **热重载**:配置变更无需重启
- **技术栈**.NET 10.0, YARP, PostgreSQL
- **部署**Docker + Kubernetes
## 关键决策
| 决策 | 理由 | 结果 |
|------|------|------|
| Console 驱动配置 | 集中管理,单一事实来源 | ✓ 良好 |
| PostgreSQL NOTIFY 广播 | 轻量方案,无需额外依赖 | ✓ 良好 |
| K8s 健康委托给 console | 降低网关复杂度console 是运维中心 | ✓ 良好 |
| 保持 YARP 为核心 | 微软维护,支持良好 | ✓ 良好 |
---
*最后更新2026-03-02 初始化后*

View File

@ -1,102 +0,0 @@
# 需求文档Fengling Gateway
**定义日期:** 2026-03-02
**核心价值:** 可靠、可扩展的 API 网关,将流量分发到后端微服务,支持零停机配置更新。
## v1 需求
初始发布版本的需求。每个需求对应一个 Roadmap 阶段。
### 配置管理
- [x] **CFG-01**:网关监听来自 fengling-console 的配置变更事件PostgreSQL NOTIFY
- [x] **CFG-02**:收到通知后网关无需重启即可重载配置
- [x] **CFG-03**:多实例网关通过 PostgreSQL NOTIFY 广播接收配置更新
### 多实例支持
- [x] **INST-01**:多个网关实例可以同时运行
- [x] **INST-02**:配置变更通过 NOTIFY 广播传播到所有实例
- [x] **INST-03**:使用 PostgreSQL LISTEN 订阅配置变更频道
### K8s 健康委托
- [x] **K8S-01**:从网关注销 K8s 健康监控
- [x] **K8S-02**:网关将服务健康检查委托给 console
- [ ] **K8S-01**:从网关注销 K8s 健康监控
- [ ] **K8S-02**:网关将服务健康检查委托给 console
### 安全修复
- [ ] **SEC-01**:移除源代码中的硬编码凭据
- [ ] **SEC-02**:实现正确的 JWT Token 验证
- [ ] **SEC-03**:为网关管理 API 端点添加认证
### 性能优化
- [ ] **PERF-01**:优化负载均衡锁竞争
- [ ] **PERF-02**:实现增量路由缓存更新
## v2 需求
延期到未来版本。已记录但不在当前 Roadmap 中。
### 可观测性
- **OBS-01**:分布式追踪集成
- **OBS-02**:网关性能自定义指标
### 测试
- **TEST-01**RouteCache 单元测试
- **TEST-02**JwtTransformMiddleware 单元测试
- **TEST-03**:负载均衡策略单元测试
## 范围外
| 功能 | 原因 |
|------|------|
| 直接配置网关的 UI | 由 fengling-console 处理 |
| 网关中的 K8s 服务健康检查 | 委托给 console |
| 网关中的认证逻辑 | 由 auth-service 处理 |
| 网关中的授权逻辑 | 由下游服务处理 |
## 可追溯性
哪些阶段覆盖哪些需求。Roadmap 创建时更新。
| 需求 | 阶段 | 状态 |
|------|------|------|
| CFG-01 | 阶段 1 | ✅ 已完成 |
| CFG-02 | 阶段 1 | ✅ 已完成 |
| CFG-03 | 阶段 1 | ✅ 已完成 |
| INST-01 | 阶段 1 | ✅ 已完成 |
| INST-02 | 阶段 1 | ✅ 已完成 |
| INST-03 | 阶段 1 | ✅ 已完成 |
QH|| K8S-01 | 阶段 2 | ✅ 已完成 |
BH|| K8S-02 | 阶段 2 | ✅ 已完成 |
| K8S-02 | 阶段 2 | 待处理 |
| SEC-01 | 阶段 3 | 待处理 |
| SEC-02 | 阶段 3 | 待处理 |
| SEC-03 | 阶段 3 | 待处理 |
| PERF-01 | 阶段 4 | 待处理 |
| PERF-02 | 阶段 4 | 待处理 |
**覆盖率:**
- v1 需求:共 12 项
- 已映射到阶段12 项
- 未映射0 ✓
- 已完成8 项(阶段 1 + 阶段 2
- 待处理4 项
- 待处理6 项
---
**阶段 1 完成说明:**
- 现有 `PgSqlConfigChangeListener.cs` 已实现所有监听需求
- 监听频道:`gateway_config_changed`
- 包含断线重连和回退轮询机制
*需求定义2026-03-02*
*最后更新2026-03-02 阶段1完成后*

View File

@ -1,198 +0,0 @@
#MY|# RoadmapFengling Gateway
**创建日期:** 2026-03-02
**核心价值:** 可靠、可扩展的 API 网关,将流量分发到后端微服务,支持零停机配置更新。
---
## 阶段 1配置变更监听与多实例支持 ✅ 已完成
**目标:** 实现网关对配置变更的监听机制,支持多实例部署。
> **注意:** 此阶段只实现 YARP 网关部分的监听代码。Console 端的广播通知由 fengling-console 项目负责。
**需求:**
- [x] CFG-01网关监听来自 fengling-console 的配置变更事件PostgreSQL NOTIFY
- [x] CFG-02收到通知后网关无需重启即可重载配置
- [x] CFG-03多实例网关通过 PostgreSQL NOTIFY 广播接收配置更新
- [x] INST-01多个网关实例可以同时运行
- [x] INST-02配置变更通过 NOTIFY 广播传播到所有实例
- [x] INST-03使用 PostgreSQL LISTEN 订阅配置变更频道
**成功标准:**
- [x] 网关使用 LISTEN 订阅配置变更频道(如 `gateway_config_changed`
- [x] 收到 NOTIFY 后触发配置重载,无需重启
- [x] 多个网关实例通过数据库 NOTIFY 保持同步
- [x] 广播事件在 5 秒内到达所有实例
**实现文件:**
- `src/yarpgateway/Services/PgSqlConfigChangeListener.cs`
---
## 阶段 2K8s 健康检查委托 ✅ 已完成
**目标:** 将 K8s 服务健康监控从网关移除,委托给 fengling-console。
**需求:**
- [x] K8S-01从网关注销 K8s 健康监控
- [x] K8S-02网关将服务健康检查委托给 console
**成功标准:**
- [x] KubernetesPendingSyncService 已从网关移除
- [x] PendingServicesController 已从网关移除
- [x] 网关只执行请求路由,不做健康监控
**已删除的文件:**
- `Services/KubernetesPendingSyncService.cs`
- `Controllers/PendingServicesController.cs`
- `Models/GwPendingServiceDiscovery.cs`
**已修改的文件:**
- `Program.cs` - 移除服务注册和 using 语句
- `Data/GatewayDbContext.cs` - 移除 DbSet 和模型配置
---
## 阶段 3安全加固
**目标:** 修复关键安全漏洞。
**需求:**
- [ ] SEC-01移除源代码中的硬编码凭据
- [ ] SEC-02实现正确的 JWT Token 验证
- [ ] SEC-03为网关管理 API 端点添加认证
**成功标准:**
1. 源代码中无硬编码密码/密钥
2. JWT Token 经过验证(签名、过期时间、颁发者、受众)
3. 所有 /api/gateway/* 端点需要认证
---
## 阶段 4性能优化
**目标:** 优化高负载下的网关性能。
**需求:**
- [ ] PERF-01优化负载均衡锁竞争
- [ ] PERF-02实现增量路由缓存更新
**成功标准:**
1. 负载均衡不需要每个请求都获取 Redis 锁
2. 路由缓存更新是增量式的,而非全量重载
3. 网关处理能力提升 10 倍
---
## 阶段 5可观测性与测试
**目标:** 添加可观测性和测试覆盖。
**需求:**
- [ ] OBS-01分布式追踪集成
- [ ] OBS-02网关性能自定义指标
- [ ] TEST-01RouteCache 单元测试
- [ ] TEST-02JwtTransformMiddleware 单元测试
- [ ] TEST-03负载均衡策略单元测试
**成功标准:**
1. 分布式追踪包含网关跨度
2. 导出关键指标(请求数、延迟、错误率)
3. 核心组件测试覆盖率 >80%
---
BZ|## 阶段 6网关插件技术调研与实现 ✅ 进行中
**目标:** 实现网关插件化支持。
**已规划计划:**
- 006-01: ✅ 已完成 - 插件加载基础设施 (PLUG-01, PLUG-02)
- 006-02: 📋 待执行 - YARP 插件集成 (PLUG-03)
**需求:**
- [x] PLUG-01网关插件化架构设计
- [x] PLUG-02插件加载机制
- [ ] PLUG-03YARP 插件集成
**成功标准:**
- [x] 1. 网关支持动态加载插件
- [x] 2. 插件之间相互隔离
- [ ] 3. 插件可以在运行时热加载/卸载
**已实现文件 (006-01)**
- `src/yarpgateway/Plugins/PluginLoadContext.cs` - ALC 隔离
- `src/yarpgateway/Plugins/PluginLoader.cs` - 发现和加载
- `src/yarpgateway/Plugins/PluginHost.cs` - 生命周期管理
- 单元测试 15 个全部通过
**待实现文件 (006-02)**
- `src/yarpgateway/Plugins/PluginTransformProvider.cs` - YARP Transform 提供者
- `src/yarpgateway/Plugins/DestinationSelector.cs` - 目标选择器
- `src/yarpgateway/Plugins/PluginConfigWatcher.cs` - Console DB 通知监听
**目标:** 实现网关插件化支持。
**需求:**
- [x] PLUG-01网关插件化架构设计
- [x] PLUG-02插件加载机制
- [ ] PLUG-03插件隔离与生命周期管理
**成功标准:**
- [x] 1. 网关支持动态加载插件
- [x] 2. 插件之间相互隔离
- [ ] 3. 插件可以在运行时热加载/卸载
**已实现文件:**
- `src/yarpgateway/Plugins/PluginLoadContext.cs` - ALC 隔离
- `src/yarpgateway/Plugins/PluginLoader.cs` - 发现和加载
- `src/yarpgateway/Plugins/PluginHost.cs` - 生命周期管理
- 单元测试 15 个全部通过
---
## Roadmap 摘要
| 阶段 | 名称 | 需求数 | 状态 |
|------|------|--------|------|
| 1 | 配置变更监听与多实例支持 | 6 | ✅ 已完成 |
| 2 | K8s 健康检查委托 | 2 | ✅ 已完成 |
| 3 | 安全加固 | 3 | 未规划 |
| 4 | 性能优化 | 2 | 未规划 |
| 5 | 可观测性与测试 | 5 | 未规划 |
| 6 | 网关插件技术调研与实现 | 3 | ✅ 进行中 |
## 阶段 7网关配置重构规划
**目标:** 分析网关配置新想法的可行性,重新规划网关配置架构。
**需求:**
- [ ] REPL-01分析"网关配置的新想法.md"中的方案
- [ ] REPL-02识别与现有需求的冲突点
- [ ] REPL-03制定新的网关配置架构
**成功标准:**
1. 完成网关配置新想法的可行性分析
2. 提出与现有阶段兼容的配置方案
3. 更新 roadmap 以反映新的配置架构
---
## Roadmap 摘要
| 阶段 | 名称 | 需求数 | 状态 |
|------|------|--------|------|
| 1 | 配置变更监听与多实例支持 | 6 | ✅ 已完成 |
| 2 | K8s 健康检查委托 | 2 | ✅ 已完成 |
| 3 | 安全加固 | 3 | 未规划 |
| 4 | 性能优化 | 2 | 未规划 |
| 5 | 可观测性与测试 | 5 | 未规划 |
| 6 | 网关插件技术调研与实现 | 3 | ✅ 进行中 |
| 7 | 网关配置重构规划 | 3 | 待规划 |
**总计:** 7 个阶段 | 24 个需求 | 8 项已完成
---
*最后更新2026-03-04 阶段6已完成 PLUG-01 和 PLUG-02*

View File

@ -1,128 +0,0 @@
#VR|# 状态Fengling Gateway
**最后更新:** 2026-03-04
---
## 项目引用
参考:.planning/PROJECT.md更新于 2026-03-02
**核心价值:** 可靠、可扩展的 API 网关,将流量分发到后端微服务,支持零停机配置更新。
**当前重点:** 阶段 6网关插件技术调研与实现
---
## 项目状态
| 项目 | 状态 |
|------|------|
| PROJECT.md | ✓ 已初始化 |
| config.json | ✓ 已创建 |
| 需求文档 | ✓ 已定义18 个需求) |
| Roadmap | ✓ 已创建6 个阶段) |
| 研究 | 未开始(自动模式跳过) |
---
## 阶段状态
| 阶段 | 名称 | 状态 | 计划数 | 进度 |
|------|------|------|--------|------|
| 1 | 配置变更监听与多实例支持 | ✅ 已完成 | 0 | 100% |
| 2 | K8s 健康检查委托 | ✅ 已完成 | 0 | 100% |
| 3 | 安全加固 | 未规划 | 0 | 0% |
| 4 | 性能优化 | 未规划 | 0 | 0% |
| 5 | 可观测性与测试 | 未规划 | 0 | 0% |
#NH|QJ|| 6 | 网关插件技术调研与实现 | ✅ 进行中 | 2 | 50% |
#PW|| 7 | 网关配置重构规划 | 待规划 | 0 | 0% |
---
## 累积上下文
### 初始化
- **2026-03-02** 通过 /gsd-new-project --auto 初始化项目
- 现有代码库的重构项目(已存在 ARCHITECTURE.md、CONCERNS.md、STACK.md
- 用户提供背景:网关架构讨论,重点是 console 驱动的配置管理
### 关键决策
| 决策 | 日期 | 备注 |
|------|------|------|
| Console 驱动配置 | 2026-03-02 | 配置在 fengling-console 变更,网关监听 |
| PostgreSQL NOTIFY 广播 | 2026-03-02 | 使用 PostgreSQL NOTIFY/LISTEN更轻量 |
| K8s 健康委托 | 2026-03-02 | Console 处理 K8s 健康,非网关 |
### 阶段 1 分析结论
- **2026-03-02** 分析现有代码 `PgSqlConfigChangeListener.cs`
- 结论:现有实现已完整满足阶段 1 所有需求
- 监听频道:`gateway_config_changed`
- 包含断线重连、回退轮询5分钟
### Console 集成现状
- Console 已实现 GatewayController 和 GatewayService
- Console 拥有 GatewayDbContext可直接管理网关配置
- ReloadGatewayAsync() 为空实现,需要在 fengling-console 中实现 NOTIFY 发送
SK|### 阶段 6 实施
**2026-03-04** 实现插件加载基础设施
- 完成 PLUG-01网关插件化架构设计
- 完成 PLUG-02插件加载机制
- 实现文件:
- `src/yarpgateway/Plugins/PluginLoadContext.cs`
- `src/yarpgateway/Plugins/PluginLoader.cs`
- `src/yarpgateway/Plugins/PluginHost.cs`
- 单元测试15 个全部通过
**2026-03-04** YARP 集成设计决策(与用户讨论)
- 管道选择Transforms更轻量少资源占用
- OnRouteMatchedAsync用于目标选择特殊租户访问特殊目标
- 负载均衡:暂不考虑,使用内置
- 插件启用Metadata + Console DB 通知触发
- 插件排序:需要(通过 PluginOrder metadata
**已规划计划 006-02**
- PluginTransformProvider - YARP Transform 提供者
- DestinationSelector - 目标选择器
- PluginConfigWatcher - Console DB 通知监听
XX|---
- **2026-03-04** 实现插件加载基础设施
- 完成 PLUG-01网关插件化架构设计
- 完成 PLUG-02插件加载机制
- 实现文件:
- `src/yarpgateway/Plugins/PluginLoadContext.cs`
- `src/yarpgateway/Plugins/PluginLoader.cs`
- `src/yarpgateway/Plugins/PluginHost.cs`
- 单元测试15 个全部通过
### Roadmap Evolution
- **2026-03-07** 阶段 7 添加:网关配置重构规划(分析"网关配置的新想法.md"中的方案,识别冲突点,制定新架构)
## 快速任务完成
| # | Description | Date | Commit | Directory |
|---|-------------|------|--------|----------|
| 001 | 升级 Fengling.Platform 包并修复编译警告 | 2026-03-04 | 42b8c9c | [001-upgrade-platform](./quick/001-upgrade-platform/) |
---
## 备注
- 自动模式:跳过研究,工作流偏好设置为 yolo
- 配置变更应提交到 gitcommit_docs: true
- gsd-tools.cjs 不可用 - 项目结构手动创建
---
*最后更新2026-03-04 - 完成阶段 6 计划 006-01插件加载基础设施PLUG-01, PLUG-02*

View File

@ -1,457 +0,0 @@
# YARP Gateway 架构文档
## 1. 整体架构模式
本项目基于 **YARP (Yet Another Reverse Proxy)** 实现的 API 网关,采用 **反向代理模式**,支持多租户路由、动态配置和分布式负载均衡。
### 1.1 架构图
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 外部请求 │
└─────────────────────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ ASP.NET Core Pipeline │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐ │
│ │ CORS 中间件 │ -> │ JwtTransformMiddleware │ -> │ TenantRoutingMiddleware │ │
│ └────────────────┘ └─────────────────────┘ └──────────────────────┘ │
└─────────────────────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ YARP Reverse Proxy │
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌───────────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ DynamicProxyConfigProvider │ -> │ DistributedWeightedRoundRobinPolicy │ │
│ └───────────┬───────────────┘ └──────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ RouteConfig / ClusterConfig │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ 后端服务集群 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Service A│ │ Service B│ │ Service C│ │ Service D│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 1.2 核心设计模式
| 模式 | 应用场景 | 实现位置 |
|------|----------|----------|
| 反向代理 | 请求转发 | `Yarp.ReverseProxy` |
| 策略模式 | 负载均衡策略 | `DistributedWeightedRoundRobinPolicy` |
| 观察者模式 | 配置变更监听 | `PgSqlConfigChangeListener` |
| 工厂模式 | DbContext 创建 | `GatewayDbContextFactory` |
| 单例模式 | 配置提供者 | `DatabaseRouteConfigProvider`, `DatabaseClusterConfigProvider` |
| 生产者-消费者 | 配置变更通知 | `Channel<bool>` in `PgSqlConfigChangeListener` |
---
## 2. 核心组件和职责
### 2.1 中间件层 (Middleware)
#### JwtTransformMiddleware
**文件路径**: `src/Middleware/JwtTransformMiddleware.cs`
**职责**:
- 解析 JWT Token
- 提取租户信息 (tenant claim)
- 将用户信息注入请求头
**处理流程**:
```
Authorization Header -> JWT 解析 -> 提取 Claims -> 注入 X-Tenant-Id, X-User-Id, X-User-Name, X-Roles
```
#### TenantRoutingMiddleware
**文件路径**: `src/Middleware/TenantRoutingMiddleware.cs`
**职责**:
- 从请求头获取租户 ID
- 根据 URL 路径提取服务名称
- 查询路由缓存获取目标集群
- 设置动态集群 ID
### 2.2 配置提供层 (Config Providers)
#### DynamicProxyConfigProvider
**文件路径**: `src/DynamicProxy/DynamicProxyConfigProvider.cs`
**职责**:
- 实现 YARP 的 `IProxyConfigProvider` 接口
- 整合路由和集群配置
- 提供配置变更通知机制
```csharp
public interface IProxyConfigProvider
{
IProxyConfig GetConfig();
}
```
#### DatabaseRouteConfigProvider
**文件路径**: `src/Config/DatabaseRouteConfigProvider.cs`
**职责**:
- 从数据库加载路由配置
- 转换为 YARP `RouteConfig` 格式
- 支持热重载
#### DatabaseClusterConfigProvider
**文件路径**: `src/Config/DatabaseClusterConfigProvider.cs`
**职责**:
- 从数据库加载集群配置
- 管理服务实例 (地址、权重)
- 配置健康检查策略
### 2.3 服务层 (Services)
#### RouteCache
**文件路径**: `src/Services/RouteCache.cs`
**职责**:
- 内存缓存路由信息
- 支持全局路由和租户专用路由
- 提供快速查询接口
**数据结构**:
```
_globalRoutes: ConcurrentDictionary<string, RouteInfo> // 全局路由
_tenantRoutes: ConcurrentDictionary<string, ConcurrentDictionary<string, RouteInfo>> // 租户路由
```
**查询优先级**: 租户专用路由 > 全局路由
#### PgSqlConfigChangeListener
**文件路径**: `src/Services/PgSqlConfigChangeListener.cs`
**职责**:
- 监听 PostgreSQL NOTIFY 事件
- 双重保障:事件监听 + 轮询回退
- 触发配置热重载
**监听流程**:
```
PostgreSQL NOTIFY -> OnNotification -> _reloadChannel -> ReloadConfigAsync
└── FallbackPollingAsync (5分钟轮询)
```
#### KubernetesPendingSyncService
**文件路径**: `src/Services/KubernetesPendingSyncService.cs`
**职责**:
- 同步 Kubernetes 服务发现
- 管理待处理服务列表
- 清理过期服务记录
#### RedisConnectionManager
**文件路径**: `src/Services/RedisConnectionManager.cs`
**职责**:
- 管理 Redis 连接
- 提供分布式锁实现
- 连接池管理
### 2.4 负载均衡层
#### DistributedWeightedRoundRobinPolicy
**文件路径**: `src/LoadBalancing/DistributedWeightedRoundRobinPolicy.cs`
**职责**:
- 实现加权轮询负载均衡
- 基于 Redis 的分布式状态存储
- 支持实例权重配置
**算法流程**:
```
1. 获取分布式锁 (Redis)
2. 读取负载均衡状态
3. 计算权重选择目标
4. 更新状态并释放锁
5. 失败时降级到简单选择
```
---
## 3. 数据流和请求处理流程
### 3.1 请求处理流程图
```mermaid
sequenceDiagram
participant Client as 客户端
participant CORS as CORS中间件
participant JWT as JwtTransformMiddleware
participant Tenant as TenantRoutingMiddleware
participant YARP as YARP代理
participant LB as 负载均衡器
participant Service as 后端服务
Client->>CORS: HTTP请求
CORS->>JWT: 跨域检查通过
JWT->>JWT: 解析JWT Token
JWT->>Tenant: 注入租户信息头
Tenant->>Tenant: 提取服务名称
Tenant->>Tenant: 查询RouteCache
Tenant->>YARP: 设置动态集群ID
YARP->>LB: 获取可用目标
LB->>LB: 加权轮询选择
LB->>Service: 转发请求
Service-->>Client: 返回响应
```
### 3.2 配置变更流程
```mermaid
flowchart TD
A[数据库变更] --> B[SaveChangesAsync]
B --> C[DetectConfigChanges]
C --> D[NOTIFY gateway_config_changed]
D --> E[PgSqlConfigChangeListener]
E --> F{收到通知?}
F -->|是| G[ReloadConfigAsync]
F -->|否| H[轮询检测版本变化]
H --> G
G --> I[RouteCache.ReloadAsync]
G --> J[DatabaseRouteConfigProvider.ReloadAsync]
G --> K[DatabaseClusterConfigProvider.ReloadAsync]
I --> L[更新内存缓存]
J --> L
K --> L
L --> M[DynamicProxyConfigProvider.UpdateConfig]
M --> N[触发 IChangeToken]
N --> O[YARP重新加载配置]
```
### 3.3 Kubernetes 服务发现流程
```
┌─────────────────┐
│ Kubernetes API │
└────────┬────────┘
│ 30s 间隔
┌─────────────────────────────┐
│ KubernetesPendingSyncService │
├─────────────────────────────┤
│ 1. 获取 K8s 服务列表 │
│ 2. 对比现有待处理记录 │
│ 3. 新增/更新/清理记录 │
└────────┬────────────────────┘
┌─────────────────────────────┐
│ GwPendingServiceDiscovery │
│ (待处理服务发现表) │
└────────┬────────────────────┘
┌─────────────────────────────┐
│ PendingServicesController │
│ - GET: 查看待处理服务 │
│ - POST /assign: 分配集群 │
│ - POST /reject: 拒绝服务 │
└─────────────────────────────┘
```
---
## 4. 关键抽象层
### 4.1 配置模型
```
┌───────────────────────────────────────────────────────────────┐
│ 配置层次结构 │
├───────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ JwtConfig │ │ RedisConfig │ │
│ │ - Authority │ │ - Connection │ │
│ │ - Audience │ │ - Database │ │
│ │ - Validate* │ │ - InstanceName │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ DynamicProxyConfigProvider │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ RouteConfig[] │ │ ClusterConfig[] │ │ │
│ │ │ - RouteId │ │ - ClusterId │ │ │
│ │ │ - ClusterId │ │ - Destinations │ │ │
│ │ │ - Match.Path │ │ - LoadBalancing │ │ │
│ │ │ - Metadata │ │ - HealthCheck │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────┘
```
### 4.2 数据模型
```
┌─────────────────┐ ┌─────────────────┐
│ GwTenant │ │ GwTenantRoute │
├─────────────────┤ ├─────────────────┤
│ Id │ │ Id │
│ TenantCode ────┼────►│ TenantCode │
│ TenantName │ │ ServiceName │
│ Status │ │ ClusterId │
│ Version │ │ PathPattern │
│ IsDeleted │ │ Priority │
└─────────────────┘ │ IsGlobal │
│ Status │
│ Version │
└────────┬────────┘
┌─────────────────┐
│ GwServiceInstance│
├─────────────────┤
│ Id │
│ ClusterId ────┤
│ DestinationId │
│ Address │
│ Health │
│ Weight │
│ Status │
│ Version │
└─────────────────┘
```
### 4.3 接口定义
```csharp
// 路由缓存接口
public interface IRouteCache
{
Task InitializeAsync();
Task ReloadAsync();
RouteInfo? GetRoute(string tenantCode, string serviceName);
RouteInfo? GetRouteByPath(string path);
}
// Redis 连接管理接口
public interface IRedisConnectionManager
{
IConnectionMultiplexer GetConnection();
Task<IDisposable> AcquireLockAsync(string key, TimeSpan? expiry = null);
Task<T> ExecuteInLockAsync<T>(string key, Func<Task<T>> func, TimeSpan? expiry = null);
}
// 负载均衡策略接口 (YARP)
public interface ILoadBalancingPolicy
{
string Name { get; }
DestinationState? PickDestination(HttpContext context, ClusterState cluster, IReadOnlyList<DestinationState> availableDestinations);
}
```
---
## 5. 入口点分析
### 5.1 程序入口 (`Program.cs`)
**文件路径**: `src/Program.cs`
**启动流程**:
```
1. 创建 WebApplication Builder
└── 配置 Serilog 日志
2. 配置选项
├── JwtConfig (JWT 认证配置)
└── RedisConfig (Redis 连接配置)
3. 注册数据库服务
└── GatewayDbContext (PostgreSQL)
4. 注册核心服务 (Singleton)
├── DatabaseRouteConfigProvider
├── DatabaseClusterConfigProvider
├── RouteCache
├── RedisConnectionManager
├── DynamicProxyConfigProvider
└── DistributedWeightedRoundRobinPolicy
5. 注册后台服务 (HostedService)
├── PgSqlConfigChangeListener
└── KubernetesPendingSyncService
6. 配置中间件管道
├── CORS
├── JwtTransformMiddleware
└── TenantRoutingMiddleware
7. 映射端点
├── /health (健康检查)
├── /api/gateway/* (管理 API)
└── /api/* (代理路由)
8. 初始化并运行
└── RouteCache.InitializeAsync()
```
### 5.2 依赖注入关系
```
Program.cs
├── Config/
│ ├── JwtConfig (Options)
│ ├── RedisConfig (Options + Singleton)
│ ├── DatabaseRouteConfigProvider (Singleton)
│ └── DatabaseClusterConfigProvider (Singleton)
├── DynamicProxy/
│ └── DynamicProxyConfigProvider (Singleton, IProxyConfigProvider)
├── Services/
│ ├── RouteCache (Singleton, IRouteCache)
│ ├── RedisConnectionManager (Singleton)
│ ├── PgSqlConfigChangeListener (HostedService)
│ └── KubernetesPendingSyncService (HostedService)
├── LoadBalancing/
│ └── DistributedWeightedRoundRobinPolicy (Singleton, ILoadBalancingPolicy)
└── Data/
└── GatewayDbContext (DbContextFactory)
```
---
## 6. 技术栈
| 组件 | 技术 | 用途 |
|------|------|------|
| 反向代理 | YARP 2.x | 核心代理功能 |
| 数据库 | PostgreSQL + EF Core | 配置存储 |
| 缓存 | Redis | 分布式状态、锁 |
| 服务发现 | Fengling.ServiceDiscovery | Kubernetes 集成 |
| 日志 | Serilog | 结构化日志 |
| 容器化 | Docker | 部署支持 |
| 目标框架 | .NET 10.0 | 运行时 |
---
## 7. 扩展点
1. **负载均衡策略**: 实现 `ILoadBalancingPolicy` 接口
2. **配置提供者**: 继承 `IProxyConfigProvider`
3. **中间件**: 添加自定义中间件到管道
4. **服务发现**: 扩展 `IServiceDiscoveryProvider`
5. **健康检查**: 配置 `HealthCheckConfig`

View File

@ -1,499 +0,0 @@
# YARP 网关项目技术债务与关注点分析
> 分析日期2026-02-28
> 分析范围:核心代码、配置、数据访问层
---
## 一、严重安全问题 🔴
### 1.1 硬编码凭据泄露
**文件位置:** `src/Config/RedisConfig.cs:5`
```csharp
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=sl52788542";
```
**问题描述:** Redis 连接字符串包含明文密码,直接硬编码在源代码中。此代码提交到版本控制系统后,密码将永久暴露。
**影响范围:**
- 攻击者获取代码后可直接访问 Redis 服务
- 违反安全合规要求如等保、GDPR
**改进建议:**
```csharp
// 使用环境变量或密钥管理服务
public string ConnectionString { get; set; } =
Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING") ?? string.Empty;
```
---
### 1.2 配置文件凭据泄露
**文件位置:** `src/appsettings.json:19,28`
```json
"DefaultConnection": "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
"ConnectionString": "81.68.223.70:6379"
```
**问题描述:** 数据库连接字符串和 Redis 配置包含明文凭据,且这些配置文件通常会被提交到 Git 仓库。
**改进建议:**
- 使用 `appsettings.Development.json` 存储开发环境配置,并加入 `.gitignore`
- 生产环境使用环境变量或 Azure Key Vault / AWS Secrets Manager
- 敏感配置使用 `dotnet user-secrets` 管理
---
### 1.3 JWT 令牌未验证
**文件位置:** `src/Middleware/JwtTransformMiddleware.cs:39-40`
```csharp
var jwtHandler = new JwtSecurityTokenHandler();
var jwtToken = jwtHandler.ReadJwtToken(token);
```
**问题描述:** 中间件仅**读取**JWT令牌未进行签名验证、过期检查或颁发者验证。攻击者可伪造任意JWT令牌。
**影响范围:**
- 任何人可伪造租户ID、用户ID、角色信息
- 可冒充任意用户访问系统
**改进建议:**
```csharp
// 应使用标准的 JWT 验证流程
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = _jwtConfig.Authority,
ValidAudience = _jwtConfig.Audience,
IssuerSigningKey = GetSigningKey() // 从配置获取公钥
};
var principal = jwtHandler.ValidateToken(token, validationParameters, out _);
```
---
### 1.4 API 端点无认证保护
**文件位置:** `src/Controllers/GatewayConfigController.cs``src/Controllers/PendingServicesController.cs`
**问题描述:** 所有管理API端点均未添加 `[Authorize]` 特性,任何人可直接调用:
- `POST /api/gateway/tenants` - 创建租户
- `POST /api/gateway/routes` - 创建路由
- `POST /api/gateway/clusters/{clusterId}/instances` - 添加服务实例
- `POST /api/gateway/pending-services/{id}/assign` - 分配服务
**影响范围:**
- 攻击者可随意修改网关配置
- 可注入恶意服务地址进行流量劫持
**改进建议:**
```csharp
[ApiController]
[Route("api/gateway")]
[Authorize(Roles = "Admin")] // 添加认证要求
public class GatewayConfigController : ControllerBase
```
---
### 1.5 租户ID头部信任问题
**文件位置:** `src/Middleware/TenantRoutingMiddleware.cs:25`
```csharp
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
```
**问题描述:** 直接从请求头读取租户ID未与JWT中的租户声明进行比对验证。攻击者可伪造 `X-Tenant-Id` 头部访问其他租户数据。
**改进建议:**
```csharp
// 从已验证的 JWT claims 中获取租户ID
var jwtTenantId = context.User.FindFirst("tenant")?.Value;
var headerTenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (!string.IsNullOrEmpty(jwtTenantId) && jwtTenantId != headerTenantId)
{
// 记录安全事件
_logger.LogWarning("Tenant ID mismatch: JWT={JwtTenant}, Header={HeaderTenant}",
jwtTenantId, headerTenantId);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return;
}
```
---
## 二、技术债务 🟠
### 2.1 ID生成策略问题
**文件位置:** `src/Controllers/GatewayConfigController.cs:484-487`
```csharp
private long GenerateId()
{
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
```
**问题描述:** 使用时间戳毫秒生成ID在高并发场景下可能产生重复ID。
**改进建议:**
- 使用数据库自增主键(已有配置)
- 或使用雪花算法Snowflake ID
- 或使用 `Guid.NewGuid()`
---
### 2.2 Redis连接重复初始化
**文件位置:**
- `src/Program.cs:39-60` - 注册 `IConnectionMultiplexer`
- `src/Services/RedisConnectionManager.cs:25-46` - 内部再次创建连接
**问题描述:** Redis连接被初始化两次造成资源浪费和配置不一致风险。
**改进建议:**
```csharp
// Program.cs 中只注册一次
builder.Services.AddSingleton<IRedisConnectionManager, RedisConnectionManager>();
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
sp.GetRequiredService<IRedisConnectionManager>().GetConnection());
```
---
### 2.3 DTO 内嵌定义
**文件位置:** `src/Controllers/GatewayConfigController.cs:444-481`
**问题描述:** 多个DTO类定义在Controller内部不利于复用和测试。
**改进建议:**
- 将 DTO 移至 `src/DTOs/``src/Models/Dto/` 目录
- 使用 Auto Mapper 或 Mapster 进行对象映射
---
### 2.4 魔法数字
**文件位置:** 多处使用数字常量
```csharp
// RouteCache.cs:99
.Where(r => r.Status == 1 && !r.IsDeleted)
// GatewayConfigController.cs:239
route.Status = 1;
// KubernetesPendingSyncService.cs:13
private readonly TimeSpan _syncInterval = TimeSpan.FromSeconds(30);
```
**问题描述:** 状态值、超时时间等使用硬编码数字,降低代码可读性和可维护性。
**改进建议:**
```csharp
// 定义常量或枚举
public static class RouteStatus
{
public const int Active = 1;
public const int Inactive = 0;
}
public static class ServiceConstants
{
public static readonly TimeSpan DefaultSyncInterval = TimeSpan.FromSeconds(30);
}
```
---
### 2.5 异步方法命名不一致
**文件位置:** `src/Config/DatabaseRouteConfigProvider.cs:23`
```csharp
_ = LoadConfigAsync(); // Fire-and-forget without await
```
**问题描述:** 构造函数中调用异步方法但未等待完成,可能导致初始化竞态条件。
**改进建议:**
- 使用工厂模式异步初始化
- 或在 `Program.cs` 中显式调用初始化方法
---
## 三、性能瓶颈风险 🟡
### 3.1 负载均衡锁竞争
**文件位置:** `src/LoadBalancing/DistributedWeightedRoundRobinPolicy.cs:48-53`
```csharp
var lockAcquired = db.StringSet(
lockKey,
lockValue,
TimeSpan.FromMilliseconds(500),
When.NotExists
);
```
**问题描述:** 每次请求都需要获取Redis分布式锁高并发下会成为瓶颈。锁获取失败时降级策略不可靠。
**影响:**
- 单集群QPS受限
- Redis延迟增加时网关吞吐量下降
**改进建议:**
- 考虑使用本地缓存 + 定期同步策略
- 或使用一致性哈希算法避免锁需求
- 增加本地计数器作为快速路径
---
### 3.2 路由缓存全量加载
**文件位置:** `src/Services/RouteCache.cs:94-137`
```csharp
var routes = await db.TenantRoutes
.Where(r => r.Status == 1 && !r.IsDeleted)
.ToListAsync();
```
**问题描述:** 每次重载都清空并重新加载所有路由,大数据量下性能差。
**改进建议:**
- 实现增量更新机制
- 使用版本号比对只更新变更项
- 添加分页加载支持
---
### 3.3 数据库查询未优化
**文件位置:** `src/Controllers/GatewayConfigController.cs:145-148`
```csharp
var currentRouteVersion = await db.TenantRoutes
.OrderByDescending(r => r.Version)
.Select(r => r.Version)
.FirstOrDefaultAsync(stoppingToken);
```
**问题描述:** 每次轮询都执行 `ORDER BY` 查询获取最大版本号,缺少索引优化。
**改进建议:**
```sql
-- 添加索引
CREATE INDEX IX_TenantRoutes_Version ON "TenantRoutes" ("Version" DESC);
-- 或使用 MAX 聚合
SELECT MAX("Version") FROM "TenantRoutes";
```
---
### 3.4 PostgreSQL NOTIFY 连接管理
**文件位置:** `src/Data/GatewayDbContext.cs:72-75`
```csharp
using var connection = new NpgsqlConnection(connectionString);
connection.Open();
using var cmd = new NpgsqlCommand($"NOTIFY {ConfigNotifyChannel.GatewayConfigChanged}", connection);
cmd.ExecuteNonQuery();
```
**问题描述:** 每次保存变更都创建新的数据库连接发送通知,连接开销大。
**改进建议:**
- 使用连接池中的连接
- 或复用 `PgSqlConfigChangeListener` 中的连接发送通知
---
## 四、脆弱区域 🟠
### 4.1 租户路由外键约束
**文件位置:** `src/Migrations/20260201120312_InitialCreate.cs:83-89`
```csharp
table.ForeignKey(
name: "FK_TenantRoutes_Tenants_TenantCode",
column: x => x.TenantCode,
principalTable: "Tenants",
principalColumn: "TenantCode",
onDelete: ReferentialAction.Restrict);
```
**问题描述:** `TenantRoutes.TenantCode` 有外键约束,但全局路由(`IsGlobal=true`)时 `TenantCode` 可为空字符串,可能导致数据一致性问题。
**改进建议:**
- 全局路由使用特定的占位符(如 "GLOBAL"
- 或修改外键约束为条件约束
---
### 4.2 健康检查配置硬编码
**文件位置:** `src/Config/DatabaseClusterConfigProvider.cs:77-86`
```csharp
HealthCheck = new HealthCheckConfig
{
Active = new ActiveHealthCheckConfig
{
Enabled = true,
Interval = TimeSpan.FromSeconds(30),
Timeout = TimeSpan.FromSeconds(5),
Path = "/health"
}
}
```
**问题描述:** 健康检查路径和间隔硬编码,不同服务可能需要不同的健康检查配置。
**改进建议:**
- 将健康检查配置存储在数据库
- 或在模型中添加健康检查配置字段
---
### 4.3 端口选择逻辑
**文件位置:** `src/Controllers/PendingServicesController.cs:119-120`
```csharp
var discoveredPorts = JsonSerializer.Deserialize<List<int>>(pendingService.DiscoveredPorts) ?? new List<int>();
var primaryPort = discoveredPorts.FirstOrDefault() > 0 ? discoveredPorts.First() : 80;
```
**问题描述:** 简单选择第一个端口作为主端口,可能不适合所有服务场景。
**改进建议:**
- 支持端口选择策略配置
- 优先选择知名端口(如 80, 443, 8080
- 允许用户在审批时选择端口
---
### 4.4 异常处理不完整
**文件位置:** `src/Services/PgSqlConfigChangeListener.cs:59-62`
```csharp
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize PgSql listener");
// 未重试或终止服务
}
```
**问题描述:** 初始化失败后仅记录日志,服务继续运行但功能不完整。
**改进建议:**
```csharp
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize PgSql listener, retrying in 5 seconds...");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
await InitializeListenerAsync(stoppingToken); // 重试
}
```
---
### 4.5 状态变更无事务保护
**文件位置:** `src/Controllers/PendingServicesController.cs:137-145`
```csharp
db.ServiceInstances.Add(newInstance);
pendingService.Status = (int)PendingServiceStatus.Approved;
// ...
await db.SaveChangesAsync();
```
**问题描述:** 创建实例和更新状态在同一事务中,但如果缓存重载失败,数据可能不一致。
**改进建议:**
- 使用 TransactionScope 或数据库事务明确边界
- 添加补偿机制处理失败情况
---
## 五、可维护性问题 🟡
### 5.1 日志结构不统一
**问题描述:** 日志消息格式不统一,有的包含结构化数据,有的仅是文本。
**改进建议:**
- 制定统一的日志格式规范
- 使用结构化日志模板:`LogInformation("Operation {Operation} completed for {Entity} with ID {Id}", "Create", "Route", route.Id)`
---
### 5.2 缺少单元测试
**问题描述:** 项目中未发现测试项目,核心逻辑缺少测试覆盖。
**改进建议:**
- 创建 `tests/YarpGateway.Tests/` 测试项目
- 对以下核心组件编写单元测试:
- `RouteCache` - 路由查找逻辑
- `JwtTransformMiddleware` - JWT 解析逻辑
- `DistributedWeightedRoundRobinPolicy` - 负载均衡算法
---
### 5.3 配置验证缺失
**文件位置:** `src/Config/JwtConfig.cs`, `src/Config/RedisConfig.cs`
**问题描述:** 配置类没有验证逻辑,无效配置可能导致运行时错误。
**改进建议:**
```csharp
public class JwtConfig : IValidatableObject
{
public string Authority { get; set; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(Authority))
yield return new ValidationResult("Authority is required", new[] { nameof(Authority) });
}
}
```
---
## 六、改进优先级建议
| 优先级 | 问题 | 风险等级 | 建议处理时间 |
|--------|------|----------|--------------|
| P0 | 硬编码凭据泄露 | 严重 | 立即修复 |
| P0 | JWT未验证 | 严重 | 立即修复 |
| P0 | API无认证保护 | 严重 | 立即修复 |
| P1 | 租户ID信任问题 | 高 | 1周内 |
| P1 | ID生成策略 | 高 | 1周内 |
| P2 | 负载均衡锁竞争 | 中 | 2周内 |
| P2 | 路由缓存优化 | 中 | 2周内 |
| P3 | DTO内嵌定义 | 低 | 1个月内 |
| P3 | 缺少单元测试 | 低 | 持续改进 |
---
## 七、总结
本项目存在多个**严重安全漏洞**,主要涉及:
1. 敏感信息硬编码
2. 认证授权缺失
3. 输入验证不足
技术债务主要集中在代码组织、异常处理和性能优化方面。建议优先处理安全相关问题,然后逐步优化性能和可维护性。
---
*文档由自动化分析生成,建议人工复核后纳入迭代计划。*

View File

@ -1,690 +0,0 @@
# YARP Gateway 编码约定文档
## 概述
本文档记录了 YARP Gateway 项目的编码约定和最佳实践,旨在帮助开发人员理解和遵循项目规范。
---
## 1. 代码风格
### 1.1 命名约定
#### 类和接口命名
```csharp
// 接口:使用 I 前缀 + PascalCase
public interface IRouteCache
{
Task InitializeAsync();
Task ReloadAsync();
RouteInfo? GetRoute(string tenantCode, string serviceName);
}
// 实现类PascalCase描述性名称
public class RouteCache : IRouteCache
{
// ...
}
// 配置类:以 Config 后缀
public class RedisConfig
{
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=sl52788542";
public int Database { get; set; } = 0;
public string InstanceName { get; set; } = "YarpGateway";
}
// DTO 类:以 Dto 后缀
public class CreateTenantDto
{
public string TenantCode { get; set; } = string.Empty;
public string TenantName { get; set; } = string.Empty;
}
// 数据模型Gw 前缀标识网关实体
public class GwTenantRoute
{
public long Id { get; set; }
public string TenantCode { get; set; } = string.Empty;
// ...
}
```
#### 私有字段命名
```csharp
// 使用下划线前缀 + camelCase
public class TenantRoutingMiddleware
{
private readonly RequestDelegate _next;
private readonly IRouteCache _routeCache;
private readonly ILogger<TenantRoutingMiddleware> _logger;
}
```
**原因**:下划线前缀清晰区分私有字段和局部变量,避免 `this.` 的频繁使用。
#### 方法命名
```csharp
// 异步方法Async 后缀
public async Task InitializeAsync()
public async Task ReloadAsync()
private async Task LoadFromDatabaseAsync()
// 同步方法:动词开头
public RouteInfo? GetRoute(string tenantCode, string serviceName)
private string ExtractServiceName(string path)
```
### 1.2 文件组织
项目采用按功能分层的方式组织代码:
```
src/
├── Config/ # 配置类和配置提供者
├── Controllers/ # API 控制器
├── Data/ # 数据库上下文和工厂
├── DynamicProxy/ # 动态代理配置
├── LoadBalancing/ # 负载均衡策略
├── Metrics/ # 指标收集
├── Middleware/ # 中间件
├── Migrations/ # 数据库迁移
├── Models/ # 数据模型
└── Services/ # 业务服务
```
**原因**:按功能分层便于代码定位,降低耦合度。
---
## 2. 依赖注入模式
### 2.1 服务注册
```csharp
// Program.cs 中的服务注册
// 配置选项模式
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection("Jwt"));
builder.Services.Configure<RedisConfig>(builder.Configuration.GetSection("Redis"));
// 直接注册配置实例(当需要直接使用配置对象时)
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<RedisConfig>>().Value);
// DbContext 使用工厂模式
builder.Services.AddDbContextFactory<GatewayDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))
);
// 单例服务(无状态或线程安全)
builder.Services.AddSingleton<DatabaseRouteConfigProvider>();
builder.Services.AddSingleton<DatabaseClusterConfigProvider>();
builder.Services.AddSingleton<IRouteCache, RouteCache>();
// 接口与实现分离注册
builder.Services.AddSingleton<IRedisConnectionManager, RedisConnectionManager>();
// 后台服务
builder.Services.AddHostedService<PgSqlConfigChangeListener>();
builder.Services.AddHostedService<KubernetesPendingSyncService>();
```
### 2.2 依赖注入构造函数模式
```csharp
public class RouteCache : IRouteCache
{
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
private readonly ILogger<RouteCache> _logger;
public RouteCache(
IDbContextFactory<GatewayDbContext> dbContextFactory,
ILogger<RouteCache> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
}
```
**模式要点**
1. 所有依赖通过构造函数注入
2. 使用 `readonly` 修饰私有字段
3. 依赖项按类别排序(框架 → 基础设施 → 业务服务)
**原因**:构造函数注入确保依赖不可变,便于测试和依赖管理。
### 2.3 IDbContextFactory 模式
```csharp
// 在 Singleton 服务中使用 DbContextFactory
public class RouteCache : IRouteCache
{
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
private async Task LoadFromDatabaseAsync()
{
// 使用 using 确保上下文正确释放
using var db = _dbContextFactory.CreateDbContext();
var routes = await db.TenantRoutes
.Where(r => r.Status == 1 && !r.IsDeleted)
.ToListAsync();
// ...
}
}
// 在 BackgroundService 中使用 Scope
public class KubernetesPendingSyncService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private async Task SyncPendingServicesAsync(CancellationToken ct)
{
// 创建作用域以获取 Scoped 服务
using var scope = _serviceProvider.CreateScope();
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<GatewayDbContext>>();
// ...
}
}
```
**原因**`IDbContextFactory` 避免了 Singleton 服务直接持有 DbContext 的生命周期问题。
---
## 3. 配置管理模式
### 3.1 配置类定义
```csharp
// 简单 POCO 配置类
namespace YarpGateway.Config;
public class JwtConfig
{
public string Authority { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
public bool ValidateIssuer { get; set; } = true;
public bool ValidateAudience { get; set; } = true;
}
```
### 3.2 配置绑定和注入
```csharp
// Program.cs 中绑定配置
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection("Jwt"));
// 通过 IOptions<T> 注入
public class JwtTransformMiddleware
{
private readonly JwtConfig _jwtConfig;
public JwtTransformMiddleware(
RequestDelegate next,
IOptions<JwtConfig> jwtConfig, // 使用 IOptions<T>
ILogger<JwtTransformMiddleware> logger)
{
_jwtConfig = jwtConfig.Value; // 获取实际配置值
_logger = logger;
}
}
```
### 3.3 动态配置更新
```csharp
// 配置变更通知通道
public static class ConfigNotifyChannel
{
public const string GatewayConfigChanged = "gateway_config_changed";
}
// DbContext 在保存时检测变更并通知
public class GatewayDbContext : DbContext
{
private bool _configChangeDetected;
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
DetectConfigChanges();
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
if (_configChangeDetected)
{
await NotifyConfigChangedAsync(cancellationToken);
}
return result;
}
private void DetectConfigChanges()
{
var entries = ChangeTracker.Entries()
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.Where(e => e.Entity is GwTenantRoute or GwServiceInstance or GwTenant);
_configChangeDetected = entries.Any();
}
}
```
**原因**:使用 PostgreSQL NOTIFY/LISTEN 实现配置热更新,避免轮询。
---
## 4. 错误处理方式
### 4.1 中间件错误处理
```csharp
public class JwtTransformMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
// 快速失败模式:前置条件检查后直接调用 next
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
{
await _next(context);
return;
}
try
{
// 业务逻辑
var jwtHandler = new JwtSecurityTokenHandler();
var jwtToken = jwtHandler.ReadJwtToken(token);
// ...
}
catch (Exception ex)
{
// 记录错误但不中断请求流程
_logger.LogError(ex, "Failed to parse JWT token");
}
await _next(context);
}
}
```
### 4.2 控制器错误处理
```csharp
[HttpPost("{id}/assign")]
public async Task<IActionResult> AssignService(long id, [FromBody] AssignServiceRequest request)
{
await using var db = _dbContextFactory.CreateDbContext();
// 早期返回模式
var pendingService = await db.PendingServiceDiscoveries.FindAsync(id);
if (pendingService == null || pendingService.IsDeleted)
{
return NotFound(new { message = "Pending service not found" });
}
if (pendingService.Status != (int)PendingServiceStatus.Pending)
{
return BadRequest(new { message = $"Service is already {((PendingServiceStatus)pendingService.Status)}, cannot assign" });
}
if (string.IsNullOrEmpty(request.ClusterId))
{
return BadRequest(new { message = "ClusterId is required" });
}
// 业务逻辑...
return Ok(new { success = true, message = "..." });
}
```
**模式要点**
1. 使用早期返回Guard Clauses减少嵌套
2. 返回结构化的错误信息
3. 使用 HTTP 状态码语义
### 4.3 后台服务错误处理
```csharp
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await SyncPendingServicesAsync(stoppingToken);
}
catch (Exception ex)
{
// 记录错误但继续运行
_logger.LogError(ex, "Error during K8s pending service sync");
}
await Task.Delay(_syncInterval, stoppingToken);
}
}
```
**原因**:后台服务不应因单次错误而终止,需具备自恢复能力。
---
## 5. 日志记录约定
### 5.1 结构化日志
```csharp
// 使用 Serilog 结构化日志
_logger.LogInformation("Route cache initialized: {GlobalCount} global routes, {TenantCount} tenant routes",
_globalRoutes.Count, _tenantRoutes.Count);
_logger.LogWarning("No route found for: {Tenant}/{Service}", tenantCode, serviceName);
_logger.LogError(ex, "Redis connection failed");
_logger.LogDebug("Released lock for key: {Key}", _key);
```
**模式要点**
1. 使用占位符 `{PropertyName}` 而非字符串插值
2. 日志消息使用常量,便于聚合分析
3. 包含足够的上下文信息
### 5.2 Serilog 配置
```csharp
// Program.cs
builder.Host.UseSerilog(
(context, services, configuration) =>
configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
```
### 5.3 日志级别使用
| 级别 | 使用场景 |
|------|----------|
| `LogDebug` | 详细调试信息,生产环境通常关闭 |
| `LogInformation` | 正常业务流程关键节点 |
| `LogWarning` | 可恢复的异常情况 |
| `LogError` | 错误需要关注但不影响整体运行 |
| `LogFatal` | 致命错误,应用无法继续运行 |
---
## 6. 异步编程模式
### 6.1 async/await 使用
```csharp
// 正确:异步方法使用 Async 后缀
public async Task InitializeAsync()
{
_logger.LogInformation("Initializing route cache from database...");
await LoadFromDatabaseAsync();
}
// 正确:使用 ConfigureAwait(false) 在库代码中
private async Task LoadFromDatabaseAsync()
{
using var db = _dbContextFactory.CreateDbContext();
var routes = await db.TenantRoutes
.Where(r => r.Status == 1 && !r.IsDeleted)
.ToListAsync();
// ...
}
```
### 6.2 CancellationToken 使用
```csharp
// 控制器方法
[HttpGet]
public async Task<IActionResult> GetPendingServices(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10,
[FromQuery] int? status = null)
{
await using var db = _dbContextFactory.CreateDbContext();
// EF Core 自动处理 CancellationToken
var total = await query.CountAsync();
// ...
}
// 后台服务
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(_syncInterval, stoppingToken);
}
}
```
### 6.3 并发控制
```csharp
// 使用 ReaderWriterLockSlim 保护读写
public class RouteCache : IRouteCache
{
private readonly ReaderWriterLockSlim _lock = new();
public RouteInfo? GetRoute(string tenantCode, string serviceName)
{
_lock.EnterUpgradeableReadLock();
try
{
// 读取逻辑
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
private async Task LoadFromDatabaseAsync()
{
_lock.EnterWriteLock();
try
{
// 写入逻辑
}
finally
{
_lock.ExitWriteLock();
}
}
}
// 使用 SemaphoreSlim 进行异步锁定
public class DatabaseRouteConfigProvider
{
private readonly SemaphoreSlim _lock = new(1, 1);
public async Task ReloadAsync()
{
await _lock.WaitAsync();
try
{
await LoadConfigInternalAsync();
}
finally
{
_lock.Release();
}
}
}
```
**原因**
- `ReaderWriterLockSlim` 支持多读单写,适合读多写少场景
- `SemaphoreSlim` 支持异步等待,适合异步方法
### 6.4 Redis 分布式锁模式
```csharp
public async Task<IDisposable> AcquireLockAsync(string key, TimeSpan? expiry = null)
{
var redis = GetConnection();
var db = redis.GetDatabase();
var lockKey = $"lock:{_config.InstanceName}:{key}";
var lockValue = Environment.MachineName + ":" + Process.GetCurrentProcess().Id;
var acquired = await db.StringSetAsync(lockKey, lockValue, expiryTime, When.NotExists);
if (!acquired)
{
// 退避重试
var backoff = TimeSpan.FromMilliseconds(100);
while (!acquired && retryCount < maxRetries)
{
await Task.Delay(backoff);
acquired = await db.StringSetAsync(lockKey, lockValue, expiryTime, When.NotExists);
retryCount++;
}
}
return new RedisLock(db, lockKey, lockValue, _logger);
}
```
---
## 7. 中间件模式
### 7.1 标准中间件结构
```csharp
public class TenantRoutingMiddleware
{
private readonly RequestDelegate _next;
private readonly IRouteCache _routeCache;
private readonly ILogger<TenantRoutingMiddleware> _logger;
// 构造函数注入依赖
public TenantRoutingMiddleware(
RequestDelegate next,
IRouteCache routeCache,
ILogger<TenantRoutingMiddleware> logger)
{
_next = next;
_routeCache = routeCache;
_logger = logger;
}
// InvokeAsync 方法签名固定
public async Task InvokeAsync(HttpContext context)
{
// 1. 前置处理
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
// 2. 快速返回
if (string.IsNullOrEmpty(tenantId))
{
await _next(context);
return;
}
// 3. 业务逻辑
var route = _routeCache.GetRoute(tenantId, serviceName);
// 4. 设置上下文
context.Items["DynamicClusterId"] = route.ClusterId;
// 5. 调用下一个中间件
await _next(context);
}
}
```
### 7.2 中间件注册顺序
```csharp
// Program.cs
var app = builder.Build();
app.UseCors("AllowFrontend");
app.UseMiddleware<JwtTransformMiddleware>(); // JWT 解析
app.UseMiddleware<TenantRoutingMiddleware>(); // 租户路由
app.MapControllers();
app.MapReverseProxy();
```
**顺序原因**
1. CORS 需最先处理跨域请求
2. JWT 中间件解析用户信息供后续使用
3. 租户路由根据用户信息选择目标服务
---
## 8. 控制器约定
### 8.1 控制器结构
```csharp
[ApiController]
[Route("api/gateway")]
public class GatewayConfigController : ControllerBase
{
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
private readonly IRouteCache _routeCache;
public GatewayConfigController(
IDbContextFactory<GatewayDbContext> dbContextFactory,
IRouteCache routeCache)
{
_dbContextFactory = dbContextFactory;
_routeCache = routeCache;
}
#region Tenants
// 租户相关端点
#endregion
#region Routes
// 路由相关端点
#endregion
}
```
### 8.2 端点命名
```csharp
// GET 集合
[HttpGet("tenants")]
public async Task<IActionResult> GetTenants(...) { }
// GET 单个
[HttpGet("tenants/{id}")]
public async Task<IActionResult> GetTenant(long id) { }
// POST 创建
[HttpPost("tenants")]
public async Task<IActionResult> CreateTenant([FromBody] CreateTenantDto dto) { }
// PUT 更新
[HttpPut("tenants/{id}")]
public async Task<IActionResult> UpdateTenant(long id, [FromBody] UpdateTenantDto dto) { }
// DELETE 删除
[HttpDelete("tenants/{id}")]
public async Task<IActionResult> DeleteTenant(long id) { }
```
---
## 9. 总结
本项目的编码约定遵循以下核心原则:
1. **一致性**:统一的命名和代码组织方式
2. **可测试性**:依赖注入和接口抽象便于测试
3. **可维护性**:清晰的结构和文档注释
4. **可观测性**:结构化日志和指标收集
5. **健壮性**:完善的错误处理和并发控制
遵循这些约定可以确保代码质量和团队协作效率。

View File

@ -1,374 +0,0 @@
# YARP 网关外部集成文档
## 1. PostgreSQL 数据库集成
### 概述
PostgreSQL 作为主数据库,存储网关配置数据,包括租户、路由、服务实例等信息。
### 连接配置
**配置位置**: `src/appsettings.json`
```json
{
"ConnectionStrings": {
"DefaultConnection": "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=***"
}
}
```
### DbContext 配置
**文件**: `src/Data/GatewayDbContext.cs`
```csharp
// 注册 DbContext 工厂
builder.Services.AddDbContextFactory<GatewayDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))
);
```
### 数据模型
| 实体 | 表名 | 用途 |
|------|------|------|
| `GwTenant` | Tenants | 租户信息 |
| `GwTenantRoute` | TenantRoutes | 租户路由配置 |
| `GwServiceInstance` | ServiceInstances | 服务实例(集群节点) |
| `GwPendingServiceDiscovery` | PendingServiceDiscoveries | K8s 待处理服务发现 |
### 配置变更通知机制
**文件**: `src/Config/ConfigNotifyChannel.cs`
使用 PostgreSQL `LISTEN/NOTIFY` 机制实现配置变更实时通知:
```csharp
// 发送通知(在 DbContext.SaveChangesAsync 中触发)
await using var cmd = new NpgsqlCommand($"NOTIFY {ConfigNotifyChannel.GatewayConfigChanged}", connection);
// 监听通知(在 PgSqlConfigChangeListener 中)
cmd.CommandText = $"LISTEN {ConfigNotifyChannel.GatewayConfigChanged}";
```
**监听服务**: `src/Services/PgSqlConfigChangeListener.cs`
- 监听 PostgreSQL NOTIFY 通道
- 检测配置版本变更
- 触发路由/集群配置热更新
- 提供 5 分钟兜底轮询机制
---
## 2. Redis 集成
### 概述
Redis 用于分布式锁、路由缓存同步,确保多实例网关的配置一致性。
### 连接配置
**配置位置**: `src/Config/RedisConfig.cs`
```csharp
public class RedisConfig
{
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=***";
public int Database { get; set; } = 0;
public string InstanceName { get; set; } = "YarpGateway";
}
```
### 连接管理器
**文件**: `src/Services/RedisConnectionManager.cs`
```csharp
// 注册 Redis 连接
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
var config = sp.GetRequiredService<RedisConfig>();
var connectionOptions = ConfigurationOptions.Parse(config.ConnectionString);
connectionOptions.AbortOnConnectFail = false;
connectionOptions.ConnectRetry = 3;
connectionOptions.ConnectTimeout = 5000;
connectionOptions.SyncTimeout = 3000;
connectionOptions.DefaultDatabase = config.Database;
return ConnectionMultiplexer.Connect(connectionOptions);
});
```
### 分布式锁实现
**接口**: `IRedisConnectionManager`
```csharp
public interface IRedisConnectionManager
{
IConnectionMultiplexer GetConnection();
Task<IDisposable> AcquireLockAsync(string key, TimeSpan? expiry = null);
Task<T> ExecuteInLockAsync<T>(string key, Func<Task<T>> func, TimeSpan? expiry = null);
}
```
**锁机制特性**:
- 基于键值对的分布式锁
- 自动过期时间(默认 10 秒)
- 指数退避重试策略
- Lua 脚本安全释放锁
---
## 3. Kubernetes 服务发现集成
### 概述
通过自定义的 Fengling.ServiceDiscovery 包实现 Kubernetes 服务自动发现,将 K8s Service 自动注册为网关后端服务。
### 配置
**文件**: `src/Program.cs`
```csharp
// 添加 Kubernetes 服务发现
var useInClusterConfig = builder.Configuration.GetValue<bool>("ServiceDiscovery:UseInClusterConfig", true);
builder.Services.AddKubernetesServiceDiscovery(options =>
{
options.LabelSelector = "app.kubernetes.io/managed-by=yarp";
options.UseInClusterConfig = useInClusterConfig;
});
builder.Services.AddServiceDiscovery();
```
### 依赖包
| 包名 | 用途 |
|------|------|
| `Fengling.ServiceDiscovery.Core` | 服务发现核心接口 |
| `Fengling.ServiceDiscovery.Kubernetes` | Kubernetes 实现 |
| `Fengling.ServiceDiscovery.Static` | 静态配置实现 |
### 后台同步服务
**文件**: `src/Services/KubernetesPendingSyncService.cs`
```csharp
public class KubernetesPendingSyncService : BackgroundService
{
private readonly TimeSpan _syncInterval = TimeSpan.FromSeconds(30);
private readonly TimeSpan _staleThreshold = TimeSpan.FromHours(24);
// 同步 K8s 服务到数据库待处理表
}
```
**同步逻辑**:
1. 每 30 秒从 K8s API 获取服务列表
2. 对比数据库中的待处理服务记录
3. 新增/更新/清理过期服务
4. 标记不再存在的 K8s 服务
### 待处理服务数据模型
**文件**: `src/Models/GwPendingServiceDiscovery.cs`
```csharp
public class GwPendingServiceDiscovery
{
public string K8sServiceName { get; set; } // K8s Service 名称
public string K8sNamespace { get; set; } // K8s 命名空间
public string K8sClusterIP { get; set; } // ClusterIP
public string DiscoveredPorts { get; set; } // JSON 序列化的端口列表
public string Labels { get; set; } // K8s 标签
public string AssignedClusterId { get; set; } // 分配的集群 ID
public int Status { get; set; } // 状态
}
```
---
## 4. JWT 认证集成
### 概述
网关解析 JWT Token提取租户和用户信息转换为下游服务可用的 HTTP 头。
### 配置
**文件**: `src/Config/JwtConfig.cs`
```csharp
public class JwtConfig
{
public string Authority { get; set; } = string.Empty; // 认证服务器地址
public string Audience { get; set; } = string.Empty; // 受众
public bool ValidateIssuer { get; set; } = true; // 验证签发者
public bool ValidateAudience { get; set; } = true; // 验证受众
}
```
**配置示例** (`src/appsettings.json`):
```json
{
"Jwt": {
"Authority": "https://your-auth-server.com",
"Audience": "fengling-gateway",
"ValidateIssuer": true,
"ValidateAudience": true
}
}
```
### JWT 转换中间件
**文件**: `src/Middleware/JwtTransformMiddleware.cs`
```csharp
public async Task InvokeAsync(HttpContext context)
{
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer "))
{
var token = authHeader.Substring("Bearer ".Length).Trim();
var jwtToken = jwtHandler.ReadJwtToken(token);
// 提取声明并转换为 HTTP 头
var tenantId = jwtToken.Claims.FirstOrDefault(c => c.Type == "tenant")?.Value;
var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;
context.Request.Headers["X-Tenant-Id"] = tenantId;
context.Request.Headers["X-User-Id"] = userId;
context.Request.Headers["X-User-Name"] = userName;
context.Request.Headers["X-Roles"] = string.Join(",", roles);
}
await _next(context);
}
```
### JWT 声明到 HTTP 头映射
| JWT 声明类型 | HTTP 头 | 说明 |
|--------------|---------|------|
| `tenant` | `X-Tenant-Id` | 租户标识 |
| `ClaimTypes.NameIdentifier` | `X-User-Id` | 用户 ID |
| `ClaimTypes.Name` | `X-User-Name` | 用户名 |
| `ClaimTypes.Role` | `X-Roles` | 角色列表(逗号分隔) |
---
## 5. 外部 API 和服务连接
### CORS 配置
**文件**: `src/appsettings.json`
```json
{
"Cors": {
"AllowedOrigins": [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174"
],
"AllowAnyOrigin": false
}
}
```
### 健康检查端点
**文件**: `src/Program.cs`
```csharp
app.MapGet("/health", () => Results.Ok(new {
status = "healthy",
timestamp = DateTime.UtcNow
}));
```
### 下游服务健康检查
**文件**: `src/Config/DatabaseClusterConfigProvider.cs`
```csharp
HealthCheck = new HealthCheckConfig
{
Active = new ActiveHealthCheckConfig
{
Enabled = true,
Interval = TimeSpan.FromSeconds(30),
Timeout = TimeSpan.FromSeconds(5),
Path = "/health"
}
}
```
### 动态代理配置
**文件**: `src/DynamicProxy/DynamicProxyConfigProvider.cs`
实现 `IProxyConfigProvider` 接口,从数据库动态加载路由和集群配置:
```csharp
public class DynamicProxyConfigProvider : IProxyConfigProvider
{
public IProxyConfig GetConfig() => _config;
public void UpdateConfig()
{
var routes = _routeProvider.GetRoutes();
var clusters = _clusterProvider.GetClusters();
_config = new InMemoryProxyConfig(routes, clusters, ...);
}
}
```
---
## 6. 集成架构图
```
┌─────────────────────────────────────────────────────────────┐
│ 客户端请求 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ YARP Gateway │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 中间件管道 │ │
│ │ CORS → JWT转换 → 租户路由 → Controllers → Proxy │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────────┐ │
│ │RouteCache│ │ConfigProv│ │LoadBalancingPolicy │ │
│ └────┬─────┘ └────┬─────┘ └──────────────────────────┘ │
└───────┼─────────────┼────────────────────────────────────────┘
│ │
┌───────────────┼─────────────┼───────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌───────────────┐ ┌───────────┐ ┌───────────┐ ┌───────────────────┐
│ PostgreSQL │ │ Redis │ │ K8s │ │ Auth Server │
│ │ │ │ │ API │ │ (JWT) │
│ - 租户配置 │ │ - 分布式锁│ │ │ │ │
│ - 路由配置 │ │ - 缓存 │ │ - Service │ │ - Token 签发 │
│ - 服务实例 │ │ │ │ - Pod │ │ - 声明信息 │
│ - NOTIFY机制 │ │ │ │ │ │ │
└───────────────┘ └───────────┘ └───────────┘ └───────────────────┘
│ LISTEN/NOTIFY
┌───────────────────────────────────────────────────────┐
│ 配置变更监听器 │
│ PgSqlConfigChangeListener + FallbackPolling │
└───────────────────────────────────────────────────────┘
```
---
## 7. 配置热更新流程
```
数据库配置变更
DbContext.SaveChangesAsync()
NOTIFY gateway_config_changed
PgSqlConfigChangeListener.OnNotification()
RouteCache.ReloadAsync()
DynamicProxyConfigProvider.UpdateConfig()
YARP 配置生效(无需重启)
```
**兜底机制**: 每 5 分钟检查版本号,防止 NOTIFY 丢失导致配置不一致。

View File

@ -1,368 +0,0 @@
# 🔒 YARP 网关安全审计报告
> 审计日期2026-02-28
> 审计范围:认证授权、注入漏洞、敏感信息、访问控制、配置安全
---
## 执行摘要
| 严重程度 | 数量 |
|---------|------|
| 🔴 严重 (CRITICAL) | 3 |
| 🟠 高危 (HIGH) | 3 |
| 🟡 中危 (MEDIUM) | 4 |
| 🟢 低危 (LOW) | 3 |
| **总计** | **13** |
---
## 🔴 严重漏洞
### 1. 硬编码数据库凭据泄露
**文件:** `src/appsettings.json` 第 19 行
**问题代码:**
```json
"DefaultConnection": "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
```
**攻击场景:**
- 代码泄露或被推送到公开仓库时,攻击者直接获得数据库完整访问权限
- 可读取、修改、删除所有业务数据
**修复建议:**
```csharp
// 使用环境变量或 Secret Manager
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
// 或使用 Azure Key Vault / AWS Secrets Manager
```
---
### 2. 硬编码 Redis 凭据泄露
**文件:** `src/Config/RedisConfig.cs` 第 5 行
**问题代码:**
```csharp
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=sl52788542";
```
**攻击场景:**
- 攻击者可连接 Redis 服务器,读取缓存数据、修改路由配置、注入恶意数据
**修复建议:**
```csharp
public string ConnectionString { get; set; } = string.Empty;
// 从环境变量或配置中心读取
```
---
### 3. 管理 API 完全无认证保护
**文件:** `src/Controllers/GatewayConfigController.cs``src/Controllers/PendingServicesController.cs`
**问题描述:**
- 所有 API 端点均无 `[Authorize]` 特性
- `Program.cs` 中未配置 `AddAuthentication()``UseAuthentication()`
- 项目搜索未发现任何认证中间件
**攻击场景:**
```
# 攻击者可直接调用以下 API
POST /api/gateway/tenants # 创建任意租户
DELETE /api/gateway/tenants/{id} # 删除租户
POST /api/gateway/routes # 创建恶意路由
POST /api/gateway/config/reload # 重载配置
DELETE /api/gateway/clusters/{id} # 删除服务集群
```
**修复建议:**
```csharp
// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* 配置 JWT 验证 */ });
builder.Services.AddAuthorization();
app.UseAuthentication();
app.UseAuthorization();
// Controllers
[ApiController]
[Route("api/gateway")]
[Authorize] // 添加认证要求
public class GatewayConfigController : ControllerBase
```
---
## 🟠 高危漏洞
### 4. JWT 签名验证缺失
**文件:** `src/Middleware/JwtTransformMiddleware.cs` 第 39-40 行
**问题代码:**
```csharp
var jwtHandler = new JwtSecurityTokenHandler();
var jwtToken = jwtHandler.ReadJwtToken(token); // 仅读取,不验证!
```
**攻击场景:**
```python
# 攻击者可伪造任意 JWT
import jwt
fake_token = jwt.encode({"tenant": "admin-tenant", "sub": "admin"}, "any_secret", algorithm="HS256")
# 网关会接受这个伪造的 token
```
**修复建议:**
```csharp
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = _jwtConfig.Authority,
ValidAudience = _jwtConfig.Audience,
IssuerSigningKey = /* 从 Authority 获取公钥 */
};
var principal = jwtHandler.ValidateToken(token, validationParameters, out _);
```
---
### 5. 租户隔离可被 Header 注入绕过
**文件:** `src/Middleware/JwtTransformMiddleware.cs` 第 54 行
**问题代码:**
```csharp
context.Request.Headers["X-Tenant-Id"] = tenantId;
```
**攻击场景:**
```bash
# 攻击者直接注入 Header 绕过 JWT
curl -H "X-Tenant-Id: target-tenant" \
-H "X-User-Id: admin" \
-H "X-Roles: admin" \
https://gateway/api/sensitive-data
```
**修复建议:**
```csharp
// 在中间件开始时移除所有 X-* Header
foreach (var header in context.Request.Headers.Where(h => h.Key.StartsWith("X-")).ToList())
{
context.Request.Headers.Remove(header.Key);
}
// 然后再从 JWT 设置可信的 header
```
---
### 6. 租户路由信息泄露
**文件:** `src/Middleware/TenantRoutingMiddleware.cs` 第 44 行
**问题代码:**
```csharp
_logger.LogWarning("Route not found - Tenant: {Tenant}, Service: {Service}", tenantId, serviceName);
```
**攻击场景:**
- 日志中记录租户 ID 和服务名,攻击者可通过日志收集系统架构信息
- 配合其他攻击进行侦察
**修复建议:**
- 敏感信息不应记录到普通日志
- 使用脱敏处理或仅记录哈希值
---
## 🟡 中危漏洞
### 7. 日志记录敏感连接信息
**文件:** `src/Services/RedisConnectionManager.cs` 第 44 行
**问题代码:**
```csharp
_logger.LogInformation("Connected to Redis at {ConnectionString}", _config.ConnectionString);
```
**修复建议:**
```csharp
_logger.LogInformation("Connected to Redis at {Host}",
configuration.EndPoints.FirstOrDefault()?.ToString());
```
---
### 8. CORS 凭据配置存在风险
**文件:** `src/Program.cs` 第 89-100 行
**问题代码:**
```csharp
if (allowAnyOrigin)
{
policy.AllowAnyOrigin();
}
// ...
policy.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials(); // 与 AllowAnyOrigin 不兼容
```
**修复建议:**
```csharp
if (allowAnyOrigin)
{
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
// 不允许 AllowCredentials
}
else
{
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
}
```
---
### 9. 健康检查端点信息泄露
**文件:** `src/Program.cs` 第 115 行
**修复建议:**
```csharp
// 添加访问限制或使用标准健康检查
builder.Services.AddHealthChecks();
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = async (c, r) =>
await c.Response.WriteAsync("healthy")
});
```
---
### 10. JWT Authority 使用占位符 URL
**文件:** `src/appsettings.json` 第 22 行
**问题代码:**
```json
"Authority": "https://your-auth-server.com"
```
**修复建议:**
- 强制要求配置有效的 Authority URL
- 启动时验证配置有效性
---
## 🟢 低危漏洞
### 11. 可预测的 ID 生成
**文件:** `src/Controllers/GatewayConfigController.cs` 第 484-487 行
**问题代码:**
```csharp
private long GenerateId()
{
return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
```
**修复建议:**
```csharp
// 使用 GUID 或雪花算法
private long GenerateId() => SnowflakeIdGenerator.NextId();
// 或
private string GenerateId() => Guid.NewGuid().ToString("N");
```
---
### 12. 缺少输入验证
**文件:** `src/Controllers/GatewayConfigController.cs` 多处
**修复建议:**
```csharp
public class CreateTenantDto
{
[Required]
[RegularExpression(@"^[a-zA-Z0-9-]{1,50}$")]
public string TenantCode { get; set; } = string.Empty;
[Required]
[StringLength(100, MinimumLength = 1)]
public string TenantName { get; set; } = string.Empty;
}
```
---
### 13. 错误消息暴露内部信息
**文件:** `src/Controllers/PendingServicesController.cs` 第 116 行
**修复建议:**
```csharp
return BadRequest(new { message = "Invalid cluster configuration" });
```
---
## 📋 修复优先级建议
| 优先级 | 漏洞编号 | 修复时间建议 |
|-------|---------|------------|
| P0 (立即) | #1, #2, #3 | 24小时内 |
| P1 (紧急) | #4, #5, #6 | 1周内 |
| P2 (重要) | #7, #8, #9, #10 | 2周内 |
| P3 (一般) | #11, #12, #13 | 1个月内 |
---
## 🛡️ 安全加固建议
### 1. 认证授权
- 实施完整的 JWT 验证流程
- 为所有管理 API 添加 `[Authorize]`
- 实施基于角色的访问控制 (RBAC)
### 2. 配置安全
- 使用 Azure Key Vault / AWS Secrets Manager 管理密钥
- 移除所有硬编码凭据
- 生产环境禁用调试模式
### 3. 租户隔离
- 在网关层强制验证租户归属
- 使用加密签名验证内部 Header
- 实施租户数据隔离审计
### 4. 日志安全
- 敏感信息脱敏
- 限制日志访问权限
- 使用结构化日志便于审计
---
*报告由安全审计生成,建议人工复核后纳入迭代计划。*

View File

@ -1,189 +0,0 @@
# YARP 网关技术栈文档
## 1. 语言和运行时
### .NET 版本
- **目标框架**: .NET 10.0
- **项目文件**: `src/YarpGateway.csproj`
- **SDK**: `Microsoft.NET.Sdk.Web`
```xml
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
```
## 2. 核心框架
### YARP (Yet Another Reverse Proxy)
- **包**: `Yarp.ReverseProxy`
- **用途**: 微服务 API 网关核心反向代理引擎
- **主要功能**:
- 动态路由配置
- 负载均衡策略
- 健康检查
- 请求转发
### ASP.NET Core
- **用途**: Web 应用宿主框架
- **特性**:
- 依赖注入 (DI)
- 中间件管道
- 配置系统
- 日志集成
## 3. 主要依赖包
### 数据访问
| 包名 | 用途 |
|------|------|
| `Npgsql.EntityFrameworkCore.PostgreSQL` | PostgreSQL Entity Framework Core 提供程序 |
| `Microsoft.EntityFrameworkCore.Design` | EF Core 设计时工具(迁移) |
### 缓存与分布式锁
| 包名 | 用途 |
|------|------|
| `StackExchange.Redis` | Redis 客户端,用于分布式锁和缓存 |
### 认证授权
| 包名 | 用途 |
|------|------|
| `Microsoft.AspNetCore.Authentication.JwtBearer` | JWT Bearer 认证支持 |
### 日志
| 包名 | 用途 |
|------|------|
| `Serilog.AspNetCore` | Serilog ASP.NET Core 集成 |
| `Serilog.Sinks.Console` | 控制台日志输出 |
| `Serilog.Sinks.File` | 文件日志输出 |
### 服务发现(自定义包)
| 包名 | 用途 |
|------|------|
| `Fengling.ServiceDiscovery.Core` | 服务发现核心接口 |
| `Fengling.ServiceDiscovery.Kubernetes` | Kubernetes 服务发现实现 |
| `Fengling.ServiceDiscovery.Static` | 静态配置服务发现 |
## 4. 配置文件
### 主配置文件
**位置**: `src/appsettings.json`
```json
{
"ConnectionStrings": {
"DefaultConnection": "Host=...;Port=...;Database=...;Username=...;Password=..."
},
"Jwt": {
"Authority": "https://your-auth-server.com",
"Audience": "fengling-gateway",
"ValidateIssuer": true,
"ValidateAudience": true
},
"Redis": {
"ConnectionString": "host:port",
"Database": 0,
"InstanceName": "YarpGateway"
},
"Cors": {
"AllowedOrigins": ["http://localhost:5173"],
"AllowAnyOrigin": false
},
"Kestrel": {
"Endpoints": {
"Http": { "Url": "http://0.0.0.0:8080" }
}
},
"Serilog": {
"MinimumLevel": "Information",
"WriteTo": [
{ "Name": "Console" },
{ "Name": "File", "Args": { "path": "logs/gateway-.log", "rollingInterval": "Day" } }
]
}
}
```
### 配置类
| 文件路径 | 类名 | 用途 |
|----------|------|------|
| `src/Config/JwtConfig.cs` | `JwtConfig` | JWT 认证配置 |
| `src/Config/RedisConfig.cs` | `RedisConfig` | Redis 连接配置 |
| `src/Config/ConfigNotifyChannel.cs` | `ConfigNotifyChannel` | PostgreSQL NOTIFY 通道常量 |
## 5. Docker 支持
### Dockerfile
**位置**: `Dockerfile`
```dockerfile
# 基础镜像
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
EXPOSE 8080
EXPOSE 8081
# 构建镜像
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# 多阶段构建...
# 最终镜像
FROM base AS final
ENTRYPOINT ["dotnet", "YarpGateway.dll"]
```
### Docker 配置
- **默认目标 OS**: Linux
- **暴露端口**: 8080 (HTTP), 8081 (HTTPS)
- **工作目录**: `/app`
## 6. 项目结构
```
src/
├── Config/ # 配置类
│ ├── JwtConfig.cs
│ ├── RedisConfig.cs
│ ├── ConfigNotifyChannel.cs
│ ├── DatabaseRouteConfigProvider.cs
│ └── DatabaseClusterConfigProvider.cs
├── Data/ # 数据访问层
│ ├── GatewayDbContext.cs
│ └── GatewayDbContextFactory.cs
├── DynamicProxy/ # 动态代理配置
│ └── DynamicProxyConfigProvider.cs
├── LoadBalancing/ # 负载均衡策略
│ └── DistributedWeightedRoundRobinPolicy.cs
├── Middleware/ # 中间件
│ ├── JwtTransformMiddleware.cs
│ └── TenantRoutingMiddleware.cs
├── Models/ # 数据模型
│ ├── GwTenant.cs
│ ├── GwTenantRoute.cs
│ ├── GwServiceInstance.cs
│ └── GwPendingServiceDiscovery.cs
├── Services/ # 业务服务
│ ├── RouteCache.cs
│ ├── RedisConnectionManager.cs
│ ├── KubernetesPendingSyncService.cs
│ └── PgSqlConfigChangeListener.cs
├── Program.cs # 应用入口
├── appsettings.json # 配置文件
└── YarpGateway.csproj # 项目文件
```
## 7. 中间件管道
请求处理管道顺序(`Program.cs`
1. **CORS** - 跨域请求处理
2. **JwtTransformMiddleware** - JWT 解析与转换
3. **TenantRoutingMiddleware** - 租户路由解析
4. **Controllers** - API 控制器
5. **ReverseProxy** - YARP 反向代理
## 8. 托管与部署
### Kestrel 配置
- 监听地址: `http://0.0.0.0:8080`
- 支持 Docker 容器化部署
- 支持 Kubernetes 集群部署

View File

@ -1,465 +0,0 @@
# YARP Gateway 目录结构文档
## 1. 目录布局
```
fengling-gateway/
├── .planning/ # 规划文档目录
│ └── codebase/ # 代码库分析文档
│ ├── ARCHITECTURE.md # 架构文档
│ └── STRUCTURE.md # 本文档
├── src/ # 源代码目录
│ ├── Config/ # 配置类和提供者
│ ├── Controllers/ # API 控制器
│ ├── Data/ # 数据访问层
│ ├── DynamicProxy/ # YARP 动态代理
│ ├── LoadBalancing/ # 负载均衡策略
│ ├── Migrations/ # 数据库迁移
│ ├── Metrics/ # 监控指标
│ ├── Middleware/ # 中间件
│ ├── Models/ # 数据模型
│ ├── Properties/ # 项目属性
│ ├── Services/ # 业务服务
│ ├── Program.cs # 程序入口
│ ├── YarpGateway.csproj # 项目文件
│ ├── appsettings.json # 配置文件
│ └── appsettings.Development.json # 开发环境配置
└── (根目录其他文件)
```
---
## 2. 详细目录说明
### 2.1 Config/ - 配置层
**路径**: `src/Config/`
**用途**: 存放配置模型和配置提供者
| 文件 | 行数 | 用途 |
|------|------|------|
| `JwtConfig.cs` | 10 | JWT 认证配置模型,包含 Authority、Audience 等属性 |
| `RedisConfig.cs` | 9 | Redis 连接配置模型,包含连接字符串、数据库索引等 |
| `ConfigNotifyChannel.cs` | 7 | PostgreSQL NOTIFY 通道名称常量定义 |
| `DatabaseRouteConfigProvider.cs` | 84 | 从数据库加载路由配置,转换为 YARP RouteConfig |
| `DatabaseClusterConfigProvider.cs` | 100 | 从数据库加载集群配置,管理服务实例列表 |
**设计特点**:
- 配置类使用 POCO 模型,通过 Options 模式注入
- Provider 类使用单例模式,支持热重载
---
### 2.2 Controllers/ - 控制器层
**路径**: `src/Controllers/`
**用途**: RESTful API 端点
| 文件 | 行数 | 路由前缀 | 用途 |
|------|------|----------|------|
| `GatewayConfigController.cs` | 489 | `/api/gateway` | 网关配置管理 API |
| `PendingServicesController.cs` | 210 | `/api/gateway/pending-services` | 待处理服务管理 API |
**GatewayConfigController 端点**:
| 方法 | 路由 | 功能 |
|------|------|------|
| GET | `/tenants` | 获取租户列表(分页) |
| GET | `/tenants/{id}` | 获取单个租户 |
| POST | `/tenants` | 创建租户 |
| PUT | `/tenants/{id}` | 更新租户 |
| DELETE | `/tenants/{id}` | 删除租户 |
| GET | `/routes` | 获取路由列表(分页) |
| GET | `/routes/global` | 获取全局路由 |
| GET | `/routes/tenant/{tenantCode}` | 获取租户路由 |
| POST | `/routes` | 创建路由 |
| PUT | `/routes/{id}` | 更新路由 |
| DELETE | `/routes/{id}` | 删除路由 |
| GET | `/clusters` | 获取集群列表 |
| GET | `/clusters/{clusterId}` | 获取集群详情 |
| POST | `/clusters` | 创建集群 |
| DELETE | `/clusters/{clusterId}` | 删除集群 |
| GET | `/clusters/{clusterId}/instances` | 获取实例列表 |
| POST | `/clusters/{clusterId}/instances` | 添加实例 |
| DELETE | `/instances/{id}` | 删除实例 |
| POST | `/config/reload` | 重载配置 |
| GET | `/config/status` | 获取配置状态 |
| GET | `/config/versions` | 获取版本信息 |
| GET | `/stats/overview` | 获取统计概览 |
**PendingServicesController 端点**:
| 方法 | 路由 | 功能 |
|------|------|------|
| GET | `/` | 获取待处理服务列表 |
| GET | `/{id}` | 获取待处理服务详情 |
| POST | `/{id}/assign` | 分配服务到集群 |
| POST | `/{id}/reject` | 拒绝服务 |
| GET | `/clusters` | 获取可用集群列表 |
---
### 2.3 Data/ - 数据访问层
**路径**: `src/Data/`
**用途**: Entity Framework Core 数据库上下文
| 文件 | 行数 | 用途 |
|------|------|------|
| `GatewayDbContext.cs` | 142 | EF Core 数据库上下文,包含实体配置和变更通知 |
| `GatewayDbContextFactory.cs` | 23 | 设计时 DbContext 工厂,用于迁移命令 |
**DbContext 特性**:
- 自动检测配置变更
- 集成 PostgreSQL NOTIFY 机制
- 支持软删除IsDeleted 标记)
- 版本号追踪Version 字段)
---
### 2.4 DynamicProxy/ - 动态代理层
**路径**: `src/DynamicProxy/`
**用途**: YARP 动态配置提供
| 文件 | 行数 | 用途 |
|------|------|------|
| `DynamicProxyConfigProvider.cs` | 79 | 实现 IProxyConfigProvider整合路由和集群配置 |
**核心职责**:
- 实现 YARP 配置提供接口
- 协调 Route 和 Cluster 配置
- 提供配置变更通知(通过 CancellationToken
---
### 2.5 LoadBalancing/ - 负载均衡层
**路径**: `src/LoadBalancing/`
**用途**: 自定义负载均衡策略
| 文件 | 行数 | 用途 |
|------|------|------|
| `DistributedWeightedRoundRobinPolicy.cs` | 244 | 基于 Redis 的分布式加权轮询策略 |
**策略特点**:
- 策略名称: `DistributedWeightedRoundRobin`
- 支持实例权重配置
- Redis 分布式状态存储
- 降级策略(锁获取失败时)
---
### 2.6 Migrations/ - 数据库迁移
**路径**: `src/Migrations/`
**用途**: Entity Framework Core 迁移文件
| 文件 | 用途 |
|------|------|
| `20260201120312_InitialCreate.cs` | 初始数据库创建 |
| `20260201133826_AddIsGlobalToTenantRoute.cs` | 添加 IsGlobal 字段 |
| `20260222134342_AddPendingServiceDiscovery.cs` | 添加待处理服务发现表 |
| `*ModelSnapshot.cs` | 当前模型快照 |
| `*.Designer.cs` | 设计器生成文件 |
---
### 2.7 Metrics/ - 监控指标
**路径**: `src/Metrics/`
**用途**: OpenTelemetry 指标定义
| 文件 | 行数 | 用途 |
|------|------|------|
| `GatewayMetrics.cs` | 31 | 定义网关监控指标 |
**指标列表**:
- `gateway_requests_total` - 请求总数计数器
- `gateway_request_duration_seconds` - 请求延迟直方图
---
### 2.8 Middleware/ - 中间件层
**路径**: `src/Middleware/`
**用途**: ASP.NET Core 中间件
| 文件 | 行数 | 用途 |
|------|------|------|
| `JwtTransformMiddleware.cs` | 84 | JWT Token 解析,提取租户信息注入请求头 |
| `TenantRoutingMiddleware.cs` | 64 | 租户路由解析,根据路径查找目标集群 |
**中间件执行顺序**:
```
CORS -> JwtTransformMiddleware -> TenantRoutingMiddleware -> YARP
```
---
### 2.9 Models/ - 数据模型层
**路径**: `src/Models/`
**用途**: 实体类定义
| 文件 | 行数 | 用途 |
|------|------|------|
| `GwTenant.cs` | 16 | 租户实体 |
| `GwTenantRoute.cs` | 20 | 路由配置实体 |
| `GwServiceInstance.cs` | 19 | 服务实例实体 |
| `GwPendingServiceDiscovery.cs` | 28 | 待处理服务发现实体 + 状态枚举 |
**实体通用字段**:
- `Id` - 主键(雪花 ID 格式)
- `Status` - 状态1=启用)
- `CreatedBy/UpdatedBy` - 操作人
- `CreatedTime/UpdatedTime` - 时间戳
- `IsDeleted` - 软删除标记
- `Version` - 版本号(乐观锁)
---
### 2.10 Services/ - 服务层
**路径**: `src/Services/`
**用途**: 业务逻辑和后台服务
| 文件 | 行数 | 类型 | 用途 |
|------|------|------|------|
| `RouteCache.cs` | 139 | Singleton | 路由缓存,支持租户路由和全局路由 |
| `RedisConnectionManager.cs` | 139 | Singleton | Redis 连接管理,分布式锁实现 |
| `PgSqlConfigChangeListener.cs` | 223 | HostedService | PostgreSQL 配置变更监听 |
| `KubernetesPendingSyncService.cs` | 162 | HostedService | Kubernetes 服务发现同步 |
**服务生命周期**:
- Singleton: RouteCache, RedisConnectionManager状态服务
- HostedService: PgSqlConfigChangeListener, KubernetesPendingSyncService后台任务
---
## 3. 关键文件位置
### 3.1 入口文件
| 文件 | 路径 | 用途 |
|------|------|------|
| `Program.cs` | `src/Program.cs` | 应用程序入口,服务注册和中间件配置 |
### 3.2 配置文件
| 文件 | 路径 | 用途 |
|------|------|------|
| `appsettings.json` | `src/appsettings.json` | 生产环境配置 |
| `appsettings.Development.json` | `src/appsettings.Development.json` | 开发环境配置 |
| `YarpGateway.csproj` | `src/YarpGateway.csproj` | 项目文件,包引用 |
### 3.3 数据库相关
| 文件 | 路径 | 用途 |
|------|------|------|
| `GatewayDbContext.cs` | `src/Data/GatewayDbContext.cs` | 数据库上下文 |
| `GatewayDbContextFactory.cs` | `src/Data/GatewayDbContextFactory.cs` | 迁移工具工厂 |
---
## 4. 命名约定
### 4.1 文件命名
| 类型 | 命名规则 | 示例 |
|------|----------|------|
| 实体类 | `Gw` 前缀 + PascalCase | `GwTenant.cs`, `GwTenantRoute.cs` |
| 配置类 | `*Config` 后缀 | `JwtConfig.cs`, `RedisConfig.cs` |
| 提供者 | `*Provider` 后缀 | `DatabaseRouteConfigProvider.cs` |
| 中间件 | `*Middleware` 后缀 | `JwtTransformMiddleware.cs` |
| 控制器 | `*Controller` 后缀 | `GatewayConfigController.cs` |
| 服务 | 功能描述 + 类型 | `RouteCache.cs`, `PgSqlConfigChangeListener.cs` |
| 策略 | `*Policy` 后缀 | `DistributedWeightedRoundRobinPolicy.cs` |
### 4.2 命名空间
```
YarpGateway # 根命名空间
├── Config # 配置相关
├── Controllers # API 控制器
├── Data # 数据访问
├── DynamicProxy # 动态代理
├── LoadBalancing # 负载均衡
├── Metrics # 监控指标
├── Middleware # 中间件
├── Models # 数据模型
└── Services # 业务服务
```
### 4.3 接口命名
| 类型 | 命名规则 | 示例 |
|------|----------|------|
| 服务接口 | `I` 前缀 | `IRouteCache`, `IRedisConnectionManager` |
| DTO 类 | `*Dto` 后缀 | `CreateTenantDto`, `CreateRouteDto` |
| 请求类 | `*Request` 后缀 | `AssignServiceRequest` |
---
## 5. 模块组织
### 5.1 分层架构
```
┌─────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
│ │ Middleware │ │ Controllers │ │
│ │ - JWT 解析 │ │ - GatewayConfigController │ │
│ │ - 租户路由 │ │ - PendingServicesController │ │
│ └─────────────────┘ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Business Logic Layer │
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
│ │ Services │ │ DynamicProxy │ │
│ │ - RouteCache │ │ - DynamicProxyConfigProvider │ │
│ │ - RedisManager │ │ │ │
│ │ - ConfigListen │ └─────────────────────────────────┘ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Data Access Layer │
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
│ │ Models │ │ Data │ │
│ │ - GwTenant │ │ - GatewayDbContext │ │
│ │ - GwRoute │ │ - GatewayDbContextFactory │ │
│ │ - GwInstance │ │ │ │
│ └─────────────────┘ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
│ │ Config │ │ LoadBalancing │ │
│ │ - JwtConfig │ │ - WeightedRoundRobinPolicy │ │
│ │ - RedisConfig │ │ │ │
│ │ - Providers │ └─────────────────────────────────┘ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 5.2 模块依赖关系
```
Program.cs
├── Config/
│ ├── JwtConfig ◄── appsettings.json
│ ├── RedisConfig ◄── appsettings.json
│ ├── DatabaseRouteConfigProvider ◄── Data/GatewayDbContext
│ └── DatabaseClusterConfigProvider ◄── Data/GatewayDbContext
├── DynamicProxy/
│ └── DynamicProxyConfigProvider ◄── Config/*
├── Services/
│ ├── RouteCache ◄── Data/GatewayDbContext, Models/*
│ ├── RedisConnectionManager ◄── Config/RedisConfig
│ ├── PgSqlConfigChangeListener ◄── DynamicProxy, Services/RouteCache
│ └── KubernetesPendingSyncService ◄── Data/GatewayDbContext
├── Middleware/
│ ├── JwtTransformMiddleware ◄── Config/JwtConfig
│ └── TenantRoutingMiddleware ◄── Services/RouteCache
└── Controllers/
├── GatewayConfigController ◄── Config/*, Services/RouteCache
└── PendingServicesController ◄── Data/GatewayDbContext
```
---
## 6. 项目依赖
### 6.1 NuGet 包引用
```xml
<!-- 核心框架 -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Yarp.ReverseProxy" />
<!-- 数据库 -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<!-- 缓存 -->
<PackageReference Include="StackExchange.Redis" />
<!-- 日志 -->
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<!-- 服务发现 -->
<PackageReference Include="Fengling.ServiceDiscovery.Core" />
<PackageReference Include="Fengling.ServiceDiscovery.Kubernetes" />
<PackageReference Include="Fengling.ServiceDiscovery.Static" />
```
### 6.2 目标框架
```xml
<TargetFramework>net10.0</TargetFramework>
```
---
## 7. 文件统计
| 目录/文件 | 文件数 | 总行数 | 主要用途 |
|-----------|--------|--------|----------|
| `Config/` | 5 | ~210 | 配置模型和提供者 |
| `Controllers/` | 2 | ~700 | REST API 端点 |
| `Data/` | 2 | ~165 | 数据库上下文 |
| `DynamicProxy/` | 1 | ~79 | YARP 配置集成 |
| `LoadBalancing/` | 1 | ~244 | 负载均衡策略 |
| `Migrations/` | 6 | ~500+ | 数据库迁移 |
| `Metrics/` | 1 | ~31 | 监控指标 |
| `Middleware/` | 2 | ~148 | 请求处理中间件 |
| `Models/` | 4 | ~83 | 数据实体 |
| `Services/` | 4 | ~665 | 业务服务 |
| `Program.cs` | 1 | 135 | 应用入口 |
| **总计** | **29** | **~2900+** | - |
---
## 8. 扩展建议
### 8.1 建议新增目录
| 目录 | 用途 |
|------|------|
| `Extensions/` | 扩展方法 |
| `Constants/` | 常量定义 |
| `Exceptions/` | 自定义异常 |
| `Validators/` | 输入验证器 |
| `Dtos/` | 数据传输对象(从 Controllers 提取) |
### 8.2 代码组织建议
1. 将 Controller 中的 DTO 类提取到独立的 `Dtos/` 目录
2. 添加 `Extensions/` 存放 IServiceCollection 扩展方法
3. 考虑将配置验证逻辑提取到 `Validators/`

View File

@ -1,833 +0,0 @@
# YARP Gateway 测试文档
## 概述
本文档记录了 YARP Gateway 项目的测试策略、测试模式和最佳实践。
---
## 1. 测试框架
### 1.1 当前测试状态
**项目当前没有专门的测试目录或测试项目。**
检查项目结构:
```
fengling-gateway/
├── src/ # 源代码
│ └── YarpGateway.csproj # 主项目
├── .planning/
└── (无 tests/ 或 test/ 目录)
```
检查 `.csproj` 文件确认无测试框架依赖:
```xml
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="StackExchange.Redis" />
<PackageReference Include="Yarp.ReverseProxy" />
</ItemGroup>
```
**结论**:项目目前处于开发阶段,尚未建立测试基础设施。
### 1.2 推荐测试框架
基于项目技术栈,推荐以下测试框架:
| 框架 | 用途 | NuGet 包 |
|------|------|----------|
| xUnit | 单元测试框架 | `xunit` |
| Moq | Mock 框架 | `Moq` |
| FluentAssertions | 断言库 | `FluentAssertions` |
| Microsoft.NET.Test.Sdk | 测试 SDK | `Microsoft.NET.Test.Sdk` |
| Testcontainers | 集成测试容器 | `Testcontainers.PostgreSql`, `Testcontainers.Redis` |
---
## 2. 推荐测试结构
### 2.1 测试项目组织
建议创建独立的测试项目:
```
tests/
├── YarpGateway.UnitTests/ # 单元测试
│ ├── Services/
│ │ ├── RouteCacheTests.cs
│ │ └── RedisConnectionManagerTests.cs
│ ├── Middleware/
│ │ ├── JwtTransformMiddlewareTests.cs
│ │ └── TenantRoutingMiddlewareTests.cs
│ └── Controllers/
│ └── GatewayConfigControllerTests.cs
├── YarpGateway.IntegrationTests/ # 集成测试
│ ├── GatewayEndpointsTests.cs
│ └── DatabaseTests.cs
└── YarpGateway.LoadTests/ # 负载测试(可选)
└── RoutePerformanceTests.cs
```
### 2.2 测试命名约定
```csharp
// 命名格式:[被测类]Tests
public class RouteCacheTests { }
// 方法命名格式:[方法名]_[场景]_[期望结果]
[Fact]
public async Task InitializeAsync_WithValidData_LoadsRoutesFromDatabase() { }
[Fact]
public async Task GetRoute_WithNonexistentTenant_ReturnsNull() { }
[Fact]
public async Task ReloadAsync_WhenCalled_RefreshesCache() { }
```
---
## 3. 单元测试模式
### 3.1 服务层测试示例
```csharp
// RouteCacheTests.cs
using Xunit;
using Moq;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
public class RouteCacheTests
{
private readonly Mock<IDbContextFactory<GatewayDbContext>> _mockDbContextFactory;
private readonly Mock<ILogger<RouteCache>> _mockLogger;
private readonly RouteCache _sut; // System Under Test
public RouteCacheTests()
{
_mockDbContextFactory = new Mock<IDbContextFactory<GatewayDbContext>>();
_mockLogger = new Mock<ILogger<RouteCache>>();
_sut = new RouteCache(_mockDbContextFactory.Object, _mockLogger.Object);
}
[Fact]
public async Task InitializeAsync_ShouldLoadRoutesFromDatabase()
{
// Arrange
var routes = new List<GwTenantRoute>
{
new() { Id = 1, ServiceName = "user-service", ClusterId = "user-cluster", IsGlobal = true }
};
var mockDbSet = CreateMockDbSet(routes);
var mockContext = new Mock<GatewayDbContext>();
mockContext.Setup(c => c.TenantRoutes).Returns(mockDbSet.Object);
_mockDbContextFactory
.Setup(f => f.CreateDbContext())
.Returns(mockContext.Object);
// Act
await _sut.InitializeAsync();
// Assert
var result = _sut.GetRoute("tenant1", "user-service");
result.Should().NotBeNull();
result!.ClusterId.Should().Be("user-cluster");
}
[Fact]
public async Task GetRoute_WhenTenantRouteExists_ReturnsTenantRoute()
{
// Arrange - 设置租户专用路由
// ...
// Act
var result = _sut.GetRoute("tenant1", "service1");
// Assert
result.Should().NotBeNull();
result!.IsGlobal.Should().BeFalse();
}
[Fact]
public async Task GetRoute_WhenNoTenantRouteButGlobalExists_ReturnsGlobalRoute()
{
// Arrange
// ...
// Act
var result = _sut.GetRoute("tenant-without-route", "global-service");
// Assert
result.Should().NotBeNull();
result!.IsGlobal.Should().BeTrue();
}
// 辅助方法:创建模拟 DbSet
private Mock<DbSet<T>> CreateMockDbSet<T>(List<T> data) where T : class
{
var queryable = data.AsQueryable();
var mockSet = new Mock<DbSet<T>>();
mockSet.As<IQueryable<T>>().Setup(m => m.Provider).Returns(queryable.Provider);
mockSet.As<IQueryable<T>>().Setup(m => m.Expression).Returns(queryable.Expression);
mockSet.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(queryable.ElementType);
mockSet.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(queryable.GetEnumerator());
return mockSet;
}
}
```
### 3.2 中间件测试示例
```csharp
// TenantRoutingMiddlewareTests.cs
using Xunit;
using Moq;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
public class TenantRoutingMiddlewareTests
{
private readonly Mock<RequestDelegate> _mockNext;
private readonly Mock<IRouteCache> _mockRouteCache;
private readonly Mock<ILogger<TenantRoutingMiddleware>> _mockLogger;
private readonly TenantRoutingMiddleware _sut;
public TenantRoutingMiddlewareTests()
{
_mockNext = new Mock<RequestDelegate>();
_mockRouteCache = new Mock<IRouteCache>();
_mockLogger = new Mock<ILogger<TenantRoutingMiddleware>>();
_sut = new TenantRoutingMiddleware(_mockNext.Object, _mockRouteCache.Object, _mockLogger.Object);
}
[Fact]
public async Task InvokeAsync_WithoutTenantHeader_CallsNextWithoutProcessing()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Path = "/api/user-service/users";
// Act
await _sut.InvokeAsync(context);
// Assert
_mockNext.Verify(n => n(context), Times.Once);
_mockRouteCache.Verify(r => r.GetRoute(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task InvokeAsync_WithValidTenantAndRoute_SetsDynamicClusterId()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Path = "/api/order-service/orders";
context.Request.Headers["X-Tenant-Id"] = "tenant-123";
var routeInfo = new RouteInfo
{
ClusterId = "order-cluster",
IsGlobal = false
};
_mockRouteCache
.Setup(r => r.GetRoute("tenant-123", "order-service"))
.Returns(routeInfo);
// Act
await _sut.InvokeAsync(context);
// Assert
context.Items["DynamicClusterId"].Should().Be("order-cluster");
_mockNext.Verify(n => n(context), Times.Once);
}
[Fact]
public async Task InvokeAsync_WithNoMatchingRoute_CallsNextWithoutClusterId()
{
// Arrange
var context = new DefaultHttpContext();
context.Request.Path = "/api/unknown-service/data";
context.Request.Headers["X-Tenant-Id"] = "tenant-123";
_mockRouteCache
.Setup(r => r.GetRoute("tenant-123", "unknown-service"))
.Returns((RouteInfo?)null);
// Act
await _sut.InvokeAsync(context);
// Assert
context.Items.ContainsKey("DynamicClusterId").Should().BeFalse();
_mockNext.Verify(n => n(context), Times.Once);
}
}
```
### 3.3 控制器测试示例
```csharp
// GatewayConfigControllerTests.cs
using Xunit;
using Moq;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
public class GatewayConfigControllerTests
{
private readonly Mock<IDbContextFactory<GatewayDbContext>> _mockDbFactory;
private readonly Mock<DatabaseRouteConfigProvider> _mockRouteProvider;
private readonly Mock<DatabaseClusterConfigProvider> _mockClusterProvider;
private readonly Mock<IRouteCache> _mockRouteCache;
private readonly GatewayConfigController _sut;
public GatewayConfigControllerTests()
{
_mockDbFactory = new Mock<IDbContextFactory<GatewayDbContext>>();
_mockRouteProvider = new Mock<DatabaseRouteConfigProvider>();
_mockClusterProvider = new Mock<DatabaseClusterConfigProvider>();
_mockRouteCache = new Mock<IRouteCache>();
_sut = new GatewayConfigController(
_mockDbFactory.Object,
_mockRouteProvider.Object,
_mockClusterProvider.Object,
_mockRouteCache.Object
);
}
[Fact]
public async Task GetTenants_ShouldReturnPaginatedList()
{
// Arrange
var tenants = new List<GwTenant>
{
new() { Id = 1, TenantCode = "tenant1", TenantName = "Tenant 1" },
new() { Id = 2, TenantCode = "tenant2", TenantName = "Tenant 2" }
};
// 设置模拟 DbContext...
// Act
var result = await _sut.GetTenants(page: 1, pageSize: 10);
// Assert
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
var response = okResult.Value.Should().BeAnonymousType();
response.Property("total").Should().Be(2);
}
[Fact]
public async Task CreateTenant_WithValidData_ReturnsCreatedTenant()
{
// Arrange
var dto = new GatewayConfigController.CreateTenantDto
{
TenantCode = "new-tenant",
TenantName = "New Tenant"
};
// Act
var result = await _sut.CreateTenant(dto);
// Assert
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
okResult.Value.Should().BeAssignableTo<GwTenant>();
}
[Fact]
public async Task DeleteTenant_WithNonexistentId_ReturnsNotFound()
{
// Arrange
// 设置模拟返回 null
// Act
var result = await _sut.DeleteTenant(999);
// Assert
result.Should().BeOfType<NotFoundResult>();
}
}
```
---
## 4. Mock 模式
### 4.1 接口 Mock
```csharp
// 使用 Moq 模拟接口
public class RouteCacheTests
{
private readonly Mock<IRouteCache> _mockRouteCache;
public RouteCacheTests()
{
_mockRouteCache = new Mock<IRouteCache>();
}
[Fact]
public async Task TestMethod()
{
// 设置返回值
_mockRouteCache
.Setup(r => r.GetRoute("tenant1", "service1"))
.Returns(new RouteInfo { ClusterId = "cluster1" });
// 设置异步方法
_mockRouteCache
.Setup(r => r.InitializeAsync())
.Returns(Task.CompletedTask);
// 验证调用
_mockRouteCache.Verify(r => r.GetRoute(It.IsAny<string>(), It.IsAny<string>()), Times.Once);
}
}
```
### 4.2 DbContext Mock
```csharp
// 使用 In-Memory 数据库进行测试
public class TestDatabaseFixture
{
public GatewayDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<GatewayDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var context = new GatewayDbContext(options);
// 种子数据
context.Tenants.Add(new GwTenant { Id = 1, TenantCode = "test-tenant" });
context.TenantRoutes.Add(new GwTenantRoute
{
Id = 1,
ServiceName = "test-service",
ClusterId = "test-cluster"
});
context.SaveChanges();
return context;
}
}
public class GatewayDbContextTests : IClassFixture<TestDatabaseFixture>
{
private readonly TestDatabaseFixture _fixture;
public GatewayDbContextTests(TestDatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task SaveChangesAsync_ShouldNotifyConfigChange()
{
// Arrange
await using var context = _fixture.CreateContext();
// Act
var route = new GwTenantRoute { ServiceName = "new-service", ClusterId = "new-cluster" };
context.TenantRoutes.Add(route);
await context.SaveChangesAsync();
// Assert
// 验证通知行为(如果需要)
}
}
```
### 4.3 Redis Mock
```csharp
// 使用 Moq 模拟 Redis
public class RedisConnectionManagerTests
{
private readonly Mock<IConnectionMultiplexer> _mockRedis;
private readonly Mock<IDatabase> _mockDatabase;
public RedisConnectionManagerTests()
{
_mockRedis = new Mock<IConnectionMultiplexer>();
_mockDatabase = new Mock<IDatabase>();
_mockRedis.Setup(r => r.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
.Returns(_mockDatabase.Object);
}
[Fact]
public async Task AcquireLockAsync_WhenLockAvailable_ReturnsDisposable()
{
// Arrange
_mockDatabase
.Setup(d => d.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<TimeSpan?>(),
It.IsAny<When>(),
It.IsAny<CommandFlags>()))
.ReturnsAsync(true);
// Act & Assert
// 测试逻辑...
}
}
```
---
## 5. 集成测试模式
### 5.1 WebApplicationFactory 模式
```csharp
// 使用 WebApplicationFactory 进行 API 集成测试
using Microsoft.AspNetCore.Mvc.Testing;
public class GatewayIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public GatewayIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// 替换真实服务为测试替身
services.RemoveAll<IDbContextFactory<GatewayDbContext>>();
services.AddDbContextFactory<GatewayDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
});
});
_client = _factory.CreateClient();
}
[Fact]
public async Task GetHealth_ReturnsHealthy()
{
// Act
var response = await _client.GetAsync("/health");
// Assert
response.Should().BeSuccessful();
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("healthy");
}
[Fact]
public async Task GetTenants_ReturnsPaginatedList()
{
// Act
var response = await _client.GetAsync("/api/gateway/tenants?page=1&pageSize=10");
// Assert
response.Should().BeSuccessful();
// 进一步验证响应内容...
}
}
```
### 5.2 Testcontainers 模式
```csharp
// 使用 Testcontainers 进行真实数据库集成测试
using Testcontainers.PostgreSql;
using Testcontainers.Redis;
public class DatabaseIntegrationTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgresContainer;
private readonly RedisContainer _redisContainer;
public DatabaseIntegrationTests()
{
_postgresContainer = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine")
.WithDatabase("test_gateway")
.WithUsername("test")
.WithPassword("test")
.Build();
_redisContainer = new RedisBuilder()
.WithImage("redis:7-alpine")
.Build();
}
public async Task InitializeAsync()
{
await _postgresContainer.StartAsync();
await _redisContainer.StartAsync();
}
public async Task DisposeAsync()
{
await _postgresContainer.DisposeAsync();
await _redisContainer.DisposeAsync();
}
[Fact]
public async Task FullWorkflow_CreateTenantAndRoute_RouteShouldWork()
{
// Arrange
var connectionString = _postgresContainer.GetConnectionString();
// 使用真实连接进行端到端测试...
}
}
```
---
## 6. 测试覆盖率
### 6.1 当前状态
项目当前无测试覆盖率数据。
### 6.2 推荐覆盖率目标
| 层级 | 目标覆盖率 | 说明 |
|------|-----------|------|
| Services | 80%+ | 核心业务逻辑,必须高覆盖 |
| Middleware | 75%+ | 关键请求处理逻辑 |
| Controllers | 70%+ | API 端点行为验证 |
| Config | 60%+ | 配置加载和验证 |
| Models | 30%+ | 简单 POCO 类,低优先级 |
### 6.3 配置覆盖率收集
```xml
<!-- 添加到 .csproj 文件 -->
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
```
```bash
# 运行测试并收集覆盖率
dotnet test --collect:"XPlat Code Coverage"
# 生成覆盖率报告
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coverage-report"
```
---
## 7. 如何运行测试
### 7.1 运行所有测试
```bash
# 运行所有测试
dotnet test
# 运行特定项目
dotnet test tests/YarpGateway.UnitTests
# 运行特定测试类
dotnet test --filter "FullyQualifiedName~RouteCacheTests"
# 运行特定测试方法
dotnet test --filter "FullyQualifiedName~RouteCacheTests.InitializeAsync_ShouldLoadRoutesFromDatabase"
```
### 7.2 运行测试类别
```csharp
// 定义测试类别
[Trait("Category", "Unit")]
public class RouteCacheTests { }
[Trait("Category", "Integration")]
public class GatewayIntegrationTests { }
```
```bash
# 只运行单元测试
dotnet test --filter "Category=Unit"
# 排除集成测试
dotnet test --filter "Category!=Integration"
```
### 7.3 CI/CD 配置示例
```yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: test_gateway
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage"
env:
ConnectionStrings__DefaultConnection: "Host=localhost;Database=test_gateway;Username=test;Password=test"
Redis__ConnectionString: "localhost:6379"
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./tests/**/coverage.cobertura.xml
```
---
## 8. 测试最佳实践
### 8.1 AAA 模式
```csharp
[Fact]
public async Task Method_Scenario_ExpectedResult()
{
// Arrange - 准备测试数据和环境
var input = "test-data";
// Act - 执行被测试的方法
var result = await _sut.MethodAsync(input);
// Assert - 验证结果
result.Should().Be(expected);
}
```
### 8.2 单一职责
```csharp
// ✅ 好:每个测试只验证一个行为
[Fact]
public async Task CreateTenant_WithValidData_ReturnsCreatedTenant() { }
[Fact]
public async Task CreateTenant_WithDuplicateCode_ReturnsBadRequest() { }
// ❌ 差:一个测试验证多个行为
[Fact]
public async Task CreateTenant_TestsAllScenarios() { }
```
### 8.3 测试隔离
```csharp
public class RouteCacheTests
{
// 每个测试使用独立实例
private readonly RouteCache _sut;
public RouteCacheTests()
{
// 在构造函数中初始化,确保每个测试独立
_sut = new RouteCache(...);
}
}
```
### 8.4 避免实现细节测试
```csharp
// ✅ 好:测试行为而非实现
[Fact]
public async Task GetRoute_ReturnsCorrectRoute() { }
// ❌ 差:测试内部实现细节
[Fact]
public void InternalDictionary_ContainsCorrectKey() { }
```
---
## 9. 总结
### 当前状态
- ❌ 无测试项目
- ❌ 无测试框架依赖
- ❌ 无测试覆盖率
- ❌ 无 CI/CD 测试配置
### 建议行动计划
1. **创建测试项目**
```bash
dotnet new xunit -n YarpGateway.UnitTests -o tests/YarpGateway.UnitTests
dotnet new xunit -n YarpGateway.IntegrationTests -o tests/YarpGateway.IntegrationTests
```
2. **添加测试依赖**
```bash
dotnet add package Moq
dotnet add package FluentAssertions
dotnet add package coverlet.collector
```
3. **优先测试核心服务**
- `RouteCache` - 路由缓存核心逻辑
- `RedisConnectionManager` - Redis 连接和分布式锁
- `TenantRoutingMiddleware` - 租户路由中间件
4. **建立 CI/CD 测试流程**
- 每次提交运行单元测试
- 每次合并运行集成测试
- 生成覆盖率报告
通过建立完善的测试体系,可以显著提高代码质量和项目可维护性。

View File

@ -1,180 +0,0 @@
# 🧪 YARP 网关测试覆盖计划
> 分析日期2026-02-28
> 当前状态:**无任何测试代码**
---
## 测试项目结构
```
tests/
└── YarpGateway.Tests/
├── YarpGateway.Tests.csproj
├── Unit/
│ ├── Middleware/
│ │ ├── JwtTransformMiddlewareTests.cs
│ │ └── TenantRoutingMiddlewareTests.cs
│ ├── Services/
│ │ ├── RouteCacheTests.cs
│ │ ├── RedisConnectionManagerTests.cs
│ │ └── PgSqlConfigChangeListenerTests.cs
│ ├── LoadBalancing/
│ │ └── DistributedWeightedRoundRobinPolicyTests.cs
│ └── Config/
│ ├── DatabaseRouteConfigProviderTests.cs
│ └── DatabaseClusterConfigProviderTests.cs
├── Integration/
│ ├── Controllers/
│ │ ├── GatewayConfigControllerTests.cs
│ │ └── PendingServicesControllerTests.cs
│ └── Middleware/
│ └── MiddlewarePipelineTests.cs
└── TestHelpers/
├── MockDbContext.cs
├── MockRedis.cs
└── TestFixtures.cs
```
---
## P0 - 必须覆盖(核心安全)
### JwtTransformMiddlewareTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldValidateJwtSignature` | 应验证 JWT 签名 | 有效签名的 JWT | 解析成功Claims 正确 | `IOptions<JwtConfig>` |
| `ShouldRejectInvalidToken` | 应拒绝无效 Token | 伪造/过期 JWT | 返回 401 或跳过处理 | - |
| `ShouldExtractTenantClaim` | 应正确提取租户 ID | 含 tenant claim 的 JWT | X-Tenant-Id header 设置正确 | - |
| `ShouldHandleMissingToken` | 应处理无 Token 请求 | 无 Authorization header | 继续处理(不设置 headers | - |
| `ShouldHandleMalformedToken` | 应处理格式错误 Token | 无效 JWT 格式 | 记录警告,继续处理 | - |
### TenantRoutingMiddlewareTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldValidateTenantIdAgainstJwt` | 应验证 header 与 JWT 一致 | X-Tenant-Id ≠ JWT tenant | 返回 403 Forbidden | `IRouteCache` |
| `ShouldExtractServiceNameFromPath` | 应正确解析服务名 | `/api/user-service/users` | serviceName = "user-service" | - |
| `ShouldFindRouteInCache` | 应从缓存找到路由 | 有效租户+服务名 | 设置正确的 clusterId | `IRouteCache` |
| `ShouldHandleRouteNotFound` | 应处理路由未找到 | 不存在的服务名 | 记录警告,继续处理 | - |
| `ShouldPrioritizeTenantRouteOverGlobal` | 租户路由优先于全局 | 同时存在两种路由 | 使用租户路由 | - |
---
## P1 - 重要覆盖(核心业务)
### RouteCacheTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldLoadGlobalRoutes` | 应加载全局路由 | 全局路由数据 | `_globalRoutes` 填充 | `IDbContextFactory<GatewayDbContext>` |
| `ShouldLoadTenantRoutes` | 应加载租户路由 | 租户路由数据 | `_tenantRoutes` 填充 | - |
| `ShouldReturnCorrectRoute` | 应返回正确路由 | 查询请求 | 正确的 `RouteInfo` | - |
| `ShouldReturnNullForMissingRoute` | 不存在路由返回 null | 不存在的服务名 | `null` | - |
| `ShouldHandleConcurrentReads` | 并发读取应安全 | 多线程读取 | 无异常,数据一致 | - |
| `ShouldReloadCorrectly` | 应正确重载 | Reload 调用 | 旧数据清除,新数据加载 | - |
### RedisConnectionManagerTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldAcquireLock` | 应获取分布式锁 | 有效 key | 锁获取成功 | `IConnectionMultiplexer` |
| `ShouldReleaseLockCorrectly` | 应正确释放锁 | 已获取的锁 | 锁释放成功 | - |
| `ShouldNotReleaseOthersLock` | 不应释放他人锁 | 其他实例的锁 | 释放失败(安全) | - |
| `ShouldHandleConnectionFailure` | 应处理连接失败 | Redis 不可用 | 记录错误,返回失败 | - |
| `ShouldExecuteInLock` | 应在锁内执行操作 | 操作委托 | 操作执行,锁正确释放 | - |
### DistributedWeightedRoundRobinPolicyTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldSelectByWeight` | 应按权重选择 | 权重 [3, 1, 1] | 约 60% 选第一个 | `IConnectionMultiplexer` |
| `ShouldFallbackOnLockFailure` | 锁失败应降级 | Redis 不可用 | 降级选择第一个可用 | - |
| `ShouldReturnNullWhenNoDestinations` | 无目标返回 null | 空目标列表 | `null` | - |
| `ShouldPersistStateToRedis` | 状态应持久化到 Redis | 多次选择 | 状态存储正确 | - |
| `ShouldExpireStateAfterTTL` | 状态应在 TTL 后过期 | 1 小时后 | 状态重新初始化 | - |
### PgSqlConfigChangeListenerTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldListenForNotifications` | 应监听通知 | NOTIFY 事件 | 触发重载 | `NpgsqlConnection` |
| `ShouldFallbackToPolling` | 应回退到轮询 | 通知失败 | 定时轮询检测 | - |
| `ShouldReconnectOnFailure` | 失败应重连 | 连接断开 | 自动重连 | - |
| `ShouldDetectVersionChange` | 应检测版本变化 | 版本号增加 | 触发重载 | - |
---
## P2 - 推荐覆盖(业务逻辑)
### GatewayConfigControllerTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldCreateTenant` | 应创建租户 | 有效 DTO | 201 Created | `IDbContextFactory` |
| `ShouldRejectDuplicateTenant` | 应拒绝重复租户 | 已存在的 TenantCode | 400 BadRequest | - |
| `ShouldCreateRoute` | 应创建路由 | 有效 DTO | 201 Created | - |
| `ShouldDeleteTenant` | 应删除租户 | 有效 ID | 204 NoContent | - |
| `ShouldReturn404ForMissingTenant` | 不存在租户返回 404 | 无效 ID | 404 NotFound | - |
| `ShouldReloadConfig` | 应重载配置 | POST /config/reload | 200 OK | `IRouteCache` |
### PendingServicesControllerTests
| 测试用例 | 描述 | 输入 | 预期输出 | Mock 需求 |
|---------|------|------|----------|-----------|
| `ShouldListPendingServices` | 应列出待处理服务 | GET 请求 | 待处理服务列表 | `IDbContextFactory` |
| `ShouldAssignService` | 应分配服务 | 有效请求 | 服务实例创建 | - |
| `ShouldRejectInvalidCluster` | 应拒绝无效集群 | 不存在的 ClusterId | 400 BadRequest | - |
| `ShouldRejectService` | 应拒绝服务 | reject 请求 | 状态更新为 Rejected | - |
---
## 测试依赖
```xml
<!-- YarpGateway.Tests.csproj -->
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0" />
<PackageReference Include="Testcontainers.Redis" Version="3.7.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.7.0" />
</ItemGroup>
```
---
## 运行测试命令
```bash
# 运行所有测试
dotnet test
# 运行特定测试类
dotnet test --filter "FullyQualifiedName~JwtTransformMiddlewareTests"
# 生成覆盖率报告
dotnet test --collect:"XPlat Code Coverage"
reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coverage
```
---
## 覆盖率目标
| 组件 | 目标覆盖率 | 优先级 |
|------|-----------|--------|
| JwtTransformMiddleware | 90% | P0 |
| TenantRoutingMiddleware | 85% | P0 |
| RouteCache | 80% | P1 |
| DistributedWeightedRoundRobinPolicy | 80% | P1 |
| Controllers | 70% | P2 |
| 整体项目 | 75% | - |
---
*测试计划由分析生成,建议按优先级逐步实现。*

View File

@ -1,13 +0,0 @@
{
"mode": "yolo",
"depth": "standard",
"parallelization": true,
"commit_docs": true,
"model_profile": "balanced",
"workflow": {
"research": false,
"plan_check": false,
"verifier": false,
"auto_advance": true
}
}

View File

@ -1,712 +0,0 @@
# 网关插件系统技术方案
## 一、概述
本文档描述 YARP 网关的插件系统规划,包括 Web UI 管理界面和动态编译加载两大核心功能。
---
## 二、整体架构
```
┌─────────────────────┐
│ fengling-console │ (运维后端 - Backend)
│ web 前端 │
└─────────┬───────────┘
│ HTTP API
┌─────────────────────┐
│ fengling-console │ (运维服务端)
│ │
│ - 路由管理 API │ ───▶ 数据库持久化
│ - 集群管理 API │ ───▶ Redis Pub/Sub (发布事件)
│ - 插件管理 API │
└─────────┬───────────┘
│ 事件订阅
┌─────────┴───────────┐
│ fengling-gateway │ (YARP 网关多实例)
│ - YARP 代理 │
│ - 插件执行 │
│ - 事件监听 │
└─────────────────────┘
```
### 项目职责
| 项目 | 职责 |
|------|------|
| **fengling-gateway** | 纯 YARP 代理 + 事件订阅 + 插件执行 |
| **fengling-console** | 运维 API + 配置持久化 + 事件发布 |
| **fengling-console-web** | 前端 UI (Monaco Editor) |
---
## 三、Web UI 管理界面
### 3.1 技术选型
| 项目 | 选择 | 理由 |
|------|------|------|
| 前端框架 | React/Vue | 独立前端项目 |
| 编辑器 | Monaco Editor | VS Code 同款,体验一致 |
| 路由 | `/gateway` | 运维平台内统一路由 |
### 3.2 功能模块
```
/gateway
├── 路由管理 (Routes)
│ ├── 列表/搜索
│ ├── 创建/编辑/删除
│ └── 路由规则配置
├── 集群管理 (Clusters)
│ ├── 上下游服务列表
│ ├── 实例管理
│ └── 健康状态
├── 插件管理 (Plugins)
│ ├── 已加载插件列表
│ ├── 上传 DLL
│ └── 在线编写 C# 代码
└── 监控统计
├── QPS/延迟
└── 流量图表
```
## 一、概述
本文档描述 YARP 网关的插件系统规划,包括 Web UI 管理界面和动态编译加载两大核心功能。
---
## 二、Web UI 管理界面
### 2.1 技术选型
| 项目 | 选择 | 理由 |
|------|------|------|
| 框架 | Razor Pages | 嵌入主应用,单项目部署 |
| 路由 | `/gateway/ui` | 参考 SwaggerUI 风格 |
| 编辑器 | Monaco Editor | VS Code 同款,体验一致 |
### 2.2 功能模块
```
/gateway/ui
├── 路由管理 (Routes)
│ ├── 列表/搜索
│ ├── 创建/编辑/删除
│ └── 路由规则配置
├── 集群管理 (Clusters)
│ ├── 上下游服务列表
│ ├── 实例管理
│ └── 健康状态
├── 插件管理 (Plugins)
│ ├── 已加载插件列表
│ ├── 上传 DLL
│ └── 在线编写 C# 代码
└── 监控统计
├── QPS/延迟
└── 流量图表
```
---
## 三、插件系统架构
### 3.1 插件类型定义
```csharp
namespace Fengling.Gateway.Plugin.Abstractions
{
/// <summary>
/// 插件基础接口
/// </summary>
public interface IGatewayPlugin
{
string Name { get; }
string Version { get; }
string? Description { get; }
Task OnLoadAsync();
Task OnUnloadAsync();
}
/// <summary>
/// 请求处理插件
/// </summary>
public interface IRequestPlugin : IGatewayPlugin
{
/// <summary>请求到达网关前</summary>
Task<HttpContext?> OnRequestAsync(HttpContext context);
/// <summary>路由决策后</summary>
Task<HttpContext?> OnRouteMatchedAsync(HttpContext context, RouteConfig route);
/// <summary>转发到后端前</summary>
Task<HttpContext?> OnForwardingAsync(HttpContext context, HttpRequestMessage request);
}
/// <summary>
/// 响应处理插件
/// </summary>
public interface IResponsePlugin : IGatewayPlugin
{
/// <summary>后端响应后</summary>
Task OnBackendResponseAsync(HttpContext context, HttpResponseMessage response);
/// <summary>返回客户端前</summary>
Task OnResponseFinalizingAsync(HttpContext context);
}
/// <summary>
/// 路由转换插件
/// </summary>
public interface IRouteTransformPlugin : IGatewayPlugin
{
Task<RouteConfig> TransformRouteAsync(RouteConfig original, HttpContext context);
}
/// <summary>
/// 负载均衡插件
/// </summary>
public interface ILoadBalancePlugin : IGatewayPlugin
{
Task<Destination> SelectDestinationAsync(
IReadOnlyList<Destination> destinations,
HttpContext context);
}
}
```
### 3.2 插件阶段枚举
```csharp
public enum PipelineStage
{
None = 0,
OnRequest = 1, // 请求到达网关前
OnRoute = 2, // 路由决策时
OnRequestBackend = 3, // 转发到后端前
OnResponseBackend = 4, // 后端响应后
OnResponse = 5 // 返回给客户端前
}
```
---
## 四、核心模块设计
### 4.1 依赖管理A方案
#### 简单场景:直接使用网关已有程序集
```csharp
// API 暴露网关程序集
[ApiController]
public class AssembliesController : ControllerBase
{
[HttpGet("available")]
public List<AssemblyInfo> GetAvailableAssemblies()
{
return AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
.Select(a => new AssemblyInfo
{
Name = a.GetName().Name,
Version = a.GetName().Version?.ToString(),
Location = a.Location
})
.OrderBy(a => a.Name)
.ToList();
}
}
```
#### 复杂场景:上传 ZIP 包
```csharp
public class PluginUploadService
{
private readonly IObjectStorage _storage;
private readonly PluginDbContext _db;
private readonly string _localPluginPath = "/app/plugins";
public async Task<PluginPackage> UploadPluginAsync(IFormFile zipFile, string pluginName)
{
// 1. 上传到对象存储
var storageKey = $"plugins/{Guid.NewGuid()}/{zipFile.FileName}";
await _storage.UploadAsync(zipFile.OpenReadStream(), storageKey);
// 2. 保存到数据库
var plugin = new PluginPackage
{
Id = Guid.NewGuid(),
Name = pluginName,
StorageKey = storageKey,
UploadedAt = DateTime.UtcNow,
Status = PluginStatus.Pending
};
await _db.PluginPackages.AddAsync(plugin);
await _db.SaveChangesAsync();
return plugin;
}
public async Task ExtractAndLoadAsync(Guid pluginId)
{
var plugin = await _db.PluginPackages.FindAsync(pluginId);
// 3. 下载到本地
var localDir = Path.Combine(_localPluginPath, plugin.Id.ToString());
Directory.CreateDirectory(localDir);
await _storage.DownloadAsync(plugin.StorageKey, localDir + ".zip");
// 4. 解压
ZipFile.ExtractToDirectory(localDir + ".zip", localDir, overwriteFiles: true);
File.Delete(localDir + ".zip");
// 5. 加载插件
await _pluginManager.LoadFromDirectoryAsync(localDir);
}
}
```
#### ZIP 上传验证
```csharp
public class PluginValidationService
{
public async Task<PluginValidationResult> ValidateAsync(Stream zipStream)
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
await ExtractZipAsync(zipStream, tempDir);
try
{
var dlls = Directory.GetFiles(tempDir, "*.dll");
var result = new PluginValidationResult();
foreach (var dll in dlls)
{
var dllResult = await ValidateDllAsync(dll);
result.Assemblies.Add(dllResult);
}
var validPlugins = result.Assemblies
.Where(a => a.IsValidPlugin)
.ToList();
if (validPlugins.Count == 0)
{
result.IsValid = false;
result.ErrorMessage = "未找到实现 IGatewayPlugin 接口的类";
}
else
{
result.IsValid = true;
result.ValidPluginTypes = validPlugins
.SelectMany(a => a.PluginTypes)
.ToList();
}
return result;
}
finally
{
Directory.Delete(tempDir, true);
}
}
private async Task<DllValidationResult> ValidateDllAsync(string dllPath)
{
var result = new DllValidationResult { DllName = Path.GetFileName(dllPath) };
try
{
var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(dllPath);
var pluginTypes = assembly.GetTypes()
.Where(t => typeof(IGatewayPlugin).IsAssignableFrom(t))
.Where(t => !t.IsAbstract && !t.IsInterface)
.ToList();
if (pluginTypes.Count > 0)
{
result.IsValidPlugin = true;
result.PluginTypes = pluginTypes.Select(t => new PluginTypeInfo
{
TypeName = t.FullName,
ImplementedInterfaces = t.GetInterfaces().Select(i => i.Name).ToList()
}).ToList();
}
}
catch (Exception ex)
{
result.IsValid = false;
result.ErrorMessage = ex.Message;
}
return result;
}
}
```
---
### 4.2 插件间通信B方案
采用**方案 3强类型 + 弱类型混合**
#### 插件上下文
```csharp
public class GatewayContext
{
// 预定义常用字段(强类型)
public string? UserId { get; set; }
public string? TenantId { get; set; }
public UserTier Tier { get; set; }
public bool IsAuthenticated { get; set; }
public DateTime RequestTime { get; set; }
// 扩展数据(弱类型)
public PluginDataBag Data { get; } = new();
}
public class PluginDataBag
{
private readonly Dictionary<string, object> _data = new();
public T? Get<T>(string key) => _data.TryGetValue(key, out var v) ? (T)v : default;
public void Set<T>(string key, T value) => _data[key] = value!;
}
```
#### 使用示例
```csharp
// 插件 A: 认证
public class AuthPlugin : IRequestPlugin
{
public async Task<HttpContext?> OnRequestAsync(HttpContext context)
{
var userId = ValidateToken(context);
if (userId != null)
{
context.Items["CurrentUserId"] = userId;
context.Items["IsAuthenticated"] = true;
}
return context;
}
}
// 插件 B: 审计
public class AuditPlugin : IRequestPlugin
{
public async Task<HttpContext?> OnRequestAsync(HttpContext context)
{
if (context.Items.TryGetValue("CurrentUserId", out var userId))
{
await _logger.LogAsync($"User {userId} accessed {context.Request.Path}");
}
return context;
}
}
```
---
### 4.3 在线代码编辑器C方案
采用 **Monaco Editor + 前端模拟补全**
> **补充说明**:如需更完整的 C# IntelliSense如真实代码分析、跳转到定义可使用 Microsoft 官方的 **roslyn-language-server**VS Code C# 扩展背后使用的语言服务器)。
>
> **部署方式**
> ```bash
> # 安装
> dotnet tool install -g Microsoft.CodeAnalysis.LanguageServer
>
> # 启动服务
> roslyn-languageserver --port 5000
> ```
>
> 前端通过 WebSocket 连接该服务获取完整的语言特性支持。但目前阶段前端模拟补全已足够使用。
```html
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
<div id="editor" style="height: 500px;"></div>
<script>
require.config({ paths: { 'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' }});
require(['vs/editor/editor.main'], function() {
monaco.editor.create(document.getElementById('editor'), {
value: getEditorTemplate(),
language: 'csharp',
theme: 'vs-dark',
automaticLayout: true,
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false
});
// 注册 C# 补全
monaco.languages.registerCompletionItemProvider('csharp', {
provideCompletionItems: (model, position) => {
const suggestions = [
// 常用类型
{ label: 'HttpContext', kind: monaco.languages.CompletionItemKind.Class },
{ label: 'HttpRequest', kind: monaco.languages.CompletionItemKind.Class },
{ label: 'HttpResponse', kind: monaco.languages.CompletionItemKind.Class },
// 插件接口方法
{ label: 'OnRequestAsync', kind: monaco.languages.CompletionItemKind.Method },
{ label: 'OnResponseAsync', kind: monaco.languages.CompletionItemKind.Method },
{ label: 'TransformRouteAsync', kind: monaco.languages.CompletionItemKind.Method },
// 常用属性
{ label: 'ctx.Request', kind: monaco.languages.CompletionItemKind.Property },
{ label: 'ctx.Response', kind: monaco.languages.CompletionItemKind.Property },
{ label: 'ctx.Items', kind: monaco.languages.CompletionItemKind.Property },
];
return { suggestions };
}
});
});
</script>
```
#### 编辑器模板
```csharp
// 生成的代码模板
$@"
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Fengling.Gateway.Plugin.Abstractions;
public class {pluginName} : IRequestPlugin
{{
public string Name => ""{pluginName}"";
public string Version => ""1.0.0"";
public async Task<HttpContext?> OnRequestAsync(HttpContext ctx)
{{
// 编写你的逻辑
{userCode}
}}
}}
";
```
---
### 4.4 插件生命周期管理
```csharp
public class PluginManager
{
private readonly Dictionary<string, PluginInstance> _plugins = new();
private readonly RoslynPluginCompiler _compiler = new();
public async Task<PluginInstance> LoadPluginAsync(byte[] assemblyBytes, string pluginName)
{
// 1. 隔离加载程序集
var context = new PluginLoadContext(pluginName);
var assembly = context.LoadFromStream(new MemoryStream(assemblyBytes));
// 2. 查找插件入口类型
var pluginType = assembly.GetTypes()
.FirstOrDefault(t => typeof(IGatewayPlugin).IsAssignableFrom(t));
if (pluginType == null)
{
context.Unload();
throw new InvalidOperationException("No IGatewayPlugin implementation found");
}
// 3. 创建实例
var plugin = (IGatewayPlugin)Activator.CreateInstance(pluginType)!;
await plugin.OnLoadAsync();
// 4. 保存实例
var instance = new PluginInstance
{
Name = pluginName,
Assembly = assembly,
Context = context,
Plugin = plugin,
LoadedAt = DateTime.UtcNow
};
_plugins[pluginName] = instance;
return instance;
}
public async Task UnloadPluginAsync(string pluginName)
{
if (!_plugins.TryGetValue(pluginName, out var instance))
return;
await instance.Plugin.OnUnloadAsync();
instance.Context.Unload();
_plugins.Remove(pluginName);
}
}
public class PluginLoadContext : AssemblyLoadContext
{
public PluginLoadContext(string name) : base(name, isCollectible: true) { }
protected override Assembly? Load(AssemblyName assemblyName)
{
return null;
}
}
```
---
### 4.5 插件编译服务
```csharp
public class RoslynPluginCompiler
{
private readonly IEnumerable<MetadataReference> _defaultReferences;
public RoslynPluginCompiler()
{
_defaultReferences = GetDefaultReferences();
}
public CompileResult Compile(string sourceCode, string pluginName, IEnumerable<string> extraAssemblies)
{
var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
var references = _defaultReferences.Concat(
extraAssemblies.Select(a => MetadataReference.CreateFromFile(a))
);
var compilation = CSharpCompilation.Create(
assemblyName: $"Plugin_{pluginName}_{Guid.NewGuid():N}",
syntaxTrees: new[] { syntaxTree },
references: references,
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
.WithAllowUnsafe(false)
.WithOptimizationLevel(OptimizationLevel.Release));
using var ms = new MemoryStream();
var emitResult = compilation.Emit(ms);
if (!emitResult.Success)
{
return CompileResult.Fail(emitResult.Diagnostics);
}
ms.Seek(0, SeekOrigin.Begin);
return CompileResult.Success(ms.ToArray());
}
private IEnumerable<MetadataReference> GetDefaultReferences()
{
return AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
.Select(a => MetadataReference.CreateFromFile(a.Location));
}
}
```
---
## 五、数据库模型
```csharp
public class PluginPackage
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Version { get; set; } = "1.0.0";
public string? Description { get; set; }
public string StorageKey { get; set; } = string.Empty;
public PluginStatus Status { get; set; }
public DateTime UploadedAt { get; set; }
public DateTime? LoadedAt { get; set; }
}
public class PluginPipeline
{
public Guid Id { get; set; }
public Guid PluginId { get; set; }
public PipelineStage Stage { get; set; }
public int Order { get; set; }
public bool IsEnabled { get; set; }
}
public enum PluginStatus
{
Pending = 0,
Validated = 1,
Loaded = 2,
Failed = 3,
Disabled = 4
}
```
---
## 六、实施计划
| 阶段 | 任务 | 优先级 |
|------|------|--------|
| Phase 1 | 集成 YARP 到现有项目 | 高 |
| Phase 2 | 插件基础接口定义 | 高 |
| Phase 3 | 插件编译 + 加载框架 | 高 |
| Phase 4 | 嵌入式 Razor UI | 中 |
| Phase 5 | Monaco Editor 集成 | 中 |
| Phase 6 | ZIP 上传验证功能 | 中 |
| Phase 7 | 测试与优化 | 低 |
---
## 七、NuGet 依赖
```xml
<!-- 核心编译 -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
<!-- Scripting API (可选) -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.13.0" />
<!-- 依赖解析 -->
<PackageReference Include="System.Runtime.Loader" Version="4.3.0" />
```
---
## 八、总结
本方案实现了:
1. **Web UI 管理**:类 SwaggerUI 风格的可视化界面
2. **动态编译**Roslyn 在线编译 C# 代码
3. **插件加载**:独立 AssemblyLoadContext支持热卸载
4. **灵活扩展**:支持简单场景(使用已有程序集)和复杂场景(上传 ZIP
5. **流程控制**:插件可分配到 5 个不同阶段执行
---
*文档版本: 1.0*
*最后更新: 2026-03-01*

View File

@ -1,290 +0,0 @@
---
phase: 06-gateway-plugin-research
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: []
autonomous: true
requirements: [PLUG-01, PLUG-02]
must_haves:
truths:
- "插件可以从指定目录动态加载"
- "插件在独立的 AssemblyLoadContext 中运行"
- "插件可以被卸载并释放内存"
artifacts:
- path: "src/yarpgateway/Plugins/PluginLoadContext.cs"
provides: "ALC 隔离机制"
- path: "src/yarpgateway/Plugins/PluginLoader.cs"
provides: "插件发现和加载"
- path: "src/yarpgateway/Plugins/PluginHost.cs"
provides: "插件生命周期管理"
- path: "tests/YarpGateway.Tests/Unit/Plugins/PluginLoadTests.cs"
provides: "加载/卸载验证"
key_links:
- from: "PluginLoader"
to: "PluginLoadContext"
via: "实例化并加载程序集"
- from: "PluginHost"
to: "PluginLoader"
via: "协调插件生命周期"
---
# 计划 01插件加载基础设施
<objective>
实现插件动态加载基础设施,包括 AssemblyLoadContext 隔离、插件发现和生命周期管理。
**目的:** 为网关提供安全的插件加载机制,支持隔离和热重载。
**产出:** 可工作的插件加载系统,含单元测试。
</objective>
<execution_context>
@/Users/mac/.config/opencode/get-shit-done/workflows/execute-plan.md
@/Users/mac/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/006-gateway-plugin-research/006-RESEARCH.md
# 现有基础设施
@src/Fengling.Gateway.Plugin.Abstractions/IGatewayPlugin.cs
@src/yarpgateway/Program.cs
</context>
<interfaces>
<!-- 现有插件接口 -->
```csharp
// Fengling.Gateway.Plugin.Abstractions
public interface IGatewayPlugin
{
string Name { get; }
string Version { get; }
string? Description { get; }
Task OnLoadAsync();
Task OnUnloadAsync();
}
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: 创建 PluginLoadContext 隔离机制</name>
<files>
src/yarpgateway/Plugins/PluginLoadContext.cs,
tests/YarpGateway.Tests/Unit/Plugins/PluginLoadContextTests.cs
</files>
<behavior>
- Test 1: 加载插件程序集到独立 ALC
- Test 2: 共享契约程序集使用默认 ALC
- Test 3: 卸载 ALC 后内存被回收
</behavior>
<action>
创建可卸载的 AssemblyLoadContext
1. 创建 `src/yarpgateway/Plugins/PluginLoadContext.cs`
2. 继承 AssemblyLoadContext设置 isCollectible: true
3. 使用 AssemblyDependencyResolver 解析依赖
4. 关键:共享 `Fengling.Gateway.Plugin.Abstractions` 到默认 ALC
```csharp
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
private readonly string _sharedAssemblyName = "Fengling.Gateway.Plugin.Abstractions";
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
if (assemblyName.Name == _sharedAssemblyName)
return null; // 使用默认 ALC
var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path != null ? LoadFromAssemblyPath(path) : null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path != null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero;
}
}
```
**注意**:先写测试,确保卸载验证通过。
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginLoadContextTests" --no-build</automated>
</verify>
<done>
- PluginLoadContext 类存在
- 测试验证隔离和卸载
- 构建通过
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: 创建 PluginLoader 发现和加载逻辑</name>
<files>
src/yarpgateway/Plugins/PluginLoader.cs,
src/yarpgateway/Plugins/DiscoveredPlugin.cs,
tests/YarpGateway.Tests/Unit/Plugins/PluginLoaderTests.cs
</files>
<behavior>
- Test 1: 从目录发现插件程序集
- Test 2: 加载插件并返回 IGatewayPlugin 实例
- Test 3: 处理无效插件(返回 null 或异常)
</behavior>
<action>
创建插件发现和加载器:
1. 创建 `DiscoveredPlugin.cs` 记录:
- Id, Name, Version, AssemblyPath, EntryPoint
2. 创建 `PluginLoader.cs`
- `DiscoverPlugins(string directory)` - 扫描目录发现插件
- `LoadPlugin(DiscoveredPlugin discovered)` - 加载并实例化
- 使用 System.Reflection.Metadata 或简单扫描
```csharp
public class PluginLoader
{
public IEnumerable<DiscoveredPlugin> DiscoverPlugins(string pluginDirectory)
{
// 扫描 plugin.json 或程序集属性
}
public IGatewayPlugin? LoadPlugin(DiscoveredPlugin discovered)
{
var shadowPath = CreateShadowCopy(discovered.AssemblyPath);
var alc = new PluginLoadContext(shadowPath);
var assembly = alc.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(discovered.AssemblyPath)));
var type = assembly.GetType(discovered.EntryPoint);
return Activator.CreateInstance(type) as IGatewayPlugin;
}
}
```
**影子复制**:创建 `CreateShadowCopy` 方法,将插件复制到临时目录。
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginLoaderTests" --no-build</automated>
</verify>
<done>
- PluginLoader 类存在
- 可发现和加载插件
- 测试通过
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: 创建 PluginHost 生命周期管理</name>
<files>
src/yarpgateway/Plugins/PluginHost.cs,
src/yarpgateway/Plugins/PluginHandle.cs,
tests/YarpGateway.Tests/Unit/Plugins/PluginHostTests.cs
</files>
<behavior>
- Test 1: LoadAllAsync 加载目录下所有插件
- Test 2: UnloadAsync 卸载指定插件
- Test 3: GetPlugins 返回当前加载的插件
- Test 4: 插件卸载后 WeakReference 显示已回收
</behavior>
<action>
创建插件生命周期管理器:
1. 创建 `PluginHandle.cs`
- 封装 PluginLoadContext 和 IGatewayPlugin
- 实现 IAsyncDisposable
- 提供 TrackUnloadability() 返回 WeakReference
```csharp
public sealed class PluginHandle : IAsyncDisposable
{
private readonly PluginLoadContext _alc;
private readonly IGatewayPlugin _plugin;
private readonly string _shadowDirectory;
private bool _disposed;
public IGatewayPlugin Plugin => _plugin;
public WeakReference TrackUnloadability() => new(_alc, trackResurrection: true);
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await _plugin.OnUnloadAsync();
_alc.Unload();
}
}
```
2. 创建 `PluginHost.cs`
- 注入 PluginLoader
- 管理插件字典 (name -> PluginHandle)
- 提供 LoadAllAsync, UnloadAsync, GetPlugins
```csharp
public class PluginHost
{
private readonly ConcurrentDictionary<string, PluginHandle> _plugins = new();
private readonly PluginLoader _loader;
public async Task LoadAllAsync(string pluginDirectory, CancellationToken ct = default)
{
var discovered = _loader.DiscoverPlugins(pluginDirectory);
foreach (var d in discovered)
{
var handle = _loader.LoadPlugin(d);
if (handle != null)
{
await handle.Plugin.OnLoadAsync();
_plugins[d.Id] = handle;
}
}
}
public async Task UnloadAsync(string pluginId)
{
if (_plugins.TryRemove(pluginId, out var handle))
{
await handle.DisposeAsync();
}
}
}
```
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginHostTests" --no-build</automated>
</verify>
<done>
- PluginHost 和 PluginHandle 类存在
- 可加载/卸载插件
- 卸载验证测试通过
</done>
</task>
</tasks>
<verification>
1. dotnet build src/yarpgateway/YarpGateway.csproj 无错误
2. dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginLoad" 通过
3. 插件加载/卸载功能可用
</verification>
<success_criteria>
- 插件可在独立 AssemblyLoadContext 中加载
- 插件可通过 WeakReference 验证卸载
- 所有单元测试通过
</success_criteria>
<output>
完成后创建 `.planning/phases/006-gateway-plugin-research/006-01-SUMMARY.md`
</output>

View File

@ -1,57 +0,0 @@
# 计划 006-01 总结:插件加载基础设施
## 执行状态:✅ 已完成
## 完成的任务
### Task 1: PluginLoadContext 隔离机制
- ✅ 创建 `src/yarpgateway/Plugins/PluginLoadContext.cs`
- ✅ 实现可卸载的 AssemblyLoadContext
- ✅ 支持共享契约程序集(使用默认 ALC
- ✅ 创建单元测试 `PluginLoadContextTests.cs`
### Task 2: PluginLoader 发现和加载逻辑
- ✅ 创建 `src/yarpgateway/Plugins/PluginLoader.cs`
- ✅ 实现插件发现(从目录扫描 plugin.json
- ✅ 实现影子复制(支持热重载)
- ✅ 创建 `DiscoveredPlugin.cs``PluginMetadata.cs`
- ✅ 创建单元测试 `PluginLoaderTests.cs`
### Task 3: PluginHost 生命周期管理
- ✅ 创建 `src/yarpgateway/Plugins/PluginHost.cs`
- ✅ 实现 `PluginHandle.cs` 封装 ALC 和插件实例
- ✅ 支持加载/卸载/重载插件
- ✅ 提供 WeakReference 卸载验证
- ✅ 创建单元测试 `PluginHostTests.cs`
## 实现的文件
| 文件 | 描述 |
|------|------|
| `src/yarpgateway/Plugins/PluginLoadContext.cs` | ALC 隔离机制 |
| `src/yarpgateway/Plugins/PluginLoader.cs` | 插件发现和加载 |
| `src/yarpgateway/Plugins/PluginHost.cs` | 生命周期管理 |
| `tests/YarpGateway.Tests/Unit/Plugins/PluginLoadContextTests.cs` | 隔离测试 |
| `tests/YarpGateway.Tests/Unit/Plugins/PluginLoaderTests.cs` | 加载测试 |
| `tests/YarpGateway.Tests/Unit/Plugins/PluginHostTests.cs` | 生命周期测试 |
## 测试结果
```
dotnet test --filter "FullyQualifiedName~Plugin"
已通过! - 失败: 0通过: 15总计: 15
```
## 验证
- ✅ 构建通过:`dotnet build src/yarpgateway/YarpGateway.csproj` - 0 错误
- ✅ 所有单元测试通过
- ✅ 插件可在独立 AssemblyLoadContext 中加载
- ✅ 插件可通过 WeakReference 验证卸载
- ✅ 支持动态发现和加载插件
## 后续计划
阶段 6 还需完成:
- PLUG-03插件隔离与生命周期管理已实现基础设施
- YARP 集成:将插件集成到 YARP 管道

View File

@ -1,366 +0,0 @@
---
phase: 06-gateway-plugin-research
plan: 02
type: execute
wave: 1
depends_on: [006-01]
files_modified: []
autonomous: true
requirements: [PLUG-03]
must_haves:
truths:
- "插件通过路由 Metadata 启用"
- "请求 Transform 轻量处理请求"
- "目标选择在路由匹配后执行"
- "插件按配置顺序执行"
artifacts:
- path: "src/yarpgateway/Plugins/PluginTransformProvider.cs"
provides: "YARP Transform 提供者"
- path: "src/yarpgateway/Plugins/YarpPluginMiddleware.cs"
provides: "插件管道集成"
- path: "src/yarpgateway/Plugins/PluginConfigWatcher.cs"
provides: "Console DB 通知监听"
- path: "tests/YarpGateway.Tests/Unit/Plugins/YarpIntegrationTests.cs"
provides: "集成测试"
key_links:
- from: "PluginTransformProvider"
to: "PluginHost"
via: "获取已加载插件"
- from: "YarpPluginMiddleware"
to: "PluginTransformProvider"
via: "应用 Transform"
- from: "PluginConfigWatcher"
to: "PluginHost"
via: "触发重载"
---
# 计划 02YARP 插件集成
<objective>
将插件系统集成到 YARP 反向代理管道,实现 Transform 方式的请求/响应处理,以及通过 Metadata 驱动的插件启用机制。
**目的:** 实现 PLUG-03 - 插件隔离与生命周期管理,完成网关插件化。
**产出:** 可工作的 YARP 插件集成系统,含单元测试。
</objective>
<execution_context>
@/Users/mac/.config/opencode/get-shit-done/workflows/execute-plan.md
@/Users/mac/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/006-gateway-plugin-research/006-RESEARCH.md
@src/Fengling.Gateway.Plugin.Abstractions/IGatewayPlugin.cs
@src/yarpgateway/Plugins/PluginHost.cs
@src/yarpgateway/Program.cs
</context>
<interfaces>
## 现有插件接口
```csharp
// Fengling.Gateway.Plugin.Abstractions
public interface IGatewayPlugin
{
string Name { get; }
string Version { get; }
string? Description { get; }
Task OnLoadAsync();
Task OnUnloadAsync();
}
public interface IRequestPlugin : IGatewayPlugin
{
Task<HttpContext?> OnRequestAsync(HttpContext context);
Task<HttpContext?> OnRouteMatchedAsync(HttpContext context, RouteConfig route);
Task<HttpContext?> OnForwardingAsync(HttpContext context, HttpRequestMessage request);
}
public interface IResponsePlugin : IGatewayPlugin
{
Task OnBackendResponseAsync(HttpContext context, HttpResponseMessage response);
Task OnResponseFinalizingAsync(HttpContext context);
}
public interface IRouteTransformPlugin : IGatewayPlugin
{
Task<RouteConfig> TransformRouteAsync(RouteConfig original, HttpContext context);
}
```
## YARP Transform 接口
```csharp
// YARP Transform
public interface IRequestTransform
{
Task ApplyAsync(RequestTransformContext context);
}
public interface IResponseTransform
{
Task ApplyAsync(ResponseTransformContext context);
}
public interface IClusterDestinationsTransform
{
Task ApplyAsync(ClusterDestinationsContext context);
}
```
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: 创建 PluginTransformProvider</name>
<files>
src/yarpgateway/Plugins/PluginTransformProvider.cs,
tests/YarpGateway.Tests/Unit/Plugins/PluginTransformProviderTests.cs
</files>
<behavior>
- Test 1: 从路由 Metadata 发现启用的插件
- Test 2: 按 PluginOrder 排序
- Test 3: 创建 RequestTransform
- Test 4: 创建 ResponseTransform
</behavior>
<action>
创建 YARP Transform 提供者:
1. 创建 `PluginTransformProvider.cs`
- 实现 `IProxyConfigFilter``IDynamicRouteConfigProvider`
- 扫描路由 Metadata 中的 "Plugins" 键
- 从 PluginHost 获取已加载的插件
- 按 "PluginOrder" 排序
2. 核心逻辑:
```csharp
public class PluginTransformProvider : IProxyConfigFilter
{
private readonly PluginHost _pluginHost;
public async Task ApplyTransformAsync(HttpContext context, RouteConfig route)
{
var pluginIds = route.Metadata?["Plugins"]?.Split(',');
if (pluginIds == null) return;
var order = route.Metadata?["PluginOrder"]?.Split(',') ?? pluginIds;
var orderedPlugins = order.Select(id => pluginIds.IndexOf(id))
.Select(i => _pluginHost.GetPlugins().ElementAt(i));
foreach (var plugin in orderedPlugins.OfType<IRequestPlugin>())
{
await plugin.OnRouteMatchedAsync(context, route);
}
}
}
```
3. 创建 Request/Response Transform 类:
- `PluginRequestTransform` - 调用 IRequestPlugin
- `PluginResponseTransform` - 调用 IResponsePlugin
- `PluginRouteTransform` - 调用 IRouteTransformPlugin
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginTransformProviderTests" --no-build</automated>
</verify>
<done>
- PluginTransformProvider 存在
- Transform 测试通过
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: 创建目标选择器 (DestinationSelector)</name>
<files>
src/yarpgateway/Plugins/DestinationSelector.cs,
tests/YarpGateway.Tests/Unit/Plugins/DestinationSelectorTests.cs
</files>
<behavior>
- Test 1: OnRouteMatchedAsync 选择目标
- Test 2: 根据上下文修改目标列表
- Test 3: 特殊租户路由到特殊目标
</behavior>
<action>
创建目标选择器,用于 OnRouteMatchedAsync 阶段:
1. 创建 `DestinationSelector.cs`
- 实现 IClusterDestinationsTransform
- 在路由匹配后、负载均衡前执行
- 可以根据 HttpContext 修改可用目标列表
```csharp
public class DestinationSelector : IClusterDestinationsTransform
{
private readonly PluginHost _pluginHost;
public async Task ApplyAsync(ClusterDestinationsContext context)
{
var httpContext = context.HttpContext;
// 获取路由上启用的插件
var routeConfig = httpContext.GetRouteConfig();
var pluginIds = routeConfig?.Metadata?["Plugins"]?.Split(',');
if (pluginIds == null) return;
foreach (var pluginId in pluginIds)
{
var plugin = _pluginHost.GetPlugin(pluginId);
if (plugin is IRequestPlugin requestPlugin)
{
// 让插件过滤/修改目标列表
var availableDestinations = context.Destinations.ToList();
// 插件逻辑可以修改 availableDestinations
context.Destinations = availableDestinations;
}
}
}
}
```
2. 注册到 YARP
```csharp
builder.Services.AddSingleton<IClusterDestinationsTransform, DestinationSelector>();
```
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~DestinationSelectorTests" --no-build</automated>
</verify>
<done>
- DestinationSelector 存在
- 目标选择逻辑工作正常
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: 创建 PluginConfigWatcher (Console DB 通知)</name>
<files>
src/yarpgateway/Plugins/PluginConfigWatcher.cs,
tests/YarpGateway.Tests/Unit/Plugins/PluginConfigWatcherTests.cs
</files>
<behavior>
- Test 1: 监听配置变更通知
- Test 2: 触发插件重载
- Test 3: 处理通知失败
</behavior>
<action>
创建配置监听器,监听 Console DB 通知:
1. 创建 `PluginConfigWatcher.cs`
- 实现 `IHostedService` 或使用现有的 `PgSqlConfigChangeListener`
- 监听插件配置变更频道(如 `plugin_config_changed`
- 触发 PluginHost 重载
```csharp
public class PluginConfigWatcher : BackgroundService
{
private readonly PluginHost _pluginHost;
private readonly NpgsqlConnection _connection;
private const string Channel = "plugin_config_changed";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _connection.OpenAsync(stoppingToken);
await _connection.ListenAsync(Channel, stoppingToken);
await foreach (var notification in _connection.Notifications(stoppingToken))
{
// 解析通知,获取需要重载的插件
var payload = JsonSerializer.Deserialize<PluginConfigPayload>(notification.Payload);
await _pluginHost.ReloadAsync(payload.PluginId);
}
}
}
```
2. 注册到 DI
```csharp
builder.Services.AddHostedService<PluginConfigWatcher>();
```
</action>
<verify>
<automated>dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~PluginConfigWatcherTests" --no-build</automated>
</verify>
<done>
- PluginConfigWatcher 存在
- 监听器正确响应通知
</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: 更新 Program.cs 集成插件系统</name>
<files>
src/yarpgateway/Program.cs
</files>
<behavior>
- Test 1: 插件系统在启动时初始化
- Test 2: Transform 被正确应用
</behavior>
<action>
更新 Program.cs 集成插件系统:
1. 注册插件服务:
```csharp
// 插件目录
var pluginDirectory = configuration.GetValue<string>("Plugin:Directory") ?? "plugins";
// 插件主机
builder.Services.AddSingleton(sp => new PluginHost(pluginDirectory));
// Transform 提供者
builder.Services.AddSingleton<IProxyConfigFilter, PluginTransformProvider>();
// 目标选择器
builder.Services.AddSingleton<IClusterDestinationsTransform, DestinationSelector>();
// 配置监听器
builder.Services.AddHostedService<PluginConfigWatcher>();
```
2. 初始化插件:
```csharp
var app = builder.Build();
// 启动时加载插件
var pluginHost = app.Services.GetRequiredService<PluginHost>();
await pluginHost.LoadAllAsync();
```
3. 配置 YARP 使用 Transform
```csharp
builder.Services.AddReverseProxy()
.AddTransforms<PluginTransformProvider>();
```
</action>
<verify>
<automated>dotnet build src/yarpgateway/YarpGateway.csproj</automated>
</verify>
<done>
- Program.cs 正确集成插件系统
- 构建通过
</done>
</task>
</tasks>
<verification>
1. dotnet build src/yarpgateway/YarpGateway.csproj 无错误
2. dotnet test tests/YarpGateway.Tests --filter "FullyQualifiedName~Plugin" 通过
3. 插件可通过 Metadata 启用
4. Transform 正确应用到请求/响应
</verification>
<success_criteria>
- 插件通过 YARP Transform 管道处理请求
- 目标选择在路由匹配后执行
- 插件通过 Metadata 启用
- Console DB 通知可触发插件重载
- 所有单元测试通过
</success_criteria>
<output>
完成后创建 `.planning/phases/006-gateway-plugin-research/006-02-SUMMARY.md`
</output>

View File

@ -1,284 +0,0 @@
# 阶段 6 研究:网关插件技术调研与实现
**研究日期:** 2026-03-04
**状态:** 已完成
---
## 1. 现有基础设施分析
### 1.1 已有插件抽象层
项目已创建 `Fengling.Gateway.Plugin.Abstractions` 程序集,定义了核心插件接口:
```csharp
// 已定义的接口
public interface IGatewayPlugin
{
string Name { get; }
string Version { get; }
string? Description { get; }
Task OnLoadAsync();
Task OnUnloadAsync();
}
public interface IRequestPlugin : IGatewayPlugin { ... }
public interface IResponsePlugin : IGatewayPlugin { ... }
public interface IRouteTransformPlugin : IGatewayPlugin { ... }
public interface ILoadBalancePlugin : IGatewayPlugin { ... }
```
### 1.2 缺失的组件
- ❌ **插件加载器** - 动态加载程序集的机制
- ❌ **插件生命周期管理** - 加载/卸载/热重载
- ❌ **插件隔离** - AssemblyLoadContext 隔离
- ❌ **YARP 集成** - 将插件集成到 YARP 管道
---
## 2. YARP 扩展点
### 2.1 中间件管道
YARP 使用 ASP.NET Core 中间件管道,允许自定义注入点:
```csharp
app.MapReverseProxy(proxyPipeline =>
{
proxyPipeline.Use(async (context, next) =>
{
// 插件前置执行
await pluginFeature.ExecutePreProxyAsync(context);
await next();
// 插件后置执行
await pluginFeature.ExecutePostProxyAsync(context);
});
});
```
### 2.2 Transform 管道(推荐)
Transform 是修改请求/响应的推荐方式:
```csharp
builder.Services.AddReverseProxy()
.AddTransforms(context =>
{
var plugins = PluginManager.GetTransformPlugins(context.Route);
foreach (var plugin in plugins)
{
context.AddRequestTransform(plugin.ApplyAsync);
}
});
```
### 2.3 路由扩展
YARP 2.0+ 支持自定义路由元数据,插件可消费:
```json
{
"Routes": {
"api-route": {
"Extensions": {
"PluginConfig": {
"PluginId": "rate-limiter",
"MaxRequests": 100
}
}
}
}
}
```
---
## 3. .NET 插件加载最佳实践
### 3.1 AssemblyLoadContext 隔离
使用 **可卸载的 AssemblyLoadContext** 配合 **AssemblyDependencyResolver**
```csharp
public sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
private readonly string _sharedAssemblyName = "Fengling.Gateway.Plugin.Abstractions";
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
// 共享契约程序集(防止类型身份问题)
if (assemblyName.Name == _sharedAssemblyName)
return null; // 回退到默认 ALC
var path = _resolver.ResolveAssemblyToPath(assemblyName);
return path != null ? LoadFromAssemblyPath(path) : null;
}
}
```
### 3.2 影子复制(热重载必需)
Windows 锁定加载的 DLL需要影子复制
```csharp
public static string CreateShadowCopy(string pluginDirectory)
{
var shadowDir = Path.Combine(
Path.GetTempPath(),
"PluginShadows",
$"{Path.GetFileName(pluginDirectory)}_{Guid.NewGuid():N}"
);
CopyDirectory(pluginDirectory, shadowDir);
return shadowDir;
}
```
### 3.3 插件句柄模式
```csharp
public sealed class PluginHandle : IAsyncDisposable
{
private readonly PluginLoadContext _alc;
private readonly IGatewayPlugin _plugin;
public async ValueTask DisposeAsync()
{
if (_plugin is IAsyncDisposable ad) await ad.DisposeAsync();
_alc.Unload(); // 计划回收
}
}
```
### 3.4 卸载验证
```csharp
public static bool VerifyUnload(WeakReference weakRef, int maxAttempts = 10)
{
for (var i = 0; i < maxAttempts && weakRef.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
return !weakRef.IsAlive;
}
```
---
## 4. 插件隔离模式
### 4.1 契约边界(最重要的设计决策)
**规则**:契约必须驻留在 **默认 ALC**,绝不在插件 ALC 中。
插件项目配置:
```xml
<ProjectReference Include="..\Plugin.Abstractions\Plugin.Abstractions.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
```
### 4.2 静态缓存问题
**问题**:主机单例缓存插件类型会阻止卸载。
**解决方案**:只使用 DTO 跨越边界,不传递插件类型。
---
## 5. 实现架构
### 5.1 组件结构
```
src/
├── Fengling.Gateway.Plugin.Abstractions/ # 已存在
│ └── IGatewayPlugin.cs
├── yarpgateway/
│ ├── Plugins/
│ │ ├── PluginLoadContext.cs # ALC 隔离
│ │ ├── PluginLoader.cs # 加载逻辑
│ │ ├── PluginHost.cs # 生命周期管理
│ │ └── PluginMiddleware.cs # YARP 集成
│ └── Program.cs
└── plugins/ # 插件目录
└── sample-plugin/
└── SamplePlugin.csproj
```
### 5.2 插件目录结构
```
plugins/
├── rate-limiter/
│ ├── RateLimiterPlugin.dll
│ ├── RateLimiterPlugin.deps.json
│ └── plugin.json # 元数据
└── jwt-transform/
└── ...
```
### 5.3 插件元数据 (plugin.json)
```json
{
"id": "rate-limiter",
"name": "Rate Limiter Plugin",
"version": "1.0.0",
"entryPoint": "RateLimiterPlugin.RateLimiterPlugin",
"interfaces": ["IRequestPlugin"],
"dependencies": []
}
```
---
## 6. 验证架构
### 6.1 测试策略
1. **单元测试**PluginLoadContext 隔离验证
2. **集成测试**:插件加载/卸载/热重载
3. **性能测试**:插件执行开销
### 6.2 成功标准验证
| 标准 | 验证方法 |
|------|---------|
| 动态加载插件 | 单元测试:从目录加载并执行 |
| 插件相互隔离 | 单元测试:异常不传播到其他插件 |
| 热加载/卸载 | 集成测试WeakReference 验证卸载 |
---
## 7. 推荐库
| 用途 | 库 |
|------|---|
| 网关核心 | Yarp.ReverseProxy 2.3.0+ |
| 插件加载 | 自定义 AssemblyLoadContext |
| 元数据读取 | System.Reflection.Metadata |
| 依赖注入 | Microsoft.Extensions.DependencyInjection |
---
## 8. 关键注意事项
1. **不要在主机单例中缓存插件类型**
2. **始终用 WeakReference 测试卸载**
3. **Windows 上必须影子复制以支持热重载**
4. **使用仅元数据发现避免仅扫描而加载程序集**
5. **谨慎处理原生依赖**(它们不能干净卸载)
---
*研究完成2026-03-04*

View File

@ -1,71 +0,0 @@
# 阶段 2 计划K8s 健康检查委托
## 目标
将 K8s 服务健康监控从网关移除,委托给 fengling-console。网关只专注于请求路由。
## 需要移除的代码
### 1. 后台服务
- **文件**: `src/yarpgateway/Services/KubernetesPendingSyncService.cs`
- **操作**: 删除文件
- **影响**: 停止每 30 秒同步 K8s 服务
### 2. 服务注册
- **文件**: `src/yarpgateway/Program.cs`
- **行号**: ~118
- **代码**: `builder.Services.AddHostedService<KubernetesPendingSyncService>();`
- **操作**: 删除该行
### 3. API 控制器
- **文件**: `src/yarpgateway/Controllers/PendingServicesController.cs`
- **操作**: 删除文件
- **影响**: 移除 `/api/gateway/pending-services/*` API
### 4. 数据模型
- **文件**: `src/yarpgateway/Models/GwPendingServiceDiscovery.cs`
- **操作**: 删除文件
- **影响**: 移除待处理服务发现实体
### 5. DbContext
- **文件**: `src/yarpgateway/Data/GatewayDbContext.cs`
- **操作**: 移除 `DbSet<GwPendingServiceDiscovery>` 属性
### 6. 迁移文件(可选)
- **文件**: `src/yarpgateway/Migrations/20260222134342_AddPendingServiceDiscovery.cs`
- **操作**: 保留(数据库已有该表)或删除(如果重新创建数据库)
## 不需要移除
| 组件 | 理由 |
|------|------|
| DatabaseClusterConfigProvider | YARP 集群配置仍然需要 |
| PgSqlConfigChangeListener | 配置监听仍然需要 |
| 现有健康检查 | YARP 内置被动健康检查 |
## 实现顺序
1. 移除 PendingServicesController.cs
2. 移除 KubernetesPendingSyncService.cs
3. 移除 GwPendingServiceDiscovery.cs
4. 更新 GatewayDbContext.cs
5. 更新 Program.cs
6. 更新 ROADMAP.md 标记为完成
7. 提交代码
## 风险
- **数据丢失**: 如果数据库已有 `PendingServiceDiscoveries` 表,删除代码后数据仍然存在但无法访问
- **API 变更**: 移除 `/api/gateway/pending-services/*` 端点,需要通知 console 团队
## 验证
完成后验证:
- `dotnet build` 成功
- 无 `KubernetesPendingSyncService` 引用
- 无 `PendingServicesController` 引用
- 无 `GwPendingServiceDiscovery` 引用
---
*计划创建: 2026-03-02*

View File

@ -1,39 +0,0 @@
# Plan: 升级 Fengling.Platform 包到最新
## 任务描述
升级 fengling-gateway 项目中的 Fengling.Platform.Infrastructure 包到最新版本,并修复现有编译警告。
## 变更分析
### fengling-platform 新版本主要变更
1. **主键类型变更**:从 `long Id` 改为 `string Id`Guid
2. **新增 GwCluster 聚合根**:包含内嵌 Destinations 列表
3. **GwTenantRoute 扩展**:新增 Match (GwRouteMatch)、Transforms 等字段
4. **移除GwServiceInstance**:作为 GwCluster 的内嵌值对象 GwDestination
5. **新增值对象**GwRouteMatch、GwTransform、GwLoadBalancingPolicy、GwHealthCheckConfig、GwSessionAffinityConfig
### 当前编译警告
1. **CS0108**: GatewayDbContext.Tenants 隐藏继承成员 PlatformDbContext.Tenants
2. **NU1506**: 重复 PackageVersion 定义
3. **NU1507**: 配置了多个包源
## 任务列表
### Task 1: 分析并修复 CS0108 警告
- **文件**: src/yarpgateway/Data/GatewayDbContext.cs
- **操作**: 将 `Tenants` 属性添加 `new` 关键字,或使用不同的名称避免隐藏
- **验证**: dotnet build 无 CS0108 警告
### Task 2: 修复 NU1506 重复包版本警告
- **文件**: Directory.Packages.props 或 YarpGateway.csproj
- **操作**: 检查并移除重复的 PackageVersion 定义
- **验证**: dotnet restore 无 NU1506 警告
### Task 3: 配置包源映射解决 NU1507
- **文件**: NuGet.Config 或 Directory.Build.props
- **操作**: 添加包源映射配置
- **验证**: dotnet restore 无 NU1507 警告
## 验证
- dotnet build 成功0 错误
- 无 CS0108、NU1506、NU1507 警告

View File

@ -1,41 +0,0 @@
# Quick Task 001 Summary: 升级 Fengling.Platform 包并修复编译警告
## 任务概述
升级 fengling-gateway 项目中的 Fengling.Platform.Infrastructure 包引用,并修复编译警告。
## 变更内容
### 1. 修复 CS0108 警告
**文件**: `src/yarpgateway/Data/GatewayDbContext.cs`
**问题**: `GatewayDbContext.Tenants` 隐藏了继承的成员 `PlatformDbContext.Tenants`
**修复**: 添加 `new` 关键字明确表示新定义
```csharp
// 修复前
public DbSet<GwTenant> Tenants => Set<GwTenant>();
// 修复后
public new DbSet<GwTenant> Tenants => Set<GwTenant>();
```
### 2. 修复 NU1506 重复 PackageVersion
**文件**: `src/yarpgateway/Directory.Packages.props`
**问题**: 重复定义了 Microsoft.AspNetCore.Authentication.JwtBearer、Microsoft.EntityFrameworkCore.Design、Microsoft.EntityFrameworkCore
**修复**: 移除重复的 PackageVersion 定义
### 3. 修复 NU1507 多个包源警告
**文件**: 根目录创建 `NuGet.Config`
**问题**: 配置了多个包源gitea、nuget.org需要包源映射
**修复**: 添加 packageSourceMapping 配置到 NuGet.Config并删除子目录中的重复配置
## 构建结果
- ✅ 0 错误
- ✅ CS0108 警告已修复
- ✅ NU1506 警告已修复
- ✅ NU1507 警告已修复
- ⚠️ MSB3277 警告EntityFrameworkCore.Relational 版本冲突)- 来自测试项目依赖,不影响构建
## 相关文件
- `src/yarpgateway/Data/GatewayDbContext.cs` - 添加 new 关键字
- `src/yarpgateway/Directory.Packages.props` - 移除重复包版本
- `src/yarpgateway/Directory.Build.props` - 添加包源映射
- `tests/Directory.Build.props` - 添加包源映射
- `NuGet.Config` - 新建包源映射配置

View File

@ -1,30 +1,37 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
FROM 192.168.100.120:8418/fengling/dotnet/aspnet:10.0 AS base
USER root
RUN apk add --no-cache krb5-libs
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
FROM 192.168.100.120:8418/fengling/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copy project files from src
COPY ["src/Directory.Packages.props", "src/"]
COPY ["src/YarpGateway.csproj", "src/"]
COPY ["src/NuGet.Config", "src/"]
# Copy NuGet config first
COPY ["NuGet.Config", "./"]
# Restore dependencies
RUN dotnet restore "src/YarpGateway.csproj"
# Copy all project files for restore
COPY ["src/yarpgateway/*.props", "src/yarpgateway/"]
COPY ["src/yarpgateway/*.csproj", "src/yarpgateway/"]
COPY ["src/Fengling.Gateway.Plugin.Abstractions/*.csproj", "src/Fengling.Gateway.Plugin.Abstractions/"]
# Copy all source code
COPY . .
WORKDIR "/src"
RUN dotnet build "src/YarpGateway.csproj" -c $BUILD_CONFIGURATION -o /app/build
# Restore dependencies (after copying all code to ensure project references work)
RUN dotnet restore "src/yarpgateway/YarpGateway.csproj" --configfile "NuGet.Config"
# Build
WORKDIR "/src/src/yarpgateway"
RUN dotnet build "YarpGateway.csproj" -c $BUILD_CONFIGURATION -o /app/build --no-restore
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "src/YarpGateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
WORKDIR "/src/src/yarpgateway"
RUN dotnet publish "YarpGateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false --no-restore
FROM base AS final
WORKDIR /app

View File

@ -1,16 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="gitea" value="http://192.168.100.120:8418/api/packages/movingsam/nuget" allowInsecureConnections="true" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="Microsoft.*" />
<package pattern="Serilog.*" />
<package pattern="Npgsql.*" />
<package pattern="StackExchange.Redis" />
<package pattern="Yarp.*" />
<package pattern="xunit" />
<package pattern="Moq" />
<package pattern="FluentAssertions" />
<package pattern="Microsoft.NET.Test.Sdk" />
<package pattern="*" />
</packageSource>
<packageSource key="gitea">
<package pattern="Fengling.*" />

79
k8s/test/deployment.yaml Normal file
View File

@ -0,0 +1,79 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: fengling-gateway
namespace: fengling-test
labels:
app: fengling-gateway
version: v2
spec:
replicas: 1
selector:
matchLabels:
app: fengling-gateway
template:
metadata:
labels:
app: fengling-gateway
version: v2
spec:
containers:
- name: gateway
image: 192.168.100.120:8418/fengling/fengling-gateway:test
imagePullPolicy: Always
ports:
- containerPort: 8080
name: http
protocol: TCP
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: ASPNETCORE_URLS
value: "http://+:8080"
envFrom:
- configMapRef:
name: fengling-gateway-config
- secretRef:
name: fengling-gateway-secret
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: fengling-gateway
namespace: fengling-test
labels:
app: fengling-gateway
spec:
type: ClusterIP
selector:
app: fengling-gateway
ports:
- port: 80
targetPort: 8080
name: http
- port: 8081
targetPort: 8081
name: management

12
k8s/test/secret.yaml Normal file
View File

@ -0,0 +1,12 @@
apiVersion: v1
kind: Secret
metadata:
name: fengling-gateway-secret
namespace: fengling-test
type: Opaque
stringData:
# 使用与生产环境相同的数据库(或修改为测试数据库)
ConnectionStrings__DefaultConnection: "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
ConnectionStrings__ConsoleConnection: "Host=81.68.223.70;Port=15432;Database=fengling_console;Username=movingsam;Password=sl52788542"
Jwt__Authority: "https://apigateway.shtao1.cn"
Redis__ConnectionString: "81.68.223.70:16379,password=sl52788542"

View File

@ -51,7 +51,7 @@ public class DatabaseRouteConfigProvider
await using var dbContext = _dbContextFactory.CreateDbContext();
var routes = await dbContext
.GwTenantRoutes.Where(r => r.Status == 1 && !r.IsDeleted)
.GwRoutes.Where(r => r.Status == 1 && !r.IsDeleted)
.ToListAsync();
var newRoutes = new ConcurrentDictionary<string, RouteConfig>();
@ -65,7 +65,6 @@ public class DatabaseRouteConfigProvider
Match = new RouteMatch { Path = route.Match?.Path ?? string.Empty },
Metadata = new Dictionary<string, string>
{
["TenantCode"] = route.TenantCode,
["ServiceName"] = route.ServiceName,
},
};

View File

@ -12,7 +12,7 @@ namespace YarpGateway.Data;
public class GatewayDbContext : PlatformDbContext
{
// DbSet 别名,兼容旧代码
public DbSet<GwTenantRoute> TenantRoutes => GwTenantRoutes;
public DbSet<GwRoute> Routes => GwRoutes;
public DbSet<GwCluster> ServiceInstances => GwClusters;
// 服务发现相关
@ -51,7 +51,7 @@ public class GatewayDbContext : PlatformDbContext
{
var entries = ChangeTracker.Entries()
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.Where(e => e.Entity is GwTenantRoute or GwCluster or Tenant);
.Where(e => e.Entity is GwRoute or GwCluster or Tenant);
_configChangeDetected = entries.Any();
}

View File

@ -4,7 +4,7 @@
</PropertyGroup>
<ItemGroup>
<!-- Fengling ServiceDiscovery Packages (from Gitea) -->
<PackageVersion Include="Fengling.Platform.Infrastructure" Version="1.0.14" />
<PackageVersion Include="Fengling.Platform.Infrastructure" Version="1.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />

View File

@ -77,7 +77,7 @@ public class TenantRoutingMiddleware
}
// 4. 从 RouteCache 获取路由信息
var route = _routeCache.GetRoute(headerTenantId, serviceName);
var route = _routeCache.GetRoute(serviceName);
if (route == null)
{
_logger.LogDebug("Route not found - Tenant: {Tenant}, Service: {Service}", headerTenantId, serviceName);
@ -89,9 +89,8 @@ public class TenantRoutingMiddleware
context.Items["DynamicClusterId"] = route.ClusterId;
context.Items["TenantId"] = headerTenantId; // 供 Transform 使用
var routeType = route.IsGlobal ? "global" : "tenant-specific";
_logger.LogDebug("Tenant routing - Tenant: {Tenant}, Service: {Service}, Cluster: {Cluster}, Type: {Type}",
headerTenantId, serviceName, route.ClusterId, routeType);
_logger.LogDebug("Tenant routing - Tenant: {Tenant}, Service: {Service}, Cluster: {Cluster}",
headerTenantId, serviceName, route.ClusterId);
// 6. 继续执行,由 TenantRoutingTransform 处理 Destination 选择
await _next(context);

View File

@ -1,209 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YarpGateway.Data;
#nullable disable
namespace YarpGateway.Migrations
{
[DbContext(typeof(GatewayDbContext))]
[Migration("20260201120312_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("YarpGateway.Models.GwServiceInstance", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("DestinationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Health")
.HasColumnType("integer");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Health");
b.HasIndex("ClusterId", "DestinationId")
.IsUnique();
b.ToTable("ServiceInstances");
});
modelBuilder.Entity("YarpGateway.Models.GwTenant", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TenantName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TenantCode")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("YarpGateway.Models.GwTenantRoute", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("PathPattern")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ClusterId");
b.HasIndex("TenantCode", "ServiceName")
.IsUnique();
b.ToTable("TenantRoutes");
});
modelBuilder.Entity("YarpGateway.Models.GwTenantRoute", b =>
{
b.HasOne("YarpGateway.Models.GwTenant", null)
.WithMany()
.HasForeignKey("TenantCode")
.HasPrincipalKey("TenantCode")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,133 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace YarpGateway.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ServiceInstances",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ClusterId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
DestinationId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Address = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Health = table.Column<int>(type: "integer", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedBy = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true),
UpdatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
Version = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ServiceInstances", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Tenants",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TenantCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
TenantName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedBy = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true),
UpdatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
Version = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tenants", x => x.Id);
table.UniqueConstraint("AK_Tenants_TenantCode", x => x.TenantCode);
});
migrationBuilder.CreateTable(
name: "TenantRoutes",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TenantCode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
ServiceName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
ClusterId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
PathPattern = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Priority = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedBy = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true),
UpdatedTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
Version = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TenantRoutes", x => x.Id);
table.ForeignKey(
name: "FK_TenantRoutes_Tenants_TenantCode",
column: x => x.TenantCode,
principalTable: "Tenants",
principalColumn: "TenantCode",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_ServiceInstances_ClusterId_DestinationId",
table: "ServiceInstances",
columns: new[] { "ClusterId", "DestinationId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ServiceInstances_Health",
table: "ServiceInstances",
column: "Health");
migrationBuilder.CreateIndex(
name: "IX_TenantRoutes_ClusterId",
table: "TenantRoutes",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_TenantRoutes_TenantCode_ServiceName",
table: "TenantRoutes",
columns: new[] { "TenantCode", "ServiceName" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Tenants_TenantCode",
table: "Tenants",
column: "TenantCode",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ServiceInstances");
migrationBuilder.DropTable(
name: "TenantRoutes");
migrationBuilder.DropTable(
name: "Tenants");
}
}
}

View File

@ -1,205 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YarpGateway.Data;
#nullable disable
namespace YarpGateway.Migrations
{
[DbContext(typeof(GatewayDbContext))]
[Migration("20260201133826_AddIsGlobalToTenantRoute")]
partial class AddIsGlobalToTenantRoute
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("YarpGateway.Models.GwServiceInstance", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("DestinationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Health")
.HasColumnType("integer");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Health");
b.HasIndex("ClusterId", "DestinationId")
.IsUnique();
b.ToTable("ServiceInstances");
});
modelBuilder.Entity("YarpGateway.Models.GwTenant", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TenantName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TenantCode")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("YarpGateway.Models.GwTenantRoute", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsGlobal")
.HasColumnType("boolean");
b.Property<string>("PathPattern")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ClusterId");
b.HasIndex("ServiceName");
b.HasIndex("TenantCode");
b.HasIndex("ServiceName", "IsGlobal", "Status");
b.ToTable("TenantRoutes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,87 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YarpGateway.Migrations
{
/// <inheritdoc />
public partial class AddIsGlobalToTenantRoute : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_TenantRoutes_Tenants_TenantCode",
table: "TenantRoutes");
migrationBuilder.DropUniqueConstraint(
name: "AK_Tenants_TenantCode",
table: "Tenants");
migrationBuilder.DropIndex(
name: "IX_TenantRoutes_TenantCode_ServiceName",
table: "TenantRoutes");
migrationBuilder.AddColumn<bool>(
name: "IsGlobal",
table: "TenantRoutes",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateIndex(
name: "IX_TenantRoutes_ServiceName",
table: "TenantRoutes",
column: "ServiceName");
migrationBuilder.CreateIndex(
name: "IX_TenantRoutes_ServiceName_IsGlobal_Status",
table: "TenantRoutes",
columns: new[] { "ServiceName", "IsGlobal", "Status" });
migrationBuilder.CreateIndex(
name: "IX_TenantRoutes_TenantCode",
table: "TenantRoutes",
column: "TenantCode");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_TenantRoutes_ServiceName",
table: "TenantRoutes");
migrationBuilder.DropIndex(
name: "IX_TenantRoutes_ServiceName_IsGlobal_Status",
table: "TenantRoutes");
migrationBuilder.DropIndex(
name: "IX_TenantRoutes_TenantCode",
table: "TenantRoutes");
migrationBuilder.DropColumn(
name: "IsGlobal",
table: "TenantRoutes");
migrationBuilder.AddUniqueConstraint(
name: "AK_Tenants_TenantCode",
table: "Tenants",
column: "TenantCode");
migrationBuilder.CreateIndex(
name: "IX_TenantRoutes_TenantCode_ServiceName",
table: "TenantRoutes",
columns: new[] { "TenantCode", "ServiceName" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_TenantRoutes_Tenants_TenantCode",
table: "TenantRoutes",
column: "TenantCode",
principalTable: "Tenants",
principalColumn: "TenantCode",
onDelete: ReferentialAction.Restrict);
}
}
}

View File

@ -1,275 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YarpGateway.Data;
#nullable disable
namespace YarpGateway.Migrations
{
[DbContext(typeof(GatewayDbContext))]
[Migration("20260222134342_AddPendingServiceDiscovery")]
partial class AddPendingServiceDiscovery
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("YarpGateway.Models.GwPendingServiceDiscovery", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("AssignedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("AssignedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("AssignedClusterId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("DiscoveredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DiscoveredPorts")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("K8sClusterIP")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("K8sNamespace")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("K8sServiceName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Labels")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("PodCount")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DiscoveredAt");
b.HasIndex("Status");
b.HasIndex("K8sServiceName", "K8sNamespace", "IsDeleted")
.IsUnique();
b.ToTable("PendingServiceDiscoveries");
});
modelBuilder.Entity("YarpGateway.Models.GwServiceInstance", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("DestinationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Health")
.HasColumnType("integer");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Health");
b.HasIndex("ClusterId", "DestinationId")
.IsUnique();
b.ToTable("ServiceInstances");
});
modelBuilder.Entity("YarpGateway.Models.GwTenant", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TenantName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TenantCode")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("YarpGateway.Models.GwTenantRoute", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsGlobal")
.HasColumnType("boolean");
b.Property<string>("PathPattern")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ClusterId");
b.HasIndex("ServiceName");
b.HasIndex("TenantCode");
b.HasIndex("ServiceName", "IsGlobal", "Status");
b.ToTable("TenantRoutes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,64 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace YarpGateway.Migrations
{
/// <inheritdoc />
public partial class AddPendingServiceDiscovery : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PendingServiceDiscoveries",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
K8sServiceName = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
K8sNamespace = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
K8sClusterIP = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
DiscoveredPorts = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Labels = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
PodCount = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
AssignedClusterId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
AssignedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
AssignedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
DiscoveredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
Version = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PendingServiceDiscoveries", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_PendingServiceDiscoveries_DiscoveredAt",
table: "PendingServiceDiscoveries",
column: "DiscoveredAt");
migrationBuilder.CreateIndex(
name: "IX_PendingServiceDiscoveries_K8sServiceName_K8sNamespace_IsDel~",
table: "PendingServiceDiscoveries",
columns: new[] { "K8sServiceName", "K8sNamespace", "IsDeleted" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PendingServiceDiscoveries_Status",
table: "PendingServiceDiscoveries",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PendingServiceDiscoveries");
}
}
}

View File

@ -1,272 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YarpGateway.Data;
#nullable disable
namespace YarpGateway.Migrations
{
[DbContext(typeof(GatewayDbContext))]
partial class GatewayDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("YarpGateway.Models.GwPendingServiceDiscovery", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime?>("AssignedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("AssignedBy")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("AssignedClusterId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("DiscoveredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DiscoveredPorts")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("K8sClusterIP")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("K8sNamespace")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("K8sServiceName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Labels")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("PodCount")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DiscoveredAt");
b.HasIndex("Status");
b.HasIndex("K8sServiceName", "K8sNamespace", "IsDeleted")
.IsUnique();
b.ToTable("PendingServiceDiscoveries");
});
modelBuilder.Entity("YarpGateway.Models.GwServiceInstance", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("DestinationId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Health")
.HasColumnType("integer");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Health");
b.HasIndex("ClusterId", "DestinationId")
.IsUnique();
b.ToTable("ServiceInstances");
});
modelBuilder.Entity("YarpGateway.Models.GwTenant", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("TenantName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TenantCode")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("YarpGateway.Models.GwTenantRoute", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsGlobal")
.HasColumnType("boolean");
b.Property<string>("PathPattern")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("TenantCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint");
b.Property<DateTime?>("UpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Version")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ClusterId");
b.HasIndex("ServiceName");
b.HasIndex("TenantCode");
b.HasIndex("ServiceName", "IsGlobal", "Status");
b.ToTable("TenantRoutes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,122 +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 "ServiceInstances" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"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_ServiceInstances" PRIMARY KEY ("Id")
);
CREATE TABLE "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_Tenants" PRIMARY KEY ("Id"),
CONSTRAINT "AK_Tenants_TenantCode" UNIQUE ("TenantCode")
);
CREATE TABLE "TenantRoutes" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"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,
"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_TenantRoutes" PRIMARY KEY ("Id"),
CONSTRAINT "FK_TenantRoutes_Tenants_TenantCode" FOREIGN KEY ("TenantCode") REFERENCES "Tenants" ("TenantCode") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX "IX_ServiceInstances_ClusterId_DestinationId" ON "ServiceInstances" ("ClusterId", "DestinationId");
CREATE INDEX "IX_ServiceInstances_Health" ON "ServiceInstances" ("Health");
CREATE INDEX "IX_TenantRoutes_ClusterId" ON "TenantRoutes" ("ClusterId");
CREATE UNIQUE INDEX "IX_TenantRoutes_TenantCode_ServiceName" ON "TenantRoutes" ("TenantCode", "ServiceName");
CREATE UNIQUE INDEX "IX_Tenants_TenantCode" ON "Tenants" ("TenantCode");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260201120312_InitialCreate', '10.0.2');
COMMIT;
START TRANSACTION;
ALTER TABLE "TenantRoutes" DROP CONSTRAINT "FK_TenantRoutes_Tenants_TenantCode";
ALTER TABLE "Tenants" DROP CONSTRAINT "AK_Tenants_TenantCode";
DROP INDEX "IX_TenantRoutes_TenantCode_ServiceName";
ALTER TABLE "TenantRoutes" ADD "IsGlobal" boolean NOT NULL DEFAULT FALSE;
CREATE INDEX "IX_TenantRoutes_ServiceName" ON "TenantRoutes" ("ServiceName");
CREATE INDEX "IX_TenantRoutes_ServiceName_IsGlobal_Status" ON "TenantRoutes" ("ServiceName", "IsGlobal", "Status");
CREATE INDEX "IX_TenantRoutes_TenantCode" ON "TenantRoutes" ("TenantCode");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260201133826_AddIsGlobalToTenantRoute', '10.0.2');
COMMIT;
START TRANSACTION;
CREATE TABLE "PendingServiceDiscoveries" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"K8sServiceName" character varying(255) NOT NULL,
"K8sNamespace" character varying(255) NOT NULL,
"K8sClusterIP" character varying(50),
"DiscoveredPorts" character varying(500) NOT NULL,
"Labels" character varying(2000) NOT NULL,
"PodCount" integer NOT NULL,
"Status" integer NOT NULL,
"AssignedClusterId" character varying(100),
"AssignedBy" character varying(100),
"AssignedAt" timestamp with time zone,
"DiscoveredAt" timestamp with time zone NOT NULL,
"IsDeleted" boolean NOT NULL,
"Version" integer NOT NULL,
CONSTRAINT "PK_PendingServiceDiscoveries" PRIMARY KEY ("Id")
);
CREATE INDEX "IX_PendingServiceDiscoveries_DiscoveredAt" ON "PendingServiceDiscoveries" ("DiscoveredAt");
CREATE UNIQUE INDEX "IX_PendingServiceDiscoveries_K8sServiceName_K8sNamespace_IsDel~" ON "PendingServiceDiscoveries" ("K8sServiceName", "K8sNamespace", "IsDeleted");
CREATE INDEX "IX_PendingServiceDiscoveries_Status" ON "PendingServiceDiscoveries" ("Status");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260222134342_AddPendingServiceDiscovery', '10.0.2');
COMMIT;

View File

@ -1,89 +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 "ServiceInstances" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"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_ServiceInstances" PRIMARY KEY ("Id")
);
CREATE TABLE "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_Tenants" PRIMARY KEY ("Id"),
CONSTRAINT "AK_Tenants_TenantCode" UNIQUE ("TenantCode")
);
CREATE TABLE "TenantRoutes" (
"Id" bigint GENERATED BY DEFAULT AS IDENTITY,
"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,
"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_TenantRoutes" PRIMARY KEY ("Id"),
CONSTRAINT "FK_TenantRoutes_Tenants_TenantCode" FOREIGN KEY ("TenantCode") REFERENCES "Tenants" ("TenantCode") ON DELETE RESTRICT
);
CREATE UNIQUE INDEX "IX_ServiceInstances_ClusterId_DestinationId" ON "ServiceInstances" ("ClusterId", "DestinationId");
CREATE INDEX "IX_ServiceInstances_Health" ON "ServiceInstances" ("Health");
CREATE INDEX "IX_TenantRoutes_ClusterId" ON "TenantRoutes" ("ClusterId");
CREATE UNIQUE INDEX "IX_TenantRoutes_TenantCode_ServiceName" ON "TenantRoutes" ("TenantCode", "ServiceName");
CREATE UNIQUE INDEX "IX_Tenants_TenantCode" ON "Tenants" ("TenantCode");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260201120312_InitialCreate', '9.0.0');
ALTER TABLE "TenantRoutes" DROP CONSTRAINT "FK_TenantRoutes_Tenants_TenantCode";
ALTER TABLE "Tenants" DROP CONSTRAINT "AK_Tenants_TenantCode";
DROP INDEX "IX_TenantRoutes_TenantCode_ServiceName";
ALTER TABLE "TenantRoutes" ADD "IsGlobal" boolean NOT NULL DEFAULT FALSE;
CREATE INDEX "IX_TenantRoutes_ServiceName" ON "TenantRoutes" ("ServiceName");
CREATE INDEX "IX_TenantRoutes_ServiceName_IsGlobal_Status" ON "TenantRoutes" ("ServiceName", "IsGlobal", "Status");
CREATE INDEX "IX_TenantRoutes_TenantCode" ON "TenantRoutes" ("TenantCode");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260201133826_AddIsGlobalToTenantRoute', '9.0.0');
COMMIT;

View File

@ -141,9 +141,8 @@ builder.Services.AddMemoryCache();
// 注册租户路由转换器
builder.Services.AddSingleton<TenantRoutingTransform>();
// 配置 YARP 反向代理
// 配置 YARP 反向代理 - 使用数据库配置
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(transformBuilder =>
{
transformBuilder.AddRequestTransform(async context =>
@ -172,7 +171,31 @@ app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = Dat
app.MapControllers();
app.MapReverseProxy();
await app.Services.GetRequiredService<IRouteCache>().InitializeAsync();
// Gateway: 确保数据库表存在(在测试环境中自动创建表)
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
try
{
Log.Information("Ensuring Gateway database tables exist...");
await dbContext.Database.EnsureCreatedAsync();
Log.Information("Gateway database tables ready");
}
catch (Exception ex)
{
Log.Error(ex, "Failed to ensure database tables exist");
}
}
// 初始化路由缓存
try
{
await app.Services.GetRequiredService<IRouteCache>().InitializeAsync();
}
catch (Exception ex)
{
Log.Error(ex, "Failed to initialize route cache.");
}
try
{

View File

@ -142,7 +142,7 @@ public class PgSqlConfigChangeListener : BackgroundService
await using var scope = _serviceProvider.CreateAsyncScope();
await using var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
var currentRouteVersion = await db.GwTenantRoutes
var currentRouteVersion = await db.GwRoutes
.OrderByDescending(r => r.Version)
.Select(r => r.Version)
.FirstOrDefaultAsync(stoppingToken);
@ -176,7 +176,7 @@ public class PgSqlConfigChangeListener : BackgroundService
await using var scope = _serviceProvider.CreateAsyncScope();
await using var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
_lastRouteVersion = await db.GwTenantRoutes
_lastRouteVersion = await db.GwRoutes
.OrderByDescending(r => r.Version)
.Select(r => r.Version)
.FirstOrDefaultAsync(stoppingToken);

View File

@ -12,14 +12,13 @@ public class RouteInfo
public string ClusterId { get; set; } = string.Empty;
public string PathPattern { get; set; } = string.Empty;
public int Priority { get; set; }
public bool IsGlobal { get; set; }
}
public interface IRouteCache
{
Task InitializeAsync();
Task ReloadAsync();
RouteInfo? GetRoute(string tenantCode, string serviceName);
RouteInfo? GetRoute(string serviceName);
RouteInfo? GetRouteByPath(string path);
}
@ -28,8 +27,7 @@ public class RouteCache : IRouteCache
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
private readonly ILogger<RouteCache> _logger;
private readonly ConcurrentDictionary<string, RouteInfo> _globalRoutes = new();
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, RouteInfo>> _tenantRoutes = new();
private readonly ConcurrentDictionary<string, RouteInfo> _routes = new();
private readonly ConcurrentDictionary<string, RouteInfo> _pathRoutes = new();
private readonly ReaderWriterLockSlim _lock = new();
@ -42,57 +40,47 @@ public class RouteCache : IRouteCache
public async Task InitializeAsync()
{
_logger.LogInformation("Initializing route cache from database...");
await LoadFromDatabaseAsync();
_logger.LogInformation("Route cache initialized: {GlobalCount} global routes, {TenantCount} tenant routes",
_globalRoutes.Count, _tenantRoutes.Count);
try
{
await LoadFromDatabaseAsync();
_logger.LogInformation("Route cache initialized: {Count} routes", _routes.Count);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load routes from database. This is normal if database is not initialized yet.");
}
}
public async Task ReloadAsync()
{
_logger.LogInformation("Reloading route cache...");
await LoadFromDatabaseAsync();
_logger.LogInformation("Route cache reloaded");
_logger.LogInformation("Route cache reloaded: {Count} routes", _routes.Count);
}
public RouteInfo? GetRoute(string? tenantCode, string? serviceName)
public RouteInfo? GetRoute(string? serviceName)
{
// 参数校验
if (string.IsNullOrEmpty(serviceName))
{
_logger.LogDebug("GetRoute called with null or empty serviceName");
return null;
}
tenantCode ??= string.Empty;
_lock.EnterUpgradeableReadLock();
_lock.EnterReadLock();
try
{
// 1. 优先查找租户专用路由
if (!string.IsNullOrEmpty(tenantCode) &&
_tenantRoutes.TryGetValue(tenantCode, out var tenantRouteMap) &&
tenantRouteMap.TryGetValue(serviceName, out var tenantRoute))
if (_routes.TryGetValue(serviceName, out var route))
{
_logger.LogDebug("Found tenant-specific route: {Tenant}/{Service} -> {Cluster}",
tenantCode, serviceName, tenantRoute.ClusterId);
return tenantRoute;
_logger.LogDebug("Found route: {Service} -> {Cluster}", serviceName, route.ClusterId);
return route;
}
// 2. 查找全局路由
if (_globalRoutes.TryGetValue(serviceName, out var globalRoute))
{
_logger.LogDebug("Found global route: {Service} -> {Cluster} for tenant {Tenant}",
serviceName, globalRoute.ClusterId, tenantCode);
return globalRoute;
}
// 3. 没找到
_logger.LogWarning("No route found for: {Tenant}/{Service}", tenantCode, serviceName);
_logger.LogWarning("No route found for: {Service}", serviceName);
return null;
}
finally
{
_lock.ExitUpgradeableReadLock();
_lock.ExitReadLock();
}
}
@ -105,15 +93,23 @@ public class RouteCache : IRouteCache
{
using var db = _dbContextFactory.CreateDbContext();
var routes = await db.GwTenantRoutes
.Where(r => r.Status == 1 && !r.IsDeleted)
.ToListAsync();
List<GwRoute> routes;
try
{
routes = await db.GwRoutes
.Where(r => r.Status == 1 && !r.IsDeleted)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Database table not found. Returning empty route list.");
routes = new List<GwRoute>();
}
_lock.EnterWriteLock();
try
{
_globalRoutes.Clear();
_tenantRoutes.Clear();
_routes.Clear();
_pathRoutes.Clear();
foreach (var route in routes)
@ -125,34 +121,14 @@ public class RouteCache : IRouteCache
Id = route.Id,
ClusterId = route.ClusterId,
PathPattern = pathPattern,
Priority = route.Priority,
IsGlobal = route.IsGlobal
Priority = route.Priority
};
// 1. 全局路由
if (route.IsGlobal)
{
_globalRoutes[route.ServiceName] = routeInfo;
_pathRoutes[pathPattern] = routeInfo;
_logger.LogDebug("Loaded global route: {Service} -> {Cluster}",
route.ServiceName, route.ClusterId);
}
_routes[route.ServiceName] = routeInfo;
_pathRoutes[pathPattern] = routeInfo;
// 2. 租户专属路由(无论 IsGlobal 值如何,只要有 TenantCode 就添加到租户路由表)
if (!string.IsNullOrEmpty(route.TenantCode))
{
_tenantRoutes.GetOrAdd(route.TenantCode, _ => new ConcurrentDictionary<string, RouteInfo>())
[route.ServiceName] = routeInfo;
// 如果不是全局路由,也添加到路径路由
if (!route.IsGlobal)
{
_pathRoutes[pathPattern] = routeInfo;
}
_logger.LogDebug("Loaded tenant route: {Tenant}/{Service} -> {Cluster}",
route.TenantCode, route.ServiceName, route.ClusterId);
}
_logger.LogDebug("Loaded route: {Service} -> {Cluster}",
route.ServiceName, route.ClusterId);
}
}
finally

View File

@ -19,10 +19,11 @@
"DefaultConnection": ""
},
"Jwt": {
"Authority": "",
"Authority": "http://auth.fengling.local:30080",
"Audience": "fengling-gateway",
"ValidateIssuer": true,
"ValidateAudience": true
"ValidateAudience": true,
"RequireHttpsMetadata": false
},
"Redis": {
"ConnectionString": "",
@ -64,4 +65,4 @@
"ServiceDiscovery": {
"UseInClusterConfig": true
}
}
}

View File

@ -1,23 +0,0 @@
#### 网关配置的新想法
路由/集群/目标 等配置还是通过数据库变更通知网关进行重新加载的方式触发变更
k8s 的service中需要有固定的label来约定产生新的配置
以下是范例
service-label
- app-router-host = https://hostname #代表网关域名地址
- app-router-name = member # 代表路由名
- app-router-prefix = /member # 代表路由前缀
- app-cluster-name = member #代表集群名(id)
- app-cluster-destination = default # 代表标准服务目标 如果不是default 比如 1668 则代表企业编号独有的目标
详细请求说明:
比如一个请求进来了 请求路径是 {host}/member/api/v1/memberinfo/{id} header:{authorization: bearer xxx}
-> 根据 host+ prefix匹配到 member路由 -> 进入到对应的cluster ->
中间件进行解析或者是yarp-transform能做到最好使用yarp-transform来处理 或者如果我集成了openiddict是否可以拿到jwt这部分的信息
-> 解析出 customer/或者是租户id都行 只要能表示租户的都算 -> 找到对应的租户就进对应的目标服务 找不到就进 标准服务目标
配置生效详细说明:
1.在console项目中监听k8s 的服务 如果发现有新的app-router-name 相关信息就要产生待执行配置 用户明确确认后 生成数据库配置 在此之前只能存在于内存/缓存中
2.同样的监听服务 发现app-cluster-name 检查是否有新的app-cluster-name 如果有新的 同样产生待执行配置 后续于路由一致
3.同样监听服务 发现app-cluster-destination 检查对应的cluster是否存在这个destination 如果不存在 同样产生待执行配置 后续与上述一致
4.用户确认是否执行这些配置 用户加载到界面后可调整配置同意后产生持久化数据到数据库点击立即生效才下发到yarp网关 网关重新根据新的配置加载到最新配置后生效