Compare commits
7 Commits
d533a8111b
...
52f4b7616e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f4b7616e | ||
|
|
5755b41664 | ||
|
|
d7007d0d7f | ||
|
|
eec65c1e05 | ||
|
|
2a4a06ddb8 | ||
|
|
0cbb5a2c0e | ||
|
|
6ea7f6c958 |
457
.planning/codebase/ARCHITECTURE.md
Normal file
457
.planning/codebase/ARCHITECTURE.md
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
# 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`
|
||||||
499
.planning/codebase/CONCERNS.md
Normal file
499
.planning/codebase/CONCERNS.md
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
# 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. 输入验证不足
|
||||||
|
|
||||||
|
技术债务主要集中在代码组织、异常处理和性能优化方面。建议优先处理安全相关问题,然后逐步优化性能和可维护性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档由自动化分析生成,建议人工复核后纳入迭代计划。*
|
||||||
690
.planning/codebase/CONVENTIONS.md
Normal file
690
.planning/codebase/CONVENTIONS.md
Normal file
@ -0,0 +1,690 @@
|
|||||||
|
# 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. **健壮性**:完善的错误处理和并发控制
|
||||||
|
|
||||||
|
遵循这些约定可以确保代码质量和团队协作效率。
|
||||||
374
.planning/codebase/INTEGRATIONS.md
Normal file
374
.planning/codebase/INTEGRATIONS.md
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
# 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 丢失导致配置不一致。
|
||||||
368
.planning/codebase/SECURITY_AUDIT.md
Normal file
368
.planning/codebase/SECURITY_AUDIT.md
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
# 🔒 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. 日志安全
|
||||||
|
- 敏感信息脱敏
|
||||||
|
- 限制日志访问权限
|
||||||
|
- 使用结构化日志便于审计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*报告由安全审计生成,建议人工复核后纳入迭代计划。*
|
||||||
189
.planning/codebase/STACK.md
Normal file
189
.planning/codebase/STACK.md
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
# 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 集群部署
|
||||||
465
.planning/codebase/STRUCTURE.md
Normal file
465
.planning/codebase/STRUCTURE.md
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
# 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/`
|
||||||
833
.planning/codebase/TESTING.md
Normal file
833
.planning/codebase/TESTING.md
Normal file
@ -0,0 +1,833 @@
|
|||||||
|
# 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 测试流程**
|
||||||
|
- 每次提交运行单元测试
|
||||||
|
- 每次合并运行集成测试
|
||||||
|
- 生成覆盖率报告
|
||||||
|
|
||||||
|
通过建立完善的测试体系,可以显著提高代码质量和项目可维护性。
|
||||||
180
.planning/codebase/TEST_PLAN.md
Normal file
180
.planning/codebase/TEST_PLAN.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
# 🧪 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% | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*测试计划由分析生成,建议按优先级逐步实现。*
|
||||||
@ -6,4 +6,7 @@
|
|||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/YarpGateway.csproj" />
|
<Project Path="src/YarpGateway.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/YarpGateway.Tests/YarpGateway.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
140
k8s/base/deployment.yaml
Normal file
140
k8s/base/deployment.yaml
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: fengling
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: fengling
|
||||||
|
app.kubernetes.io/managed-by: kubectl
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: yarp-gateway-config
|
||||||
|
namespace: fengling
|
||||||
|
labels:
|
||||||
|
app: yarp-gateway
|
||||||
|
data:
|
||||||
|
ASPNETCORE_ENVIRONMENT: "Production"
|
||||||
|
Logging__LogLevel__Default: "Information"
|
||||||
|
Logging__LogLevel__Microsoft__AspNetCore: "Warning"
|
||||||
|
Logging__LogLevel__Yarp__ReverseProxy: "Information"
|
||||||
|
Serilog__MinimumLevel: "Information"
|
||||||
|
Kestrel__Endpoints__Http__Url: "http://0.0.0.0:8080"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: yarp-gateway-secret
|
||||||
|
namespace: fengling
|
||||||
|
labels:
|
||||||
|
app: yarp-gateway
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
# PostgreSQL 连接字符串
|
||||||
|
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=fengling_gateway;Username=movingsam;Password=${POSTGRES_PASSWORD}"
|
||||||
|
# Redis 连接字符串
|
||||||
|
Redis__ConnectionString: "redis:6379"
|
||||||
|
# JWT 配置
|
||||||
|
Jwt__Authority: "https://your-auth-server.com"
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: yarp-gateway
|
||||||
|
namespace: fengling
|
||||||
|
labels:
|
||||||
|
app: yarp-gateway
|
||||||
|
version: v1
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: yarp-gateway
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: yarp-gateway
|
||||||
|
version: v1
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: yarp-gateway
|
||||||
|
image: gitea.shtao1.cn/fengling/YarpGateway:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8080
|
||||||
|
protocol: TCP
|
||||||
|
- name: metrics
|
||||||
|
containerPort: 8081
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: ASPNETCORE_ENVIRONMENT
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: yarp-gateway-config
|
||||||
|
key: ASPNETCORE_ENVIRONMENT
|
||||||
|
- name: ConnectionStrings__DefaultConnection
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: yarp-gateway-secret
|
||||||
|
key: ConnectionStrings__DefaultConnection
|
||||||
|
- name: Redis__ConnectionString
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: yarp-gateway-secret
|
||||||
|
key: Redis__ConnectionString
|
||||||
|
- name: Jwt__Authority
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: yarp-gateway-secret
|
||||||
|
key: Jwt__Authority
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
volumeMounts:
|
||||||
|
- name: logs
|
||||||
|
mountPath: /app/logs
|
||||||
|
volumes:
|
||||||
|
- name: logs
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: yarp-gateway
|
||||||
|
namespace: fengling
|
||||||
|
labels:
|
||||||
|
app: yarp-gateway
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
- name: metrics
|
||||||
|
port: 8081
|
||||||
|
targetPort: metrics
|
||||||
|
protocol: TCP
|
||||||
|
selector:
|
||||||
|
app: yarp-gateway
|
||||||
@ -1,8 +1,25 @@
|
|||||||
namespace YarpGateway.Config;
|
namespace YarpGateway.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Redis 连接配置
|
||||||
|
/// 注意:ConnectionString 应从环境变量或密钥管理服务获取,不要硬编码凭据
|
||||||
|
/// </summary>
|
||||||
public class RedisConfig
|
public class RedisConfig
|
||||||
{
|
{
|
||||||
public string ConnectionString { get; set; } = "81.68.223.70:16379,password=sl52788542";
|
/// <summary>
|
||||||
|
/// Redis 连接字符串
|
||||||
|
/// 从环境变量 REDIS_CONNECTION_STRING 读取,或从配置文件获取
|
||||||
|
/// 格式: host:port,password=xxx
|
||||||
|
/// </summary>
|
||||||
|
public string ConnectionString { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Redis 数据库编号
|
||||||
|
/// </summary>
|
||||||
public int Database { get; set; } = 0;
|
public int Database { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 实例名称前缀
|
||||||
|
/// </summary>
|
||||||
public string InstanceName { get; set; } = "YarpGateway";
|
public string InstanceName { get; set; } = "YarpGateway";
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using YarpGateway.Data;
|
using YarpGateway.Data;
|
||||||
@ -9,6 +10,7 @@ namespace YarpGateway.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/gateway")]
|
[Route("api/gateway")]
|
||||||
|
[Authorize] // 要求所有管理 API 都需要认证
|
||||||
public class GatewayConfigController : ControllerBase
|
public class GatewayConfigController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
|
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using YarpGateway.Data;
|
using YarpGateway.Data;
|
||||||
@ -7,6 +8,7 @@ namespace YarpGateway.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/gateway/pending-services")]
|
[Route("api/gateway/pending-services")]
|
||||||
|
[Authorize] // 要求所有管理 API 都需要认证
|
||||||
public class PendingServicesController : ControllerBase
|
public class PendingServicesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
|
private readonly IDbContextFactory<GatewayDbContext> _dbContextFactory;
|
||||||
|
|||||||
@ -6,6 +6,14 @@ using YarpGateway.Config;
|
|||||||
|
|
||||||
namespace YarpGateway.Middleware;
|
namespace YarpGateway.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JWT 转换中间件
|
||||||
|
///
|
||||||
|
/// 安全说明:
|
||||||
|
/// 1. 从已验证的 JWT Claims 中提取用户信息(不是直接解析 token)
|
||||||
|
/// 2. 清除请求中所有 X-* Header 以防止 Header 注入攻击
|
||||||
|
/// 3. 验证租户 ID 与 JWT 中的 tenant claim 一致
|
||||||
|
/// </summary>
|
||||||
public class JwtTransformMiddleware
|
public class JwtTransformMiddleware
|
||||||
{
|
{
|
||||||
private readonly RequestDelegate _next;
|
private readonly RequestDelegate _next;
|
||||||
@ -25,32 +33,46 @@ public class JwtTransformMiddleware
|
|||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
|
// 安全措施:清除所有 X-* Header 防止 Header 注入攻击
|
||||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
|
var xHeaders = context.Request.Headers
|
||||||
|
.Where(h => h.Key.StartsWith("X-", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(h => h.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var header in xHeaders)
|
||||||
|
{
|
||||||
|
context.Request.Headers.Remove(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否已通过 JWT 认证
|
||||||
|
if (context.User?.Identity?.IsAuthenticated != true)
|
||||||
{
|
{
|
||||||
await _next(context);
|
await _next(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var token = authHeader.Substring("Bearer ".Length).Trim();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var jwtHandler = new JwtSecurityTokenHandler();
|
// 从已验证的 ClaimsPrincipal 中提取信息(安全)
|
||||||
var jwtToken = jwtHandler.ReadJwtToken(token);
|
var claims = context.User.Claims;
|
||||||
|
|
||||||
var tenantId = jwtToken.Claims.FirstOrDefault(c => c.Type == "tenant")?.Value;
|
var tenantId = claims.FirstOrDefault(c => c.Type == "tenant")?.Value
|
||||||
var userId = jwtToken
|
?? claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value;
|
||||||
.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)
|
|
||||||
?.Value;
|
var userId = claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value
|
||||||
var userName = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
|
?? claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||||
var roles = jwtToken
|
|
||||||
.Claims.Where(c => c.Type == ClaimTypes.Role)
|
var userName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value
|
||||||
|
?? claims.FirstOrDefault(c => c.Type == "name")?.Value;
|
||||||
|
|
||||||
|
var roles = claims
|
||||||
|
.Where(c => c.Type == ClaimTypes.Role || c.Type == "role")
|
||||||
.Select(c => c.Value)
|
.Select(c => c.Value)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(tenantId))
|
if (!string.IsNullOrEmpty(tenantId))
|
||||||
{
|
{
|
||||||
|
// 安全地设置 Header(从已验证的 JWT 中提取)
|
||||||
context.Request.Headers["X-Tenant-Id"] = tenantId;
|
context.Request.Headers["X-Tenant-Id"] = tenantId;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(userId))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
@ -63,7 +85,7 @@ public class JwtTransformMiddleware
|
|||||||
context.Request.Headers["X-Roles"] = string.Join(",", roles);
|
context.Request.Headers["X-Roles"] = string.Join(",", roles);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"JWT transformed - Tenant: {Tenant}, User: {User}",
|
"JWT claims transformed - Tenant: {Tenant}, User: {User}",
|
||||||
tenantId,
|
tenantId,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
@ -75,7 +97,7 @@ public class JwtTransformMiddleware
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to parse JWT token");
|
_logger.LogError(ex, "Failed to extract claims from authenticated user");
|
||||||
}
|
}
|
||||||
|
|
||||||
await _next(context);
|
await _next(context);
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using YarpGateway.Services;
|
using YarpGateway.Services;
|
||||||
|
|
||||||
namespace YarpGateway.Middleware;
|
namespace YarpGateway.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户路由中间件
|
||||||
|
///
|
||||||
|
/// 安全说明:
|
||||||
|
/// 1. 验证 X-Tenant-Id Header 与 JWT 中的 tenant claim 一致
|
||||||
|
/// 2. 防止租户隔离绕过攻击
|
||||||
|
/// 3. 只有验证通过后才进行路由查找
|
||||||
|
/// </summary>
|
||||||
public class TenantRoutingMiddleware
|
public class TenantRoutingMiddleware
|
||||||
{
|
{
|
||||||
private readonly RequestDelegate _next;
|
private readonly RequestDelegate _next;
|
||||||
@ -22,13 +31,37 @@ public class TenantRoutingMiddleware
|
|||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
var headerTenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
||||||
if (string.IsNullOrEmpty(tenantId))
|
|
||||||
|
if (string.IsNullOrEmpty(headerTenantId))
|
||||||
{
|
{
|
||||||
await _next(context);
|
await _next(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 安全验证:检查 Header 中的租户 ID 是否与 JWT 一致
|
||||||
|
if (context.User?.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
var jwtTenantId = context.User.Claims
|
||||||
|
.FirstOrDefault(c => c.Type == "tenant" || c.Type == "tenant_id")?.Value;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(jwtTenantId) && jwtTenantId != headerTenantId)
|
||||||
|
{
|
||||||
|
// 记录安全事件
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Tenant ID mismatch detected! JWT tenant: {JwtTenant}, Header tenant: {HeaderTenant}, User: {User}",
|
||||||
|
jwtTenantId,
|
||||||
|
headerTenantId,
|
||||||
|
context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 拒绝请求
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
await context.Response.WriteAsync("Tenant ID verification failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var path = context.Request.Path.Value ?? string.Empty;
|
var path = context.Request.Path.Value ?? string.Empty;
|
||||||
var serviceName = ExtractServiceName(path);
|
var serviceName = ExtractServiceName(path);
|
||||||
|
|
||||||
@ -38,10 +71,10 @@ public class TenantRoutingMiddleware
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var route = _routeCache.GetRoute(tenantId, serviceName);
|
var route = _routeCache.GetRoute(headerTenantId, serviceName);
|
||||||
if (route == null)
|
if (route == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Route not found - Tenant: {Tenant}, Service: {Service}", tenantId, serviceName);
|
_logger.LogDebug("Route not found - Tenant: {Tenant}, Service: {Service}", headerTenantId, serviceName);
|
||||||
await _next(context);
|
await _next(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -49,8 +82,8 @@ public class TenantRoutingMiddleware
|
|||||||
context.Items["DynamicClusterId"] = route.ClusterId;
|
context.Items["DynamicClusterId"] = route.ClusterId;
|
||||||
|
|
||||||
var routeType = route.IsGlobal ? "global" : "tenant-specific";
|
var routeType = route.IsGlobal ? "global" : "tenant-specific";
|
||||||
_logger.LogInformation("Tenant routing - Tenant: {Tenant}, Service: {Service}, Cluster: {Cluster}, Type: {Type}",
|
_logger.LogDebug("Tenant routing - Tenant: {Tenant}, Service: {Service}, Cluster: {Cluster}, Type: {Type}",
|
||||||
tenantId, serviceName, route.ClusterId, routeType);
|
headerTenantId, serviceName, route.ClusterId, routeType);
|
||||||
|
|
||||||
await _next(context);
|
await _next(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@ -23,10 +24,48 @@ builder.Host.UseSerilog(
|
|||||||
.Enrich.FromLogContext()
|
.Enrich.FromLogContext()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 配置 JWT
|
||||||
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection("Jwt"));
|
builder.Services.Configure<JwtConfig>(builder.Configuration.GetSection("Jwt"));
|
||||||
builder.Services.Configure<RedisConfig>(builder.Configuration.GetSection("Redis"));
|
builder.Services.Configure<RedisConfig>(builder.Configuration.GetSection("Redis"));
|
||||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<RedisConfig>>().Value);
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<RedisConfig>>().Value);
|
||||||
|
|
||||||
|
// 添加 JWT 认证
|
||||||
|
var jwtConfig = builder.Configuration.GetSection("Jwt").Get<JwtConfig>();
|
||||||
|
if (jwtConfig != null && !string.IsNullOrEmpty(jwtConfig.Authority))
|
||||||
|
{
|
||||||
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.Authority = jwtConfig.Authority;
|
||||||
|
options.Audience = jwtConfig.Audience;
|
||||||
|
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = jwtConfig.ValidateIssuer,
|
||||||
|
ValidateAudience = jwtConfig.ValidateAudience,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ClockSkew = TimeSpan.Zero
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = context =>
|
||||||
|
{
|
||||||
|
Log.Warning("JWT authentication failed: {Error}", context.Exception?.Message);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnTokenValidated = context =>
|
||||||
|
{
|
||||||
|
Log.Information("JWT token validated for user: {User}",
|
||||||
|
context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "unknown");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
}
|
||||||
|
|
||||||
builder.Services.AddDbContextFactory<GatewayDbContext>(options =>
|
builder.Services.AddDbContextFactory<GatewayDbContext>(options =>
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))
|
||||||
);
|
);
|
||||||
@ -78,6 +117,7 @@ builder.Services.AddServiceDiscovery();
|
|||||||
|
|
||||||
builder.Services.AddHostedService<KubernetesPendingSyncService>();
|
builder.Services.AddHostedService<KubernetesPendingSyncService>();
|
||||||
|
|
||||||
|
// CORS 配置 - 修复 AllowAnyOrigin 与 AllowCredentials 不兼容问题
|
||||||
var corsSettings = builder.Configuration.GetSection("Cors");
|
var corsSettings = builder.Configuration.GetSection("Cors");
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
@ -88,16 +128,18 @@ builder.Services.AddCors(options =>
|
|||||||
{
|
{
|
||||||
if (allowAnyOrigin)
|
if (allowAnyOrigin)
|
||||||
{
|
{
|
||||||
policy.AllowAnyOrigin();
|
// AllowAnyOrigin 与 AllowCredentials 不兼容
|
||||||
|
policy.AllowAnyOrigin()
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
policy.WithOrigins(allowedOrigins);
|
policy.WithOrigins(allowedOrigins)
|
||||||
}
|
.AllowAnyHeader()
|
||||||
|
|
||||||
policy.AllowAnyHeader()
|
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowCredentials();
|
.AllowCredentials();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -109,6 +151,14 @@ builder.Services.AddReverseProxy();
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseCors("AllowFrontend");
|
app.UseCors("AllowFrontend");
|
||||||
|
|
||||||
|
// 添加认证和授权中间件(必须在自定义中间件之前)
|
||||||
|
if (jwtConfig != null && !string.IsNullOrEmpty(jwtConfig.Authority))
|
||||||
|
{
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
}
|
||||||
|
|
||||||
app.UseMiddleware<JwtTransformMiddleware>();
|
app.UseMiddleware<JwtTransformMiddleware>();
|
||||||
app.UseMiddleware<TenantRoutingMiddleware>();
|
app.UseMiddleware<TenantRoutingMiddleware>();
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,8 @@ public class RedisConnectionManager : IRedisConnectionManager
|
|||||||
_logger.LogError(e.Exception, "Redis connection failed");
|
_logger.LogError(e.Exception, "Redis connection failed");
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogInformation("Connected to Redis at {ConnectionString}", _config.ConnectionString);
|
// 脱敏连接字符串中的密码
|
||||||
|
_logger.LogInformation("Connected to Redis at {Host}", configuration.EndPoints.FirstOrDefault()?.ToString() ?? "unknown");
|
||||||
return connection;
|
return connection;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,34 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Debug",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Information",
|
||||||
}
|
"Yarp.ReverseProxy": "Debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": ""
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Authority": "https://your-auth-server.com",
|
||||||
|
"Audience": "fengling-gateway",
|
||||||
|
"ValidateIssuer": true,
|
||||||
|
"ValidateAudience": true
|
||||||
|
},
|
||||||
|
"Redis": {
|
||||||
|
"ConnectionString": "",
|
||||||
|
"Database": 0,
|
||||||
|
"InstanceName": "YarpGateway"
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:5174"
|
||||||
|
],
|
||||||
|
"AllowAnyOrigin": false
|
||||||
|
},
|
||||||
|
"ServiceDiscovery": {
|
||||||
|
"UseInClusterConfig": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -16,16 +16,16 @@
|
|||||||
"AllowAnyOrigin": false
|
"AllowAnyOrigin": false
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=81.68.223.70;Port=15432;Database=fengling_gateway;Username=movingsam;Password=sl52788542"
|
"DefaultConnection": ""
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Authority": "https://your-auth-server.com",
|
"Authority": "",
|
||||||
"Audience": "fengling-gateway",
|
"Audience": "fengling-gateway",
|
||||||
"ValidateIssuer": true,
|
"ValidateIssuer": true,
|
||||||
"ValidateAudience": true
|
"ValidateAudience": true
|
||||||
},
|
},
|
||||||
"Redis": {
|
"Redis": {
|
||||||
"ConnectionString": "81.68.223.70:6379",
|
"ConnectionString": "",
|
||||||
"Database": 0,
|
"Database": 0,
|
||||||
"InstanceName": "YarpGateway"
|
"InstanceName": "YarpGateway"
|
||||||
},
|
},
|
||||||
@ -60,5 +60,8 @@
|
|||||||
"Url": "http://0.0.0.0:8080"
|
"Url": "http://0.0.0.0:8080"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ServiceDiscovery": {
|
||||||
|
"UseInClusterConfig": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
7
tests/Directory.Build.props
Normal file
7
tests/Directory.Build.props
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
21
tests/Directory.Packages.props
Normal file
21
tests/Directory.Packages.props
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Test Packages -->
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.7.0" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.7" />
|
||||||
|
<PackageVersion Include="Moq" Version="4.20.70" />
|
||||||
|
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
|
||||||
|
|
||||||
|
<!-- Centralized from src/ -->
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="StackExchange.Redis" Version="2.8.31" />
|
||||||
|
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,238 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
using FluentAssertions;
|
||||||
|
using YarpGateway.Config;
|
||||||
|
using YarpGateway.Middleware;
|
||||||
|
|
||||||
|
namespace YarpGateway.Tests.Unit.Middleware;
|
||||||
|
|
||||||
|
public class JwtTransformMiddlewareTests
|
||||||
|
{
|
||||||
|
private readonly Mock<ILogger<JwtTransformMiddleware>> _loggerMock;
|
||||||
|
private readonly JwtConfig _jwtConfig;
|
||||||
|
|
||||||
|
public JwtTransformMiddlewareTests()
|
||||||
|
{
|
||||||
|
_jwtConfig = new JwtConfig
|
||||||
|
{
|
||||||
|
Authority = "https://auth.example.com",
|
||||||
|
Audience = "yarp-gateway"
|
||||||
|
};
|
||||||
|
_loggerMock = new Mock<ILogger<JwtTransformMiddleware>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private JwtTransformMiddleware CreateMiddleware()
|
||||||
|
{
|
||||||
|
var jwtConfigOptions = Options.Create(_jwtConfig);
|
||||||
|
return new JwtTransformMiddleware(
|
||||||
|
next: Mock.Of<RequestDelegate>(),
|
||||||
|
jwtConfig: jwtConfigOptions,
|
||||||
|
logger: _loggerMock.Object
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private DefaultHttpContext CreateAuthenticatedContext(string? tenantId = "tenant-1", string? userId = "user-1")
|
||||||
|
{
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
|
||||||
|
var claims = new List<Claim>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tenantId))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim("tenant", tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId));
|
||||||
|
claims.Add(new Claim("sub", userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
claims.Add(new Claim(ClaimTypes.Name, "testuser"));
|
||||||
|
claims.Add(new Claim("name", "Test User"));
|
||||||
|
claims.Add(new Claim(ClaimTypes.Role, "admin"));
|
||||||
|
claims.Add(new Claim("role", "user"));
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
context.User = principal;
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DefaultHttpContext CreateUnauthenticatedContext()
|
||||||
|
{
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.User = new ClaimsPrincipal();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractTenantClaim()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var context = CreateAuthenticatedContext(tenantId: "tenant-123");
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractUserId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var context = CreateAuthenticatedContext(userId: "user-456");
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
context.Request.Headers["X-User-Id"].Should().Contain("user-456");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractUserName()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var context = CreateAuthenticatedContext();
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
context.Request.Headers["X-User-Name"].Should().Contain("Test User");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithAuthenticatedUser_ShouldExtractRoles()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var context = CreateAuthenticatedContext();
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
context.Request.Headers["X-Roles"].Should().Contain("admin,user");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithUnauthenticatedUser_ShouldNotSetHeaders()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var context = CreateUnauthenticatedContext();
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
context.Request.Headers.Should().NotContainKey("X-Tenant-Id");
|
||||||
|
context.Request.Headers.Should().NotContainKey("X-User-Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithMissingTenantClaim_ShouldLogWarning()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var context = CreateAuthenticatedContext(tenantId: null!);
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert - should not throw, just log warning
|
||||||
|
context.Request.Headers.Should().NotContainKey("X-Tenant-Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithTenantClaimUsingTenantIdType_ShouldExtractCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new Claim("tenant_id", "tenant-using-id-type"),
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "user-1")
|
||||||
|
};
|
||||||
|
var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
|
||||||
|
context.User = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-using-id-type");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_ShouldRemoveExistingXHeaders_PreventHeaderInjection()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var context = CreateAuthenticatedContext();
|
||||||
|
// Simulate header injection attempt
|
||||||
|
context.Request.Headers["X-Tenant-Id"] = "injected-tenant";
|
||||||
|
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert - the injected header should be removed and replaced with JWT value
|
||||||
|
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithMultipleTenantClaims_ShouldPrioritizeTenantType()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new Claim("tenant", "tenant-from-claim"),
|
||||||
|
new Claim("tenant_id", "tenant-id-claim"),
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "user-1")
|
||||||
|
};
|
||||||
|
var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
|
||||||
|
context.User = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert - should prioritize "tenant" over "tenant_id"
|
||||||
|
context.Request.Headers["X-Tenant-Id"].Should().Contain("tenant-from-claim");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithEmptyClaims_ShouldNotThrow()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
var identity = new ClaimsIdentity(Array.Empty<Claim>(), JwtBearerDefaults.AuthenticationScheme);
|
||||||
|
context.User = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
|
||||||
|
// Act & Assert - should not throw
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,313 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
using FluentAssertions;
|
||||||
|
using YarpGateway.Middleware;
|
||||||
|
using YarpGateway.Services;
|
||||||
|
|
||||||
|
namespace YarpGateway.Tests.Unit.Middleware;
|
||||||
|
|
||||||
|
public class TenantRoutingMiddlewareTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IRouteCache> _routeCacheMock;
|
||||||
|
private readonly Mock<ILogger<TenantRoutingMiddleware>> _loggerMock;
|
||||||
|
private readonly RequestDelegate _nextDelegate;
|
||||||
|
|
||||||
|
public TenantRoutingMiddlewareTests()
|
||||||
|
{
|
||||||
|
_routeCacheMock = new Mock<IRouteCache>();
|
||||||
|
_loggerMock = new Mock<ILogger<TenantRoutingMiddleware>>();
|
||||||
|
|
||||||
|
// Default: call next
|
||||||
|
_nextDelegate = _ => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TenantRoutingMiddleware CreateMiddleware(
|
||||||
|
IRouteCache? routeCache = null,
|
||||||
|
RequestDelegate? next = null)
|
||||||
|
{
|
||||||
|
return new TenantRoutingMiddleware(
|
||||||
|
next: next ?? _nextDelegate,
|
||||||
|
routeCache: routeCache ?? _routeCacheMock.Object,
|
||||||
|
logger: _loggerMock.Object
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DefaultHttpContext CreateContext(string? tenantId = null, string path = "/api/user-service/users")
|
||||||
|
{
|
||||||
|
var context = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
Request = { Path = path }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(tenantId))
|
||||||
|
{
|
||||||
|
context.Request.Headers["X-Tenant-Id"] = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DefaultHttpContext CreateAuthenticatedContext(string tenantId, string headerTenantId)
|
||||||
|
{
|
||||||
|
var context = CreateContext(headerTenantId);
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new Claim("tenant", tenantId),
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "user-1")
|
||||||
|
};
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
|
||||||
|
context.User = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithoutTenantHeader_ShouldCallNext()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var nextCalled = false;
|
||||||
|
var middleware = CreateMiddleware(next: _ => { nextCalled = true; return Task.CompletedTask; });
|
||||||
|
var context = CreateContext(tenantId: null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
nextCalled.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithValidTenantAndRoute_ShouldSetClusterId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeInfo = new RouteInfo
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ClusterId = "cluster-user-service",
|
||||||
|
PathPattern = "/api/user-service/**",
|
||||||
|
Priority = 1,
|
||||||
|
IsGlobal = false
|
||||||
|
};
|
||||||
|
|
||||||
|
_routeCacheMock
|
||||||
|
.Setup(x => x.GetRoute("tenant-1", "user-service"))
|
||||||
|
.Returns(routeInfo);
|
||||||
|
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
var context = CreateContext(tenantId: "tenant-1");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
context.Items["DynamicClusterId"].Should().Be("cluster-user-service");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WhenRouteNotFound_ShouldCallNext()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_routeCacheMock
|
||||||
|
.Setup(x => x.GetRoute(It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.Returns((RouteInfo?)null);
|
||||||
|
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
var context = CreateContext(tenantId: "tenant-1");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert - should not throw, just continue
|
||||||
|
context.Items.Should().NotContainKey("DynamicClusterId");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithTenantIdMismatch_ShouldReturn403()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// JWT has tenant-1, but header has tenant-2
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
var context = CreateAuthenticatedContext("tenant-1", "tenant-2");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithMatchingTenant_ShouldAllowRequest()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeInfo = new RouteInfo
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ClusterId = "cluster-1",
|
||||||
|
IsGlobal = false
|
||||||
|
};
|
||||||
|
|
||||||
|
_routeCacheMock
|
||||||
|
.Setup(x => x.GetRoute("tenant-1", "user-service"))
|
||||||
|
.Returns(routeInfo);
|
||||||
|
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
var context = CreateAuthenticatedContext("tenant-1", "tenant-1");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
context.Response.StatusCode.Should().NotBe(StatusCodes.Status403Forbidden);
|
||||||
|
context.Items["DynamicClusterId"].Should().Be("cluster-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithoutAuthentication_ShouldAllowRequest()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeInfo = new RouteInfo
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ClusterId = "cluster-1",
|
||||||
|
IsGlobal = false
|
||||||
|
};
|
||||||
|
|
||||||
|
_routeCacheMock
|
||||||
|
.Setup(x => x.GetRoute("tenant-1", "user-service"))
|
||||||
|
.Returns(routeInfo);
|
||||||
|
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
var context = CreateContext(tenantId: "tenant-1");
|
||||||
|
// No authentication set
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert - should proceed without 403
|
||||||
|
context.Response.StatusCode.Should().NotBe(StatusCodes.Status403Forbidden);
|
||||||
|
context.Items["DynamicClusterId"].Should().Be("cluster-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/api/user-service/users", "user-service")]
|
||||||
|
[InlineData("/api/order-service/orders", "order-service")]
|
||||||
|
[InlineData("/api/payment/", "payment")]
|
||||||
|
[InlineData("/api/auth", "auth")]
|
||||||
|
[InlineData("/other/path", "")]
|
||||||
|
public async Task InvokeAsync_ShouldExtractServiceNameFromPath(string path, string expectedServiceName)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
var context = CreateContext(tenantId: "tenant-1", path: path);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
if (!string.IsNullOrEmpty(expectedServiceName))
|
||||||
|
{
|
||||||
|
_routeCacheMock.Verify(
|
||||||
|
x => x.GetRoute("tenant-1", expectedServiceName),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithTenantRoute_ShouldLogAsTenantSpecific()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeInfo = new RouteInfo
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ClusterId = "cluster-1",
|
||||||
|
IsGlobal = false
|
||||||
|
};
|
||||||
|
|
||||||
|
_routeCacheMock
|
||||||
|
.Setup(x => x.GetRoute("tenant-1", "user-service"))
|
||||||
|
.Returns(routeInfo);
|
||||||
|
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
var context = CreateContext(tenantId: "tenant-1");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert - just verify it completes without error
|
||||||
|
context.Items["DynamicClusterId"].Should().Be("cluster-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithGlobalRoute_ShouldLogAsGlobal()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeInfo = new RouteInfo
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ClusterId = "global-cluster",
|
||||||
|
IsGlobal = true
|
||||||
|
};
|
||||||
|
|
||||||
|
_routeCacheMock
|
||||||
|
.Setup(x => x.GetRoute("tenant-1", "user-service"))
|
||||||
|
.Returns(routeInfo);
|
||||||
|
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
var context = CreateContext(tenantId: "tenant-1");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
context.Items["DynamicClusterId"].Should().Be("global-cluster");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WithEmptyPath_ShouldCallNext()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
var context = CreateContext(tenantId: "tenant-1", path: "");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert - should not try to extract service name
|
||||||
|
_routeCacheMock.Verify(
|
||||||
|
x => x.GetRoute(It.IsAny<string>(), It.IsAny<string>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_PrioritizesTenantRouteOverGlobal()
|
||||||
|
{
|
||||||
|
// Arrange - This test verifies the middleware calls GetRoute with tenant code
|
||||||
|
// The priority logic is in RouteCache, not in middleware
|
||||||
|
|
||||||
|
var tenantRoute = new RouteInfo
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ClusterId = "tenant-specific-cluster",
|
||||||
|
IsGlobal = false
|
||||||
|
};
|
||||||
|
|
||||||
|
_routeCacheMock
|
||||||
|
.Setup(x => x.GetRoute("tenant-1", "user-service"))
|
||||||
|
.Returns(tenantRoute);
|
||||||
|
|
||||||
|
var middleware = CreateMiddleware();
|
||||||
|
var context = CreateContext(tenantId: "tenant-1");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
context.Items["DynamicClusterId"].Should().Be("tenant-specific-cluster");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,303 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Moq;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using Xunit;
|
||||||
|
using FluentAssertions;
|
||||||
|
using YarpGateway.Config;
|
||||||
|
using YarpGateway.Services;
|
||||||
|
|
||||||
|
namespace YarpGateway.Tests.Unit.Services;
|
||||||
|
|
||||||
|
public class RedisConnectionManagerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IConnectionMultiplexer> _connectionMock;
|
||||||
|
private readonly Mock<IDatabase> _databaseMock;
|
||||||
|
private readonly Mock<ILogger<RedisConnectionManager>> _loggerMock;
|
||||||
|
private readonly RedisConfig _config;
|
||||||
|
|
||||||
|
public RedisConnectionManagerTests()
|
||||||
|
{
|
||||||
|
_connectionMock = new Mock<IConnectionMultiplexer>();
|
||||||
|
_databaseMock = new Mock<IDatabase>();
|
||||||
|
|
||||||
|
_connectionMock
|
||||||
|
.Setup(x => x.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
|
||||||
|
.Returns(_databaseMock.Object);
|
||||||
|
|
||||||
|
_loggerMock = new Mock<ILogger<RedisConnectionManager>>();
|
||||||
|
|
||||||
|
_config = new RedisConfig
|
||||||
|
{
|
||||||
|
ConnectionString = "localhost:6379",
|
||||||
|
InstanceName = "test-instance",
|
||||||
|
Database = 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private RedisConnectionManager CreateManager(IConnectionMultiplexer? connection = null)
|
||||||
|
{
|
||||||
|
var conn = connection ?? _connectionMock.Object;
|
||||||
|
|
||||||
|
// Use reflection to create the manager with a mock connection
|
||||||
|
var manager = new RedisConnectionManager(_config, _loggerMock.Object);
|
||||||
|
|
||||||
|
// Replace the lazy connection
|
||||||
|
var lazyConnectionField = typeof(RedisConnectionManager)
|
||||||
|
.GetField("_lazyConnection", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
|
||||||
|
var lazyConnection = new Lazy<IConnectionMultiplexer>(() => conn);
|
||||||
|
lazyConnectionField!.SetValue(manager, lazyConnection);
|
||||||
|
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetConnection_ShouldReturnConnection()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var manager = CreateManager();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var connection = manager.GetConnection();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
connection.Should().BeSameAs(_connectionMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcquireLockAsync_WhenLockAvailable_ShouldAcquireLock()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var db = _databaseMock;
|
||||||
|
|
||||||
|
db.Setup(x => x.StringSetAsync(
|
||||||
|
It.IsAny<RedisKey>(),
|
||||||
|
It.IsAny<RedisValue>(),
|
||||||
|
It.IsAny<TimeSpan?>(),
|
||||||
|
It.IsAny<bool>(),
|
||||||
|
When.NotExists,
|
||||||
|
It.IsAny<CommandFlags>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var manager = CreateManager();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var lockObj = await manager.AcquireLockAsync("test-key", TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
lockObj.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcquireLockAsync_WhenLockNotAvailable_ShouldRetry()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var callCount = 0;
|
||||||
|
_databaseMock
|
||||||
|
.Setup(x => x.StringSetAsync(
|
||||||
|
It.IsAny<RedisKey>(),
|
||||||
|
It.IsAny<RedisValue>(),
|
||||||
|
It.IsAny<TimeSpan?>(),
|
||||||
|
It.IsAny<bool>(),
|
||||||
|
When.NotExists,
|
||||||
|
It.IsAny<CommandFlags>()))
|
||||||
|
.ReturnsAsync(() =>
|
||||||
|
{
|
||||||
|
callCount++;
|
||||||
|
return callCount > 3; // Succeed after 3 retries
|
||||||
|
});
|
||||||
|
|
||||||
|
var manager = CreateManager();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var lockObj = await manager.AcquireLockAsync("test-key", TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
lockObj.Should().NotBeNull();
|
||||||
|
callCount.Should().BeGreaterThan(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcquireLockAsync_WhenRetryExhausted_ShouldThrowTimeoutException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_databaseMock
|
||||||
|
.Setup(x => x.StringSetAsync(
|
||||||
|
It.IsAny<RedisKey>(),
|
||||||
|
It.IsAny<RedisValue>(),
|
||||||
|
It.IsAny<TimeSpan?>(),
|
||||||
|
It.IsAny<bool>(),
|
||||||
|
When.NotExists,
|
||||||
|
It.IsAny<CommandFlags>()))
|
||||||
|
.ReturnsAsync(false); // Always fail
|
||||||
|
|
||||||
|
var manager = CreateManager();
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(() => manager.AcquireLockAsync("test-key", TimeSpan.FromMilliseconds(100)))
|
||||||
|
.Should().ThrowAsync<TimeoutException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteInLockAsync_ShouldExecuteFunction()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var functionExecuted = false;
|
||||||
|
|
||||||
|
_databaseMock
|
||||||
|
.Setup(x => x.StringSetAsync(
|
||||||
|
It.IsAny<RedisKey>(),
|
||||||
|
It.IsAny<RedisValue>(),
|
||||||
|
It.IsAny<TimeSpan?>(),
|
||||||
|
It.IsAny<bool>(),
|
||||||
|
When.NotExists,
|
||||||
|
It.IsAny<CommandFlags>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var manager = CreateManager();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await manager.ExecuteInLockAsync("test-key", () =>
|
||||||
|
{
|
||||||
|
functionExecuted = true;
|
||||||
|
return Task.FromResult("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
functionExecuted.Should().BeTrue();
|
||||||
|
result.Should().Be("success");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteInLockAsync_ShouldReleaseLockAfterExecution()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var lockReleased = false;
|
||||||
|
|
||||||
|
_databaseMock
|
||||||
|
.Setup(x => x.StringSetAsync(
|
||||||
|
It.IsAny<RedisKey>(),
|
||||||
|
It.IsAny<RedisValue>(),
|
||||||
|
It.IsAny<TimeSpan?>(),
|
||||||
|
It.IsAny<bool>(),
|
||||||
|
When.NotExists,
|
||||||
|
It.IsAny<CommandFlags>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
_databaseMock
|
||||||
|
.Setup(x => x.ScriptEvaluate(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<RedisKey[]>(),
|
||||||
|
It.IsAny<RedisValue[]>(),
|
||||||
|
It.IsAny<CommandFlags>()))
|
||||||
|
.Callback(() => lockReleased = true);
|
||||||
|
|
||||||
|
var manager = CreateManager();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await manager.ExecuteInLockAsync("test-key", () => Task.FromResult("done"));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
lockReleased.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcquireLockAsync_ShouldUseCorrectKeyFormat()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
RedisKey? capturedKey = null;
|
||||||
|
|
||||||
|
_databaseMock
|
||||||
|
.Setup(x => x.StringSetAsync(
|
||||||
|
It.IsAny<RedisKey>(),
|
||||||
|
It.IsAny<RedisValue>(),
|
||||||
|
It.IsAny<TimeSpan?>(),
|
||||||
|
It.IsAny<bool>(),
|
||||||
|
When.NotExists,
|
||||||
|
It.IsAny<CommandFlags>()))
|
||||||
|
.Callback<RedisKey, RedisValue, TimeSpan?, bool, When, CommandFlags>((key, _, _, _, _, _) =>
|
||||||
|
{
|
||||||
|
capturedKey = key;
|
||||||
|
})
|
||||||
|
.Returns(Task.FromResult(true));
|
||||||
|
|
||||||
|
|
||||||
|
var manager = CreateManager();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await manager.AcquireLockAsync("my-resource");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
capturedKey.Should().NotBeNull();
|
||||||
|
capturedKey!.ToString().Should().Contain("lock:test-instance:");
|
||||||
|
capturedKey.ToString().Should().Contain("my-resource");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcquireLockAsync_ShouldUseDefaultExpiryWhenNotProvided()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
TimeSpan? capturedExpiry = null;
|
||||||
|
|
||||||
|
_databaseMock
|
||||||
|
.Setup(x => x.StringSetAsync(
|
||||||
|
It.IsAny<RedisKey>(),
|
||||||
|
It.IsAny<RedisValue>(),
|
||||||
|
It.IsAny<TimeSpan?>(),
|
||||||
|
It.IsAny<bool>(),
|
||||||
|
When.NotExists,
|
||||||
|
It.IsAny<CommandFlags>()))
|
||||||
|
.Callback<RedisKey, RedisValue, TimeSpan?, bool, When, CommandFlags>((_, _, expiry, _, _, _) =>
|
||||||
|
{
|
||||||
|
capturedExpiry = expiry;
|
||||||
|
})
|
||||||
|
.Returns(Task.FromResult(true));
|
||||||
|
|
||||||
|
|
||||||
|
var manager = CreateManager();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await manager.AcquireLockAsync("test-key");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
capturedExpiry.Should().NotBeNull();
|
||||||
|
capturedExpiry.Should().Be(TimeSpan.FromSeconds(10)); // Default is 10 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteInLockAsync_WithException_ShouldStillReleaseLock()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var lockReleased = false;
|
||||||
|
|
||||||
|
_databaseMock
|
||||||
|
.Setup(x => x.StringSetAsync(
|
||||||
|
It.IsAny<RedisKey>(),
|
||||||
|
It.IsAny<RedisValue>(),
|
||||||
|
It.IsAny<TimeSpan?>(),
|
||||||
|
It.IsAny<bool>(),
|
||||||
|
When.NotExists,
|
||||||
|
It.IsAny<CommandFlags>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
_databaseMock
|
||||||
|
.Setup(x => x.ScriptEvaluate(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<RedisKey[]>(),
|
||||||
|
It.IsAny<RedisValue[]>(),
|
||||||
|
It.IsAny<CommandFlags>()))
|
||||||
|
.Callback(() => lockReleased = true);
|
||||||
|
|
||||||
|
var manager = CreateManager();
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(() =>
|
||||||
|
manager.ExecuteInLockAsync<string>("test-key", () => throw new InvalidOperationException("Test")))
|
||||||
|
.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
await FluentActions.Invoking(() =>
|
||||||
|
manager.ExecuteInLockAsync<string>("test-key", () => throw new InvalidOperationException("Test")))
|
||||||
|
.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
// Lock should still be released
|
||||||
|
lockReleased.Should().BeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
433
tests/YarpGateway.Tests/Unit/Services/RouteCacheTests.cs
Normal file
433
tests/YarpGateway.Tests/Unit/Services/RouteCacheTests.cs
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
using FluentAssertions;
|
||||||
|
using YarpGateway.Data;
|
||||||
|
using YarpGateway.Models;
|
||||||
|
using YarpGateway.Services;
|
||||||
|
|
||||||
|
namespace YarpGateway.Tests.Unit.Services;
|
||||||
|
|
||||||
|
public class RouteCacheTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IDbContextFactory<GatewayDbContext>> _dbContextFactoryMock;
|
||||||
|
private readonly Mock<ILogger<RouteCache>> _loggerMock;
|
||||||
|
|
||||||
|
public RouteCacheTests()
|
||||||
|
{
|
||||||
|
_dbContextFactoryMock = new Mock<IDbContextFactory<GatewayDbContext>>();
|
||||||
|
_loggerMock = new Mock<ILogger<RouteCache>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private GatewayDbContext CreateInMemoryDbContext(List<GwTenantRoute> routes)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<GatewayDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
var context = new GatewayDbContext(options);
|
||||||
|
context.TenantRoutes.AddRange(routes);
|
||||||
|
context.SaveChanges();
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RouteCache CreateRouteCache(GatewayDbContext context)
|
||||||
|
{
|
||||||
|
_dbContextFactoryMock
|
||||||
|
.Setup(x => x.CreateDbContext())
|
||||||
|
.Returns(context);
|
||||||
|
|
||||||
|
return new RouteCache(
|
||||||
|
dbContextFactory: _dbContextFactoryMock.Object,
|
||||||
|
logger: _loggerMock.Object
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InitializeAsync_ShouldLoadGlobalRoutes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routes = new List<GwTenantRoute>
|
||||||
|
{
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "user-service",
|
||||||
|
ClusterId = "cluster-user",
|
||||||
|
PathPattern = "/api/user/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = false
|
||||||
|
},
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "order-service",
|
||||||
|
ClusterId = "cluster-order",
|
||||||
|
PathPattern = "/api/order/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = CreateInMemoryDbContext(routes);
|
||||||
|
var routeCache = CreateRouteCache(context);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await routeCache.InitializeAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var result = routeCache.GetRoute("any-tenant", "user-service");
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.ClusterId.Should().Be("cluster-user");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InitializeAsync_ShouldLoadTenantRoutes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routes = new List<GwTenantRoute>
|
||||||
|
{
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TenantCode = "tenant-1",
|
||||||
|
ServiceName = "user-service",
|
||||||
|
ClusterId = "cluster-tenant-user",
|
||||||
|
PathPattern = "/api/user/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = false,
|
||||||
|
IsDeleted = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = CreateInMemoryDbContext(routes);
|
||||||
|
var routeCache = CreateRouteCache(context);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await routeCache.InitializeAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var result = routeCache.GetRoute("tenant-1", "user-service");
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.ClusterId.Should().Be("cluster-tenant-user");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRoute_WithTenantRouteAvailable_ShouldReturnTenantRoute()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routes = new List<GwTenantRoute>
|
||||||
|
{
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TenantCode = "tenant-1",
|
||||||
|
ServiceName = "user-service",
|
||||||
|
ClusterId = "tenant-cluster",
|
||||||
|
PathPattern = "/api/user/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = false,
|
||||||
|
IsDeleted = false
|
||||||
|
},
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "user-service",
|
||||||
|
ClusterId = "global-cluster",
|
||||||
|
PathPattern = "/api/user/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = CreateInMemoryDbContext(routes);
|
||||||
|
var routeCache = CreateRouteCache(context);
|
||||||
|
await routeCache.InitializeAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = routeCache.GetRoute("tenant-1", "user-service");
|
||||||
|
|
||||||
|
// Assert - tenant route should be prioritized
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.ClusterId.Should().Be("tenant-cluster");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRoute_WithoutTenantRoute_ShouldFallbackToGlobal()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routes = new List<GwTenantRoute>
|
||||||
|
{
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "user-service",
|
||||||
|
ClusterId = "global-cluster",
|
||||||
|
PathPattern = "/api/user/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = CreateInMemoryDbContext(routes);
|
||||||
|
var routeCache = CreateRouteCache(context);
|
||||||
|
await routeCache.InitializeAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = routeCache.GetRoute("unknown-tenant", "user-service");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.ClusterId.Should().Be("global-cluster");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRoute_WithMissingRoute_ShouldReturnNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routes = new List<GwTenantRoute>();
|
||||||
|
|
||||||
|
var context = CreateInMemoryDbContext(routes);
|
||||||
|
var routeCache = CreateRouteCache(context);
|
||||||
|
await routeCache.InitializeAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = routeCache.GetRoute("tenant-1", "non-existent");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRouteByPath_WithValidPath_ShouldReturnRoute()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routes = new List<GwTenantRoute>
|
||||||
|
{
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "user-service",
|
||||||
|
ClusterId = "cluster-user",
|
||||||
|
PathPattern = "/api/user/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = CreateInMemoryDbContext(routes);
|
||||||
|
var routeCache = CreateRouteCache(context);
|
||||||
|
await routeCache.InitializeAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = routeCache.GetRouteByPath("/api/user/users");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.ClusterId.Should().Be("cluster-user");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRouteByPath_WithMissingPath_ShouldReturnNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routes = new List<GwTenantRoute>();
|
||||||
|
|
||||||
|
var context = CreateInMemoryDbContext(routes);
|
||||||
|
var routeCache = CreateRouteCache(context);
|
||||||
|
await routeCache.InitializeAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = routeCache.GetRouteByPath("/unknown/path");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReloadAsync_ShouldClearOldRoutes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var initialRoutes = new List<GwTenantRoute>
|
||||||
|
{
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "old-service",
|
||||||
|
ClusterId = "old-cluster",
|
||||||
|
PathPattern = "/api/old/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = CreateInMemoryDbContext(initialRoutes);
|
||||||
|
var routeCache = CreateRouteCache(context);
|
||||||
|
await routeCache.InitializeAsync();
|
||||||
|
|
||||||
|
// Verify initial state
|
||||||
|
routeCache.GetRoute("any", "old-service").Should().NotBeNull();
|
||||||
|
|
||||||
|
// Modify the database (replace routes)
|
||||||
|
context.TenantRoutes.RemoveRange(context.TenantRoutes);
|
||||||
|
context.TenantRoutes.Add(new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "new-service",
|
||||||
|
ClusterId = "new-cluster",
|
||||||
|
PathPattern = "/api/new/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = false
|
||||||
|
});
|
||||||
|
context.SaveChanges();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await routeCache.ReloadAsync();
|
||||||
|
|
||||||
|
// Assert - old route should be gone, new route should exist
|
||||||
|
routeCache.GetRoute("any", "old-service").Should().BeNull();
|
||||||
|
routeCache.GetRoute("any", "new-service").Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InitializeAsync_ShouldExcludeDeletedRoutes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routes = new List<GwTenantRoute>
|
||||||
|
{
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "active-service",
|
||||||
|
ClusterId = "cluster-1",
|
||||||
|
PathPattern = "/api/active/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = false
|
||||||
|
},
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "deleted-service",
|
||||||
|
ClusterId = "cluster-2",
|
||||||
|
PathPattern = "/api/deleted/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = CreateInMemoryDbContext(routes);
|
||||||
|
var routeCache = CreateRouteCache(context);
|
||||||
|
await routeCache.InitializeAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
routeCache.GetRoute("any", "active-service").Should().NotBeNull();
|
||||||
|
routeCache.GetRoute("any", "deleted-service").Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InitializeAsync_ShouldExcludeInactiveRoutes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routes = new List<GwTenantRoute>
|
||||||
|
{
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "active-service",
|
||||||
|
ClusterId = "cluster-1",
|
||||||
|
PathPattern = "/api/active/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = false
|
||||||
|
},
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 2,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "inactive-service",
|
||||||
|
ClusterId = "cluster-2",
|
||||||
|
PathPattern = "/api/inactive/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 0, // Inactive
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = CreateInMemoryDbContext(routes);
|
||||||
|
var routeCache = CreateRouteCache(context);
|
||||||
|
await routeCache.InitializeAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
routeCache.GetRoute("any", "active-service").Should().NotBeNull();
|
||||||
|
routeCache.GetRoute("any", "inactive-service").Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRoute_ConcurrentReads_ShouldBeThreadSafe()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routes = new List<GwTenantRoute>
|
||||||
|
{
|
||||||
|
new GwTenantRoute
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TenantCode = "",
|
||||||
|
ServiceName = "user-service",
|
||||||
|
ClusterId = "cluster-user",
|
||||||
|
PathPattern = "/api/user/**",
|
||||||
|
Priority = 1,
|
||||||
|
Status = 1,
|
||||||
|
IsGlobal = true,
|
||||||
|
IsDeleted = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = CreateInMemoryDbContext(routes);
|
||||||
|
var routeCache = CreateRouteCache(context);
|
||||||
|
await routeCache.InitializeAsync();
|
||||||
|
|
||||||
|
// Act & Assert - multiple concurrent reads should not throw
|
||||||
|
var tasks = Enumerable.Range(0, 100)
|
||||||
|
.Select(_ => Task.Run(() => routeCache.GetRoute("any", "user-service")))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
// All results should be consistent
|
||||||
|
results.Should().AllSatisfy(r => r.Should().NotBeNull());
|
||||||
|
}
|
||||||
|
}
|
||||||
27
tests/YarpGateway.Tests/YarpGateway.Tests.csproj
Normal file
27
tests/YarpGateway.Tests/YarpGateway.Tests.csproj
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Moq" />
|
||||||
|
<PackageReference Include="FluentAssertions" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/YarpGateway.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Loading…
Reference in New Issue
Block a user